mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-08 08:04:32 +00:00
Rework child attribution to resolve each subagent to the PR its launching turn was working on, using the parent's UNFILTERED turn data, and enforce that every dollar is counted exactly once. - Mutual exclusion: a child that referenced its own PR attributes standalone and is never folded; a child with no links is folded only. Fixes a double-charge where a self-linking child was both folded and self-attributed. - Recursion: a fold aggregates a child plus its non-self-linking descendants (depth-first, cycle-guarded), so grandchildren spawned by subagents reach the PR report. - Global linkage: the subagent index keys by parentSessionId alone (UUIDs are globally unique), so a child whose worktree resolves to a different project still links. - Date-range correctness: spawn-to-PR sets are built at assembly from the full turn list, so a spawn in a pre-range turn attributes to the right PR; a PR-linked parent whose own turns fall out of range is kept as a 0-cost fold anchor so its in-range child is not lost. - Timestamp fallback compares epoch ms (mixed UTC offsets order right) and is end-bounded: a child active after the parent's last turn is unlinked (contributes nothing), matching orphan semantics. - Cache adoption tries the newest prior versioned file (v6 then v5) so the preceding build's expired-PR history survives the v7 bump; an invariant note requires the list to cover every version that can exist on disk. - Spawn-result pairing matches the tool_result block that carries the agentId, not the first block, when a record batches several results. - resolveSubagentAttribution is computed once and shared by aggregateByPr and prLinkedTotals. subagentSessions now counts folded subtrees (children plus descendants). Verified on real data: attributed + unattributed reconciles to cost, and parent-only cost plus folded-children cost equals the folded total to the cent (no double-count).
82 lines
4.5 KiB
TypeScript
82 lines
4.5 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises'
|
|
import { join } from 'path'
|
|
import { tmpdir } from 'os'
|
|
|
|
import { parseAllSessions, clearSessionCache } from '../src/parser.js'
|
|
import { loadPricing } from '../src/models.js'
|
|
import { aggregateByPr, prLinkedTotals } from '../src/sessions-report.js'
|
|
|
|
// A parent that spawned an async subagent whose work landed inside the report
|
|
// range, while the parent's OWN turns fall just before it. The parent must be kept
|
|
// as a 0-cost fold anchor so the in-range child still attributes to the parent's
|
|
// PR (finding: a parent with no in-range calls must not drop its child's spend).
|
|
|
|
let tmpDir: string
|
|
let configDir: string
|
|
const CWD = '/tmp/anchor-proj'
|
|
const PR = 'https://github.com/o/r/pull/1'
|
|
const PARENT = '11111111-1111-4111-8111-111111111111'
|
|
const AGENT = 'a1234567890abcdef'
|
|
const SPAWN = 'toolu_spawn_anchor'
|
|
|
|
beforeEach(async () => {
|
|
clearSessionCache()
|
|
tmpDir = await mkdtemp(join(tmpdir(), 'anchor-'))
|
|
configDir = join(tmpDir, 'claude')
|
|
process.env['CLAUDE_CONFIG_DIR'] = configDir
|
|
process.env['CODEBURN_CACHE_DIR'] = join(tmpDir, 'cache')
|
|
})
|
|
|
|
afterEach(async () => {
|
|
clearSessionCache()
|
|
delete process.env['CLAUDE_CONFIG_DIR']
|
|
delete process.env['CODEBURN_CACHE_DIR']
|
|
await rm(tmpDir, { recursive: true, force: true })
|
|
})
|
|
|
|
async function writeTranscripts(): Promise<void> {
|
|
const projDir = join(configDir, 'projects', 'anchor-proj')
|
|
const subDir = join(projDir, PARENT, 'subagents')
|
|
await mkdir(subDir, { recursive: true })
|
|
|
|
// Parent transcript: a turn that references the PR and spawns AGENT (tool_use
|
|
// SPAWN), then the spawn result recording agentId -> SPAWN. All dated just BEFORE
|
|
// the report range (within the 24h parse lookback so the spawn is still parsed).
|
|
await writeFile(join(projDir, `${PARENT}.jsonl`),
|
|
JSON.stringify({ type: 'user', sessionId: PARENT, timestamp: '2026-07-19T23:00:00.000Z', cwd: CWD, message: { role: 'user', content: 'ship the PR and launch a reviewer' } }) + '\n' +
|
|
JSON.stringify({ type: 'assistant', sessionId: PARENT, timestamp: '2026-07-19T23:00:01.000Z', cwd: CWD, message: { id: 'm1', type: 'message', role: 'assistant', model: 'claude-sonnet-4-5', content: [{ type: 'tool_use', id: SPAWN, name: 'Agent', input: {} }], usage: { input_tokens: 10, output_tokens: 5 } } }) + '\n' +
|
|
JSON.stringify({ type: 'pr-link', sessionId: PARENT, timestamp: '2026-07-19T23:00:02.000Z', cwd: CWD, prUrl: PR }) + '\n' +
|
|
JSON.stringify({ type: 'user', sessionId: PARENT, timestamp: '2026-07-19T23:05:00.000Z', cwd: CWD, message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: SPAWN, content: 'reviewer done' }] }, toolUseResult: { status: 'completed', agentId: AGENT, content: 'reviewer done' } }) + '\n')
|
|
|
|
// Child transcript: the subagent's own work, dated INSIDE the report range.
|
|
await writeFile(join(subDir, `agent-${AGENT}.jsonl`),
|
|
JSON.stringify({ type: 'user', isSidechain: true, sessionId: PARENT, agentId: AGENT, timestamp: '2026-07-20T10:00:00.000Z', cwd: CWD, message: { role: 'user', content: 'review this' } }) + '\n' +
|
|
JSON.stringify({ type: 'assistant', isSidechain: true, sessionId: PARENT, agentId: AGENT, timestamp: '2026-07-20T10:00:05.000Z', cwd: CWD, message: { id: 'c1', type: 'message', role: 'assistant', model: 'claude-opus-4-8', content: [], usage: { input_tokens: 1000, output_tokens: 500 } } }) + '\n')
|
|
}
|
|
|
|
describe('subagent fold across a date-range boundary', () => {
|
|
it('folds an in-range child into its parent PR even though the parent has no in-range turns', async () => {
|
|
await loadPricing()
|
|
await writeTranscripts()
|
|
|
|
const range = { start: new Date('2026-07-20T00:00:00Z'), end: new Date('2026-07-20T23:59:59Z') }
|
|
const projects = await parseAllSessions(range, 'claude')
|
|
|
|
// The child session is present (its work is in range) as a standalone session.
|
|
const childPresent = projects.some(p => p.sessions.some(s => s.sessionId === `agent-${AGENT}`))
|
|
expect(childPresent).toBe(true)
|
|
|
|
const rows = aggregateByPr(projects)
|
|
const row = rows.find(r => r.url === PR)
|
|
expect(row).toBeDefined()
|
|
// The parent contributed $0 own spend (turns out of range); the row is entirely
|
|
// the folded child, priced from its opus tokens.
|
|
expect(row!.cost).toBeGreaterThan(0)
|
|
expect(row!.models).toContain('Opus 4.8')
|
|
|
|
const totals = prLinkedTotals(projects)
|
|
expect(totals.subagentSessions).toBe(1)
|
|
expect(totals.attributedCost).toBeCloseTo(row!.cost, 6)
|
|
})
|
|
})
|