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:
Resham Joshi 2026-08-03 11:54:06 -07:00 committed by GitHub
commit 201b289ada
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 129 additions and 7 deletions

View file

@ -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[] = []

View file

@ -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)
})
})