diff --git a/src/main/ai-vault-search/session-search-backfill.ts b/src/main/ai-vault-search/session-search-backfill.ts index c11d487093b..b9805180156 100644 --- a/src/main/ai-vault-search/session-search-backfill.ts +++ b/src/main/ai-vault-search/session-search-backfill.ts @@ -10,12 +10,13 @@ import { retireDeletedSessionSearchSources } from './session-search-deleted-sour import { runSessionSearchIndexPass } from './session-search-index-pass' import type { SessionSearchIndexingStatus } from './session-search-indexing-status' import { - degradedSessionSearchRoots, - rootFileCounts, - type SessionSearchDegradedRoot + sessionSearchRootHealth, + type SessionSearchDegradedRoot, + type SessionSearchRootState } from './session-search-root-health' import { discoverSessionSearchCandidates, + sessionSearchRootListings, type SessionSearchScanRoots } from './session-search-scan-roots' import type { SessionSearchStore } from './session-search-store' @@ -31,8 +32,8 @@ export type SessionSearchBackfillArgs = { status: SessionSearchIndexingStatus /** Oldest transcript mtime worth indexing, or null for all history. */ cutoffMs: number | null - /** What each root listed last sweep, so a tree that went empty is visible. */ - previousRootFileCounts?: ReadonlyMap + /** What the last sweeps saw of each root, so a tree that went empty is visible. */ + previousRootStates?: ReadonlyMap pace?: (signal?: AbortSignal) => Promise signal?: AbortSignal } @@ -40,7 +41,7 @@ export type SessionSearchBackfillArgs = { export type SessionSearchBackfillResult = { /** Paths to watch for disappearance, plus whatever this sweep could not settle. */ watchPaths: Set - rootFileCounts: Map + rootStates: Map degradedRoots: SessionSearchDegradedRoot[] /** False when the sweep was aborted; it stays due until one finishes. */ completed: boolean @@ -93,11 +94,16 @@ export async function runSessionSearchBackfill( message: refusal.message }) } - const counts = rootFileCounts(swept.discoveries) - const degradedRoots = await degradedSessionSearchRoots(swept.discoveries, issues, { - signal, - previousFileCounts: args.previousRootFileCounts + const health = await sessionSearchRootHealth({ + listings: sessionSearchRootListings(args.roots, swept.discoveries), + issues, + previous: args.previousRootStates ?? new Map(), + // A sweep walks every root without a limit, so its observation is the one + // allowed to conclude that a root really was emptied. + census: completed, + signal }) + const degradedRoots = health.degraded const discoveredPaths = new Set(swept.candidates.map((candidate) => candidate.file.path)) const retirement = completed ? await retireSweptAwaySources(args, discoveredPaths, degradedRoots) @@ -109,7 +115,7 @@ export async function runSessionSearchBackfill( ...retirement.unverifiable, ...retirement.unchecked ]), - rootFileCounts: counts, + rootStates: health.states, degradedRoots, completed } diff --git a/src/main/ai-vault-search/session-search-index-pass.ts b/src/main/ai-vault-search/session-search-index-pass.ts index 2692813b0a7..983ca027b30 100644 --- a/src/main/ai-vault-search/session-search-index-pass.ts +++ b/src/main/ai-vault-search/session-search-index-pass.ts @@ -60,6 +60,11 @@ export async function runSessionSearchIndexPass( options.onSkipped?.(candidate) continue } + // Discovery's size, not the post-read one. A file that grew between the + // stat and the read is charged short, deliberately: the allowance paces a + // cycle rather than accounting for it, the error is bounded by what one + // cycle's writers appended, and re-statting every file to close it would + // cost more than the number is worth. const bytes = forced ? (candidate.file.sizeBytes ?? 0) : unreadBytes(store, candidate) if (options.allowance && !options.allowance.spend(bytes)) { deferred.push(...candidates.slice(index)) diff --git a/src/main/ai-vault-search/session-search-indexer-test-fixture.ts b/src/main/ai-vault-search/session-search-indexer-test-fixture.ts index ba21460cf95..5b52a0da80a 100644 --- a/src/main/ai-vault-search/session-search-indexer-test-fixture.ts +++ b/src/main/ai-vault-search/session-search-indexer-test-fixture.ts @@ -125,3 +125,27 @@ export async function renameReplaceTranscript( const later = new Date(before.mtimeMs + 5_000) await utimes(path, later, later) } + +/** + * A message-graph transcript, the shape OpenClaw, Pi, OMP and Prime Agent + * write. The session id comes from the file name, so callers name the file. + */ +export async function writeMessageGraphTranscript( + path: string, + turns: readonly string[] +): Promise { + await mkdir(dirname(path), { recursive: true }) + const lines = turns.flatMap((turn, index) => [ + JSON.stringify({ + type: 'message', + timestamp: new Date(CLOCK_EPOCH_MS + index * 120_000).toISOString(), + message: { role: 'user', content: turn } + }), + JSON.stringify({ + type: 'message', + timestamp: new Date(CLOCK_EPOCH_MS + index * 120_000 + 60_000).toISOString(), + message: { role: 'assistant', content: `noted: ${turn}` } + }) + ]) + await writeFile(path, `${lines.join('\n')}\n`) +} 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 e48ffa73b1c..68779826dc7 100644 --- a/src/main/ai-vault-search/session-search-indexer.test.ts +++ b/src/main/ai-vault-search/session-search-indexer.test.ts @@ -492,39 +492,61 @@ it('reads a stale file at its current stat, not the one it was recorded with', a // Round 2, item 1: the sweep kept the rows and a cycle twenty seconds later // deleted them, because the degraded-root fence was on the sweep path only. it.skipIf(!CAN_DENY_READ)( - 'keeps an unmounted root through the cycles that follow the sweep', + 'keeps an unlistable root through the cycles that follow the sweep', async () => { await writeClaudeTranscript(transcriptPath(), ['a session on a removable volume'], SESSION_ID) await newIndexer().start() expect(sessionsMatching('removable')).toEqual([SESSION_ID]) - await rm(harness.claudeProjectDir, { recursive: true, force: true }) - await indexer?.reconcile({ full: true }) - expect(sessionsMatching('removable')).toEqual([SESSION_ID]) + await chmod(harness.roots.claudeProjectsDir ?? '', 0o000) + try { + await indexer?.reconcile({ full: true }) + expect(sessionsMatching('removable')).toEqual([SESSION_ID]) - await nextCycle() - expect(sessionsMatching('removable')).toEqual([SESSION_ID]) - expect(indexer?.status().phase).toBe('degraded') + await nextCycle() + expect(sessionsMatching('removable')).toEqual([SESSION_ID]) + expect(indexer?.status().phase).toBe('degraded') + } finally { + await chmod(harness.roots.claudeProjectsDir ?? '', 0o755) + } } ) -// Round 2, item 3: the alarm was single-shot. The degraded sweep's zero became -// the baseline, so the second sweep compared zero with zero and retired. -it.skipIf(!CAN_DENY_READ)('keeps an unmounted root across repeated sweeps', async () => { +// Round 3, item 2: the alarm has to release. A root the user legitimately +// emptied would otherwise stay degraded for the life of the process, pinning +// the phase and never retiring the rows. +it('retires a root the user really emptied, once a second sweep agrees', async () => { + await writeClaudeTranscript(transcriptPath(), ['a session the user deleted'], SESSION_ID) + await newIndexer().start() + + // The root itself stays readable; only its transcripts are gone. + await rm(harness.claudeProjectDir, { recursive: true, force: true }) + await indexer?.reconcile({ full: true }) + // One sweep cannot tell this from a freshly unmounted volume. + expect(sessionsMatching('deleted')).toEqual([SESSION_ID]) + expect(indexer?.status().phase).toBe('degraded') + + await indexer?.reconcile({ full: true }) + expect(sessionsMatching('deleted')).toEqual([]) + expect(indexer?.status()).toMatchObject({ phase: 'current', degradedRoots: [] }) +}) + +// Round 3, item 2, the other half: a root that cannot be listed is never +// believed to be empty, however many times it is asked. +it.skipIf(!CAN_DENY_READ)('keeps an unlistable root degraded across repeated sweeps', async () => { await writeClaudeTranscript(transcriptPath(), ['a session on a removable volume'], SESSION_ID) await newIndexer().start() - await rm(harness.claudeProjectDir, { recursive: true, force: true }) - await indexer?.reconcile({ full: true }) - await indexer?.reconcile({ full: true }) - expect(sessionsMatching('removable')).toEqual([SESSION_ID]) - expect(indexer?.status().phase).toBe('degraded') - - // Remounted: the root lists transcripts again and the alarm clears. - await writeClaudeTranscript(transcriptPath(), ['a session on a removable volume'], SESSION_ID) - await indexer?.reconcile({ full: true }) - expect(indexer?.status().degradedRoots).toEqual([]) - expect(sessionsMatching('removable')).toEqual([SESSION_ID]) + await chmod(harness.roots.claudeProjectsDir ?? '', 0o000) + try { + for (let sweep = 0; sweep < 5; sweep++) { + await indexer?.reconcile({ full: true }) + } + expect(sessionsMatching('removable')).toEqual([SESSION_ID]) + expect(indexer?.status().phase).toBe('degraded') + } finally { + await chmod(harness.roots.claudeProjectsDir ?? '', 0o755) + } }) // Round 2, item 2: a forced whole re-read of an unchanged file writes an diff --git a/src/main/ai-vault-search/session-search-indexer.ts b/src/main/ai-vault-search/session-search-indexer.ts index 72814f67d41..7f61984e6ea 100644 --- a/src/main/ai-vault-search/session-search-indexer.ts +++ b/src/main/ai-vault-search/session-search-indexer.ts @@ -21,7 +21,7 @@ import { sessionSearchHistoryCutoffMs, widensSessionSearchHistory } from './session-search-retention-policy' -import { withLastHealthyRootCounts } from './session-search-root-health' +import type { SessionSearchRootState } from './session-search-root-health' import { removeSessionSearchDatabase } from './session-search-schema' import type { SessionSearchScanRoots } from './session-search-scan-roots' import { SessionSearchStore } from './session-search-store' @@ -80,7 +80,7 @@ export class SessionSearchIndexer { private store: SessionSearchStore | null = null private unregister: (() => void) | null = null private previousRecent = new Set() - private rootFileCounts = new Map() + private rootStates = new Map() private historyDays: number | null private started = false private paused = false @@ -162,7 +162,7 @@ export class SessionSearchIndexer { removeSessionSearchDatabase(this.options.databasePath) this.pending.clear() this.previousRecent = new Set() - this.rootFileCounts = new Map() + this.rootStates = new Map() this.openStore() this.fullSweepDue = true }) @@ -271,13 +271,11 @@ export class SessionSearchIndexer { roots: this.options.roots, status: this.indexingStatus, cutoffMs, - previousRootFileCounts: this.rootFileCounts, + previousRootStates: this.rootStates, pace: this.pace, signal }) - // Last healthy count, not last count: a degraded sweep's zero would - // otherwise become the baseline and the next sweep would retire the tree. - this.rootFileCounts = withLastHealthyRootCounts(this.rootFileCounts, sweep.rootFileCounts) + this.rootStates = sweep.rootStates this.indexingStatus.setDegradedRoots(sweep.degradedRoots) this.indexingStatus.sweepFinished(sweep.completed) if (!sweep.completed) { @@ -307,7 +305,7 @@ export class SessionSearchIndexer { retirementChecksPerCycle: this.options.retirementChecksPerCycle, // Read but not written: a recent-window discovery is not a census, so it // can spot a root that went to zero without redefining what healthy was. - previousRootFileCounts: this.rootFileCounts, + previousRootStates: this.rootStates, signal }) // Work that was drained and then not read is a hole in the index, not diff --git a/src/main/ai-vault-search/session-search-merged-root-health.test.ts b/src/main/ai-vault-search/session-search-merged-root-health.test.ts new file mode 100644 index 00000000000..ba614ba8bc6 --- /dev/null +++ b/src/main/ai-vault-search/session-search-merged-root-health.test.ts @@ -0,0 +1,137 @@ +import { chmod, rm } from 'node:fs/promises' +import { delimiter, join } from 'node:path' +import { afterEach, beforeEach, expect, it } from 'vitest' +import { resetSessionParseCacheForTests } from '../ai-vault/session-scanner-parse-cache' +import { resetTranscriptConsumersForTests } from '../ai-vault/session-transcript-consumers' +import type SyncDatabase from '../sqlite/sync-database' +import { SessionSearchIndexer } from './session-search-indexer' +import { + FakeSessionSearchClock, + openSessionSearchIndexerHarness, + writeMessageGraphTranscript, + type SessionSearchIndexerHarness +} from './session-search-indexer-test-fixture' + +// OpenClaw is the one agent whose roots are alternates for a single install, so +// discovery reports them as ONE discovery whose rootDir is every path joined by +// the platform's path delimiter. That string is not a directory, and every part +// of the degraded-root fence silently did nothing for it: the probe readdir'd +// the joined string and got ENOENT, containment never matched a real file, and +// a scan issue recorded against a real root never compared equal. The result +// was that the one agent most likely to live on a mounted volume was the one +// whose transcripts a single unmount deleted. + +const CAN_DENY_READ = process.platform !== 'win32' && process.getuid?.() !== 0 +const INTERVAL_MS = 20_000 + +let harness: SessionSearchIndexerHarness +let clock: FakeSessionSearchClock +let indexer: SessionSearchIndexer + +beforeEach(async () => { + resetSessionParseCacheForTests() + resetTranscriptConsumersForTests() + clock = new FakeSessionSearchClock() + harness = await openSessionSearchIndexerHarness('ss-merged-roots') +}) + +afterEach(async () => { + indexer.close() + resetTranscriptConsumersForTests() + resetSessionParseCacheForTests() + await harness.cleanup() +}) + +/** OpenClaw reads `/agents/**` and keeps only paths through `sessions`. */ +function openclawTranscript(stateDir: string, name: string): string { + return join(stateDir, 'agents', 'main', 'sessions', `${name}.jsonl`) +} + +function sessionsMatching(term: string): string[] { + return harness.read((db: SyncDatabase) => + ( + db + .prepare( + `SELECT DISTINCT s.session_id AS id FROM messages_fts + JOIN visible_messages m ON m.id = messages_fts.rowid + JOIN visible_sessions s ON s.id = m.session_row_id + WHERE messages_fts MATCH ? ORDER BY s.session_id` + ) + .all(term) as { id: string }[] + ).map((row) => row.id) + ) +} + +it.skipIf(!CAN_DENY_READ)('fences one merged root without taking its partner down', async () => { + const current = harness.roots.openclawStateDir ?? '' + const legacy = harness.roots.openclawLegacyStateDir ?? '' + const mounted = openclawTranscript(current, 'mounted-session') + const local = openclawTranscript(legacy, 'local-session') + await writeMessageGraphTranscript(mounted, ['a conversation on the mounted volume']) + await writeMessageGraphTranscript(local, ['a conversation on local disk']) + + indexer = new SessionSearchIndexer({ + databasePath: harness.databasePath, + roots: harness.roots, + historyDays: null, + clock, + reconcileIntervalMs: INTERVAL_MS, + pace: async () => undefined + }) + await indexer.start() + expect(sessionsMatching('conversation').sort()).toEqual(['local-session', 'mounted-session']) + + // One of the two roots goes away; the other is untouched. + await chmod(join(current, 'agents'), 0o000) + try { + await indexer.reconcile({ full: true }) + + const status = indexer.status() + const degraded = status.degradedRoots.map((root) => root.root) + // A real directory, not the joined string discovery reports. + expect(degraded).toContain(join(current, 'agents')) + expect(degraded.every((root) => !root.includes(delimiter))).toBe(true) + // Fenced: the unreadable root keeps its rows. + expect(sessionsMatching('mounted')).toEqual(['mounted-session']) + } finally { + await chmod(join(current, 'agents'), 0o755) + } +}) + +it('retires from one merged root while its partner is healthy', async () => { + const current = harness.roots.openclawStateDir ?? '' + const legacy = harness.roots.openclawLegacyStateDir ?? '' + const going = openclawTranscript(current, 'going-session') + await writeMessageGraphTranscript(going, ['a conversation about to be deleted']) + // A sibling in the same root, so deleting one leaves the root listing files + // and therefore healthy: this is a deletion, not an unmount. + await writeMessageGraphTranscript(openclawTranscript(current, 'sibling-session'), [ + 'a conversation beside it' + ]) + await writeMessageGraphTranscript(openclawTranscript(legacy, 'staying-session'), [ + 'a conversation that stays' + ]) + + indexer = new SessionSearchIndexer({ + databasePath: harness.databasePath, + roots: harness.roots, + historyDays: null, + clock, + reconcileIntervalMs: INTERVAL_MS, + pace: async () => undefined + }) + await indexer.start() + expect(sessionsMatching('conversation').sort()).toEqual([ + 'going-session', + 'sibling-session', + 'staying-session' + ]) + + // A genuine deletion inside a healthy root still retires normally. + await rm(going) + await indexer.reconcile({ full: true }) + + expect(sessionsMatching('deleted')).toEqual([]) + expect(indexer.status().degradedRoots).toEqual([]) + expect(sessionsMatching('conversation').sort()).toEqual(['sibling-session', 'staying-session']) +}) diff --git a/src/main/ai-vault-search/session-search-reconciler.ts b/src/main/ai-vault-search/session-search-reconciler.ts index fd9bf691f5c..ad21436be8e 100644 --- a/src/main/ai-vault-search/session-search-reconciler.ts +++ b/src/main/ai-vault-search/session-search-reconciler.ts @@ -16,12 +16,14 @@ import type { SessionSearchIndexingStatus } from './session-search-indexing-stat import type { SessionSearchPendingFile } from './session-search-pending-files' import type { SessionSearchCycleAllowance } from './session-search-reconcile-budget' import { - degradedSessionSearchRoots, - type SessionSearchDegradedRoot + sessionSearchRootHealth, + type SessionSearchDegradedRoot, + type SessionSearchRootState } from './session-search-root-health' import { discoverSessionSearchCandidates, sessionSearchAgentForPath, + sessionSearchRootListings, type SessionSearchScanRoots } from './session-search-scan-roots' import type { SessionSearchStore } from './session-search-store' @@ -44,8 +46,8 @@ export type SessionSearchReconcileArgs = { previousRecent: ReadonlySet /** Stats a cycle spends proving deletions; the rest stay watched. */ retirementChecksPerCycle?: number - /** What each root listed when it was last healthy, so a tree that went empty is visible. */ - previousRootFileCounts?: ReadonlyMap + /** What the last sweeps saw of each root; read, never written, by a cycle. */ + previousRootStates?: ReadonlyMap signal?: AbortSignal } @@ -138,10 +140,17 @@ export async function runSessionSearchReconcileCycle( // Before the retirement, not after it: the cycle deletes rows too, so it // needs the same fence the sweep has or one interval undoes the sweep's care. - const degradedRoots = await degradedSessionSearchRoots(swept.discoveries, issues, { - signal, - previousFileCounts: args.previousRootFileCounts - }) + const degradedRoots = ( + await sessionSearchRootHealth({ + listings: sessionSearchRootListings(args.roots, swept.discoveries), + issues, + previous: args.previousRootStates ?? new Map(), + // A recent-window discovery can see a root that went to zero, but it is + // not a census and must never conclude one was emptied. + census: false, + signal + }) + ).degraded const retirement = completed ? await retireDeletedSessionSearchSources( store, diff --git a/src/main/ai-vault-search/session-search-root-health.test.ts b/src/main/ai-vault-search/session-search-root-health.test.ts index 2fa040fb4d9..16aab52c7eb 100644 --- a/src/main/ai-vault-search/session-search-root-health.test.ts +++ b/src/main/ai-vault-search/session-search-root-health.test.ts @@ -2,8 +2,11 @@ import { chmod, mkdir, mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, expect, it } from 'vitest' -import type { SessionFileDiscovery } from '../ai-vault/session-scanner-types' -import { degradedSessionSearchRoots } from './session-search-root-health' +import { + sessionSearchRootHealth, + underDegradedRoot, + type SessionSearchRootState +} from './session-search-root-health' const CAN_DENY_READ = process.platform !== 'win32' && process.getuid?.() !== 0 @@ -20,57 +23,119 @@ async function tempRoot(): Promise { return root } -function discovery(rootDir: string, files: number): SessionFileDiscovery { - return { - agent: 'claude', - rootDir, - files: Array.from({ length: files }, (_unused, index) => ({ - path: join(rootDir, `${index}.jsonl`), - mtimeMs: 0, - modifiedAt: new Date(0).toISOString() - })) - } +function health(args: { + listings: { root: string; files: number }[] + issues?: Parameters[0]['issues'] + previous?: Map + census?: boolean +}) { + return sessionSearchRootHealth({ + listings: args.listings, + issues: args.issues ?? [], + previous: args.previous ?? new Map(), + census: args.census ?? true + }) } it('leaves an agent that is simply not installed alone', async () => { const root = await tempRoot() - expect(await degradedSessionSearchRoots([discovery(join(root, 'never-created'), 0)], [])).toEqual( - [] - ) + const result = await health({ listings: [{ root: join(root, 'never-created'), files: 0 }] }) + expect(result.degraded).toEqual([]) }) -it('never probes a root that returned files', async () => { +it('never probes a root that returned files, and records it as healthy', async () => { // The path does not exist, so a probe would report it degraded; a root that // yielded transcripts is readable by construction and must not be re-checked. - expect(await degradedSessionSearchRoots([discovery('/definitely/not/here', 3)], [])).toEqual([]) + const result = await health({ listings: [{ root: '/definitely/not/here', files: 3 }] }) + expect(result.degraded).toEqual([]) + expect(result.states.get('/definitely/not/here')).toEqual({ + lastHealthyCount: 3, + emptySweeps: 0 + }) }) -it.skipIf(!CAN_DENY_READ)('names a root that exists but cannot be read', async () => { +it.skipIf(!CAN_DENY_READ)('keeps a root that cannot be listed degraded indefinitely', async () => { const root = await tempRoot() const blocked = join(root, 'blocked') await mkdir(blocked) await chmod(blocked, 0o000) try { - const degraded = await degradedSessionSearchRoots([discovery(blocked, 0)], []) - expect(degraded).toHaveLength(1) - expect(degraded[0]?.root).toBe(blocked) - expect(degraded[0]?.reason).toContain('EACCES') + let previous = new Map([ + [blocked, { lastHealthyCount: 9, emptySweeps: 0 }] + ]) + for (let sweep = 0; sweep < 5; sweep++) { + const result = await health({ listings: [{ root: blocked, files: 0 }], previous }) + expect(result.degraded).toHaveLength(1) + expect(result.degraded[0]?.root).toBe(blocked) + previous = result.states + } + // Never believed to be empty, so its last healthy count is never given up. + expect(previous.get(blocked)?.lastHealthyCount).toBe(9) } finally { await chmod(blocked, 0o755) } }) +it('believes a readable root that lists empty twice, and not once', async () => { + const root = await tempRoot() + const emptied = join(root, 'emptied') + await mkdir(emptied) + + const first = await health({ + listings: [{ root: emptied, files: 0 }], + previous: new Map([[emptied, { lastHealthyCount: 4, emptySweeps: 0 }]]) + }) + // One sweep cannot tell an emptied tree from a freshly unmounted one. + expect(first.degraded).toHaveLength(1) + + const second = await health({ listings: [{ root: emptied, files: 0 }], previous: first.states }) + expect(second.degraded).toEqual([]) + expect(second.states.get(emptied)).toEqual({ lastHealthyCount: 0, emptySweeps: 2 }) +}) + +it('does not let a cycle advance the tally that decides a root was emptied', async () => { + const root = await tempRoot() + const emptied = join(root, 'emptied') + await mkdir(emptied) + // One full sweep has already seen it empty. Only a second sweep may conclude + // anything; the cycles in between keep the fence and leave the tally alone. + let previous = new Map([[emptied, { lastHealthyCount: 4, emptySweeps: 1 }]]) + + for (let cycle = 0; cycle < 5; cycle++) { + const result = await health({ + listings: [{ root: emptied, files: 0 }], + previous, + census: false + }) + expect(result.degraded).toHaveLength(1) + expect(result.states).toEqual(previous) + previous = result.states + } + + // The second sweep is what releases it. + const sweep = await health({ listings: [{ root: emptied, files: 0 }], previous }) + expect(sweep.degraded).toEqual([]) +}) + it('carries a root-level scan issue through, but not a per-file notice', async () => { const root = await tempRoot() await mkdir(join(root, 'healthy')) const rootDir = join(root, 'healthy') - const degraded = await degradedSessionSearchRoots( - [discovery(rootDir, 2)], - [ + const result = await health({ + listings: [{ root: rootDir, files: 2 }], + issues: [ { agent: 'claude', path: rootDir, message: 'The distro stopped responding.' }, { agent: 'claude', path: rootDir, kind: 'notice', message: 'issue list truncated' }, { agent: 'claude', path: join(rootDir, 'one.jsonl'), message: 'a single unreadable file' } ] - ) - expect(degraded).toEqual([{ root: rootDir, reason: 'The distro stopped responding.' }]) + }) + expect(result.degraded).toEqual([{ root: rootDir, reason: 'The distro stopped responding.' }]) +}) + +it('fences files by real root boundaries', () => { + const degraded = [{ root: '/a/agents', reason: 'gone' }] + expect(underDegradedRoot('/a/agents/s/one.jsonl', degraded)).toBe(true) + expect(underDegradedRoot('/b/agents/s/one.jsonl', degraded)).toBe(false) + // A sibling whose name merely starts with the root is not inside it. + expect(underDegradedRoot('/a/agents-old/one.jsonl', degraded)).toBe(false) }) diff --git a/src/main/ai-vault-search/session-search-root-health.ts b/src/main/ai-vault-search/session-search-root-health.ts index cc0b7c8d9fa..1f16e7513e5 100644 --- a/src/main/ai-vault-search/session-search-root-health.ts +++ b/src/main/ai-vault-search/session-search-root-health.ts @@ -1,104 +1,102 @@ import type { AiVaultScanIssue } from '../../shared/ai-vault-types' -import type { SessionFileDiscovery } from '../ai-vault/session-scanner-types' import { wslGatedReaddir } from '../native-chat/wsl-transcript-fs-access' +import type { SessionSearchRootListing } from './session-search-scan-roots' /** A scan root the index could not read, and what stopped it. */ export type SessionSearchDegradedRoot = { root: string; reason: string } -// Why the indexer probes at all: the walker swallows a readdir failure and +/** What the last full sweeps saw of one root, carried between passes. */ +export type SessionSearchRootState = { + /** Transcripts it listed when it was last seen holding any. */ + lastHealthyCount: number + /** Consecutive full sweeps that listed it, successfully, as empty. */ + emptySweeps: number +} + +export type SessionSearchRootHealth = { + degraded: SessionSearchDegradedRoot[] + /** Carried forward; only a census writes it. */ + states: Map +} + +// Why the indexer probes at all: the file walker swallows a readdir failure and // returns, so an EACCES root and an agent that was never installed both arrive // as "no files". Reporting the first as an empty index would be the // loss-of-contact-as-absence mistake docs/reference/ssh-execution-boundary.md // forbids, so an empty root is re-checked and only ENOENT counts as absent. const ABSENT_ROOT = new Set(['ENOENT', 'ENOTDIR']) -export type SessionSearchRootHealthOptions = { - signal?: AbortSignal - /** What each root listed last time, so a tree that emptied out is visible. */ - previousFileCounts?: ReadonlyMap -} - -/** Transcripts each root listed, for comparison against the next sweep. */ -export function rootFileCounts(discoveries: readonly SessionFileDiscovery[]): Map { - const counts = new Map() - for (const discovery of discoveries) { - counts.set(discovery.rootDir, (counts.get(discovery.rootDir) ?? 0) + discovery.files.length) - } - return counts -} +/** How many consecutive listable-but-empty sweeps mean the user emptied it. */ +const EMPTY_SWEEPS_BEFORE_TRUSTED = 2 /** - * Carries a root's last healthy count forward across a sweep that listed it - * empty. Without this the alarm is single-shot: the degraded sweep's zero - * becomes the baseline, the next sweep compares zero against zero, and the - * unmounted tree is retired on the second pass instead of the first. - */ -export function withLastHealthyRootCounts( - previous: ReadonlyMap, - observed: ReadonlyMap -): Map { - const merged = new Map(previous) - for (const [root, count] of observed) { - if (count > 0) { - merged.set(root, count) - } - } - return merged -} - -/** - * Classifies the roots a sweep just walked. Only roots that yielded nothing are - * probed: a root that returned files is readable by construction, which keeps - * the cost at one readdir per genuinely empty tree. + * Classifies the roots a pass walked. Only roots that listed nothing are + * probed: one that returned files is readable by construction. * - * A readable but suddenly empty root counts too. An unmounted SSH home or a - * detached external drive often reads as a present, listable, empty directory, - * and every transcript under it then answers ENOENT at once. Going from N to - * zero is not something an agent's transcript store does on its own. + * The rule an empty root is judged by, and why it takes two sweeps: + * + * - Cannot be listed at all: degraded, keeping its last healthy count. An + * unmounted SSH home or a detached drive is not an emptied one, and its + * transcripts must not be retired on an ENOENT they all answer at once. + * - Lists successfully but empty, having held transcripts before: degraded for + * now. This is what a freshly unmounted volume also looks like, and one sweep + * cannot tell the two apart. + * - Lists successfully but empty on two consecutive full sweeps: the user + * really did delete them. Degraded clears and the rows retire. Without this + * the alarm never releases, so a legitimately emptied root pins the whole + * index at `degraded` for the life of the process. + * + * Only a full sweep counts toward that tally. A recent-window cycle can see a + * root that went to zero, but it is not a census and must not conclude one. */ -export async function degradedSessionSearchRoots( - discoveries: readonly SessionFileDiscovery[], - issues: readonly AiVaultScanIssue[], - options: SessionSearchRootHealthOptions = {} -): Promise { - const { signal } = options +export async function sessionSearchRootHealth(args: { + listings: readonly SessionSearchRootListing[] + issues: readonly AiVaultScanIssue[] + previous: ReadonlyMap + /** True for a full sweep, whose observation is allowed to move the tally. */ + census: boolean + signal?: AbortSignal +}): Promise { const degraded = new Map() - for (const issue of issues) { - if (issue.kind !== 'notice' && discoveries.some((one) => one.rootDir === issue.path)) { + const states = new Map(args.previous) + for (const issue of args.issues) { + if (issue.kind !== 'notice' && args.listings.some((one) => one.root === issue.path)) { degraded.set(issue.path, issue.message) } } - const counts = rootFileCounts(discoveries) - for (const [root, count] of counts) { - if (count > 0 || degraded.has(root) || signal?.aborted) { + for (const listing of args.listings) { + const previous = args.previous.get(listing.root) ?? { lastHealthyCount: 0, emptySweeps: 0 } + if (listing.files > 0) { + states.set(listing.root, { lastHealthyCount: listing.files, emptySweeps: 0 }) continue } - const previous = options.previousFileCounts?.get(root) ?? 0 - if (previous > 0) { - degraded.set(root, `Listed no transcripts where it listed ${previous} before.`) + if (degraded.has(listing.root) || args.signal?.aborted) { continue } - const reason = await unreadableRootReason(root, signal) - if (reason) { - degraded.set(root, reason) + const unreadable = await unreadableRootReason(listing.root, args.signal) + if (unreadable !== null) { + degraded.set(listing.root, unreadable) + continue + } + // Listable and empty. The tally only advances on a census, so a cycle reads + // the sweep's count without ever concluding a root was emptied. + const emptySweeps = previous.emptySweeps + (args.census ? 1 : 0) + if (args.census) { + states.set(listing.root, { ...previous, emptySweeps }) + } + if (previous.lastHealthyCount > 0 && emptySweeps < EMPTY_SWEEPS_BEFORE_TRUSTED) { + degraded.set( + listing.root, + `Listed no transcripts where it listed ${previous.lastHealthyCount} before.` + ) + continue + } + if (args.census && emptySweeps >= EMPTY_SWEEPS_BEFORE_TRUSTED) { + // Believed: stop carrying a healthy count that is no longer true. + states.set(listing.root, { lastHealthyCount: 0, emptySweeps }) } } - return [...degraded].map(([root, reason]) => ({ root, reason })) -} - -/** True when a path lives under a root this sweep could not trust. */ -export function underDegradedRoot( - path: string, - degradedRoots: readonly SessionSearchDegradedRoot[] -): boolean { - // Both separators: discovery joins with the platform's, and a root can arrive - // from a config value written with the other one. - return degradedRoots.some( - (degraded) => - path === degraded.root || - path.startsWith(`${degraded.root}/`) || - path.startsWith(`${degraded.root}\\`) - ) + return { degraded: [...degraded].map(([root, reason]) => ({ root, reason })), states } } async function unreadableRootReason(root: string, signal?: AbortSignal): Promise { @@ -116,3 +114,18 @@ async function unreadableRootReason(root: string, signal?: AbortSignal): Promise return error instanceof Error ? error.message : String(error) } } + +/** True when a path lives under a root this pass could not trust. */ +export function underDegradedRoot( + path: string, + degradedRoots: readonly SessionSearchDegradedRoot[] +): boolean { + // Both separators: discovery joins with the platform's, and a root can arrive + // from a config value written with the other one. + return degradedRoots.some( + (degraded) => + path === degraded.root || + path.startsWith(`${degraded.root}/`) || + path.startsWith(`${degraded.root}\\`) + ) +} diff --git a/src/main/ai-vault-search/session-search-scan-roots.ts b/src/main/ai-vault-search/session-search-scan-roots.ts index 3e66d37c878..7e8bd8cb681 100644 --- a/src/main/ai-vault-search/session-search-scan-roots.ts +++ b/src/main/ai-vault-search/session-search-scan-roots.ts @@ -9,6 +9,9 @@ import type { SessionFileDiscovery } from '../ai-vault/session-scanner-types' +/** One real directory a scan walked, and what it listed there. */ +export type SessionSearchRootListing = { root: string; files: number } + /** * Where the indexer looks. The caller resolves these so the index enumerates * exactly the trees the session list does; the indexer owns the bounds @@ -75,3 +78,62 @@ export function sessionSearchAgentForPath( function underRoot(path: string, root: string): boolean { return root.length > 0 && (path.startsWith(`${root}/`) || path.startsWith(`${root}\\`)) } + +/** + * The real directories behind a scan's discoveries, with their file counts. + * + * Why this exists: an agent whose roots are alternates for one install reports + * them as a single discovery whose `rootDir` is every path joined by the + * platform's path delimiter. That string is not a directory. Health probes + * readdir it and get ENOENT, a containment check never matches a file under it, + * and a scan issue recorded against a real root never equals it — so the fence + * meant to protect an unmounted tree is inert for exactly the agent most likely + * to have one. Splitting the joined string back apart would be worse: a + * directory may legally contain the delimiter. The constituent paths come from + * the same source table discovery read. + */ +export function sessionSearchRootListings( + roots: SessionSearchScanRoots, + discoveries: readonly SessionFileDiscovery[] +): SessionSearchRootListing[] { + const wslHomeDirs = normalizedWslHomeDirs(roots.wslHomeDirs) + const counts = new Map() + for (const discovery of discoveries) { + const constituents = constituentRoots(roots, wslHomeDirs, discovery) + for (const root of constituents) { + counts.set(root, counts.get(root) ?? 0) + } + for (const file of discovery.files) { + const owner = owningRoot(constituents, file.path) + if (owner !== null) { + counts.set(owner, (counts.get(owner) ?? 0) + 1) + } + } + } + return [...counts].map(([root, files]) => ({ root, files })) +} + +function constituentRoots( + roots: SessionSearchScanRoots, + wslHomeDirs: readonly string[], + discovery: SessionFileDiscovery +): string[] { + const declared = AI_VAULT_AGENT_SOURCES[discovery.agent]?.rootDirs(roots, wslHomeDirs) ?? [] + if (declared.includes(discovery.rootDir)) { + return [discovery.rootDir] + } + // Either a merged discovery, whose rootDir is the joined string, or a source + // that builds its own discoveries (OpenCode, Antigravity) and reports a real + // directory that this table does not list. + return declared.length > 0 ? declared : [discovery.rootDir] +} + +function owningRoot(constituents: readonly string[], path: string): string | null { + let owner: string | null = null + for (const root of constituents) { + if (underRoot(path, root) && (owner === null || root.length > owner.length)) { + owner = root + } + } + return owner +}