From be033c2ab22defede3982f0adccc230bd68fc61e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 16 Oct 2026 14:58:39 -0700 Subject: [PATCH] Session History: stop re-reading unchanged transcripts past 4,096 files (#28821) * Persist session parse results beyond the memory cache limit * Expire Session History parse rows by idle time instead of a count cap The parse cache dropped its oldest row once it held 4,096, so a scan over more transcripts evicted its own upcoming hits and re-parsed every file on every scan. Rows now stay until no reader has used them for 24 hours, swept after each completed History scan and each search-index pass. Only the live parser state keeps a 4,096 count bound; dropping it costs one whole re-read of a file that changed, never a re-parse of one that did not. Removes the overflow directory and the orcad persistence wiring from the earlier revision of this change. * Expire only rows History lists, and keep the newest transcripts under bounds The search pass ran the idle sweep every 20 s but never re-stores History's unchanged rows, so with search on a day away from History dropped them all and reopening it re-parsed everything. Only a completed History scan sweeps now, and it expires only rows History listed (on a monotonic clock). Rows only the search index or a title lookup created keep main's 4,096 count bound, so an index build can't grow the scanner process without limit. Scans store newest transcripts first, so every store-order bound dropped the newest, most active files: the saved snapshot kept the oldest ~11k rows and the resume bound stripped the newest files' parser state. The snapshot is now ordered by transcript mtime, and both count bounds drop the tenth of their rows with the oldest transcripts. * Drop the 24 h idle sweep; History rows live for the process The sweep let whichever listing ran last decide when a row died, and listings differ in scope: a smaller one (the phone's Agent History, a lower desktop limit) expired rows only a larger one used, so the next Unlimited scan re-parsed them. Rows History lists now live as long as the scanner process; the snapshot's newest-transcripts cap bounds what survives a reload, and rows other readers made keep the 4,096 bound. Also clears the resume bookkeeping for a row the unlisted bound drops in the same store. * Skip resume bookkeeping for a row the unlisted bound just dropped Running the resume trim first let a row the unlisted trim then deleted push the resume set over its bound and cost 409 live resume points. --- config/scripts/vitest-node-runtime-files.mjs | 1 + .../session-parse-cache-bounds.test.ts | 21 +---- .../session-parse-cache-checkpoint.test.ts | 6 +- .../session-parse-cache-large-scan.test.ts | 90 ++++++++++++++++++ .../session-parse-cache-persistence.ts | 10 +- .../session-parse-cache-save-lane.test.ts | 4 +- .../ai-vault/session-parse-cache-store.ts | 94 +++++++++++++++---- .../ai-vault/session-scanner-parse-cache.ts | 15 +++ .../session-scanner-unlimited-dedup.test.ts | 2 +- src/main/ai-vault/session-scanner.ts | 13 +-- 10 files changed, 195 insertions(+), 61 deletions(-) create mode 100644 src/main/ai-vault/session-parse-cache-large-scan.test.ts 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