Public: anyone can read this; the crew can change it.
An agent run keeps its tokens in and out, and its cost-ledger entry carries them #12274
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/12270-an-agent-run-keeps-its-tokens-in-and-out → main 2 commits fork
6 files
changed +51
−0
src/core/ledger.ts +2
| @@ -66,6 +66,8 @@ export function entryOf(run: Run, prev: string): LedgerEntry { | ||
| 66 | 66 | ...(run.startedAt !== null ? { startedAt: run.startedAt } : {}), |
| 67 | 67 | endedAt, |
| 68 | 68 | ...(Number.isFinite(wall) ? { wall_s: Math.max(0, Math.round(wall * 1000) / 1000) } : {}), |
| 69 | ...(run.tokensIn != null ? { tokens_in: run.tokensIn } : {}), | |
| 70 | ...(run.tokensOut != null ? { tokens_out: run.tokensOut } : {}), | |
| 69 | 71 | ...(run.costUsd > 0 ? { usd_tokens: run.costUsd } : {}), |
| 70 | 72 | prev, |
| 71 | 73 | }; |
src/core/migration-pins/20261114080000-run-tokens.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | 7e29c198069df785904b69e678805e530acf16a0ed646c026df72319321ecef0 |
src/core/migrations/20261114080000-run-tokens.ts added +8
| @@ -0,0 +1,8 @@ | ||
| 1 | // A finished agent run keeps the tokens it read and wrote, summed over every | |
| 2 | // model invocation it made (the cost ledger's `tokens_in` / `tokens_out`). | |
| 3 | // Nullable: NULL is unknown, and every run from before, every check and | |
| 4 | // deploy, and an agent run that reported no usage stay NULL, never 0. Additive. | |
| 5 | export default ` | |
| 6 | ALTER TABLE run ADD COLUMN tokens_in INTEGER; | |
| 7 | ALTER TABLE run ADD COLUMN tokens_out INTEGER; | |
| 8 | `; |
src/core/runs.ts +20
| @@ -87,6 +87,12 @@ export interface Run { | ||
| 87 | 87 | readonly turns: number; |
| 88 | 88 | /** For an agent run: the context its session held at the end, in tokens; 0 when unreported. */ |
| 89 | 89 | readonly contextTokens: number; |
| 90 | /** | |
| 91 | * For an agent run: tokens it read and wrote, all invocations summed; | |
| 92 | * null when unknown (never 0 for unknown). | |
| 93 | */ | |
| 94 | readonly tokensIn: number | null; | |
| 95 | readonly tokensOut: number | null; | |
| 90 | 96 | /** For an agent run: whether it moved its branch; null when unrecorded (runs from before). */ |
| 91 | 97 | readonly pushed: boolean | null; |
| 92 | 98 | /** For an agent run: whether its shell was held to the sandbox; null when unrecorded. */ |
| @@ -132,6 +138,8 @@ interface RunRow { | ||
| 132 | 138 | heartbeat_at: string | null; |
| 133 | 139 | turns: number; |
| 134 | 140 | context_tokens: number; |
| 141 | tokens_in: number | null; | |
| 142 | tokens_out: number | null; | |
| 135 | 143 | pushed: number | null; |
| 136 | 144 | sandbox_enforced: number | null; |
| 137 | 145 | sandbox_note: string; |
| @@ -167,6 +175,8 @@ const toRun = (r: RunRow): Run => ({ | ||
| 167 | 175 | heartbeatAt: r.heartbeat_at, |
| 168 | 176 | turns: r.turns, |
| 169 | 177 | contextTokens: r.context_tokens, |
| 178 | tokensIn: r.tokens_in ?? null, | |
| 179 | tokensOut: r.tokens_out ?? null, | |
| 170 | 180 | pushed: r.pushed === null ? null : r.pushed !== 0, |
| 171 | 181 | sandboxEnforced: |
| 172 | 182 | r.sandbox_enforced === null ? null : r.sandbox_enforced !== 0, |
| @@ -290,6 +300,10 @@ export function markServing(db: Database, runId: string): Run | undefined { | ||
| 290 | 300 | return changes > 0 ? getRun(db, runId) : undefined; |
| 291 | 301 | } |
| 292 | 302 | |
| 303 | /** A token count worth storing: a positive whole number; anything else is unknown (NULL). */ | |
| 304 | const knownTokens = (n: number | undefined): number | null => | |
| 305 | n !== undefined && Number.isFinite(n) && n > 0 ? Math.round(n) : null; | |
| 306 | ||
| 293 | 307 | /** |
| 294 | 308 | * Finish a run. Only a running run finishes; a cancelled one stays |
| 295 | 309 | * cancelled however late its worker reports in. Returns the final row, or |
| @@ -312,6 +326,9 @@ export function finishRun( | ||
| 312 | 326 | turns?: number | undefined; |
| 313 | 327 | /** For an agent run: the context its session held at the end, in tokens. */ |
| 314 | 328 | contextTokens?: number | undefined; |
| 329 | /** For an agent run: tokens read and written; leave out when the run reported none. */ | |
| 330 | tokensIn?: number | undefined; | |
| 331 | tokensOut?: number | undefined; | |
| 315 | 332 | /** For an agent run: whether it moved its branch. */ |
| 316 | 333 | pushed?: boolean | undefined; |
| 317 | 334 | /** A failed check's own detail, when its output was recognised. */ |
| @@ -327,6 +344,7 @@ export function finishRun( | ||
| 327 | 344 | session_id = COALESCE(?4, session_id), cost_usd = COALESCE(?5, cost_usd), |
| 328 | 345 | turns = COALESCE(?6, turns), check_detail = ?7, |
| 329 | 346 | context_tokens = COALESCE(?9, context_tokens), pushed = COALESCE(?10, pushed), |
| 347 | tokens_in = COALESCE(?11, tokens_in), tokens_out = COALESCE(?12, tokens_out), | |
| 330 | 348 | finished_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') |
| 331 | 349 | WHERE id = ?1 AND state = 'running'`, |
| 332 | 350 | ) |
| @@ -341,6 +359,8 @@ export function finishRun( | ||
| 341 | 359 | outcome.commitOid ?? null, |
| 342 | 360 | outcome.contextTokens ?? null, |
| 343 | 361 | outcome.pushed === undefined ? null : outcome.pushed ? 1 : 0, |
| 362 | knownTokens(outcome.tokensIn), | |
| 363 | knownTokens(outcome.tokensOut), | |
| 344 | 364 | ).changes; |
| 345 | 365 | const done = changes > 0 ? getRun(db, runId) : undefined; |
| 346 | 366 | if (done !== undefined) appendRun(done); |
src/crew/runner.ts +3
| @@ -585,6 +585,9 @@ async function runClaimed( | ||
| 585 | 585 | costUsd: outcome.costUsd, |
| 586 | 586 | turns: outcome.turns, |
| 587 | 587 | contextTokens: outcome.contextTokens, |
| 588 | // Every invocation's usage, summed by the meter; 0 means none reported. | |
| 589 | tokensIn: meter.tokensIn, | |
| 590 | tokensOut: meter.tokensOut, | |
| 588 | 591 | pushed: outcome.pushed, |
| 589 | 592 | commitOid: left, |
| 590 | 593 | }); |
test/cost-ledger.test.ts +17
| @@ -152,3 +152,20 @@ test("a second writer on the same directory chains from what is on disk", () => | ||
| 152 | 152 | const ls = readFileSync(file, "utf8").split("\n").filter(Boolean); |
| 153 | 153 | expect(JSON.parse(ls[2] ?? "").prev).toBe(other.hash); |
| 154 | 154 | }); |
| 155 | ||
| 156 | test("an agent run that finishes with usage writes both token fields; a check's entry has neither", async () => { | |
| 157 | const { db, repoId } = await openForTest(); | |
| 158 | end(db, repoId); | |
| 159 | const agent = createRun(db, { repoId, kind: "agent" }); | |
| 160 | claimRun(db, agent.id); | |
| 161 | finishRun(db, agent.id, { state: "ok", summary: "done", tokensIn: 1200, tokensOut: 340 }); | |
| 162 | const silent = createRun(db, { repoId, kind: "agent" }); | |
| 163 | claimRun(db, silent.id); | |
| 164 | finishRun(db, silent.id, { state: "ok", summary: "done", tokensIn: 0, tokensOut: 0 }); | |
| 165 | const [check, withUsage, noUsage] = lines().map((l) => JSON.parse(l)); | |
| 166 | expect("tokens_in" in check || "tokens_out" in check).toBe(false); | |
| 167 | expect(withUsage.tokens_in).toBe(1200); | |
| 168 | expect(withUsage.tokens_out).toBe(340); | |
| 169 | expect("tokens_in" in noUsage || "tokens_out" in noUsage).toBe(false); | |
| 170 | expect(verifyDir(dir)).toEqual({ ok: true, entries: 3 }); | |
| 171 | }); |
new above
offline