diff --git a/config/scripts/vitest-node-runtime-files.mjs b/config/scripts/vitest-node-runtime-files.mjs index 1e50492bf60..f81643751ad 100644 --- a/config/scripts/vitest-node-runtime-files.mjs +++ b/config/scripts/vitest-node-runtime-files.mjs @@ -14,6 +14,7 @@ export const NODE_RUNTIME_INCLUDE = [ 'src/main/ai-vault/session-scanner-unlimited-dedup.test.ts', // Disk checkpoints require Node's iterable writeFile semantics. 'src/main/ai-vault/session-parse-cache-checkpoint.test.ts', + 'src/main/ai-vault/session-parse-cache-large-scan.test.ts', 'src/main/ai-vault/session-parse-cache-save-lane.test.ts', 'src/main/ai-vault/session-parse-cache-metadata-checkpoint.test.ts', // Electron main bridges streams with Node's implementation. diff --git a/src/main/ai-vault/session-parse-cache-bounds.test.ts b/src/main/ai-vault/session-parse-cache-bounds.test.ts index ce9e3f7b6db..2481ec32666 100644 --- a/src/main/ai-vault/session-parse-cache-bounds.test.ts +++ b/src/main/ai-vault/session-parse-cache-bounds.test.ts @@ -54,7 +54,7 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }) }) -it('loads the newest 4096 unique rows across duplicate-heavy input and keeps in-process entries', async () => { +it('loads every unique row across duplicate-heavy input and keeps in-process entries', async () => { const entries = Array.from({ length: 4200 }, (_, index) => row(`p${index}`, index)) entries.push(...Array.from({ length: 5000 }, (_, index) => row('p4199', 5000 + index))) entries.push(row('in-process', 1)) @@ -68,27 +68,12 @@ it('loads the newest 4096 unique rows across duplicate-heavy input and keeps in- }) ) await ensureSessionParseCacheLoaded() - expect(snapshotSessionParseCacheForPersistence()).toHaveLength(4096) + expect(snapshotSessionParseCacheForPersistence()).toHaveLength(4201) expect(getSessionParseCacheEntry('p4199')?.mtimeMs).toBe(9999) - expect(getSessionParseCacheEntry('p104')).toBeUndefined() - expect(getSessionParseCacheEntry('p105')?.mtimeMs).toBe(105) + expect(getSessionParseCacheEntry('p104')?.mtimeMs).toBe(104) expect(getSessionParseCacheEntry('in-process')?.mtimeMs).toBe(20_000) }) -it('seeds newest unique rows directly without retaining an unbounded iterable', () => { - function* entries() { - for (let index = 0; index < 5000; index++) { - yield row(`p${index}`, index) - } - yield row('p4999', 6000) - } - seedSessionParseCache(entries()) - expect(snapshotSessionParseCacheForPersistence()).toHaveLength(4096) - expect(getSessionParseCacheEntry('p903')).toBeUndefined() - expect(getSessionParseCacheEntry('p904')?.mtimeMs).toBe(904) - expect(getSessionParseCacheEntry('p4999')?.mtimeMs).toBe(6000) -}) - it('rejects an oversized sparse file before decoding and preserves resident work', async () => { seedSessionParseCache([row('resident', 3)]) const handle = await open(file, 'w') diff --git a/src/main/ai-vault/session-parse-cache-checkpoint.test.ts b/src/main/ai-vault/session-parse-cache-checkpoint.test.ts index d86c4b13f62..5e657819011 100644 --- a/src/main/ai-vault/session-parse-cache-checkpoint.test.ts +++ b/src/main/ai-vault/session-parse-cache-checkpoint.test.ts @@ -40,12 +40,10 @@ vi.mock('./session-scanner-parse-cache', async (importOriginal) => { const actual = await importOriginal() return { ...actual, - parseAgentSessionFileCached: async ( - ...args: Parameters - ) => { + parseListedSessionFile: async (...args: Parameters) => { hooks.stats = args[2] ?? null await hooks.beforeParse?.() - return actual.parseAgentSessionFileCached(...args) + return actual.parseListedSessionFile(...args) } } }) diff --git a/src/main/ai-vault/session-parse-cache-large-scan.test.ts b/src/main/ai-vault/session-parse-cache-large-scan.test.ts new file mode 100644 index 00000000000..4b799068d38 --- /dev/null +++ b/src/main/ai-vault/session-parse-cache-large-scan.test.ts @@ -0,0 +1,90 @@ +import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it } from 'vitest' +import { + ensureSessionParseCacheLoaded, + flushSessionParseCachePersist, + initSessionParseCachePersistence, + resetSessionParseCachePersistenceForTests +} from './session-parse-cache-persistence' +import { + getSessionParseCacheEntry, + MAX_RESUME_ENTRIES, + MAX_UNLISTED_ENTRIES, + noteSessionParseCacheListed, + resetSessionParseCacheForTests, + storeSessionParseCacheEntry +} from './session-parse-cache-store' +import { createSessionParseStats, parseListedSessionFile } from './session-scanner-parse-cache' +import type { SessionFileCandidate } from './session-scanner-types' + +const CANDIDATES = Math.max(MAX_RESUME_ENTRIES, MAX_UNLISTED_ENTRIES) + 178 +let root: string | undefined + +afterEach(async () => { + resetSessionParseCachePersistenceForTests() + resetSessionParseCacheForTests() + if (root) { + await rm(root, { recursive: true, force: true }) + } + root = undefined +}) + +it('count-bounds rows only other readers made, never rows History listed', () => { + const row = { mtimeMs: 1, sizeBytes: 1, platform: 'linux', session: null, resume: null } as const + noteSessionParseCacheListed('listed') + storeSessionParseCacheEntry('listed', row) + for (let index = 0; index <= MAX_UNLISTED_ENTRIES; index++) { + storeSessionParseCacheEntry(`search-${index}`, row) + } + expect(getSessionParseCacheEntry('listed')).toBeDefined() + expect(getSessionParseCacheEntry('search-0')).toBeUndefined() + expect(getSessionParseCacheEntry(`search-${MAX_UNLISTED_ENTRIES}`)).toBeDefined() +}) + +it('does not reparse an unchanged corpus past the resume bound, across scans or restart', async () => { + resetSessionParseCacheForTests() + root = await mkdtemp(join(tmpdir(), 'orca-history-no-reparse-')) + const cacheFile = join(root, 'cache.json') + initSessionParseCachePersistence({ filePath: cacheFile, appVersion: 'test' }) + await ensureSessionParseCacheLoaded() + const candidates: SessionFileCandidate[] = [] + for (let index = 0; index < CANDIDATES; index++) { + const path = join(root, `session-${index}.jsonl`) + await writeFile(path, '') + const fileStat = await stat(path) + candidates.push({ + agent: 'claude', + file: { + path, + mtimeMs: fileStat.mtimeMs, + modifiedAt: fileStat.mtime.toISOString(), + sizeBytes: fileStat.size + }, + codexHome: null + }) + } + const first = createSessionParseStats() + const second = createSessionParseStats() + const restarted = createSessionParseStats() + const list = async (stats: ReturnType) => { + for (const candidate of candidates) { + await parseListedSessionFile(candidate, process.platform, stats) + } + } + await list(first) + await list(second) + await flushSessionParseCachePersist() + const snapshot = JSON.parse(await readFile(cacheFile, 'utf8')) + expect(snapshot.entries).toHaveLength(CANDIDATES) + resetSessionParseCachePersistenceForTests() + resetSessionParseCacheForTests() + initSessionParseCachePersistence({ filePath: cacheFile, appVersion: 'test' }) + await ensureSessionParseCacheLoaded() + expect(getSessionParseCacheEntry(candidates[0]!.file.path)).toBeDefined() + await list(restarted) + expect(first.fullParses).toBe(CANDIDATES) + expect(second.fullParses).toBe(0) + expect(restarted.fullParses).toBe(0) +}, 240_000) diff --git a/src/main/ai-vault/session-parse-cache-persistence.ts b/src/main/ai-vault/session-parse-cache-persistence.ts index 177b1338ab9..8fbba0cd004 100644 --- a/src/main/ai-vault/session-parse-cache-persistence.ts +++ b/src/main/ai-vault/session-parse-cache-persistence.ts @@ -17,7 +17,6 @@ import { import { seedSessionParseCache, observeSessionParseCacheChanges, - MAX_CACHE_ENTRIES, type PersistedSessionParseCacheEntry } from './session-parse-cache-store' import type { SessionSidecarObservation } from './session-sidecar-stat' @@ -136,12 +135,6 @@ async function parsePersistedFile( } entries.delete(entry[0]) entries.set(entry[0], entry[1]) - if (entries.size > MAX_CACHE_ENTRIES) { - const oldest = entries.keys().next() - if (!oldest.done) { - entries.delete(oldest.value) - } - } } }) // Why: application releases that keep this schema promise compatible cached @@ -220,6 +213,9 @@ async function persistSnapshot( const directory = dirname(current.filePath) const tempPath = join(directory, `session-parse-cache-${process.pid}-${Date.now()}.tmp`) try { + // The serializer keeps the newest suffix that fits; order by transcript age + // so that is the newest transcripts, not the last ones a scan happened to store. + entries.sort((left, right) => left[1].mtimeMs - right[1].mtimeMs) const payload = await serializeSessionParseCacheSnapshotPiecesCooperatively( entries, current.appVersion diff --git a/src/main/ai-vault/session-parse-cache-save-lane.test.ts b/src/main/ai-vault/session-parse-cache-save-lane.test.ts index 6db9d071dda..b4ad53fd046 100644 --- a/src/main/ai-vault/session-parse-cache-save-lane.test.ts +++ b/src/main/ai-vault/session-parse-cache-save-lane.test.ts @@ -305,8 +305,8 @@ it('waits for hydration before capturing a replacement and preserves live update } await flushSessionParseCachePersist() expect(await diskEntries()).toEqual([ - ['entry', expect.objectContaining({ mtimeMs: 3 })], - ['older', expect.objectContaining({ mtimeMs: 0 })] + ['older', expect.objectContaining({ mtimeMs: 0 })], + ['entry', expect.objectContaining({ mtimeMs: 3 })] ]) await vi.advanceTimersByTimeAsync(60_000) await flushSessionParseCachePersist() diff --git a/src/main/ai-vault/session-parse-cache-store.ts b/src/main/ai-vault/session-parse-cache-store.ts index bf6757d974e..da6cb88bb2e 100644 --- a/src/main/ai-vault/session-parse-cache-store.ts +++ b/src/main/ai-vault/session-parse-cache-store.ts @@ -4,9 +4,16 @@ import type { SessionSidecarObservation } from './session-sidecar-stat' import type { TranscriptMessageChannel } from './session-transcript-channel' import type { SkippedTranscriptRecord } from './session-transcript-record-budget' -// Sized past the default recency cap (1000) plus the in-scope cap (2000) so a -// full steady-state result set stays resident between forced rescans. -export const MAX_CACHE_ENTRIES = 4096 +// Rows Session History lists have no count bound: one smaller than an Unlimited +// scan evicts that scan's own upcoming hits, so every scan re-parsed everything +// (STA-10405). They live as long as this process; the persisted snapshot keeps +// the newest transcripts. Rows only other readers created (search index, title +// lookups) keep a count bound, since an index build reads the whole corpus. +export const MAX_UNLISTED_ENTRIES = 4096 +// Live parser state is the heavy part of a row and is count-bounded even on +// listed rows. Dropping it costs one whole re-read of a file that changed, never +// a re-parse of one that did not. +export const MAX_RESUME_ENTRIES = 4096 export type SessionParseResumePoint = { state: ResumableSessionParseState @@ -35,9 +42,18 @@ export type SessionParseCacheEntry = { // onto it. In-memory only: without it a sibling change costs one re-parse. foldSession?: AiVaultSession | null resume: SessionParseResumePoint | null + // The dropped resume point's skipped records, so its notice outlives it. + skippedRecords?: SkippedTranscriptRecord[] } const cache = new Map() +// Paths a History scan has listed. Kept apart from the rows so a forced whole +// re-read, which drops the row, leaves it listed. +const listedPaths = new Set() +// Rows no History scan has listed, and rows holding a resume point: the two +// count-bounded sets. +const unlistedPaths = new Set() +const resumePaths = new Set() let revision = 0 let onChange: (() => void) | null = null @@ -60,6 +76,9 @@ function notifyChange(): void { export function resetSessionParseCacheForTests(): void { cache.clear() + listedPaths.clear() + unlistedPaths.clear() + resumePaths.clear() revision = 0 onChange = null } @@ -67,11 +86,18 @@ export function resetSessionParseCacheForTests(): void { // Drops one entry after its file is deleted. Cleanliness, not correctness: // discovery walks disk first, so a trashed file is never rediscovered anyway. export function invalidateSessionParseCacheEntry(path: string): void { + unlistedPaths.delete(path) + resumePaths.delete(path) if (cache.delete(path)) { notifyChange() } } +export function noteSessionParseCacheListed(path: string): void { + listedPaths.add(path) + unlistedPaths.delete(path) +} + // Persisted subset of a cache entry: the non-serializable `resume` parser // state is dropped, and `foldSession` with it, so a restart pays one re-parse // for a session whose sibling moved rather than storing every row twice @@ -96,7 +122,8 @@ export function snapshotSessionParseCacheForPersistence(): [ // Seeded entries carry `resume: null`: after a restart an unchanged file is a // cache hit; a file that changed while the app was closed pays one full -// (not incremental) re-parse. +// (not incremental) re-parse. They count as listed: the file can't say which +// reader made a row, and the unlisted bound would cut History's own rows to it. export function seedSessionParseCache( entries: Iterable<[string, PersistedSessionParseCacheEntry]> ): void { @@ -107,19 +134,10 @@ export function seedSessionParseCache( } newest.delete(path) newest.set(path, entry) - if (newest.size > MAX_CACHE_ENTRIES) { - const oldest = newest.keys().next() - if (!oldest.done) { - newest.delete(oldest.value) - } - } - } - const available = MAX_CACHE_ENTRIES - cache.size - if (available <= 0) { - return } // In-process entries win; among persisted duplicates the newest row wins. - for (const [path, entry] of [...newest].slice(-available)) { + for (const [path, entry] of newest) { + listedPaths.add(path) cache.set(path, { mtimeMs: entry.mtimeMs, sizeBytes: entry.sizeBytes, @@ -148,17 +166,53 @@ export function storeSessionParseCacheEntry(path: string, entry: SessionParseCac !sameSidecar(previous.sidecar, entry.sidecar) cache.delete(path) cache.set(path, entry) - if (cache.size > MAX_CACHE_ENTRIES) { - const oldest = cache.keys().next() - if (!oldest.done) { - cache.delete(oldest.value) - } + resumePaths.delete(path) + if (!listedPaths.has(path)) { + unlistedPaths.add(path) + trimOldestTranscripts(unlistedPaths, MAX_UNLISTED_ENTRIES, dropRow) + } + // Skipped when the trim above dropped this very row, so it can't cost others. + if (entry.resume !== null && cache.has(path)) { + resumePaths.add(path) + trimOldestTranscripts(resumePaths, MAX_RESUME_ENTRIES, dropResumePoint) } if (changed) { notifyChange() } } +// Over the bound, drops the tenth whose transcripts were modified longest ago. +// Scans visit newest first, so store order would drop the most active files. +function trimOldestTranscripts( + paths: Set, + max: number, + drop: (path: string) => void +): void { + if (paths.size <= max) { + return + } + const oldest = [...paths] + .sort((left, right) => (cache.get(left)?.mtimeMs ?? 0) - (cache.get(right)?.mtimeMs ?? 0)) + .slice(0, paths.size - max + Math.floor(max / 10)) + for (const path of oldest) { + paths.delete(path) + drop(path) + } +} + +function dropRow(path: string): void { + resumePaths.delete(path) + cache.delete(path) +} + +function dropResumePoint(path: string): void { + const entry = cache.get(path) + if (entry?.resume) { + // Same key, so the row keeps its place in the map. + cache.set(path, { ...entry, resume: null, skippedRecords: entry.resume.skippedRecords }) + } +} + function sameSidecar( left: SessionSidecarObservation | undefined, right: SessionSidecarObservation | undefined diff --git a/src/main/ai-vault/session-scanner-parse-cache.ts b/src/main/ai-vault/session-scanner-parse-cache.ts index a74ebfecf1c..8503c70ddb4 100644 --- a/src/main/ai-vault/session-scanner-parse-cache.ts +++ b/src/main/ai-vault/session-scanner-parse-cache.ts @@ -16,6 +16,7 @@ import type { ResumableSessionParseState, SessionFileCandidate } from './session import { refreshCachedCodexTitle } from './session-scanner-codex-cached-title' import { getSessionParseCacheEntry, + noteSessionParseCacheListed, storeSessionParseCacheEntry, type SessionParseCacheEntry } from './session-parse-cache-store' @@ -134,6 +135,20 @@ export async function parseAgentSessionFileCached( ) } +/** + * A Session History scan's parse. Listing the path first is what exempts its row + * from the count bound kept for other readers. + */ +export function parseListedSessionFile( + candidate: SessionFileCandidate, + platform: NodeJS.Platform, + stats: SessionParseStats, + signal?: AbortSignal +): Promise { + noteSessionParseCacheListed(candidate.file.path) + return parseAgentSessionFileCached(candidate, platform, stats, undefined, signal) +} + /** * What a caller other than the session list needs out of this parse. * diff --git a/src/main/ai-vault/session-scanner-unlimited-dedup.test.ts b/src/main/ai-vault/session-scanner-unlimited-dedup.test.ts index a18b89646cc..29bf2b3ee3d 100644 --- a/src/main/ai-vault/session-scanner-unlimited-dedup.test.ts +++ b/src/main/ai-vault/session-scanner-unlimited-dedup.test.ts @@ -25,7 +25,7 @@ vi.mock('./session-scanner-parse-cache', () => ({ earlyStopped: 0, bytesRead: 0 }), - parseAgentSessionFileCached: async (candidate: { session: AiVaultSession }) => candidate.session + parseListedSessionFile: async (candidate: { session: AiVaultSession }) => candidate.session })) vi.mock('./session-root-dedup', async (original) => { const actual = await original() diff --git a/src/main/ai-vault/session-scanner.ts b/src/main/ai-vault/session-scanner.ts index bf467f16793..4a0aea85c27 100644 --- a/src/main/ai-vault/session-scanner.ts +++ b/src/main/ai-vault/session-scanner.ts @@ -17,7 +17,7 @@ import { sessionCandidatesFromDiscoveries } from './session-scanner-candidates' import { ensureSessionParseCacheLoaded } from './session-parse-cache-persistence' import { createSessionParseStats, - parseAgentSessionFileCached, + parseListedSessionFile, type SessionParseStats } from './session-scanner-parse-cache' import { recordSessionScanIssue } from './session-scan-issues' @@ -280,13 +280,7 @@ async function parseSessionCandidate( signal?: AbortSignal ): Promise { try { - let session = await parseAgentSessionFileCached( - candidate, - platform, - parseStats, - undefined, - signal - ) + let session = await parseListedSessionFile(candidate, platform, parseStats, signal) if (session && candidate.antigravityHistoryPath && antigravityWorkspaceResolver) { session = await antigravityWorkspaceResolver.enrich(session, candidate.antigravityHistoryPath) } @@ -313,7 +307,8 @@ function skippedRecordIssue( candidate: SessionFileCandidate, executionHostId: ExecutionHostId ): AiVaultScanIssue | null { - const skipped = getSessionParseCacheEntry(candidate.file.path)?.resume?.skippedRecords + const entry = getSessionParseCacheEntry(candidate.file.path) + const skipped = entry?.resume?.skippedRecords ?? entry?.skippedRecords const message = skipped ? describeSkippedTranscriptRecords(skipped) : null if (message === null) { return null