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 { 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);