From eac96f1ecfb408d7931b930c1c00f74eaa4e1f8f Mon Sep 17 00:00:00 2001 From: brennanb2025 Date: Mon, 6 Jul 2026 17:13:46 -0700 Subject: [PATCH] perf(ai-vault): reuse unchanged remote session files across scans Remote AI-vault rescans re-read every candidate session file over SSH in full on every refresh. Cache parse results per host keyed by remote path with an (mtime,size) signature so unchanged files skip the body transfer and re-parse entirely; only new/changed files are read. LRU-bounded at 4096 entries per host (matches the local parse cache bound), kept across reconnects, evicted on target removal. Extends `#7593`'s local-only fix to the SSH path. Co-authored-by: Orca --- ...remote-session-scanner-parse-cache.test.ts | 174 ++++++++++++++++++ .../remote-session-scanner-parse-cache.ts | 80 ++++++++ .../remote-session-scanner-test-provider.ts | 119 ++++++++++++ .../ai-vault/remote-session-scanner.test.ts | 119 ++---------- src/main/ai-vault/remote-session-scanner.ts | 25 ++- src/main/ipc/ssh.ts | 6 +- 6 files changed, 412 insertions(+), 111 deletions(-) create mode 100644 src/main/ai-vault/remote-session-scanner-parse-cache.test.ts create mode 100644 src/main/ai-vault/remote-session-scanner-parse-cache.ts create mode 100644 src/main/ai-vault/remote-session-scanner-test-provider.ts diff --git a/src/main/ai-vault/remote-session-scanner-parse-cache.test.ts b/src/main/ai-vault/remote-session-scanner-parse-cache.test.ts new file mode 100644 index 00000000000..fec516d4e3c --- /dev/null +++ b/src/main/ai-vault/remote-session-scanner-parse-cache.test.ts @@ -0,0 +1,174 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import type { ExecutionHostId } from '../../shared/execution-host' +import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' +import { scanRemoteAiVaultSessions } from './remote-session-scanner' +import { + evictRemoteSessionParseCache, + readCachedRemoteSessionParse, + REMOTE_PARSE_CACHE_MAX_ENTRIES_PER_HOST, + resetRemoteSessionParseCacheForTests, + storeRemoteSessionParse +} from './remote-session-scanner-parse-cache' +import { + codexTranscript, + jsonLines, + MemoryRemoteProvider +} from './remote-session-scanner-test-provider' +import type { FileWithMtime } from './session-scanner-types' + +const CLAUDE_ONE = '/home/ada/.claude/projects/repo/one.jsonl' +const CLAUDE_TWO = '/home/ada/.claude/projects/repo/two.jsonl' +const CODEX_A = '/home/ada/.codex/sessions/codex-a.jsonl' + +function scan(provider: MemoryRemoteProvider, executionHostId: ExecutionHostId = 'ssh:dev-box') { + return scanRemoteAiVaultSessions({ + provider, + executionHostId, + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64') + }) +} + +function claudeTranscript(args: { sessionId: string; title: string; messages?: number }): string { + const records: unknown[] = [ + { + sessionId: args.sessionId, + timestamp: '2026-07-04T04:00:00.000Z', + type: 'user', + cwd: '/home/ada/repo', + message: { content: [{ type: 'text', text: args.title }] } + } + ] + for (let index = 1; index < (args.messages ?? 2); index++) { + records.push({ + sessionId: args.sessionId, + timestamp: `2026-07-04T04:00:0${index}.000Z`, + type: 'assistant', + message: { model: 'claude-opus-4', content: `Answer ${index}` } + }) + } + return jsonLines(records) +} + +function fileAt(path: string, mtimeMs = 1): FileWithMtime { + return { path, mtimeMs, modifiedAt: new Date(mtimeMs).toISOString(), sizeBytes: 1 } +} + +describe('remote session parse cache', () => { + beforeEach(() => { + resetRemoteSessionParseCacheForTests() + }) + + it('reads every body once, then rescans unchanged files with zero reads', async () => { + const provider = new MemoryRemoteProvider() + provider.addFile(CLAUDE_ONE, claudeTranscript({ sessionId: 'one', title: 'First' }), 10) + provider.addFile(CLAUDE_TWO, claudeTranscript({ sessionId: 'two', title: 'Second' }), 20) + provider.addFile( + CODEX_A, + codexTranscript({ + sessionId: 'codex-a', + title: 'Codex session', + cwd: '/home/ada/repo', + timestamp: '2026-07-04T05:00:00.000Z' + }), + 30 + ) + + const first = await scan(provider) + expect(first.issues).toEqual([]) + expect(first.sessions).toHaveLength(3) + const firstReads = new Set(provider.readFileCalls) + expect(firstReads).toContain(CLAUDE_ONE) + expect(firstReads).toContain(CLAUDE_TWO) + expect(firstReads).toContain(CODEX_A) + + provider.clearReadFileCalls() + const second = await scan(provider) + expect(provider.readFileCalls).toEqual([]) + expect(second.sessions).toEqual(first.sessions) + expect(second.issues).toEqual([]) + }) + + it('re-reads exactly the file whose mtime changed', async () => { + const provider = new MemoryRemoteProvider() + provider.addFile(CLAUDE_ONE, claudeTranscript({ sessionId: 'one', title: 'First' }), 10) + provider.addFile(CLAUDE_TWO, claudeTranscript({ sessionId: 'two', title: 'Second' }), 20) + await scan(provider) + + provider.addFile( + CLAUDE_TWO, + claudeTranscript({ sessionId: 'two', title: 'Second', messages: 3 }), + 25 + ) + provider.clearReadFileCalls() + const result = await scan(provider) + + expect(provider.readFileCalls).toEqual([CLAUDE_TWO]) + expect(result.sessions.find((session) => session.sessionId === 'two')?.messageCount).toBe(3) + }) + + it('re-reads a file whose size changed even when the mtime did not', async () => { + const provider = new MemoryRemoteProvider() + provider.addFile(CLAUDE_ONE, claudeTranscript({ sessionId: 'one', title: 'First' }), 10) + await scan(provider) + + provider.addFile( + CLAUDE_ONE, + claudeTranscript({ sessionId: 'one', title: 'First', messages: 4 }), + 10 + ) + provider.clearReadFileCalls() + const result = await scan(provider) + + expect(provider.readFileCalls).toEqual([CLAUDE_ONE]) + expect(result.sessions[0]?.messageCount).toBe(4) + }) + + it('never serves entries cached for one host to another host', async () => { + const content = claudeTranscript({ sessionId: 'shared', title: 'Same path everywhere' }) + const providerA = new MemoryRemoteProvider() + providerA.addFile(CLAUDE_ONE, content, 10) + await scan(providerA, 'ssh:host-a') + + const providerB = new MemoryRemoteProvider() + providerB.addFile(CLAUDE_ONE, content, 10) + const result = await scan(providerB, 'ssh:host-b') + + expect(providerB.readFileCalls).toContain(CLAUDE_ONE) + expect(result.sessions[0]?.executionHostId).toBe('ssh:host-b') + }) + + it('re-reads bodies after the host cache is evicted', async () => { + const provider = new MemoryRemoteProvider() + provider.addFile(CLAUDE_ONE, claudeTranscript({ sessionId: 'one', title: 'First' }), 10) + await scan(provider) + + evictRemoteSessionParseCache('ssh:dev-box') + provider.clearReadFileCalls() + await scan(provider) + + expect(provider.readFileCalls).toEqual([CLAUDE_ONE]) + }) + + it('bounds entries per host, evicting least recently used first', () => { + storeRemoteSessionParse('ssh:host-b', fileAt('/other-host'), null) + for (let index = 0; index < REMOTE_PARSE_CACHE_MAX_ENTRIES_PER_HOST; index++) { + storeRemoteSessionParse('ssh:host-a', fileAt(`/f${index}`), null) + } + + // Reading /f0 refreshes its recency, so the overflow evicts /f1 instead. + expect(readCachedRemoteSessionParse('ssh:host-a', fileAt('/f0'))).not.toBeNull() + storeRemoteSessionParse('ssh:host-a', fileAt('/overflow'), null) + + expect(readCachedRemoteSessionParse('ssh:host-a', fileAt('/f1'))).toBeNull() + expect(readCachedRemoteSessionParse('ssh:host-a', fileAt('/f0'))).not.toBeNull() + expect(readCachedRemoteSessionParse('ssh:host-a', fileAt('/overflow'))).not.toBeNull() + expect(readCachedRemoteSessionParse('ssh:host-b', fileAt('/other-host'))).not.toBeNull() + }) + + it('misses when the stat changed and when the host was never scanned', () => { + storeRemoteSessionParse('ssh:host-a', fileAt('/f', 1), null) + expect(readCachedRemoteSessionParse('ssh:host-a', fileAt('/f', 2))).toBeNull() + expect(readCachedRemoteSessionParse('ssh:host-c', fileAt('/f', 1))).toBeNull() + }) +}) diff --git a/src/main/ai-vault/remote-session-scanner-parse-cache.ts b/src/main/ai-vault/remote-session-scanner-parse-cache.ts new file mode 100644 index 00000000000..e8e400fdd72 --- /dev/null +++ b/src/main/ai-vault/remote-session-scanner-parse-cache.ts @@ -0,0 +1,80 @@ +import type { AiVaultSession } from '../../shared/ai-vault-types' +import type { ExecutionHostId } from '../../shared/execution-host' +import type { FileWithMtime } from './session-scanner-types' + +// Sized past the remote recency cap (1000) plus the in-scope parse cap (2000) +// so a full steady-state result set stays resident between rescans; matches +// the local session-scanner-parse-cache bound. +export const REMOTE_PARSE_CACHE_MAX_ENTRIES_PER_HOST = 4096 + +type RemoteSessionParseCacheEntry = { + mtimeMs: number + sizeBytes: number | null + session: AiVaultSession | null +} + +export type RemoteSessionParseCacheHit = { + session: AiVaultSession | null +} + +// Keyed by execution host so identical remote paths on different hosts can +// never serve each other's sessions. Reconnects keep entries (paths and +// mtimes stay valid); target removal evicts via evictRemoteSessionParseCache. +const cacheByHost = new Map>() + +/** + * Return the parse result cached for this remote file when its (mtime, size) + * stat is unchanged, refreshing its LRU recency. A hit means the rescan skips + * the SSH body transfer and re-parse entirely; `null` means read + parse. + */ +export function readCachedRemoteSessionParse( + executionHostId: ExecutionHostId, + file: FileWithMtime +): RemoteSessionParseCacheHit | null { + const hostCache = cacheByHost.get(executionHostId) + const entry = hostCache?.get(file.path) + if (!hostCache || !entry) { + return null + } + const unchanged = + entry.mtimeMs === file.mtimeMs && + (entry.sizeBytes === null || file.sizeBytes === undefined || entry.sizeBytes === file.sizeBytes) + if (!unchanged) { + return null + } + hostCache.delete(file.path) + hostCache.set(file.path, entry) + return { session: entry.session } +} + +export function storeRemoteSessionParse( + executionHostId: ExecutionHostId, + file: FileWithMtime, + session: AiVaultSession | null +): void { + let hostCache = cacheByHost.get(executionHostId) + if (!hostCache) { + hostCache = new Map() + cacheByHost.set(executionHostId, hostCache) + } + hostCache.delete(file.path) + hostCache.set(file.path, { + mtimeMs: file.mtimeMs, + sizeBytes: file.sizeBytes ?? null, + session + }) + if (hostCache.size > REMOTE_PARSE_CACHE_MAX_ENTRIES_PER_HOST) { + const oldest = hostCache.keys().next() + if (!oldest.done) { + hostCache.delete(oldest.value) + } + } +} + +export function evictRemoteSessionParseCache(executionHostId: ExecutionHostId): void { + cacheByHost.delete(executionHostId) +} + +export function resetRemoteSessionParseCacheForTests(): void { + cacheByHost.clear() +} diff --git a/src/main/ai-vault/remote-session-scanner-test-provider.ts b/src/main/ai-vault/remote-session-scanner-test-provider.ts new file mode 100644 index 00000000000..08adb5c5e5b --- /dev/null +++ b/src/main/ai-vault/remote-session-scanner-test-provider.ts @@ -0,0 +1,119 @@ +import type { DirEntry } from '../../shared/types' +import type { FileReadResult, FileStat, IFilesystemProvider } from '../providers/types' + +/** + * In-memory remote filesystem for remote-session-scanner tests. Records every + * readFile call (each one models a full SSH body transfer) so parse-cache + * tests can assert exactly which bodies a rescan re-reads. + */ +export class MemoryRemoteProvider implements IFilesystemProvider { + private readonly files = new Map() + readonly readFileCalls: string[] = [] + + addFile(path: string, content: string, mtimeMs: number): void { + this.files.set(normalize(path), { content, mtimeMs }) + } + + clearReadFileCalls(): void { + this.readFileCalls.length = 0 + } + + async readDir(dirPath: string): Promise { + const dir = normalize(dirPath) + const prefix = dir.endsWith('/') ? dir : `${dir}/` + const entries = new Map() + for (const path of this.files.keys()) { + if (!path.startsWith(prefix)) { + continue + } + const relative = path.slice(prefix.length) + if (!relative) { + continue + } + const [name, ...rest] = relative.split('/') + if (!name) { + continue + } + entries.set(name, { + name, + isDirectory: rest.length > 0, + isSymlink: false + }) + } + return [...entries.values()].sort((left, right) => left.name.localeCompare(right.name)) + } + + async readFile(filePath: string): Promise { + const normalized = normalize(filePath) + this.readFileCalls.push(normalized) + const file = this.files.get(normalized) + if (!file) { + throw new Error(`ENOENT: ${filePath}`) + } + return { content: file.content, isBinary: false } + } + + async stat(filePath: string): Promise { + const file = this.files.get(normalize(filePath)) + if (!file) { + throw new Error(`ENOENT: ${filePath}`) + } + return { size: file.content.length, type: 'file', mtime: file.mtimeMs, mtimeMs: file.mtimeMs } + } + + writeFile = unsupported + writeFileBase64 = unsupported + writeFileBase64Chunk = unsupported + deletePath = unsupported + createFile = unsupported + createDir = unsupported + createDirNoClobber = unsupported + rename = unsupported + renameNoClobber = unsupported + copy = unsupported + realpath = async (path: string): Promise => path + search = unsupported + listFiles = unsupported + watch = unsupported +} + +async function unsupported(): Promise { + throw new Error('unsupported') +} + +function normalize(path: string): string { + return path.replace(/\\/g, '/').replace(/\/+$/, '') +} + +export function jsonLines(records: unknown[]): string { + return records.map((record) => JSON.stringify(record)).join('\n') +} + +export function codexTranscript(args: { + sessionId: string + title: string + cwd: string + timestamp: string + threadSource?: string +}): string { + return jsonLines([ + { + timestamp: args.timestamp, + type: 'session_meta', + payload: { + id: args.sessionId, + cwd: args.cwd, + ...(args.threadSource ? { thread_source: args.threadSource } : {}) + } + }, + { + timestamp: args.timestamp.replace(':00.000Z', ':01.000Z'), + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'text', text: args.title }] + } + } + ]) +} diff --git a/src/main/ai-vault/remote-session-scanner.test.ts b/src/main/ai-vault/remote-session-scanner.test.ts index 16173f7b736..ccfe61046df 100644 --- a/src/main/ai-vault/remote-session-scanner.test.ts +++ b/src/main/ai-vault/remote-session-scanner.test.ts @@ -1,86 +1,18 @@ -import { describe, expect, it } from 'vitest' -import type { DirEntry } from '../../shared/types' -import type { FileReadResult, FileStat, IFilesystemProvider } from '../providers/types' +import { beforeEach, describe, expect, it } from 'vitest' import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' import { scanRemoteAiVaultSessions } from './remote-session-scanner' - -class MemoryRemoteProvider implements IFilesystemProvider { - private readonly files = new Map() - - addFile(path: string, content: string, mtimeMs: number): void { - this.files.set(normalize(path), { content, mtimeMs }) - } - - async readDir(dirPath: string): Promise { - const dir = normalize(dirPath) - const prefix = dir.endsWith('/') ? dir : `${dir}/` - const entries = new Map() - for (const path of this.files.keys()) { - if (!path.startsWith(prefix)) { - continue - } - const relative = path.slice(prefix.length) - if (!relative) { - continue - } - const [name, ...rest] = relative.split('/') - if (!name) { - continue - } - entries.set(name, { - name, - isDirectory: rest.length > 0, - isSymlink: false - }) - } - return [...entries.values()].sort((left, right) => left.name.localeCompare(right.name)) - } - - async readFile(filePath: string): Promise { - const file = this.files.get(normalize(filePath)) - if (!file) { - throw new Error(`ENOENT: ${filePath}`) - } - return { content: file.content, isBinary: false } - } - - async stat(filePath: string): Promise { - const file = this.files.get(normalize(filePath)) - if (!file) { - throw new Error(`ENOENT: ${filePath}`) - } - return { size: file.content.length, type: 'file', mtime: file.mtimeMs, mtimeMs: file.mtimeMs } - } - - writeFile = unsupported - writeFileBase64 = unsupported - writeFileBase64Chunk = unsupported - deletePath = unsupported - createFile = unsupported - createDir = unsupported - createDirNoClobber = unsupported - rename = unsupported - renameNoClobber = unsupported - copy = unsupported - realpath = async (path: string): Promise => path - search = unsupported - listFiles = unsupported - watch = unsupported -} - -async function unsupported(): Promise { - throw new Error('unsupported') -} - -function normalize(path: string): string { - return path.replace(/\\/g, '/').replace(/\/+$/, '') -} - -function jsonLines(records: unknown[]): string { - return records.map((record) => JSON.stringify(record)).join('\n') -} +import { resetRemoteSessionParseCacheForTests } from './remote-session-scanner-parse-cache' +import { + codexTranscript, + jsonLines, + MemoryRemoteProvider +} from './remote-session-scanner-test-provider' describe('scanRemoteAiVaultSessions', () => { + beforeEach(() => { + resetRemoteSessionParseCacheForTests() + }) + it('parses remote default and Orca-managed Codex homes with SSH host ids', async () => { const provider = new MemoryRemoteProvider() provider.addFile( @@ -313,32 +245,3 @@ describe('scanRemoteAiVaultSessions', () => { ]) }) }) - -function codexTranscript(args: { - sessionId: string - title: string - cwd: string - timestamp: string - threadSource?: string -}): string { - return jsonLines([ - { - timestamp: args.timestamp, - type: 'session_meta', - payload: { - id: args.sessionId, - cwd: args.cwd, - ...(args.threadSource ? { thread_source: args.threadSource } : {}) - } - }, - { - timestamp: args.timestamp.replace(':00.000Z', ':01.000Z'), - type: 'response_item', - payload: { - type: 'message', - role: 'user', - content: [{ type: 'text', text: args.title }] - } - } - ]) -} diff --git a/src/main/ai-vault/remote-session-scanner.ts b/src/main/ai-vault/remote-session-scanner.ts index 1b7d66cb7b0..da63a62c810 100644 --- a/src/main/ai-vault/remote-session-scanner.ts +++ b/src/main/ai-vault/remote-session-scanner.ts @@ -13,6 +13,10 @@ import { joinRemotePath } from '../ssh/ssh-remote-platform' import { sessionSortTime } from './session-scanner-accumulator' import type { FileWithMtime } from './session-scanner-types' import { errorMessage } from './session-scanner-values' +import { + readCachedRemoteSessionParse, + storeRemoteSessionParse +} from './remote-session-scanner-parse-cache' import { remoteSessionSources } from './remote-session-scanner-sources' import type { RemoteScannerContext, @@ -194,13 +198,25 @@ async function parseRemoteSessionCandidate( context: RemoteScannerContext, issues: AiVaultScanIssue[] ): Promise { + // A (mtime,size)-unchanged file skips the SSH body transfer and re-parse + // entirely; rescans previously re-read every candidate in full. Like the + // local cache, an unchanged Codex transcript keeps its cached index title. + const cached = readCachedRemoteSessionParse(context.executionHostId, candidate.file) + if (cached) { + return cached.session + } try { const read = await context.provider.readFile(candidate.file.path) if (read.isBinary) { + storeRemoteSessionParse(context.executionHostId, candidate.file, null) return null } - return candidate.source.parse(candidate.file, read.content, context) + const session = await candidate.source.parse(candidate.file, read.content, context) + storeRemoteSessionParse(context.executionHostId, candidate.file, session) + return session } catch (err) { + // Failures stay uncached so the issue resurfaces on every scan and a + // transient read error retries next time. issues.push({ executionHostId: context.executionHostId, agent: candidate.source.agent, @@ -247,7 +263,12 @@ async function statRemoteFile( try { const stat = await provider.stat(path) const mtimeMs = remoteStatMtimeMs(stat) - return { path, mtimeMs, modifiedAt: new Date(mtimeMs).toISOString() } + return { + path, + mtimeMs, + modifiedAt: new Date(mtimeMs).toISOString(), + sizeBytes: Number.isFinite(stat.size) ? stat.size : undefined + } } catch (err) { issues.push({ executionHostId, agent, path, message: errorMessage(err) }) return null diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index ff676112218..eb20c19f27b 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -16,7 +16,8 @@ import type { SshConnectionState } from '../../shared/ssh-types' import { SSH_TERMINATE_RECONNECT_REQUIRED } from '../../shared/constants' -import { isRuntimeOwnedSshTargetId } from '../../shared/execution-host' +import { isRuntimeOwnedSshTargetId, toSshExecutionHostId } from '../../shared/execution-host' +import { evictRemoteSessionParseCache } from '../ai-vault/remote-session-scanner-parse-cache' import { isAuthError } from '../ssh/ssh-connection-utils' import { forceStopRelayForTarget } from '../ssh/ssh-relay-reset' import { isSshPtyNotFoundError } from '../providers/ssh-pty-provider' @@ -115,6 +116,9 @@ export async function removeRegisteredSshTarget(targetId: string): Promise ) } persistedStore?.removeSshRemotePtyLeases(targetId) + // Why: reconnects keep cached session parses (paths/mtimes stay valid), but + // a removed target must not pin its parsed transcripts in main-process memory. + evictRemoteSessionParseCache(toSshExecutionHostId(targetId)) sshStore.removeTarget(targetId) }