From 674376b3320892bb94d30665c643cf9ec8d45d43 Mon Sep 17 00:00:00 2001 From: m4air Date: Fri, 11 Sep 2026 23:05:11 -0700 Subject: [PATCH] perf(ai-vault): count recent sessions for scan cutoffs --- .../scripts/session-scan-cutoff-benchmark.mjs | 197 ++++++++++++++++++ src/main/ai-vault/remote-session-scanner.ts | 20 +- src/main/ai-vault/session-scan-cutoff.test.ts | 121 +++++++++++ src/main/ai-vault/session-scan-cutoff.ts | 41 ++++ .../ai-vault/session-scanner-cutoff.test.ts | 77 +++++++ src/main/ai-vault/session-scanner.ts | 18 +- 6 files changed, 439 insertions(+), 35 deletions(-) create mode 100644 config/scripts/session-scan-cutoff-benchmark.mjs create mode 100644 src/main/ai-vault/session-scan-cutoff.test.ts create mode 100644 src/main/ai-vault/session-scan-cutoff.ts create mode 100644 src/main/ai-vault/session-scanner-cutoff.test.ts diff --git a/config/scripts/session-scan-cutoff-benchmark.mjs b/config/scripts/session-scan-cutoff-benchmark.mjs new file mode 100644 index 00000000000..4353754199e --- /dev/null +++ b/config/scripts/session-scan-cutoff-benchmark.mjs @@ -0,0 +1,197 @@ +#!/usr/bin/env node +// git show :src/main/ai-vault/session-scanner.ts | node config/scripts/session-scan-cutoff-benchmark.mjs +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import path from 'node:path' +import { performance } from 'node:perf_hooks' +import ts from 'typescript-api' +import { build } from 'esbuild' +import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs' + +const baselineSource = ts.createSourceFile( + 'session-scanner.ts', + readFileSync(0, 'utf8'), + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS +) +const baselineFunction = baselineSource.statements.find( + (node) => ts.isFunctionDeclaration(node) && node.name?.text === 'canStopParsingSessions' +) +assert(baselineFunction, 'Pipe the baseline session-scanner.ts on stdin') + +async function load(contents) { + const result = await build({ + stdin: { contents, resolveDir: path.resolve('src/main/ai-vault'), loader: 'ts' }, + platform: 'node', + format: 'esm', + bundle: true, + write: false + }) + const encoded = Buffer.from(result.outputFiles[0].text).toString('base64') + return import(`data:text/javascript;base64,${encoded}`) +} +const [baselineModule, currentModule] = await Promise.all([ + load(`import { sessionSortTime } from './session-scanner-accumulator'; +export ${baselineFunction.getText(baselineSource)}`), + load(`export { canStopParsingSessions } from './session-scan-cutoff'; +export { CodexSessionCollection } from './codex-session-root-dedup';`) +]) +const baseline = baselineModule.canStopParsingSessions +const current = currentModule.canStopParsingSessions +const { CodexSessionCollection } = currentModule + +let randomState = 91114 +function random(bound) { + randomState = (Math.imul(randomState, 1664525) + 1013904223) >>> 0 + return Math.floor((randomState / 2 ** 32) * bound) +} +function session(index, overrides = {}) { + return Object.freeze({ + agent: 'claude', + executionHostId: 'local', + sessionId: `session-${index}`, + filePath: `/home/ada/.codex/sessions/rollout-${index}.jsonl`, + codexHome: null, + updatedAt: new Date(index).toISOString(), + modifiedAt: new Date(0).toISOString(), + ...overrides + }) +} +function collection(rows) { + const result = new CodexSessionCollection() + for (const row of rows) { + result.add(row) + } + return result +} +function check(sessions, limit, next) { + const rows = [...sessions.values()] + assert.equal(current(sessions, limit, next), baseline(sessions, limit, next)) + assert.deepEqual([...sessions.values()], rows) +} + +const dates = [ + null, + '', + 'invalid', + '1970-01-01T00:00:00Z', + '1970-01-01T00:00:02+00:00', + '-000001-01-01T00:00:00Z', + '+010000-01-01T00:00:00Z', + '-271821-04-20T00:00:00.000Z' +] +const limits = [0, -1, -3, 0.5, 1.5, Number.NaN, Infinity, -Infinity] +const nextTimes = [undefined, Number.NaN, Infinity, -Infinity, 0, 1, 2, 2000] +let comparisons = 0 +for (let trial = 0; trial < 4_000; trial += 1) { + const sessions = new CodexSessionCollection() + const admitted = [] + for (let batch = 0; batch < 10; batch += 1) { + const count = random(8) + for (let index = 0; index < count; index += 1) { + const id = random(12) + const row = + admitted.length && random(5) === 0 + ? admitted[random(admitted.length)] + : session(id, { + agent: random(3) ? 'codex' : 'claude', + executionHostId: random(4) ? 'local' : 'ssh:dev', + codexHome: random(2) ? null : '/custom', + updatedAt: random(3) + ? new Date(random(5000) - 2500).toISOString() + : dates[random(dates.length)], + modifiedAt: random(4) ? new Date(random(5000)).toISOString() : 'invalid' + }) + admitted.push(row) + sessions.add(row) + } + const limit = random(3) ? 1 + random(40) : limits[random(limits.length)] + const next = random(2) ? random(5000) - 2500 : nextTimes[random(nextTimes.length)] + check(sessions, limit, next) + comparisons += 1 + } +} +console.log(`${comparisons} differential batch cutoffs passed.`) + +function median(values) { + const sorted = values.toSorted((left, right) => left - right) + return (sorted[sorted.length / 2 - 1] + sorted[sorted.length / 2]) / 2 +} +function measure(name, run, repeats) { + const expected = run(baseline) + assert.deepEqual(run(current), expected) + const sample = (cutoff) => { + let result + const start = performance.now() + for (let index = 0; index < repeats; index += 1) { + result = run(cutoff) + } + const elapsed = (performance.now() - start) / repeats + assert.deepEqual(result, expected) + return elapsed + } + sample(baseline) + sample(current) + const samples = { baseline: [], current: [] } + for (const pair of buildCounterbalancedSchedule(8, 'baseline', 'current')) { + for (const arm of pair) { + samples[arm].push(sample(arm === 'baseline' ? baseline : current)) + } + } + return { name, beforeMs: median(samples.baseline), afterMs: median(samples.current) } +} + +console.log( + JSON.stringify({ node: process.version, platform: process.platform, arch: process.arch }) +) +const results = [] +for (const count of [8, 100, 1_000, 2_000, 10_000]) { + for (const order of ['ordered', 'shuffled']) { + const rows = Array.from({ length: count }, (_, index) => session(count - index)) + if (order === 'shuffled') { + for (let index = count - 1; index > 0; index -= 1) { + const other = random(index + 1) + ;[rows[index], rows[other]] = [rows[other], rows[index]] + } + } + const sessions = collection(rows) + for (const next of [0, count]) { + results.push( + measure( + `${count} ${order} / ${next === 0 ? 'stop' : 'continue'}`, + (cutoff) => cutoff(sessions, Math.ceil(count / 2), next), + Math.max(20, Math.floor(30_000 / count)) + ) + ) + } + } +} +for (const invalidIndex of [0, 999]) { + const sessions = collection( + Array.from({ length: 1_000 }, (_, index) => + session(index, invalidIndex === index ? { updatedAt: 'invalid' } : {}) + ) + ) + results.push(measure(`1000 invalid at ${invalidIndex}`, (cutoff) => cutoff(sessions, 500, 0), 50)) +} +const rows = Array.from({ length: 2_000 }, () => session(random(2_000))) +results.push( + measure( + '2000-candidate scan cutoff + admission / limit1000', + (cutoff) => { + const sessions = new CodexSessionCollection() + let index = 0 + while (index < rows.length && !cutoff(sessions, 1_000, 10_000)) { + const end = Math.min(rows.length, index + Math.min(8, Math.max(1, 1_000 - sessions.size))) + while (index < end) { + sessions.add(rows[index++]) + } + } + return { parsed: index, sessions: sessions.size } + }, + 2 + ) +) +console.table(results) +console.log('Synthetic cutoff/admission CPU; excludes discovery, parsing, I/O and final sorting.') diff --git a/src/main/ai-vault/remote-session-scanner.ts b/src/main/ai-vault/remote-session-scanner.ts index b3fcc56ec9d..259c84d66ef 100644 --- a/src/main/ai-vault/remote-session-scanner.ts +++ b/src/main/ai-vault/remote-session-scanner.ts @@ -31,6 +31,7 @@ import { errorMessage } from './session-scanner-values' import { mapRemoteScanBatches } from './remote-session-scan-batching' import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' import { recordSessionScanIssue } from './session-scan-issues' +import { canStopParsingSessions } from './session-scan-cutoff' import { refreshCodexTitleFromIndex } from './session-scanner-codex-cached-title' import { limitRemoteScanFilesystemConcurrency } from './remote-session-scan-concurrency' import { aiVaultScanLimit } from '../../shared/ai-vault-session-depth' @@ -145,7 +146,7 @@ async function parseRemoteSessionCandidates(args: { let index = 0 while (index < args.candidates.length) { - if (canStopParsingRemoteSessions(sessions, args.limit, args.candidates[index]?.file.mtimeMs)) { + if (canStopParsingSessions(sessions, args.limit, args.candidates[index]?.file.mtimeMs)) { break } @@ -311,23 +312,6 @@ function normalizeRemoteScopePaths(scopePaths: readonly string[]): string[] { return scopePaths.map((scopePath) => scopePath.trim()).filter(Boolean) } -function canStopParsingRemoteSessions( - sessions: CodexSessionCollection, - limit: number, - nextCandidateMtimeMs: number | undefined -): boolean { - if (sessions.size < limit || typeof nextCandidateMtimeMs !== 'number') { - return false - } - const visibleCutoff = Array.from(sessions.values(), sessionSortTime) - .sort((left, right) => right - left) - .at(limit - 1) - - // Transcript mtimes bound the remaining candidate order; once the visible - // cutoff is newer, older files cannot enter the unscoped top-N result. - return typeof visibleCutoff === 'number' && nextCandidateMtimeMs < visibleCutoff -} - function isAiVaultSession(session: AiVaultSession | null): session is AiVaultSession { return Boolean(session) } diff --git a/src/main/ai-vault/session-scan-cutoff.test.ts b/src/main/ai-vault/session-scan-cutoff.test.ts new file mode 100644 index 00000000000..53ddd0a019b --- /dev/null +++ b/src/main/ai-vault/session-scan-cutoff.test.ts @@ -0,0 +1,121 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AiVaultSession } from '../../shared/ai-vault-types' +import { CodexSessionCollection } from './codex-session-root-dedup' +import { canStopParsingSessions } from './session-scan-cutoff' +import { createAccumulator, finalizeSession, sessionSortTime } from './session-scanner-accumulator' + +function session(time: number | string, overrides: Partial = {}): AiVaultSession { + const parsed = finalizeSession( + createAccumulator({ + agent: 'claude', + sessionId: 'session', + file: { path: '/sessions/session.jsonl', mtimeMs: 0, modifiedAt: new Date(0).toISOString() } + }), + 'linux' + ) + if (!parsed) { + throw new Error('Expected a session fixture') + } + return Object.freeze({ + ...parsed, + updatedAt: typeof time === 'number' ? new Date(time).toISOString() : time, + ...overrides + }) +} + +function collection(rows: AiVaultSession[]): CodexSessionCollection { + const result = new CodexSessionCollection() + rows.forEach((row) => result.add(row)) + return result +} + +function sortedReference(rows: AiVaultSession[], limit: number, next: number | undefined): boolean { + if (rows.length < limit || typeof next !== 'number') { + return false + } + const cutoff = rows + .map(sessionSortTime) + .sort((left, right) => right - left) + .at(limit - 1) + return typeof cutoff === 'number' && next < cutoff +} + +afterEach(() => vi.restoreAllMocks()) + +describe('canStopParsingSessions', () => { + it('counts strictly newer rows without sorting or mutating their order', () => { + const rows = [session(8), session(2), session(6), session(4)] + const sessions = collection(rows) + const sort = vi.spyOn(Array.prototype, 'sort') + expect(canStopParsingSessions(sessions, 2, 5)).toBe(true) + expect(canStopParsingSessions(sessions, 2, 6)).toBe(false) + expect(canStopParsingSessions(sessions, 4, 1)).toBe(true) + expect(canStopParsingSessions(sessions, 4, 2)).toBe(false) + expect(sort).not.toHaveBeenCalled() + expect([...sessions.values()]).toEqual(rows) + }) + + it('does not visit rows before the unique-session budget is met', () => { + const sessions = collection([session(5)]) + const values = vi.spyOn(sessions, 'values') + expect(canStopParsingSessions(sessions, 2, 0)).toBe(false) + expect(canStopParsingSessions(sessions, Number.POSITIVE_INFINITY, 0)).toBe(false) + expect(canStopParsingSessions(sessions, 1, undefined)).toBe(false) + expect(values).not.toHaveBeenCalled() + }) + + it('recounts a preferred alias even when replacement lowers its timestamp', () => { + const alias = { + agent: 'codex' as const, + sessionId: 'same', + filePath: '/sessions/rollout-same.jsonl' + } + const sessions = collection([session(100, { ...alias, codexHome: '/custom' }), session(100)]) + expect(canStopParsingSessions(sessions, 2, 50)).toBe(true) + const preferred = session(10, { ...alias, codexHome: null }) + sessions.add(preferred) + expect(sessions.size).toBe(2) + expect(canStopParsingSessions(sessions, 2, 50)).toBe(false) + sessions.add(preferred) + expect(sessions.size).toBe(3) + expect(canStopParsingSessions(sessions, 3, 9)).toBe(true) + }) + + it('preserves the legacy sort result when a later timestamp is invalid', () => { + const rows = [session(0), session('invalid'), session(20)] + const sessions = collection(rows) + expect(canStopParsingSessions(sessions, 1, 10)).toBe(false) + for (const candidate of [rows, rows.toReversed(), [rows[2], rows[0], rows[1]]]) { + for (const limit of [1, 2, 3]) { + for (const next of [-1, 0, 10, 20]) { + expect(canStopParsingSessions(collection(candidate), limit, next)).toBe( + sortedReference(candidate, limit, next) + ) + } + } + } + }) + + it('parses timestamps only once when an invalid date appears at the end', () => { + const sessions = collection([session(10), session(5), session('invalid')]) + const parse = vi.spyOn(Date, 'parse') + canStopParsingSessions(sessions, 1, 0) + expect(parse).toHaveBeenCalledTimes(3) + }) + + it('uses the same nullish modified-time fallback and numeric limit semantics', () => { + const rows = [ + session('', { updatedAt: null, modifiedAt: '1970-01-01T00:00:02+00:00' }), + session('-000001-01-01T00:00:00Z'), + session('+010000-01-01T00:00:00Z'), + session(0) + ] + for (const limit of [0, -1, -5, 0.5, 1.5, Number.NaN, Infinity, -Infinity, 1, 2, 4, 5]) { + for (const next of [undefined, Number.NaN, -Infinity, Infinity, -1, 0, 1, 2000]) { + expect(canStopParsingSessions(collection(rows), limit, next)).toBe( + sortedReference(rows, limit, next) + ) + } + } + }) +}) diff --git a/src/main/ai-vault/session-scan-cutoff.ts b/src/main/ai-vault/session-scan-cutoff.ts new file mode 100644 index 00000000000..e93d25bc985 --- /dev/null +++ b/src/main/ai-vault/session-scan-cutoff.ts @@ -0,0 +1,41 @@ +import type { CodexSessionCollection } from './codex-session-root-dedup' +import { sessionSortTime } from './session-scanner-accumulator' + +type ScanSessions = Pick + +function sortedCutoffIsNewer( + times: number[], + limit: number, + nextCandidateMtimeMs: number +): boolean { + const visibleCutoff = times.sort((left, right) => right - left).at(limit - 1) + return typeof visibleCutoff === 'number' && nextCandidateMtimeMs < visibleCutoff +} + +export function canStopParsingSessions( + sessions: ScanSessions, + limit: number, + nextCandidateMtimeMs: number | undefined +): boolean { + if (sessions.size < limit || typeof nextCandidateMtimeMs !== 'number') { + return false + } + const times = Array.from(sessions.values(), sessionSortTime) + if (!Number.isInteger(limit) || limit <= 0) { + return sortedCutoffIsNewer(times, limit, nextCandidateMtimeMs) + } + + // The top-N cutoff is newer exactly when N retained sessions beat the next mtime. + let newerCount = 0 + for (const time of times) { + if (Number.isNaN(time)) { + // NaN makes the old comparator inconsistent; preserve its ordering verbatim. + return sortedCutoffIsNewer(times, limit, nextCandidateMtimeMs) + } + if (time > nextCandidateMtimeMs) { + newerCount += 1 + } + } + // Check every timestamp before deciding: a later NaN requires the legacy sort. + return newerCount >= limit +} diff --git a/src/main/ai-vault/session-scanner-cutoff.test.ts b/src/main/ai-vault/session-scanner-cutoff.test.ts new file mode 100644 index 00000000000..76488ddabbe --- /dev/null +++ b/src/main/ai-vault/session-scanner-cutoff.test.ts @@ -0,0 +1,77 @@ +import { mkdtemp, mkdir, rm, utimes, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' +import { scanRemoteAiVaultSessions } from './remote-session-scanner' +import { MemoryRemoteProvider } from './remote-session-scanner-test-fixtures' +import { scanAiVaultSessions } from './session-scanner' +import { isolatedScanRoots, jsonLines } from './session-scanner-test-fixtures' + +const tempRoots: string[] = [] +afterEach(async () => { + vi.restoreAllMocks() + await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('session scanner cutoff', () => { + it.each(['native', 'remote'] as const)( + '%s does not sort timestamps at every post-limit candidate', + async (host) => { + const count = 128 + const limit = count / 2 + const provider = new MemoryRemoteProvider() + const root = await mkdtemp(join(tmpdir(), 'orca-session-cutoff-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + await mkdir(roots.codexSessionsDir, { recursive: true }) + for (let index = 0; index < count; index++) { + const name = `rollout-session-${index}.jsonl` + const content = jsonLines([ + { type: 'session_meta', payload: { id: `session-${index}`, cwd: '/repo/folder' } }, + { + type: 'event_msg', + timestamp: new Date(index).toISOString(), + payload: { type: 'user_message', message: 'Check this session' } + } + ]) + const mtime = 10_000 - index + if (host === 'native') { + const filePath = join(roots.codexSessionsDir, name) + await writeFile(filePath, content) + await utimes(filePath, new Date(mtime), new Date(mtime)) + } else { + provider.addFile(`/home/ada/.codex/sessions/${name}`, content, mtime) + } + } + let numericSorts = 0 + const originalSort = Array.prototype.sort + vi.spyOn(Array.prototype, 'sort').mockImplementation(function (this: unknown[], compare) { + if (typeof this[0] === 'number') { + numericSorts++ + } + return originalSort.call(this, compare) + }) + const scan = () => + host === 'native' + ? scanAiVaultSessions({ ...roots, limit }) + : scanRemoteAiVaultSessions({ + provider, + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64'), + executionHostId: 'ssh:scan-cutoff', + limit + }) + + for (let pass = 0; pass < 2; pass++) { + numericSorts = 0 + const result = await scan() + expect(result.issues).toEqual([]) + expect(result.sessions.map((row) => row.sessionId)).toEqual( + Array.from({ length: limit }, (_, index) => `session-${count - index - 1}`) + ) + expect(numericSorts).toBe(0) + } + } + ) +}) diff --git a/src/main/ai-vault/session-scanner.ts b/src/main/ai-vault/session-scanner.ts index 9461fef6525..94c69258d59 100644 --- a/src/main/ai-vault/session-scanner.ts +++ b/src/main/ai-vault/session-scanner.ts @@ -23,6 +23,7 @@ import { type SessionParseStats } from './session-scanner-parse-cache' import { recordSessionScanIssue } from './session-scan-issues' +import { canStopParsingSessions } from './session-scan-cutoff' import { discoverInScopeClaudeFiles } from './session-scanner-scope-discovery' import { discoverAiVaultSessionSources } from './session-scanner-source-discovery' import { cursorChatMetaRefusals, withCursorChatMetaScan } from './session-scanner-cursor-chat-meta' @@ -296,20 +297,3 @@ function withSessionExecutionHost( id: `${executionHostId}:${session.agent}:${session.sessionId}:${session.filePath}` } } - -function canStopParsingSessions( - sessions: CodexSessionCollection, - limit: number, - nextCandidateMtimeMs: number | undefined -): boolean { - if (sessions.size < limit || typeof nextCandidateMtimeMs !== 'number') { - return false - } - const visibleCutoff = Array.from(sessions.values(), sessionSortTime) - .sort((left, right) => right - left) - .at(limit - 1) - - // Transcript mtime is already our discovery bound and fallback sort key; older - // files cannot displace the current visible set once the cutoff is newer. - return typeof visibleCutoff === 'number' && nextCandidateMtimeMs < visibleCutoff -}