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.
This commit is contained in:
Brennan Benson
2026-10-16 14:58:39 -07:00
committed by GitHub
parent 702ffc7bc4
commit be033c2ab2
10 changed files with 195 additions and 61 deletions
@@ -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.
@@ -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')
@@ -40,12 +40,10 @@ vi.mock('./session-scanner-parse-cache', async (importOriginal) => {
const actual = await importOriginal<typeof ParseCacheModule>()
return {
...actual,
parseAgentSessionFileCached: async (
...args: Parameters<typeof actual.parseAgentSessionFileCached>
) => {
parseListedSessionFile: async (...args: Parameters<typeof actual.parseListedSessionFile>) => {
hooks.stats = args[2] ?? null
await hooks.beforeParse?.()
return actual.parseAgentSessionFileCached(...args)
return actual.parseListedSessionFile(...args)
}
}
})
@@ -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<typeof createSessionParseStats>) => {
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)
@@ -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
@@ -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()
+74 -20
View File
@@ -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<string, SessionParseCacheEntry>()
// 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<string>()
// Rows no History scan has listed, and rows holding a resume point: the two
// count-bounded sets.
const unlistedPaths = new Set<string>()
const resumePaths = new Set<string>()
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<string>,
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
@@ -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<AiVaultSession | null> {
noteSessionParseCacheListed(candidate.file.path)
return parseAgentSessionFileCached(candidate, platform, stats, undefined, signal)
}
/**
* What a caller other than the session list needs out of this parse.
*
@@ -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<typeof SessionDedup>()
+4 -9
View File
@@ -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<SessionParseResult> {
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