mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-07 23:54:45 +00:00
sessions: round-2 hardening of subagent PR attribution
Address a second adversarial review of the new machinery. - ID collision: parents and a child's parent reference are keyed by provider + sessionId, not bare sessionId. When two distinct parents still share a key (true duplicate/imported data), the child folds into NEITHER (deterministic skip, stays standalone): correctness over coverage. - Recursion dedup is global: one claimed-set spans all of a parent's direct children, so a descendant reachable through two paths (a diamond or duplicate id) folds exactly once and a parent-link cycle terminates. - Cache adoption migrates every prior version oldest-to-newest and MERGES per source path (newer wins per entry), so a sparse or partial newer file no longer masks older-only expired-PR orphans. - Fold anchors (0-cost PR-linked parents kept only for attribution) live in a new ProjectSummary.subagentAnchors, never in `sessions`, so they no longer contaminate session counts, averages, or any per-session report. Folded PR rows take their date span from the contributing child activity rather than the anchor's empty timestamps. - One-pass buildPrAttribution computes rows and totals together; the payload builder and CLI call it once. Drops the identity-keyed memoization, which could return stale folds if the array was mutated. - Ambiguous multi-block spawn-result pairing leaves the spawn link unset on purpose; the child then folds via the timestamp fallback rather than pairing with the wrong id or disappearing. Every fix is mutation-verified. A fresh real-data drive re-proves the no-double-count identity to the cent (parent-only cost plus folded cost equals the folded total) and that the PR rows sum to attributedCost.
This commit is contained in:
parent
384fb5ef0b
commit
139727b8c7
10 changed files with 333 additions and 163 deletions
|
|
@ -2011,7 +2011,7 @@ program
|
|||
.action(async (opts) => {
|
||||
assertProvider(opts.provider, 'sessions')
|
||||
assertFormat(opts.format, ['table', 'json'], 'sessions')
|
||||
const { aggregateSessions, aggregateByPr, prLinkedTotals, renderJson, renderTable } = await import('./sessions-report.js')
|
||||
const { aggregateSessions, buildPrAttribution, renderJson, renderTable } = await import('./sessions-report.js')
|
||||
await loadPricing()
|
||||
|
||||
let range
|
||||
|
|
@ -2028,16 +2028,16 @@ program
|
|||
|
||||
const projects = await parseAllSessions(range, opts.provider)
|
||||
if (opts.byPr) {
|
||||
const prRows = aggregateByPr(projects)
|
||||
const { rows: prRows, totals } = buildPrAttribution(projects)
|
||||
if (opts.format === 'json') {
|
||||
process.stdout.write(JSON.stringify({ prs: prRows, distinct: prLinkedTotals(projects) }, null, 2) + '\n')
|
||||
process.stdout.write(JSON.stringify({ prs: prRows, distinct: totals }, null, 2) + '\n')
|
||||
return
|
||||
}
|
||||
if (prRows.length === 0) {
|
||||
process.stdout.write('No sessions with captured PR links in this period. Links are captured as sessions are parsed; older transcripts gain them on their next re-parse.\n')
|
||||
return
|
||||
}
|
||||
const { unattributedCost, sessions, subagentSessions } = prLinkedTotals(projects)
|
||||
const { unattributedCost, sessions, subagentSessions } = totals
|
||||
const { renderTable: renderTextTable } = await import('./text-table.js')
|
||||
const modelsCell = (models: string[]): string =>
|
||||
models.length === 0 ? '' : models.slice(0, 2).join(', ') + (models.length > 2 ? ` +${models.length - 2}` : '')
|
||||
|
|
|
|||
|
|
@ -1284,7 +1284,10 @@ export function collectSessionMeta(entry: JournalEntry, meta: SessionMeta): void
|
|||
} 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.
|
||||
// so an unrelated sibling block cannot capture the id. When the match is
|
||||
// ambiguous (identical blocks, or none match) the spawn link is left
|
||||
// unset ON PURPOSE: the child then folds via the timestamp-bucket fallback
|
||||
// in resolveChild rather than risk pairing with the wrong id.
|
||||
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
|
||||
|
|
@ -2130,7 +2133,7 @@ async function scanProjectDirs(
|
|||
}
|
||||
}
|
||||
|
||||
const projectMap = new Map<string, { project: string; projectPath: string; sessions: SessionSummary[]; dirNames: Set<string> }>()
|
||||
const projectMap = new Map<string, { project: string; projectPath: string; sessions: SessionSummary[]; anchors: SessionSummary[]; dirNames: Set<string> }>()
|
||||
|
||||
const allFiles = [
|
||||
...unchangedFiles.map(f => ({ filePath: f.filePath, dirName: f.dirName, source: f.source })),
|
||||
|
|
@ -2187,10 +2190,13 @@ async function scanProjectDirs(
|
|||
}
|
||||
|
||||
// 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
|
||||
// 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.
|
||||
// `prLinks` / `spawnPrSets` to attribute. An anchor carries no in-range spend
|
||||
// and is stored OUTSIDE `sessions` (see subagentAnchors) so it never
|
||||
// contaminates session counts, averages, or any other per-session report.
|
||||
const isSpawnAnchor = Object.keys(spawnPrSets).length > 0 && cachedFile.isSidechain !== true
|
||||
const anchorOnly = classifiedTurns.length === 0 && isSpawnAnchor
|
||||
if (classifiedTurns.length === 0 && !isSpawnAnchor) continue
|
||||
|
||||
const sessionId = basename(filePath, '.jsonl')
|
||||
|
|
@ -2217,17 +2223,17 @@ async function scanProjectDirs(
|
|||
}
|
||||
if (Object.keys(spawnPrSets).length > 0) session.spawnPrSets = spawnPrSets
|
||||
|
||||
if (session.apiCalls > 0 || isSpawnAnchor) {
|
||||
if (session.apiCalls > 0 || anchorOnly) {
|
||||
const projectKey = cachedFile.canonicalCwd
|
||||
? normalizeProjectPathKey(cachedFile.canonicalCwd)
|
||||
: `slug:${dirName}`
|
||||
const existing = projectMap.get(projectKey)
|
||||
if (existing) {
|
||||
existing.sessions.push(session)
|
||||
existing.dirNames.add(dirName)
|
||||
} else {
|
||||
projectMap.set(projectKey, { project: projectName, projectPath, sessions: [session], dirNames: new Set([dirName]) })
|
||||
}
|
||||
// An anchor (no in-range spend) goes into a separate bucket, never `sessions`.
|
||||
const target = existing ?? { project: projectName, projectPath, sessions: [], anchors: [], dirNames: new Set([dirName]) }
|
||||
if (anchorOnly) target.anchors.push(session)
|
||||
else target.sessions.push(session)
|
||||
target.dirNames.add(dirName)
|
||||
if (!existing) projectMap.set(projectKey, target)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2245,12 +2251,13 @@ async function scanProjectDirs(
|
|||
if (!cwdKey) continue
|
||||
const target = projectMap.get(cwdKey)!
|
||||
target.sessions.push(...entry.sessions)
|
||||
target.anchors.push(...entry.anchors)
|
||||
projectMap.delete(key)
|
||||
}
|
||||
|
||||
const projects: ProjectSummary[] = []
|
||||
for (const { project, projectPath, sessions } of projectMap.values()) {
|
||||
projects.push(summarizeProject(project, projectPath, sessions))
|
||||
for (const { project, projectPath, sessions, anchors } of projectMap.values()) {
|
||||
projects.push(summarizeProject(project, projectPath, sessions, anchors))
|
||||
}
|
||||
|
||||
return projects
|
||||
|
|
@ -2263,7 +2270,7 @@ async function scanProjectDirs(
|
|||
/// `totalProxiedCostUSD` (subscription-covered). All ProjectSummary callers go
|
||||
/// through here so the rule stays consistent across the fresh, cached, and
|
||||
/// date/day-filtered paths.
|
||||
function summarizeProject(project: string, projectPath: string, sessions: SessionSummary[]): ProjectSummary {
|
||||
function summarizeProject(project: string, projectPath: string, sessions: SessionSummary[], anchors: SessionSummary[] = []): ProjectSummary {
|
||||
const totalCostUSD = sessions.reduce((s, sess) => s + sess.totalCostUSD, 0)
|
||||
return {
|
||||
project,
|
||||
|
|
@ -2274,6 +2281,8 @@ function summarizeProject(project: string, projectPath: string, sessions: Sessio
|
|||
totalEstimatedCostUSD: sessions.reduce((s, sess) => s + (sess.totalEstimatedCostUSD ?? 0), 0),
|
||||
totalApiCalls: sessions.reduce((s, sess) => s + sess.apiCalls, 0),
|
||||
totalProxiedCostUSD: isProxiedPath(projectPath) ? totalCostUSD : 0,
|
||||
// Fold anchors travel separately (0-cost, out of every per-session total).
|
||||
...(anchors.length > 0 ? { subagentAnchors: anchors } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -454,13 +454,28 @@ async function adoptPriorCache(version: number): Promise<SessionCache | null> {
|
|||
}
|
||||
}
|
||||
|
||||
// Adopt the newest prior versioned cache present on disk (v6 before v5).
|
||||
// Adopt EVERY prior versioned cache present on disk, migrating OLDEST first and
|
||||
// merging per source path so a newer version wins per entry. Returning the newest
|
||||
// alone would be wrong: a sparse or partial newer file (e.g. v6 holding only some
|
||||
// orphans) would mask older-only orphans that still hold attributable spend. Newer
|
||||
// entries overwrite older ones for the same path; entries unique to an older
|
||||
// version survive.
|
||||
async function adoptNewestPriorCache(): Promise<SessionCache | null> {
|
||||
for (const version of PRIOR_CACHE_VERSIONS) {
|
||||
const oldestFirst = [...PRIOR_CACHE_VERSIONS].sort((a, b) => a - b)
|
||||
let merged: SessionCache | null = null
|
||||
for (const version of oldestFirst) {
|
||||
const adopted = await adoptPriorCache(version)
|
||||
if (adopted) return adopted
|
||||
if (!adopted) continue
|
||||
if (!merged) { merged = adopted; continue }
|
||||
for (const [provider, section] of Object.entries(adopted.providers)) {
|
||||
const existing = merged.providers[provider]
|
||||
if (!existing) { merged.providers[provider] = section; continue }
|
||||
// Newer version's entries overwrite older ones for the same source path.
|
||||
Object.assign(existing.files, section.files)
|
||||
if (section.durable) existing.durable = true
|
||||
}
|
||||
}
|
||||
return null
|
||||
return merged
|
||||
}
|
||||
|
||||
export async function loadCache(): Promise<SessionCache> {
|
||||
|
|
|
|||
|
|
@ -179,13 +179,17 @@ export function allocateEven(total: number, n: number): number[] {
|
|||
/// 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).
|
||||
/// subtree resolves against the top parent through the top child's spawn);
|
||||
/// `firstTs`/`lastTs` are the subtree's real activity span, used for the PR row's
|
||||
/// date range when the parent has no in-range turns of its own.
|
||||
export type ChildFold = {
|
||||
agentId: string
|
||||
cost: number
|
||||
calls: number
|
||||
savingsUSD: number
|
||||
spawnAtMs: number
|
||||
firstTs: string
|
||||
lastTs: string
|
||||
models: Map<string, number>
|
||||
categories: Map<string, number>
|
||||
foldedSessions: number
|
||||
|
|
@ -203,31 +207,50 @@ function selfLinks(session: { prLinks?: string[] }): boolean {
|
|||
return !!session.prLinks?.length
|
||||
}
|
||||
|
||||
// A session id alone is NOT globally unique: imported or duplicated transcripts,
|
||||
// or two providers, can reuse one id, letting two parents claim one child and
|
||||
// corrupting the attribution map. Key parents (and a child's parent reference) by
|
||||
// provider + id so linkage stays one-to-one; the ambiguity skip in
|
||||
// resolveSubagentAttribution handles a genuine same-provider id collision.
|
||||
// Subagent linkage (parentSessionId / agentSpawnLinks / spawnPrSets) is Claude
|
||||
// only, and a fold ANCHOR has no in-range turns to infer a provider from, so a
|
||||
// linkage-bearing session is always attributed to 'claude'; this keeps a parent
|
||||
// and its child on the same key.
|
||||
function linkageProvider(session: SessionSummary): string {
|
||||
if (session.parentSessionId || session.agentSpawnLinks || session.spawnPrSets) return 'claude'
|
||||
return inferProvider(session)
|
||||
}
|
||||
function providerSessionKey(session: SessionSummary): string {
|
||||
return `${linkageProvider(session)} | ||||