mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-07 07:34:39 +00:00
sessions: address adversarial review of subagent PR attribution
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).
This commit is contained in:
parent
4b787194d7
commit
384fb5ef0b
8 changed files with 718 additions and 374 deletions
|
|
@ -1275,11 +1275,21 @@ export function collectSessionMeta(entry: JournalEntry, meta: SessionMeta): void
|
|||
const msg = entry.message
|
||||
const content = msg && typeof msg === 'object' ? (msg as { content?: unknown }).content : undefined
|
||||
if (Array.isArray(content)) {
|
||||
for (const b of content) {
|
||||
if (!b || typeof b !== 'object' || (b as { type?: unknown }).type !== 'tool_result') continue
|
||||
const id = (b as { tool_use_id?: unknown }).tool_use_id
|
||||
if (typeof id === 'string' && id) { meta.agentSpawnLinks[agentId] = id; break }
|
||||
const results = content.filter((b): b is Record<string, unknown> =>
|
||||
!!b && typeof b === 'object' && (b as { type?: unknown }).type === 'tool_result'
|
||||
&& typeof (b as { tool_use_id?: unknown }).tool_use_id === 'string' && !!(b as { tool_use_id?: unknown }).tool_use_id)
|
||||
let spawnId: string | undefined
|
||||
if (results.length === 1) {
|
||||
spawnId = results[0]!['tool_use_id'] as string
|
||||
} else if (results.length > 1) {
|
||||
// Several batched tool results share one entry: pair the agentId with the
|
||||
// block whose `content` is the spawn result (equals `toolUseResult.content`),
|
||||
// so an unrelated sibling block cannot capture the id. Skip if ambiguous.
|
||||
const turContent = JSON.stringify((tur as Record<string, unknown>)['content'])
|
||||
const matches = results.filter(b => JSON.stringify(b['content']) === turContent)
|
||||
if (matches.length === 1) spawnId = matches[0]!['tool_use_id'] as string
|
||||
}
|
||||
if (spawnId) meta.agentSpawnLinks[agentId] = spawnId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1551,6 +1561,22 @@ export function groupIntoTurns(entries: JournalEntry[], seenMsgIds: Set<string>,
|
|||
return turns
|
||||
}
|
||||
|
||||
// Map each subagent-spawn `tool_use` id to the PR set active at the turn that
|
||||
// emitted it, walking the FULL turn list in order. A turn's own `prRefs` apply to
|
||||
// spawns within it; otherwise the carried set does. First occurrence of a spawn id
|
||||
// wins deterministically (tool_use ids are unique in practice; this only guards a
|
||||
// pathological restatement). Drives cross-range subagent PR attribution.
|
||||
export function buildSpawnPrSets(turns: Array<{ prRefs?: string[]; spawnToolUseIds?: string[] }>): Record<string, string[]> {
|
||||
const out: Record<string, string[]> = {}
|
||||
let cur: string[] = []
|
||||
for (const turn of turns) {
|
||||
const active = turn.prRefs?.length ? turn.prRefs : cur
|
||||
for (const id of turn.spawnToolUseIds ?? []) if (!(id in out)) out[id] = active
|
||||
if (turn.prRefs?.length) cur = turn.prRefs
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract MCP tool inventory observed across a session's JSONL entries.
|
||||
*
|
||||
|
|
@ -2144,6 +2170,12 @@ async function scanProjectDirs(
|
|||
// session's in-range unbranched spend as `null` instead of discarding it.
|
||||
const everHadBranch = carriedBranch !== undefined
|
||||
|
||||
// Built from the FULL (pre-slice) turn list: each subagent-spawn tool_use id ->
|
||||
// the PR set active at the turn that emitted it. Lets a subagent fold into the
|
||||
// right PR even when its launching turn is later sliced out of range. Only for
|
||||
// sessions that both spawned subagents and referenced a PR.
|
||||
const spawnPrSets = cachedFile.prLinks?.length ? buildSpawnPrSets(cachedFile.turns) : {}
|
||||
|
||||
if (dateRange) {
|
||||
classifiedTurns = classifiedTurns.filter(turn => {
|
||||
if (turn.assistantCalls.length === 0) return false
|
||||
|
|
@ -2154,7 +2186,12 @@ async function scanProjectDirs(
|
|||
})
|
||||
}
|
||||
|
||||
if (classifiedTurns.length === 0) continue
|
||||
// A PR-linked parent that spawned subagents is kept even when its OWN turns all
|
||||
// fall out of range, as a 0-cost fold anchor: an in-range child (an async agent
|
||||
// that outlived the parent's last in-range turn) still needs the parent's
|
||||
// `prLinks` / `spawnPrSets` to attribute. It contributes no spend of its own.
|
||||
const isSpawnAnchor = Object.keys(spawnPrSets).length > 0 && cachedFile.isSidechain !== true
|
||||
if (classifiedTurns.length === 0 && !isSpawnAnchor) continue
|
||||
|
||||
const sessionId = basename(filePath, '.jsonl')
|
||||
const projectPath = cachedFile.canonicalCwd ?? claudeSlugFallbackPath(dirName)
|
||||
|
|
@ -2174,12 +2211,13 @@ async function scanProjectDirs(
|
|||
if (cachedFile.parentSessionId) session.parentSessionId = cachedFile.parentSessionId
|
||||
session.agentId = sessionId.startsWith('agent-') ? sessionId.slice('agent-'.length) : sessionId
|
||||
}
|
||||
// Parent linkage map (only present on sessions that spawned subagents).
|
||||
// Parent linkage maps (only present on sessions that spawned subagents).
|
||||
if (cachedFile.agentSpawnLinks && Object.keys(cachedFile.agentSpawnLinks).length > 0) {
|
||||
session.agentSpawnLinks = cachedFile.agentSpawnLinks
|
||||
}
|
||||
if (Object.keys(spawnPrSets).length > 0) session.spawnPrSets = spawnPrSets
|
||||
|
||||
if (session.apiCalls > 0) {
|
||||
if (session.apiCalls > 0 || isSpawnAnchor) {
|
||||
const projectKey = cachedFile.canonicalCwd
|
||||
? normalizeProjectPathKey(cachedFile.canonicalCwd)
|
||||
: `slug:${dirName}`
|
||||
|
|
|
|||
|
|
@ -141,10 +141,12 @@ export type SessionCache = {
|
|||
// v6: per-turn `prRefs` capture for turn-level PR spend attribution. Existing
|
||||
// cache turns carry no prRefs; bumping forces a one-time re-parse so surviving
|
||||
// transcripts populate the field. (Daily-cache versioning is untouched.)
|
||||
// v7: sidechain->parent linkage — per-turn `spawnToolUseIds`, per-file
|
||||
// `parentSessionId` / `agentSpawnLinks` — so subagent spend folds into the parent
|
||||
// turn's PR set. v6 never shipped, so users cross v5->v7 in a single combined
|
||||
// bump; the v5 adoption path below still rescues expired-PR orphans.
|
||||
// v7: sidechain->parent linkage - per-turn `spawnToolUseIds`, per-file
|
||||
// `parentSessionId` / `agentSpawnLinks` - so subagent spend folds into the parent
|
||||
// turn's PR set. v6 never shipped, so users cross v5->v7 in a single combined bump.
|
||||
// INVARIANT: a version bump must extend `PRIOR_CACHE_VERSIONS` (the adoption path
|
||||
// below) to EVERY prior version that can still exist on disk, or expired-PR
|
||||
// history from the immediately preceding build silently vanishes.
|
||||
export const CACHE_VERSION = 7
|
||||
|
||||
// The cache filename is version-suffixed so different binaries (e.g. an old
|
||||
|
|
@ -392,37 +394,43 @@ function validateCache(raw: unknown): raw is SessionCache {
|
|||
return Object.values(o['providers'] as Record<string, unknown>).every(validateProviderSection)
|
||||
}
|
||||
|
||||
// The immediately-prior versioned file. On the 5 -> 6 bump we adopt its
|
||||
// still-relevant entries (see adoptV5Cache) rather than abandoning them; the
|
||||
// file itself is never written or deleted (old binaries still own it).
|
||||
const V5_CACHE_FILE = 'session-cache.v5.json'
|
||||
// Every prior versioned cache file that can still exist on disk from a shipped or
|
||||
// dev build, NEWEST first. On a bump we adopt the newest one present: its
|
||||
// expired-source PR orphans (transcripts since deleted) hold attributable spend
|
||||
// that can never be re-parsed, and each newer version already carried the older
|
||||
// versions' orphans forward, so the newest is a superset. INVARIANT: a
|
||||
// CACHE_VERSION bump MUST extend this list to every prior version that can still
|
||||
// exist on disk, or that history silently vanishes. (v5 was missed on the 5->6
|
||||
// bump; v6 on the 6->7 bump; both are listed here.)
|
||||
const PRIOR_CACHE_VERSIONS = [6, 5] as const
|
||||
|
||||
// Lightweight top-level check: a version-5 cache with a providers object. The
|
||||
// individual files are validated per-entry in adoptV5Cache so one corrupt entry
|
||||
// cannot drop every valid expired-transcript PR session along with it.
|
||||
function isV5CacheEnvelope(raw: unknown): raw is { version: number; providers: Record<string, unknown> } {
|
||||
function priorCacheFile(version: number): string {
|
||||
return `session-cache.v${version}.json`
|
||||
}
|
||||
|
||||
// Lightweight top-level check: a specific prior-version cache envelope with a
|
||||
// providers object. Files are validated per-entry in adoptPriorCache so one
|
||||
// corrupt entry cannot drop every valid expired-transcript PR session.
|
||||
function isCacheEnvelope(raw: unknown, version: number): raw is { version: number; providers: Record<string, unknown> } {
|
||||
if (!raw || typeof raw !== 'object') return false
|
||||
const o = raw as Record<string, unknown>
|
||||
return o['version'] === 5
|
||||
return o['version'] === version
|
||||
&& !!o['providers'] && typeof o['providers'] === 'object' && !Array.isArray(o['providers'])
|
||||
}
|
||||
|
||||
// One-time migration for the 5 -> 6 bump (per-turn prRefs capture). A fresh v6
|
||||
// cache would abandon v5 wholesale, so any PR-linked session whose transcript was
|
||||
// since deleted would vanish instead of taking the by-PR legacy even-split path.
|
||||
// Carry forward exactly the v5 entries whose source no longer exists AND that
|
||||
// carry prLinks (they can never re-parse, but they hold attributable PR spend);
|
||||
// present sources are intentionally dropped so they re-parse fresh under v6 and
|
||||
// gain per-turn refs. Each file is validated individually, so a single corrupt
|
||||
// entry is skipped rather than discarding the whole cache. Each carried section
|
||||
// takes the CURRENT envFingerprint so the scan reuses it and appends the
|
||||
// freshly-parsed present sources. The daily cache (durable cost history) is not
|
||||
// touched.
|
||||
async function adoptV5Cache(): Promise<SessionCache | null> {
|
||||
// One-time migration on a version bump: carry forward exactly the prior-version
|
||||
// entries whose source no longer exists AND that carry prLinks (they can never
|
||||
// re-parse, but they hold attributable PR spend); present sources are dropped so
|
||||
// they re-parse fresh under the new version and gain the new fields. Each file is
|
||||
// validated individually, so a single corrupt entry is skipped rather than
|
||||
// discarding the whole cache. Each carried section takes the CURRENT
|
||||
// envFingerprint so the scan reuses it and appends the freshly-parsed present
|
||||
// sources. The daily cache (durable cost history) is not touched.
|
||||
async function adoptPriorCache(version: number): Promise<SessionCache | null> {
|
||||
try {
|
||||
const raw = await readFile(join(getCacheDir(), V5_CACHE_FILE), 'utf-8')
|
||||
const raw = await readFile(join(getCacheDir(), priorCacheFile(version)), 'utf-8')
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!isV5CacheEnvelope(parsed)) return null
|
||||
if (!isCacheEnvelope(parsed, version)) return null
|
||||
const migrated: SessionCache = { version: CACHE_VERSION, providers: {}, complete: false }
|
||||
for (const [provider, section] of Object.entries(parsed.providers)) {
|
||||
if (!section || typeof section !== 'object') continue
|
||||
|
|
@ -446,6 +454,15 @@ async function adoptV5Cache(): Promise<SessionCache | null> {
|
|||
}
|
||||
}
|
||||
|
||||
// Adopt the newest prior versioned cache present on disk (v6 before v5).
|
||||
async function adoptNewestPriorCache(): Promise<SessionCache | null> {
|
||||
for (const version of PRIOR_CACHE_VERSIONS) {
|
||||
const adopted = await adoptPriorCache(version)
|
||||
if (adopted) return adopted
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function loadCache(): Promise<SessionCache> {
|
||||
try {
|
||||
const raw = await readFile(getCachePath(), 'utf-8')
|
||||
|
|
@ -457,12 +474,13 @@ export async function loadCache(): Promise<SessionCache> {
|
|||
}
|
||||
}
|
||||
|
||||
// The versioned (v6) file is absent/unreadable. Prefer adopting the prior v5
|
||||
// file's expired-source PR orphans; failing that, fall back to the legacy
|
||||
// unversioned file. Either way the versioned file is minted on the next save.
|
||||
// The current versioned file is absent/unreadable. Prefer adopting the newest
|
||||
// prior versioned file's expired-source PR orphans (v6 before v5); failing that,
|
||||
// fall back to the legacy unversioned file. Either way the versioned file is
|
||||
// minted on the next save.
|
||||
async function afterMissingVersionedCache(): Promise<SessionCache> {
|
||||
const v5 = await adoptV5Cache()
|
||||
if (v5) return v5
|
||||
const prior = await adoptNewestPriorCache()
|
||||
if (prior) return prior
|
||||
// validateCache requires version === CACHE_VERSION, so a different-version
|
||||
// legacy file is ignored (left intact). We copy it into the versioned file once
|
||||
// via saveCache; the legacy file is never modified.
|
||||
|
|
|
|||
|
|
@ -172,23 +172,62 @@ export function allocateEven(total: number, n: number): number[] {
|
|||
return Array.from({ length: n }, (_, i) => base + (i < extra ? 1 : 0))
|
||||
}
|
||||
|
||||
/// A subagent (sidechain) session's spend, pre-aggregated for folding into the
|
||||
/// parent turn that launched it. `models` is keyed by RAW model name (the row
|
||||
/// builder collapses it to short names, exactly like a turn's own calls);
|
||||
/// `categories` by TaskCategory. Both maps sum to `cost` (all three derive from
|
||||
/// the same child turns), so folding a child in never introduces a rounding gap.
|
||||
/// A subagent (sidechain) session's spend plus its non-self-linking descendants,
|
||||
/// pre-aggregated for folding into the PR its launching parent turn was working
|
||||
/// on. `models` keys are RAW model names (the row builder collapses them to short
|
||||
/// names, exactly like a turn's own calls); `categories` are TaskCategory. All
|
||||
/// three sum to `cost` (derived from the same sessions), so folding never adds a
|
||||
/// rounding gap. `foldedSessions` counts the subtree (self plus folded
|
||||
/// descendants); `spawnAtMs` is the TOP child's first-activity epoch (the whole
|
||||
/// subtree resolves against the top parent through the top child's spawn).
|
||||
export type ChildFold = {
|
||||
agentId: string
|
||||
cost: number
|
||||
calls: number
|
||||
savingsUSD: number
|
||||
/// The child's first-activity timestamp, used by the timestamp-bucket fallback.
|
||||
spawnAt: string
|
||||
spawnAtMs: number
|
||||
models: Map<string, number>
|
||||
categories: Map<string, number>
|
||||
foldedSessions: number
|
||||
}
|
||||
|
||||
function childFoldFromSession(child: SessionSummary): ChildFold {
|
||||
function parseMs(ts: string | undefined): number {
|
||||
return ts ? Date.parse(ts) : NaN
|
||||
}
|
||||
|
||||
// A child "self-links" when it referenced its own PR: it then attributes
|
||||
// standalone (its own turn-level attribution is more precise) and is NEVER folded
|
||||
// into a parent, so its spend is counted exactly once. A child with no links is
|
||||
// folded only. This mutual exclusion is what prevents a double-charge.
|
||||
function selfLinks(session: { prLinks?: string[] }): boolean {
|
||||
return !!session.prLinks?.length
|
||||
}
|
||||
|
||||
/// Index every sidechain (subagent) session by the parent that spawned it, keyed
|
||||
/// by `parentSessionId` ALONE. Parent ids are UUIDs and subagent ids are
|
||||
/// `agent-<hex>`, both globally unique, so a composite project key is unnecessary
|
||||
/// and would drop a child whose worktree cwd resolves to a different
|
||||
/// ProjectSummary than its parent. (The base PR's project+sessionId composite key
|
||||
/// guards distinct-session COUNTING, a separate concern.) A child whose parent is
|
||||
/// absent from the scan is never looked up, so it stays a standalone orphan.
|
||||
export function buildSubagentIndex(projects: ProjectSummary[]): Map<string, SessionSummary[]> {
|
||||
const index = new Map<string, SessionSummary[]>()
|
||||
for (const project of projects)
|
||||
for (const session of project.sessions) {
|
||||
if (!session.parentSessionId) continue
|
||||
const list = index.get(session.parentSessionId)
|
||||
if (list) list.push(session)
|
||||
else index.set(session.parentSessionId, [session])
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
// Aggregate a child session and its non-self-linking descendants, depth-first,
|
||||
// guarded by `visited` against cycles (and against a session being pulled twice).
|
||||
// A self-linking descendant is skipped: it attributes standalone. `spawnAtMs`
|
||||
// stays the TOP child's, since the whole subtree resolves against the top parent.
|
||||
function buildChildFold(child: SessionSummary, index: Map<string, SessionSummary[]>, visited: Set<string>): ChildFold {
|
||||
visited.add(child.sessionId)
|
||||
const models = new Map<string, number>()
|
||||
const categories = new Map<string, number>()
|
||||
for (const turn of child.turns) {
|
||||
|
|
@ -199,97 +238,94 @@ function childFoldFromSession(child: SessionSummary): ChildFold {
|
|||
}
|
||||
if (turn.category) addToMap(categories, turn.category, turnCost)
|
||||
}
|
||||
return {
|
||||
const fold: ChildFold = {
|
||||
agentId: child.agentId ?? child.sessionId,
|
||||
cost: child.totalCostUSD,
|
||||
calls: child.apiCalls,
|
||||
savingsUSD: child.totalSavingsUSD,
|
||||
spawnAt: child.firstTimestamp,
|
||||
models,
|
||||
categories,
|
||||
cost: child.totalCostUSD, calls: child.apiCalls, savingsUSD: child.totalSavingsUSD,
|
||||
spawnAtMs: parseMs(child.firstTimestamp), models, categories, foldedSessions: 1,
|
||||
}
|
||||
}
|
||||
|
||||
// A sessionId can repeat across projects, so the parent index is keyed on
|
||||
// project + parentSessionId (a child and its parent share a project).
|
||||
function subagentKey(project: string, parentSessionId: string): string {
|
||||
return `${project} | ||||