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 <help@stably.ai>
This commit is contained in:
brennanb2025
2026-07-06 17:14:05 -07:00
co-authored by Orca
parent f5d1a9d549
commit eac96f1ecf
6 changed files with 412 additions and 111 deletions
@@ -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()
})
})
@@ -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<ExecutionHostId, Map<string, RemoteSessionParseCacheEntry>>()
/**
* 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()
}
@@ -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<string, { content: string; mtimeMs: number }>()
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<DirEntry[]> {
const dir = normalize(dirPath)
const prefix = dir.endsWith('/') ? dir : `${dir}/`
const entries = new Map<string, DirEntry>()
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<FileReadResult> {
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<FileStat> {
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<string> => path
search = unsupported
listFiles = unsupported
watch = unsupported
}
async function unsupported(): Promise<never> {
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 }]
}
}
])
}
+11 -108
View File
@@ -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<string, { content: string; mtimeMs: number }>()
addFile(path: string, content: string, mtimeMs: number): void {
this.files.set(normalize(path), { content, mtimeMs })
}
async readDir(dirPath: string): Promise<DirEntry[]> {
const dir = normalize(dirPath)
const prefix = dir.endsWith('/') ? dir : `${dir}/`
const entries = new Map<string, DirEntry>()
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<FileReadResult> {
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<FileStat> {
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<string> => path
search = unsupported
listFiles = unsupported
watch = unsupported
}
async function unsupported(): Promise<never> {
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 }]
}
}
])
}
+23 -2
View File
@@ -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<AiVaultSession | null> {
// 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
+5 -1
View File
@@ -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<void>
)
}
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)
}