fix(ai-vault-search): fence real directories, and let the alarm release

The degraded-root fence did nothing at all for OpenClaw, the one agent whose
roots are alternates for a single install. Discovery reports those as one
discovery whose rootDir is every path joined by the platform's path delimiter,
and that string is not a directory: the probe readdir'd it and got ENOENT, the
containment check never matched a file under it, and a scan issue recorded
against a real root never compared equal to it. So the agent most likely to
live on a mounted volume was the one an unmount deleted, and the degraded root
it reported was not a path anyone could act on.

Health now runs on the constituent directories, taken from the same source
table discovery reads rather than by splitting the joined string back apart,
which would be its own bug: a directory may legally contain the delimiter.
Files are attributed to the root they actually live under, so one alternate can
be unreadable while the other keeps indexing and retiring normally.

The N-to-zero alarm also never released. Carrying only counts above zero meant
a root the user legitimately emptied stayed degraded for the life of the
process, its rows never retired and the phase pinned. The rule is now explicit:
a root that cannot be listed keeps its last healthy count and stays degraded
indefinitely, while one that lists successfully and empty on two consecutive
full sweeps is believed. One sweep is not enough, because that is also what a
freshly unmounted volume looks like, and only a sweep counts: a recent-window
cycle can see a root at zero but is not a census.

The allowance still charges a forced read at the size discovery saw. That is an
under-count when a file grows mid-cycle, and it is deliberate: the budget paces
a cycle rather than accounting for it, and the error is bounded by what one
cycle's writers appended.
This commit is contained in:
Jinwoo-H
2026-09-10 23:29:12 -04:00
parent d53c7ffa58
commit 967788da5d
10 changed files with 491 additions and 150 deletions
@@ -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<string, number>
/** What the last sweeps saw of each root, so a tree that went empty is visible. */
previousRootStates?: ReadonlyMap<string, SessionSearchRootState>
pace?: (signal?: AbortSignal) => Promise<void>
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<string>
rootFileCounts: Map<string, number>
rootStates: Map<string, SessionSearchRootState>
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
}
@@ -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))
@@ -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<void> {
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`)
}
@@ -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
@@ -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<string>()
private rootFileCounts = new Map<string, number>()
private rootStates = new Map<string, SessionSearchRootState>()
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
@@ -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 `<stateDir>/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'])
})
@@ -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<string>
/** 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<string, number>
/** What the last sweeps saw of each root; read, never written, by a cycle. */
previousRootStates?: ReadonlyMap<string, SessionSearchRootState>
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,
@@ -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<string> {
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<typeof sessionSearchRootHealth>[0]['issues']
previous?: Map<string, SessionSearchRootState>
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<string, SessionSearchRootState>([
[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)
})
@@ -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<string, SessionSearchRootState>
}
// 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<string, number>
}
/** Transcripts each root listed, for comparison against the next sweep. */
export function rootFileCounts(discoveries: readonly SessionFileDiscovery[]): Map<string, number> {
const counts = new Map<string, number>()
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<string, number>,
observed: ReadonlyMap<string, number>
): Map<string, number> {
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<SessionSearchDegradedRoot[]> {
const { signal } = options
export async function sessionSearchRootHealth(args: {
listings: readonly SessionSearchRootListing[]
issues: readonly AiVaultScanIssue[]
previous: ReadonlyMap<string, SessionSearchRootState>
/** True for a full sweep, whose observation is allowed to move the tally. */
census: boolean
signal?: AbortSignal
}): Promise<SessionSearchRootHealth> {
const degraded = new Map<string, string>()
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<string | null> {
@@ -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}\\`)
)
}
@@ -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<string, number>()
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
}