fix(ai-vault): drop remote parse cache when a target id is repointed

ssh:updateTarget, ~/.ssh/config re-import, and runtime reprovision can all
point an existing target id at a different machine without removing it, so
host-id + (mtime,size) alone could serve the previous machine's sessions.
Tag each host's cache with an opaque connect-time connection identity
(host/port/username/configHost/proxy/jump from the live connection's target
snapshot) and reset the host's entries on mismatch. The identity comes from
the connection rather than the store because edits only take effect at
reconnect — the provider keeps reading the old machine until then, and
evicting at edit time would let interim scans re-poison the cache. Also
split statRemoteFile into remote-session-scanner-file-stat.ts to stay under
max-lines.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
brennanb2025
2026-07-08 16:57:14 -07:00
co-authored by Orca
parent eac96f1ecf
commit abdee604aa
8 changed files with 177 additions and 67 deletions
@@ -0,0 +1,34 @@
import type { AiVaultAgent, AiVaultScanIssue } from '../../shared/ai-vault-types'
import type { ExecutionHostId } from '../../shared/execution-host'
import type { FileStat, IFilesystemProvider } from '../providers/types'
import type { FileWithMtime } from './session-scanner-types'
import { errorMessage } from './session-scanner-values'
export async function statRemoteFile(
provider: IFilesystemProvider,
path: string,
agent: AiVaultAgent,
executionHostId: ExecutionHostId,
issues: AiVaultScanIssue[]
): Promise<FileWithMtime | null> {
try {
const stat = await provider.stat(path)
const mtimeMs = remoteStatMtimeMs(stat)
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
}
}
function remoteStatMtimeMs(stat: FileStat): number {
if (typeof stat.mtimeMs === 'number' && Number.isFinite(stat.mtimeMs)) {
return stat.mtimeMs
}
return stat.mtime > 10_000_000_000 ? stat.mtime : stat.mtime * 1000
}
@@ -20,12 +20,17 @@ 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') {
function scan(
provider: MemoryRemoteProvider,
executionHostId: ExecutionHostId = 'ssh:dev-box',
connectionIdentity: string | null = null
) {
return scanRemoteAiVaultSessions({
provider,
executionHostId,
remoteHome: '/home/ada',
hostPlatform: getRemoteHostPlatform('linux-x64')
hostPlatform: getRemoteHostPlatform('linux-x64'),
connectionIdentity
})
}
@@ -150,25 +155,63 @@ describe('remote session parse cache', () => {
expect(provider.readFileCalls).toEqual([CLAUDE_ONE])
})
it('drops entries when the same target id points at a different machine', async () => {
// Same target id, same path, same (mtime,size) — only the machine behind
// the connection changed (ssh:updateTarget / config import / reprovision).
const content = claudeTranscript({ sessionId: 'one', title: 'Machine A session' })
const machineA = new MemoryRemoteProvider()
machineA.addFile(CLAUDE_ONE, content, 10)
await scan(machineA, 'ssh:dev-box', 'identity-a')
machineA.clearReadFileCalls()
await scan(machineA, 'ssh:dev-box', 'identity-a')
expect(machineA.readFileCalls).toEqual([])
const machineB = new MemoryRemoteProvider()
const contentB = claudeTranscript({ sessionId: 'one', title: 'Machine B session' })
// Equal size + equal mtime: only the identity gate can force the re-read.
expect(contentB.length).toBe(content.length)
machineB.addFile(CLAUDE_ONE, contentB, 10)
const repointed = await scan(machineB, 'ssh:dev-box', 'identity-b')
expect(machineB.readFileCalls).toEqual([CLAUDE_ONE])
expect(repointed.sessions[0]?.title).toBe('Machine B session')
// The new identity now caches normally.
machineB.clearReadFileCalls()
await scan(machineB, 'ssh:dev-box', 'identity-b')
expect(machineB.readFileCalls).toEqual([])
})
it('bounds entries per host, evicting least recently used first', () => {
storeRemoteSessionParse('ssh:host-b', fileAt('/other-host'), null)
storeRemoteSessionParse('ssh:host-b', null, 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)
storeRemoteSessionParse('ssh:host-a', null, 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', null, fileAt('/f0'))).not.toBeNull()
storeRemoteSessionParse('ssh:host-a', null, 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()
expect(readCachedRemoteSessionParse('ssh:host-a', null, fileAt('/f1'))).toBeNull()
expect(readCachedRemoteSessionParse('ssh:host-a', null, fileAt('/f0'))).not.toBeNull()
expect(readCachedRemoteSessionParse('ssh:host-a', null, fileAt('/overflow'))).not.toBeNull()
expect(readCachedRemoteSessionParse('ssh:host-b', null, 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()
storeRemoteSessionParse('ssh:host-a', null, fileAt('/f', 1), null)
expect(readCachedRemoteSessionParse('ssh:host-a', null, fileAt('/f', 2))).toBeNull()
expect(readCachedRemoteSessionParse('ssh:host-c', null, fileAt('/f', 1))).toBeNull()
})
it('only serves entries whose connection identity matches exactly', () => {
storeRemoteSessionParse('ssh:host-a', 'identity-a', fileAt('/f'), null)
expect(readCachedRemoteSessionParse('ssh:host-a', 'identity-b', fileAt('/f'))).toBeNull()
expect(readCachedRemoteSessionParse('ssh:host-a', null, fileAt('/f'))).toBeNull()
expect(readCachedRemoteSessionParse('ssh:host-a', 'identity-a', fileAt('/f'))).not.toBeNull()
// Storing under a new identity resets the host, so switching back misses.
storeRemoteSessionParse('ssh:host-a', 'identity-b', fileAt('/g'), null)
expect(readCachedRemoteSessionParse('ssh:host-a', 'identity-a', fileAt('/f'))).toBeNull()
expect(readCachedRemoteSessionParse('ssh:host-a', 'identity-b', fileAt('/g'))).not.toBeNull()
})
})
@@ -13,27 +13,42 @@ type RemoteSessionParseCacheEntry = {
session: AiVaultSession | null
}
type RemoteHostParseCache = {
// Opaque connect-time machine identity supplied by the scan. A target id
// can be repointed at a different machine (ssh:updateTarget, ssh-config
// import, runtime reprovision) without being removed, so entries are only
// valid while the scan talks to the machine they were read from.
connectionIdentity: string | null
entries: Map<string, RemoteSessionParseCacheEntry>
}
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>>()
// never serve each other's sessions. Reconnects to the same machine keep
// entries (paths and mtimes stay valid); an identity change resets the host's
// entries and target removal evicts via evictRemoteSessionParseCache.
const cacheByHost = new Map<ExecutionHostId, RemoteHostParseCache>()
/**
* 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.
* Return the parse result cached for this remote file when the host's
* connection identity matches and the (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,
connectionIdentity: string | null,
file: FileWithMtime
): RemoteSessionParseCacheHit | null {
const hostCache = cacheByHost.get(executionHostId)
const entry = hostCache?.get(file.path)
if (!hostCache || !entry) {
if (!hostCache || hostCache.connectionIdentity !== connectionIdentity) {
return null
}
const entry = hostCache.entries.get(file.path)
if (!entry) {
return null
}
const unchanged =
@@ -42,31 +57,34 @@ export function readCachedRemoteSessionParse(
if (!unchanged) {
return null
}
hostCache.delete(file.path)
hostCache.set(file.path, entry)
hostCache.entries.delete(file.path)
hostCache.entries.set(file.path, entry)
return { session: entry.session }
}
export function storeRemoteSessionParse(
executionHostId: ExecutionHostId,
connectionIdentity: string | null,
file: FileWithMtime,
session: AiVaultSession | null
): void {
let hostCache = cacheByHost.get(executionHostId)
if (!hostCache) {
hostCache = new Map()
if (!hostCache || hostCache.connectionIdentity !== connectionIdentity) {
// A different machine now answers for this host id: entries read from the
// previous machine must never be served again, so drop them wholesale.
hostCache = { connectionIdentity, entries: new Map() }
cacheByHost.set(executionHostId, hostCache)
}
hostCache.delete(file.path)
hostCache.set(file.path, {
hostCache.entries.delete(file.path)
hostCache.entries.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 (hostCache.entries.size > REMOTE_PARSE_CACHE_MAX_ENTRIES_PER_HOST) {
const oldest = hostCache.entries.keys().next()
if (!oldest.done) {
hostCache.delete(oldest.value)
hostCache.entries.delete(oldest.value)
}
}
}
@@ -7,6 +7,9 @@ import type { FileWithMtime } from './session-scanner-types'
export type RemoteScannerContext = {
provider: IFilesystemProvider
executionHostId: ExecutionHostId
// Connect-time machine identity for the parse cache; entries cached under a
// different identity (a repointed target id) are never served.
connectionIdentity: string | null
hostPlatform: RemoteHostPlatform
titleCaches: Map<string, Promise<Map<string, string>>>
}
+21 -34
View File
@@ -1,18 +1,18 @@
import { extname } from 'node:path'
import type {
AiVaultAgent,
AiVaultListResult,
AiVaultScanIssue,
AiVaultSession
} from '../../shared/ai-vault-types'
import { isPathInsideOrEqual } from '../../shared/cross-platform-path'
import type { ExecutionHostId } from '../../shared/execution-host'
import type { FileStat, IFilesystemProvider } from '../providers/types'
import type { IFilesystemProvider } from '../providers/types'
import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform'
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 { statRemoteFile } from './remote-session-scanner-file-stat'
import {
readCachedRemoteSessionParse,
storeRemoteSessionParse
@@ -33,6 +33,7 @@ export async function scanRemoteAiVaultSessions(args: {
executionHostId: ExecutionHostId
remoteHome: string
hostPlatform: RemoteHostPlatform
connectionIdentity?: string | null
limit?: number
scopePaths?: readonly string[]
}): Promise<AiVaultListResult> {
@@ -41,6 +42,7 @@ export async function scanRemoteAiVaultSessions(args: {
const context: RemoteScannerContext = {
provider: args.provider,
executionHostId: args.executionHostId,
connectionIdentity: args.connectionIdentity ?? null,
hostPlatform: args.hostPlatform,
titleCaches: new Map()
}
@@ -201,18 +203,32 @@ async function parseRemoteSessionCandidate(
// 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)
const cached = readCachedRemoteSessionParse(
context.executionHostId,
context.connectionIdentity,
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)
storeRemoteSessionParse(
context.executionHostId,
context.connectionIdentity,
candidate.file,
null
)
return null
}
const session = await candidate.source.parse(candidate.file, read.content, context)
storeRemoteSessionParse(context.executionHostId, candidate.file, session)
storeRemoteSessionParse(
context.executionHostId,
context.connectionIdentity,
candidate.file,
session
)
return session
} catch (err) {
// Failures stay uncached so the issue resurfaces on every scan and a
@@ -253,35 +269,6 @@ function normalizeRemoteScopePaths(scopePaths: readonly string[]): string[] {
return scopePaths.map((scopePath) => scopePath.trim()).filter(Boolean)
}
async function statRemoteFile(
provider: IFilesystemProvider,
path: string,
agent: AiVaultAgent,
executionHostId: ExecutionHostId,
issues: AiVaultScanIssue[]
): Promise<FileWithMtime | null> {
try {
const stat = await provider.stat(path)
const mtimeMs = remoteStatMtimeMs(stat)
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
}
}
function remoteStatMtimeMs(stat: FileStat): number {
if (typeof stat.mtimeMs === 'number' && Number.isFinite(stat.mtimeMs)) {
return stat.mtimeMs
}
return stat.mtime > 10_000_000_000 ? stat.mtime : stat.mtime * 1000
}
function canStopParsingRemoteSessions(
sessions: AiVaultSession[],
limit: number,
+3 -1
View File
@@ -83,6 +83,7 @@ describe('listAiVaultSessions host routing', () => {
provider,
executionHostId: 'ssh:dev-box',
remoteHome: '/home/ada',
connectionIdentity: 'identity-dev-box',
scopePaths: ['/home/ada/repo']
})
)
@@ -128,7 +129,8 @@ function hostInfo(targetId: string) {
targetId,
executionHostId: `ssh:${targetId}` as const,
remoteHome: '/home/ada',
hostPlatform: getRemoteHostPlatform('linux-x64')
hostPlatform: getRemoteHostPlatform('linux-x64'),
connectionIdentity: `identity-${targetId}`
}
}
+1
View File
@@ -151,6 +151,7 @@ async function scanSshAiVaultSessions(
executionHostId: hostInfo.executionHostId,
remoteHome: hostInfo.remoteHome,
hostPlatform: hostInfo.hostPlatform,
connectionIdentity: hostInfo.connectionIdentity,
limit: args?.limit,
scopePaths: args?.scopePaths
})
+24 -2
View File
@@ -61,7 +61,8 @@ import {
type DetectedPort,
MAX_SSH_RELAY_GRACE_PERIOD_SECONDS,
MIN_SSH_RELAY_GRACE_PERIOD_SECONDS,
SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD
SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD,
type SshTarget
} from '../../shared/ssh-types'
import type { Store } from '../persistence'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
@@ -113,6 +114,25 @@ export type SshRelayAiVaultHostInfo = {
executionHostId: ExecutionHostId
remoteHome: string
hostPlatform: RemoteHostPlatform
/** Opaque connect-time machine identity; null while no connection is live. */
connectionIdentity: string | null
}
// Identifies the machine an AI-vault scan actually reads from: the target
// snapshot the live connection dialed. Store edits (ssh:updateTarget, config
// import, runtime reprovision) don't take effect until reconnect, so the
// stored target may describe a different machine than the active provider.
// Auth-only fields (identityFile/agent/identitiesOnly) are excluded — they
// change how we authenticate, not whose files we read.
function aiVaultConnectionIdentity(target: SshTarget): string {
return JSON.stringify([
target.configHost ?? null,
target.host,
target.port,
target.username,
target.proxyCommand ?? null,
target.jumpHost ?? null
])
}
const RECONNECT_REPLAY_DUPLICATE_WINDOW_MS = 1000
@@ -230,11 +250,13 @@ export class SshRelaySession {
if (!env) {
return null
}
const connectionTarget = this.currentConnection?.getTarget() ?? null
return {
targetId: this.targetId,
executionHostId: toSshExecutionHostId(this.targetId),
remoteHome: env.remoteHome,
hostPlatform: env.hostPlatform
hostPlatform: env.hostPlatform,
connectionIdentity: connectionTarget ? aiVaultConnectionIdentity(connectionTarget) : null
}
}