diff --git a/__tests__/cold-start-non-persistent-scan.test.ts b/__tests__/cold-start-non-persistent-scan.test.ts new file mode 100644 index 00000000..d03fe368 --- /dev/null +++ b/__tests__/cold-start-non-persistent-scan.test.ts @@ -0,0 +1,81 @@ +import { ScannerManager } from "../src/scanner-manager"; + +/** + * Captures what each ConversationScanner was constructed with. The scanner is + * mocked rather than driven for real because the defect is entirely in the + * constructor argument: a persistent scanner reads its own index, and on a + * cold start that index is the only thing it can read. + */ +const hoisted = vi.hoisted(() => ({ ctorArgs: [] as unknown[] })); + +vi.mock("@threadbase-sh/scanner", () => ({ + ConversationScanner: class { + constructor(options?: unknown) { + hoisted.ctorArgs.push(options); + } + scan() { + return Promise.resolve([]); + } + getMetadataCache() { + return new Map(); + } + close() { + return Promise.resolve(); + } + }, +})); + +/** persistenceDisabled:false is the normal case — the flag only flips when cache.open() throws. */ +const makeManager = () => + new ScannerManager({ + scanProfiles: [], + codexRoots: [], + directoryDebounceMs: 0, + persistenceDisabled: false, + // No cache at all is the same condition an emptied cache.db produces: + // buildStatCache returns undefined, so nothing forced persistent:false. + cache: () => null, + cacheMonitor: () => null, + projectsRepo: () => null, + conversationsRepo: () => null, + cacheMetadataRepo: () => null, + trackCacheWrite: () => {}, + }); + +describe("cold-start scans never use the scanner's persistent index", () => { + beforeEach(() => { + hoisted.ctorArgs.length = 0; + }); + + it("builds a non-persistent scanner when there is no stat cache", async () => { + const manager = makeManager(); + await manager.get(); + + expect(hoisted.ctorArgs.length).toBeGreaterThan(0); + for (const args of hoisted.ctorArgs) { + expect(args).toEqual({ persistent: false }); + } + await manager.close(); + }); + + it("builds a non-persistent scanner for a full rescan with no stat cache", async () => { + const manager = makeManager(); + hoisted.ctorArgs.length = 0; + await manager.getFresh(); + + expect(hoisted.ctorArgs.length).toBeGreaterThan(0); + for (const args of hoisted.ctorArgs) { + expect(args).toEqual({ persistent: false }); + } + await manager.close(); + }); + + it("never constructs a scanner with undefined options, which defaults to persistent", async () => { + const manager = makeManager(); + await manager.get(); + await manager.getFresh(); + + expect(hoisted.ctorArgs).not.toContain(undefined); + await manager.close(); + }); +}); diff --git a/src/scanner-manager.ts b/src/scanner-manager.ts index 0f3bfb23..cb612105 100644 --- a/src/scanner-manager.ts +++ b/src/scanner-manager.ts @@ -559,8 +559,11 @@ export class ScannerManager { } this.takeStaleFiles(); const statCache = this.buildStatCache(this.scanner); - // Scanner 0.9.4 reads statCache only in non-persistent scans. - this.scanner = this.newScanner(statCache ? { persistent: false } : undefined); + // Scanner 0.9.4 reads statCache only in non-persistent scans, and the + // streamer never serves from the scanner's persistent index. Letting the + // no-statCache case fall through to the persistent default made an empty + // cache import rows for files deleted long ago (#876). + this.scanner = this.newScanner({ persistent: false }); this.allScanners.add(this.scanner); this.scannerReady = this.scanner.scan({ ...(this.deps.scanProfiles ? { profiles: this.deps.scanProfiles } : {}), @@ -609,7 +612,7 @@ export class ScannerManager { this.takeStaleFiles(); const previous = this.scanner; const statCache = this.buildStatCache(previous); - const shadow = this.newScanner(statCache ? { persistent: false } : undefined); + const shadow = this.newScanner({ persistent: false }); this.allScanners.add(shadow); this.scannerReady = shadow.scan({ ...(this.deps.scanProfiles ? { profiles: this.deps.scanProfiles } : {}), diff --git a/src/server.ts b/src/server.ts index 41019e5b..73a1f643 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1762,10 +1762,9 @@ export class StreamerServer { // that onConversationChanged invalidations during the scan cannot cause // getScanner() to restart indefinitely and leave the warm-up stuck. const warmupStatCache = this.scannerManager.buildStatCache(null); - // Scanner 0.9.4 reads statCache only in non-persistent scans. - const warmupScanner = this.scannerManager.newScanner( - warmupStatCache ? { persistent: false } : undefined, - ); + // Scanner 0.9.4 reads statCache only in non-persistent scans, and a + // cold start has none — see the note in ScannerManager.get (#876). + const warmupScanner = this.scannerManager.newScanner({ persistent: false }); this.scannerManager.track(warmupScanner); // Throttle the per-file onProgress firings to ~one frame per whole // percent (plus the final tick) so a large scan doesn't flood every