From ed50db11801c17a42191a90d64f76e364886249a Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Thu, 10 Sep 2026 15:30:52 -0400 Subject: [PATCH] test(ai-vault-search): prove the three conclusions a broken pass must not reach Three round-10 mutations survived the first pass of tests, all of them the same shape: a pass that failed still reached a verdict, and nothing checked. The drop reset moves into the sweep itself, where a test holding the store can overflow the queue and watch a completed sweep clear it; from the indexer the call was unreachable without twenty thousand files. A sweep whose held-file read throws now has a test that the failure travels rather than being folded into an empty list, and a sweep that threw part way has one that the flag saying a sweep is owed comes back. --- .../session-search-backfill.test.ts | 43 ++++++++++++++++++- .../session-search-backfill.ts | 6 +++ .../session-search-indexer.test.ts | 34 +++++++++++++++ .../ai-vault-search/session-search-indexer.ts | 6 --- 4 files changed, 82 insertions(+), 7 deletions(-) diff --git a/src/main/ai-vault-search/session-search-backfill.test.ts b/src/main/ai-vault-search/session-search-backfill.test.ts index 5f905851ffb..5ddafe7cb06 100644 --- a/src/main/ai-vault-search/session-search-backfill.test.ts +++ b/src/main/ai-vault-search/session-search-backfill.test.ts @@ -12,7 +12,7 @@ import { type SessionSearchIndexerHarness } from './session-search-indexer-test-fixture' import { SessionSearchIndexingStatus } from './session-search-indexing-status' -import { SessionSearchStore } from './session-search-store' +import { STALE_PATH_LIMIT, SessionSearchStore } from './session-search-store' let harness: SessionSearchIndexerHarness let store: SessionSearchStore @@ -117,3 +117,44 @@ it('reports the real roots it listed transcripts under', async () => { const result = await sweep() expect([...result.rootsWithFiles]).toEqual([harness.roots.claudeProjectsDir]) }) + +// A drop means the queue is knowingly missing something. The sweep is what +// makes that stop being true, because it re-enumerates every root; left as a +// lifetime tally the count described an incompleteness that had been resolved +// minutes earlier and never returned to zero for the life of the process. +it('forgets the queue drops it just re-enumerated past', async () => { + await writeTranscripts(1) + for (let index = 0; index < STALE_PATH_LIMIT + 3; index++) { + store.markStale({ + agent: 'claude', + codexHome: null, + file: { + path: join(harness.claudeProjectDir, `dropped-${index}.jsonl`), + mtimeMs: index, + modifiedAt: new Date(index).toISOString(), + sizeBytes: 1 + } + }) + } + expect(store.droppedPendingFileCount).toBe(3) + + await sweep() + + expect(store.droppedPendingFileCount).toBe(0) +}) + +// A sweep decides which rows nothing rediscovered by reading the whole file +// table. A handle that cannot answer that must not be read as "nothing is +// missing": an empty list is the one conclusion an unreadable handle cannot +// earn, so the failure travels rather than being folded into a verdict. +it('refuses to conclude anything from a sweep whose held-file read failed', async () => { + await writeTranscripts(2) + await sweep() + expect(store.indexedSources()).toHaveLength(2) + + store.indexedSources = () => { + throw new Error('database is not open') + } + + await expect(sweep()).rejects.toThrow('database is not open') +}) diff --git a/src/main/ai-vault-search/session-search-backfill.ts b/src/main/ai-vault-search/session-search-backfill.ts index 4c01a1ff10b..598da34cc3d 100644 --- a/src/main/ai-vault-search/session-search-backfill.ts +++ b/src/main/ai-vault-search/session-search-backfill.ts @@ -195,6 +195,12 @@ export async function runSessionSearchBackfill( // Only a sweep that finished publishes: an aborted one saw part of the // machine, and its empty findings would clear a live alarm. status.setDegradedRoots(degradedRoots) + // A drop says the queue is knowingly missing something. This pass just + // re-enumerated every root, so anything still owed is back on the queue + // and a drop from before it no longer describes the queue. What this + // sweep hands back, and the queue cannot hold, is counted again: the + // caller re-queues it after this returns. + store.forgetDroppedPending() } return { diff --git a/src/main/ai-vault-search/session-search-indexer.test.ts b/src/main/ai-vault-search/session-search-indexer.test.ts index 9a1d7aa8504..6061be0a395 100644 --- a/src/main/ai-vault-search/session-search-indexer.test.ts +++ b/src/main/ai-vault-search/session-search-indexer.test.ts @@ -439,6 +439,40 @@ it('retires a transcript deleted between a sweep and the cycle after it', async expect(sessionsMatching('surviving')).toEqual([OTHER_SESSION_ID]) }) +// Round 10, M2. A sweep that throws part way learned nothing, and the flag that +// says one is owed was taken on entry. Losing it there leaves nothing armed to +// try again, so the machine outside the recency window goes unread until +// something else happens to ask for a sweep. +it('keeps a sweep due when the one that was running threw', async () => { + const older = transcriptPath(OTHER_SESSION_ID) + await writeClaudeTranscript(older, ['an older conversation'], OTHER_SESSION_ID) + const yesterday = new Date(Date.now() - 86_400_000) + await utimes(older, yesterday, yesterday) + await writeClaudeTranscript(transcriptPath(), ['the newest conversation'], SESSION_ID) + + // Newest-one per root, so only a sweep can reach the older file. The clock is + // read inside the pass, which is where a failure part way through lands. + newIndexer({ recentPerAgent: 1 }) + let thrown = false + clock.onNow = () => { + if (thrown || indexedSessionCount() === 0) { + return + } + thrown = true + throw new Error('the sweep fell over') + } + await indexer?.start() + await indexer?.settled() + clock.onNow = null + + expect(errors.map((error) => (error as Error).message)).toEqual(['the sweep fell over']) + expect(sessionsMatching('older')).toEqual([]) + + // The pass after it is a sweep, not a cycle: a cycle reads one file per root. + await nextCycle() + expect(sessionsMatching('older')).toEqual([OTHER_SESSION_ID]) +}) + // Round 10, M1. A transcript the reader cannot open is recorded stale by the // consumer on every attempt, so it was re-read every cycle for ever: pending // stuck at one, a failure count climbing without bound, and a phase that never diff --git a/src/main/ai-vault-search/session-search-indexer.ts b/src/main/ai-vault-search/session-search-indexer.ts index 191cde6e7a0..36efd76f6a8 100644 --- a/src/main/ai-vault-search/session-search-indexer.ts +++ b/src/main/ai-vault-search/session-search-indexer.ts @@ -243,12 +243,6 @@ export class SessionSearchIndexer { signal }) this.indexingStatus.sweepFinished(sweep.completed) - if (sweep.completed) { - // Before the requeue, not after: this sweep re-enumerated every root, so - // a drop from before it no longer means anything is missing, while what - // this sweep hands back and the queue cannot hold does. - this.store.forgetDroppedPending() - } this.requeue(sweep.deferred) if (!sweep.completed) { // A sweep stays due until one finishes: an aborted one saw part of the