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.
This commit is contained in:
Jinwoo-H
2026-09-10 23:29:15 -04:00
parent 0780366500
commit ed50db1180
4 changed files with 82 additions and 7 deletions
@@ -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')
})
@@ -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 {
@@ -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
@@ -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