mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 08:03:14 +00:00
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:
@@ -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 }]
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
@@ -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 }]
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user