diff --git a/src/parser.ts b/src/parser.ts index 24e16290..32988940 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -5491,7 +5491,24 @@ async function runParseInner( // it keeps the normal stamp. const deferredForFirstPaint = firstPaintDeferredThisRun > 0 const wasComplete = isCacheComplete(diskCache) - if (!readOnly && !wasComplete && !deferredForFirstPaint) diskCache.complete = true + // A provider-scoped run walks only its own provider's sessions, so it never + // saw whatever the providers it skipped hold on disk. Stamping the WHOLE + // cache complete off that partial view writes a wrong "done" to disk (#912): + // per the marker's own contract above, a complete cache stops being re-read + // as cold, so the unscanned providers are not revisited and the gap stops + // looking like a gap. The guard is conditioned on real on-disk data, not on + // scoping alone: when every provider the run skipped has NO discoverable + // sessions, the scoped run really did see the whole corpus (a claude-only + // machine, and exactly what the warm-refresh snapshot tests rely on), so the + // stamp is correct and stands. Discovery here is a bounded directory walk, + // not a parse — the scoping win (skipping the other providers' PARSE) holds. + const scopedRun = !!providerFilter && providerFilter !== 'all' + let skippedProviderHasSessions = false + if (scopedRun && !readOnly && !wasComplete && !deferredForFirstPaint) { + const corpusSources = await discoverAllSessions() + skippedProviderHasSessions = corpusSources.some(s => s.provider !== providerFilter) + } + if (!readOnly && !wasComplete && !deferredForFirstPaint && !skippedProviderHasSessions) diskCache.complete = true if (!readOnly && (isCacheDirty(diskCache) || (!wasComplete && !deferredForFirstPaint))) { try { const published = await saveCache(diskCache, refreshLock?.verifyStillOwner) diff --git a/tests/scoped-run-completeness.test.ts b/tests/scoped-run-completeness.test.ts new file mode 100644 index 00000000..68d30850 --- /dev/null +++ b/tests/scoped-run-completeness.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, beforeEach, afterEach } 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 { readCacheOnDisk } from './fixtures/session-cache-io.js' + +let tmpDir: string +let savedCodexHome: string | undefined + +beforeEach(async () => { + clearSessionCache() + tmpDir = await mkdtemp(join(tmpdir(), 'scoped-complete-')) + process.env['CLAUDE_CONFIG_DIR'] = tmpDir + process.env['CODEBURN_CACHE_DIR'] = join(tmpDir, 'cache') + process.env['CODEBURN_DESKTOP_SESSIONS_DIR'] = join(tmpDir, 'desktop-sessions') + // Hermetic: Codex discovery defaults to CODEX_HOME / ~/.codex, so without this + // the scoped 'codex' run below reads the developer's real local corpus. Point it + // at an empty temp root and restore in teardown. + savedCodexHome = process.env['CODEX_HOME'] + process.env['CODEX_HOME'] = join(tmpDir, 'codex-home') + await mkdir(join(tmpDir, 'codex-home', 'sessions'), { recursive: true }) +}) + +afterEach(async () => { + clearSessionCache() + if (savedCodexHome === undefined) delete process.env['CODEX_HOME'] + else process.env['CODEX_HOME'] = savedCodexHome + await rm(tmpDir, { recursive: true, force: true }) +}) + +async function writeClaudeSession(): Promise { + const dir = join(tmpDir, 'projects', 'proj') + await mkdir(dir, { recursive: true }) + await writeFile(join(dir, 'sess.jsonl'), JSON.stringify({ + type: 'assistant', + sessionId: 'sess', + timestamp: '2026-05-15T10:00:00Z', + cwd: '/tmp/proj', + message: { + id: 'msg-1', type: 'message', role: 'assistant', model: 'claude-sonnet-4-5', + content: [], usage: { input_tokens: 100, output_tokens: 50 }, + }, + }) + '\n') +} + +describe('provider-scoped run and the whole-cache completeness marker', () => { + // Regression for #912: the stamp now learns about scope. A run scoped to one provider + // must not mark the whole cache complete while a provider it skipped still has sessions + // on disk it never scanned. + it('does not stamp the cache complete when providers were left out of scope', async () => { + await writeClaudeSession() + + // Scoped to 'codex': discovery is filtered and the cached-provider loop skips every + // other name, so the claude session on disk is never read by this run. + await parseAllSessions(undefined, 'codex') + + const raw = await readCacheOnDisk() + + // Premise: claude really was left unscanned. If this fails the scoped run read it + // anyway and the rest proves nothing. + expect(Object.keys(raw?.providers?.claude?.files ?? {}).length).toBe(0) + + // The guard: the stamp at the end of runParseInner refuses to mark the whole cache + // complete when a skipped provider (claude here) still has discoverable sessions. + expect(raw?.complete ?? false).toBe(false) + }) + + it('a subsequent full run repairs the cache — the mislabel is transient, not persistent', async () => { + // Honouring the maintainer review: an ordinary all-provider refresh rediscovers the + // omitted provider, so the wrong flag does not strand it forever. Pinning the bound + // keeps the claim accurate. + await writeClaudeSession() + + await parseAllSessions(undefined, 'codex') // scoped: claude unscanned + clearSessionCache() + await parseAllSessions() // full refresh: claude rediscovered + + const raw = await readCacheOnDisk() + expect(Object.keys(raw?.providers?.claude?.files ?? {}).length).toBeGreaterThan(0) + }) +})