mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-07 07:34:39 +00:00
Merge pull request #872 from ozymandiashh/fix/provider-filter-claude-orphan-leak
fix(parser): stop --provider filters from re-surfacing cached claude sessions
This commit is contained in:
commit
201b289ada
2 changed files with 129 additions and 7 deletions
|
|
@ -3736,15 +3736,26 @@ async function runParse(
|
|||
? { id: s.sourceId, label: s.sourceLabel, path: s.sourcePath, kind: s.sourceKind }
|
||||
: undefined,
|
||||
}))
|
||||
// Claude is scanned through scanProjectDirs rather than parseProviderSources, so
|
||||
// it needs the same provider-filter guard the durable-orphan loop below applies at
|
||||
// its own level. Without it a --provider <other> run still enters scanProjectDirs
|
||||
// with an empty dirs list, and the orphan pass there (which reads the whole cached
|
||||
// claude section) treats every cached file as "no longer discovered" and re-injects
|
||||
// it into the result. Note this is deliberately NOT a `claudeDirs.length > 0` check:
|
||||
// when claude IS in scope but every transcript has been pruned from disk, that
|
||||
// orphan pass is exactly what keeps PR-attributed spend from vanishing.
|
||||
const claudeInScope = !providerFilter || providerFilter === 'all' || providerFilter === 'claude'
|
||||
if (claudeSources.length > 0) emitScanProgress({ kind: 'provider', provider: 'claude', state: 'start' })
|
||||
let claudeProjects: ProjectSummary[] = []
|
||||
try {
|
||||
claudeProjects = await scanProjectDirs(claudeDirs, seenMsgIds, diskCache, dateRange, saveProgress, readOnly)
|
||||
if (claudeSources.length > 0) emitScanProgress({ kind: 'provider', provider: 'claude', state: 'done', files: claudeSources.length })
|
||||
} catch (err) {
|
||||
if (!isPermissionError(err)) throw err
|
||||
process.stderr.write(`codeburn: skipped claude data (permission denied; grant Full Disk Access to include it)\n`)
|
||||
emitScanProgress({ kind: 'provider', provider: 'claude', state: 'skipped' })
|
||||
if (claudeInScope) {
|
||||
try {
|
||||
claudeProjects = await scanProjectDirs(claudeDirs, seenMsgIds, diskCache, dateRange, saveProgress, readOnly)
|
||||
if (claudeSources.length > 0) emitScanProgress({ kind: 'provider', provider: 'claude', state: 'done', files: claudeSources.length })
|
||||
} catch (err) {
|
||||
if (!isPermissionError(err)) throw err
|
||||
process.stderr.write(`codeburn: skipped claude data (permission denied; grant Full Disk Access to include it)\n`)
|
||||
emitScanProgress({ kind: 'provider', provider: 'claude', state: 'skipped' })
|
||||
}
|
||||
}
|
||||
|
||||
const otherProjects: ProjectSummary[] = []
|
||||
|
|
|
|||
|
|
@ -488,3 +488,114 @@ describe('(g) skill attribution is independent of turn category', () => {
|
|||
expect(session!.skillBreakdown['telemetry-review']?.turns).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// (h) Provider filter isolates claude: a --provider <other> run must not
|
||||
// re-surface cached claude sessions through the orphan pass, while a run
|
||||
// that DOES include claude still preserves PR-bearing orphans.
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
describe('(h) provider filter excludes claude from the orphan pass', () => {
|
||||
const SYNTH_SOURCE = (path: string): SessionSource[] =>
|
||||
[{ path, project: 'synth-proj', provider: 'test-synthetic' }]
|
||||
|
||||
// The provider lives on each parsed call, not on SessionSummary.
|
||||
const providersOf = (projects: Awaited<ReturnType<typeof parseAllSessions>>): Set<string> =>
|
||||
new Set(projects
|
||||
.flatMap(p => p.sessions)
|
||||
.flatMap(s => s.turns)
|
||||
.flatMap(t => t.assistantCalls)
|
||||
.map(c => c.provider))
|
||||
|
||||
const SYNTH_CALL: ParsedProviderCall = {
|
||||
provider: 'test-synthetic', model: 'gpt-4o',
|
||||
inputTokens: 10, outputTokens: 5,
|
||||
cacheCreationInputTokens: 0, cacheReadInputTokens: 0,
|
||||
cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0,
|
||||
costUSD: 0.25, tools: [], bashCommands: [],
|
||||
skills: [],
|
||||
timestamp: '2026-07-18T12:00:00.000Z',
|
||||
speed: 'standard',
|
||||
deduplicationKey: 'synth-isolation-call',
|
||||
userMessage: '', sessionId: 'synth-isolation-session',
|
||||
}
|
||||
|
||||
// A claude transcript carrying a pr-link: `prLinks` is exactly what lets a
|
||||
// cached entry survive the write-mode orphan gate, so it is the shape that
|
||||
// leaks. Cost is deliberately far larger than the synthetic call's, so a leak
|
||||
// is unmistakable rather than a rounding difference.
|
||||
async function writeClaudeSessionWithPrLink(): Promise<string> {
|
||||
const projectDir = join(tmpHome, '.claude', 'projects', 'leaky-app')
|
||||
await mkdir(projectDir, { recursive: true })
|
||||
const filePath = join(projectDir, 'session.jsonl')
|
||||
await writeFile(filePath, [
|
||||
JSON.stringify({
|
||||
type: 'user', sessionId: 'claude-leak-1', timestamp: '2026-07-18T12:00:00.000Z',
|
||||
message: { role: 'user', content: 'ship it' },
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'assistant', sessionId: 'claude-leak-1', timestamp: '2026-07-18T12:00:10.000Z',
|
||||
message: {
|
||||
id: 'msg-leak-1', type: 'message', role: 'assistant', model: 'claude-sonnet-4-5',
|
||||
content: [{ type: 'text', text: 'done' }],
|
||||
usage: { input_tokens: 900_000, output_tokens: 90_000 },
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'pr-link', sessionId: 'claude-leak-1', timestamp: '2026-07-18T12:00:20.000Z',
|
||||
prUrl: 'https://github.com/getagentseal/codeburn/pull/1',
|
||||
}),
|
||||
].join('\n') + '\n')
|
||||
return filePath
|
||||
}
|
||||
|
||||
it('does not surface cached claude sessions when filtering to another provider', async () => {
|
||||
const synthFile = join(tmpHome, 'synth-isolation.txt')
|
||||
await writeFile(synthFile, 'placeholder')
|
||||
await writeClaudeSessionWithPrLink()
|
||||
|
||||
_synthSources = SYNTH_SOURCE(synthFile)
|
||||
_synthYields = [SYNTH_CALL]
|
||||
|
||||
// Baseline: what the synthetic provider costs on its own, before anything
|
||||
// claude-shaped has ever entered the session cache. Self-calibrating, since
|
||||
// cost is re-derived from tokens by the pricing engine.
|
||||
const baseline = await parseAllSessions(undefined, 'test-synthetic')
|
||||
const synthOnlyCost = totalCost(baseline)
|
||||
expect([...providersOf(baseline)]).toEqual(['test-synthetic'])
|
||||
clearSessionCache()
|
||||
|
||||
// Warm the session cache so the claude file is persisted WITH its prLinks.
|
||||
const all = await parseAllSessions(undefined, 'all')
|
||||
expect(providersOf(all)).toContain('claude')
|
||||
expect(totalCost(all)).toBeGreaterThan(synthOnlyCost)
|
||||
|
||||
clearSessionCache()
|
||||
|
||||
// Filtering to the synthetic provider must yield ONLY its own spend. Before
|
||||
// the fix, claudeDirs was empty yet scanProjectDirs still ran, so every
|
||||
// cached PR-bearing claude file was treated as a pruned orphan and re-added.
|
||||
const filtered = await parseAllSessions(undefined, 'test-synthetic')
|
||||
|
||||
expect([...providersOf(filtered)]).toEqual(['test-synthetic'])
|
||||
expect(totalCost(filtered)).toBeCloseTo(synthOnlyCost, 10)
|
||||
})
|
||||
|
||||
it('still preserves a PR-bearing claude orphan when claude IS in scope', async () => {
|
||||
const filePath = await writeClaudeSessionWithPrLink()
|
||||
_synthSources = []
|
||||
_synthYields = []
|
||||
|
||||
const before = await parseAllSessions(undefined, 'all')
|
||||
const costBefore = totalCost(before)
|
||||
expect(costBefore).toBeGreaterThan(0)
|
||||
|
||||
// Every claude transcript disappears from disk. Claude is still in scope, so
|
||||
// the orphan pass must keep the PR-attributed spend alive — this is the case
|
||||
// a naive `claudeDirs.length > 0` guard would silently break.
|
||||
await unlink(filePath)
|
||||
clearSessionCache()
|
||||
|
||||
const after = await parseAllSessions(undefined, 'all')
|
||||
expect(totalCost(after)).toBeCloseTo(costBefore, 10)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue