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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

torque/forge:torque/12270-an-agent-run-keeps-its-tokens-in-and-out → main 2 commits fork

  • Merged. torque/12270-an-agent-run-keeps-its-tokens-in-and-out was merged into main as 119f4d2.

Review

Sign in to review this change.
6 files changed +51 −0
src/core/ledger.ts +2
@@ -66,6 +66,8 @@ export function entryOf(run: Run, prev: string): LedgerEntry {
6666 ...(run.startedAt !== null ? { startedAt: run.startedAt } : {}),
6767 endedAt,
6868 ...(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 } : {}),
6971 ...(run.costUsd > 0 ? { usd_tokens: run.costUsd } : {}),
7072 prev,
7173 };
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 {
8787 readonly turns: number;
8888 /** For an agent run: the context its session held at the end, in tokens; 0 when unreported. */
8989 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;
9096 /** For an agent run: whether it moved its branch; null when unrecorded (runs from before). */
9197 readonly pushed: boolean | null;
9298 /** For an agent run: whether its shell was held to the sandbox; null when unrecorded. */
@@ -132,6 +138,8 @@ interface RunRow {
132138 heartbeat_at: string | null;
133139 turns: number;
134140 context_tokens: number;
141 tokens_in: number | null;
142 tokens_out: number | null;
135143 pushed: number | null;
136144 sandbox_enforced: number | null;
137145 sandbox_note: string;
@@ -167,6 +175,8 @@ const toRun = (r: RunRow): Run => ({
167175 heartbeatAt: r.heartbeat_at,
168176 turns: r.turns,
169177 contextTokens: r.context_tokens,
178 tokensIn: r.tokens_in ?? null,
179 tokensOut: r.tokens_out ?? null,
170180 pushed: r.pushed === null ? null : r.pushed !== 0,
171181 sandboxEnforced:
172182 r.sandbox_enforced === null ? null : r.sandbox_enforced !== 0,
@@ -290,6 +300,10 @@ export function markServing(db: Database, runId: string): Run | undefined {
290300 return changes > 0 ? getRun(db, runId) : undefined;
291301 }
292302
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
293307 /**
294308 * Finish a run. Only a running run finishes; a cancelled one stays
295309 * cancelled however late its worker reports in. Returns the final row, or
@@ -312,6 +326,9 @@ export function finishRun(
312326 turns?: number | undefined;
313327 /** For an agent run: the context its session held at the end, in tokens. */
314328 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;
315332 /** For an agent run: whether it moved its branch. */
316333 pushed?: boolean | undefined;
317334 /** A failed check's own detail, when its output was recognised. */
@@ -327,6 +344,7 @@ export function finishRun(
327344 session_id = COALESCE(?4, session_id), cost_usd = COALESCE(?5, cost_usd),
328345 turns = COALESCE(?6, turns), check_detail = ?7,
329346 context_tokens = COALESCE(?9, context_tokens), pushed = COALESCE(?10, pushed),
347 tokens_in = COALESCE(?11, tokens_in), tokens_out = COALESCE(?12, tokens_out),
330348 finished_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
331349 WHERE id = ?1 AND state = 'running'`,
332350 )
@@ -341,6 +359,8 @@ export function finishRun(
341359 outcome.commitOid ?? null,
342360 outcome.contextTokens ?? null,
343361 outcome.pushed === undefined ? null : outcome.pushed ? 1 : 0,
362 knownTokens(outcome.tokensIn),
363 knownTokens(outcome.tokensOut),
344364 ).changes;
345365 const done = changes > 0 ? getRun(db, runId) : undefined;
346366 if (done !== undefined) appendRun(done);
src/crew/runner.ts +3
@@ -585,6 +585,9 @@ async function runClaimed(
585585 costUsd: outcome.costUsd,
586586 turns: outcome.turns,
587587 contextTokens: outcome.contextTokens,
588 // Every invocation's usage, summed by the meter; 0 means none reported.
589 tokensIn: meter.tokensIn,
590 tokensOut: meter.tokensOut,
588591 pushed: outcome.pushed,
589592 commitOid: left,
590593 });
test/cost-ledger.test.ts +17
@@ -152,3 +152,20 @@ test("a second writer on the same directory chains from what is on disk", () =>
152152 const ls = readFileSync(file, "utf8").split("\n").filter(Boolean);
153153 expect(JSON.parse(ls[2] ?? "").prev).toBe(other.hash);
154154 });
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs