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:
2026-08-18 09:03:52 +00:00
co-authored by Claude Opus 5
parent 56911f6874
commit 764ba23206
6 changed files with 477 additions and 64 deletions
+39 -1
View File
@@ -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.