command hq: per-turn cost ledger, watcher fires as their own table
The owner asked for individual turn token/dollar costs, a running total, cost over time, and cost per trigger — derived, not stored, following the same rule `running` already uses. Two new tables carry it: - command_hq_watcher_fires: one row per trigger (sessionRowId, taskId, firedAt). Replaces the single watcherLastFiredAt column, which lost the previous fire's timestamp every time a new one landed — fine for "when did it last fire", useless for "how many times, and what did each one cost". - command_hq_turns: one row per completed turn (sessionRowId, fireId nullable, inputTokens, outputTokens, totalUsd). Nothing pre-summed — running total, cost-over-time and cost-per-trigger are all queries over this table. Turn count needed no column either: COUNT(*) already answers it, confirmed explicitly with the owner rather than assumed. command_hq_sessions gains watcherArmedAt/watcherStoppedAt in place of any "last fired" cache — set only on the false<->true transitions, so duration (current run, or the last completed one) is computable without a full transition history table. Two new read routes, GET /commanders/:sessionId/turns and .../fires, expose the raw series behind the cost summary and watcherLastFiredAt (now computed via MAX(fired_at), not read off a column — the field survives in the JSON response under the same name). Bug found and fixed while wiring this up, recorded in docs/command-hq.md: recordCommandHqWatcherFired's signature changed from (userId, sessionId) to (sessionRowId, taskId) and one call site kept the old argument order — tsgo stayed clean because number/string still matched structurally. Caught by re-reading every call site by hand, not by any tool. Needs bun db:push before any route answers with real data. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,7 @@ import {
|
||||
getCommandHqSession,
|
||||
armCommandHqWatcher,
|
||||
recordCommandHqWatcherFired,
|
||||
recordCommandHqTurn,
|
||||
} from 'officerdb';
|
||||
import { resolveHomeDir } from '@@/user-home';
|
||||
import { claudeLoginState } from '@@/os-user-claude';
|
||||
@@ -570,8 +571,12 @@ function createMessageHandler(
|
||||
const taskId = msg.taskId;
|
||||
void getCommandHqSession(userId, capturedTranscriptId)
|
||||
.then((row) => {
|
||||
// `row.id` — the commander's OWN row id, not `userId` — is the FK `command_hq_watcher_fires`
|
||||
// actually takes. Passing `userId`/`transcriptId` here type-checks by coincidence (number,
|
||||
// string) while writing a fire row for the wrong commander entirely; caught by tracing the
|
||||
// call, not by the compiler.
|
||||
if (row?.watcherArmed && row.watcherTaskId === taskId) {
|
||||
return recordCommandHqWatcherFired(userId, capturedTranscriptId);
|
||||
return recordCommandHqWatcherFired(row.id, taskId);
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
@@ -584,6 +589,39 @@ function createMessageHandler(
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
One turn's cost, recorded whenever it ends — not only on a firing's acting turn. The setup
|
||||
conversation before a watcher is ever armed costs something too, and `fireId` is null for exactly
|
||||
those turns (`recordCommandHqTurn` resolves it, not this call site). This is the raw ledger
|
||||
`docs/command-hq.md` describes: running total, cost over time, cost per trigger and turn count are
|
||||
all queries over it, nothing summed here.
|
||||
*/
|
||||
if (msg.type === 'result' && commandHq && transcriptId) {
|
||||
const userId = session.userId;
|
||||
if (userId) {
|
||||
const capturedTranscriptId = transcriptId;
|
||||
// Named explicitly, not spread — `MessageCost.totalUSD` and `CommandHqTurnCost.totalUsd` differ
|
||||
// only in casing, which a spread would carry both keys of and TypeScript would wave through
|
||||
// (excess-property checking does not catch a spread the way it catches a literal).
|
||||
const cost = {
|
||||
inputTokens: msg.cost.inputTokens,
|
||||
outputTokens: msg.cost.outputTokens,
|
||||
totalUsd: msg.cost.totalUSD,
|
||||
};
|
||||
void getCommandHqSession(userId, capturedTranscriptId)
|
||||
.then((row) => {
|
||||
if (row) return recordCommandHqTurn(row.id, cost);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
logger.warn('Could not record commander turn cost', {
|
||||
sessionId,
|
||||
transcriptId: capturedTranscriptId,
|
||||
error: String(err),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
A turn ended — take a usage snapshot while the person is reading the answer.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user