From 71c0d5f8dec004f058962d7d5ae5dae032ba0a20 Mon Sep 17 00:00:00 2001 From: serhiizghama Date: Wed, 24 Jun 2026 08:17:36 +0700 Subject: [PATCH 1/2] fix(extract): attribute extraction cost to its project recordCost upserted the sessions row by session_id alone and never wrote project_id. For a sparse session whose first DB write is the cost row (no SessionStart/UserPromptSubmit injection ever fired), the cost lands on a project_id IS NULL row, and status's per-project total filters by project_id, so the cost is dropped from the project view it belongs to. Resolve the project id before recording cost and persist it in the upsert, filling it only when absent (COALESCE) so an injection-set value is never clobbered. --- packages/agentctx/src/extract/run.ts | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/packages/agentctx/src/extract/run.ts b/packages/agentctx/src/extract/run.ts index 32d8faf..49b38d5 100644 --- a/packages/agentctx/src/extract/run.ts +++ b/packages/agentctx/src/extract/run.ts @@ -120,13 +120,13 @@ async function extract( const db = openDatabase(env.dbPath); try { - recordCost(db, sessionId, totalCost); + const projectId = sessionProjectId(db, sessionId) ?? resolveProjectId(env.cwd); + recordCost(db, sessionId, projectId, totalCost); const result = parseExtraction(responseText); if (result === null) { log(`extract: unparseable model output for session ${sessionId} — skipped`); return 0; } - const projectId = sessionProjectId(db, sessionId) ?? resolveProjectId(env.cwd); const stats = ingestExtraction(db, projectId, sessionId, result, log); logStats(log, sessionId, stats, totalCost, result.flushOk); } finally { @@ -135,13 +135,27 @@ async function extract( return 0; } -/** Self-accounting (SPEC §3.1, §7): cost lands on the session row. */ -function recordCost(db: ReturnType, sessionId: string, cost: number): void { +/** + * Self-accounting (SPEC §3.1, §7): cost lands on the session row, attributed to + * its project. When the cost row is the session's first DB write (a sparse + * session that never triggered a SessionStart/UserPromptSubmit injection), + * `project_id` would otherwise stay NULL and `status` would drop the cost from + * the per-project total. Fill it here, but never clobber a value an injection + * already set. + */ +function recordCost( + db: ReturnType, + sessionId: string, + projectId: string, + cost: number, +): void { db.prepare( - `INSERT INTO sessions (session_id, extraction_cost_usd) VALUES (@sessionId, @cost) + `INSERT INTO sessions (session_id, project_id, extraction_cost_usd) + VALUES (@sessionId, @projectId, @cost) ON CONFLICT(session_id) DO UPDATE SET - extraction_cost_usd = extraction_cost_usd + @cost`, - ).run({ sessionId, cost }); + extraction_cost_usd = extraction_cost_usd + @cost, + project_id = COALESCE(project_id, @projectId)`, + ).run({ sessionId, projectId, cost }); } function sessionProjectId(db: ReturnType, sessionId: string): string | null { From bd27901bcc01f8be9aa74797f1a4a66c36d504c7 Mon Sep 17 00:00:00 2001 From: serhiizghama Date: Wed, 24 Jun 2026 08:17:36 +0700 Subject: [PATCH 2/2] test(extract): cover project attribution with no prior injection --- packages/agentctx/test/extract/run.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/agentctx/test/extract/run.test.ts b/packages/agentctx/test/extract/run.test.ts index c4c0e36..c7efa04 100644 --- a/packages/agentctx/test/extract/run.test.ts +++ b/packages/agentctx/test/extract/run.test.ts @@ -104,6 +104,24 @@ describe("agentctx extract", () => { } }); + it("attributes the cost to the project even with no prior injection", async () => { + // Sparse session: the cost row is the first DB write (no SessionStart / + // UserPromptSubmit injection ever ran), so project_id must be filled here + // or `status` drops the cost from the per-project total (#66). + const calls: MockCall[] = []; + expect(await run({ fetchFn: mockFetch(calls), apiKey: "key" })).toBe(0); + + const db = openDatabase(t.env.dbPath); + try { + const session = db + .prepare("SELECT project_id FROM sessions WHERE session_id = 's1'") + .get() as { project_id: string | null }; + expect(session.project_id).toBe(resolveProjectId(t.env.cwd)); + } finally { + db.close(); + } + }); + it("degrades cleanly without an API key (SPEC §8 rung 3)", async () => { const calls: MockCall[] = []; expect(await run({ fetchFn: mockFetch(calls), apiKey: undefined })).toBe(0);