diff --git a/src/main/agent-hooks/wsl-hook-relay-sentinel.test.ts b/src/main/agent-hooks/wsl-hook-relay-sentinel.test.ts index b57be2544..d8f504bec 100644 --- a/src/main/agent-hooks/wsl-hook-relay-sentinel.test.ts +++ b/src/main/agent-hooks/wsl-hook-relay-sentinel.test.ts @@ -21,7 +21,11 @@ function fakeChild(): FakeChild { resume: ReturnType } stderr: EventEmitter - stdin: EventEmitter & { write: ReturnType } + stdin: EventEmitter & { + write: ReturnType + destroyed: boolean + writable: boolean + } kill: ReturnType } child.stdout = Object.assign(new EventEmitter(), { @@ -29,7 +33,18 @@ function fakeChild(): FakeChild { resume: vi.fn() }) child.stderr = new EventEmitter() - child.stdin = Object.assign(new EventEmitter(), { write: vi.fn(() => true) }) + const stdin = new EventEmitter() as EventEmitter & { + write: ReturnType + destroyed: boolean + writable: boolean + } + stdin.write = vi.fn((_data, onWritten?: (error?: Error | null) => void) => { + onWritten?.(null) + return true + }) + stdin.destroyed = false + stdin.writable = true + child.stdin = stdin child.kill = vi.fn() return child as unknown as FakeChild } @@ -166,7 +181,7 @@ describe('waitForWslRelaySentinel', () => { const drain = vi.fn() const writeMock = child.stdin.write as ReturnType writeMock.mockImplementation((_data, onWritten) => { - onWritten(null) + onWritten?.(null) return false }) const promise = waitForWslRelaySentinel(child) @@ -180,4 +195,16 @@ describe('waitForWslRelaySentinel', () => { expect(drain).toHaveBeenCalledOnce() expect(transport.supportsWriteSettlement).toBe(true) }) + + it('swallows async stdin EPIPE after the guest dies so it is not uncaught', async () => { + const child = fakeChild() + const promise = waitForWslRelaySentinel(child) + emitStdout(child, RELAY_SENTINEL) + await promise + // Real pipes emit EPIPE on the stream after a failed write; without a + // listener Node surfaces that as an unhandled exception (CI flake). + expect(() => { + child.stdin.emit('error', Object.assign(new Error('write EPIPE'), { code: 'EPIPE' })) + }).not.toThrow() + }) }) diff --git a/src/main/agent-hooks/wsl-hook-relay-sentinel.ts b/src/main/agent-hooks/wsl-hook-relay-sentinel.ts index 5e1b5b5a8..41e6ebe96 100644 --- a/src/main/agent-hooks/wsl-hook-relay-sentinel.ts +++ b/src/main/agent-hooks/wsl-hook-relay-sentinel.ts @@ -88,6 +88,12 @@ export function waitForWslRelaySentinel( child.on('error', (err) => fail({ kind: 'exit', code: null, stderr: `${stderrOutput}\n${err.message}` }) ) + // Why: stdin.write() surfaces EPIPE asynchronously when the guest dies + // mid-flight (try/catch only covers the sync throw). Without a listener + // Node treats that as an uncaught exception and fails the whole process. + child.stdin.on('error', () => { + // Channel already closing — mux close handling takes over. + }) child.on('exit', (code) => { exitCode = code }) diff --git a/src/main/ai-vault/ai-vault-scan-cancellation.test.ts b/src/main/ai-vault/ai-vault-scan-cancellation.test.ts new file mode 100644 index 000000000..74bf27bdc --- /dev/null +++ b/src/main/ai-vault/ai-vault-scan-cancellation.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { abandonRemoteSessionScanOnCancel } from './ai-vault-scan-cancellation' + +describe('abandonRemoteSessionScanOnCancel', () => { + it('stops waiting on a scan that has no transport-level abort', async () => { + const controller = new AbortController() + const pending = abandonRemoteSessionScanOnCancel( + new Promise(() => {}), + controller.signal + ) + + controller.abort() + + await expect(pending).rejects.toMatchObject({ name: 'AbortError' }) + }) + + it('rejects immediately when the caller already cancelled', async () => { + const controller = new AbortController() + controller.abort() + + await expect( + abandonRemoteSessionScanOnCancel(Promise.resolve('scanned'), controller.signal) + ).rejects.toMatchObject({ name: 'AbortError' }) + }) + + it('passes the scan result through without a signal', async () => { + await expect(abandonRemoteSessionScanOnCancel(Promise.resolve('scanned'))).resolves.toBe( + 'scanned' + ) + }) +}) diff --git a/src/main/ai-vault/ai-vault-scan-cancellation.ts b/src/main/ai-vault/ai-vault-scan-cancellation.ts new file mode 100644 index 000000000..65ecf772c --- /dev/null +++ b/src/main/ai-vault/ai-vault-scan-cancellation.ts @@ -0,0 +1,47 @@ +import { AI_VAULT_SCAN_CANCELLED_MESSAGE } from '../../shared/ai-vault-types' + +export function throwIfAiVaultScanCancelled(signal?: AbortSignal): void { + if (!signal?.aborted) { + return + } + throw createAiVaultScanCancelledError() +} + +// Why: the runtime RPC transport has no abort hook, so cancellation can only +// stop the caller waiting on it — the in-flight request settles on its own +// timeout instead of holding an 'all'-scope merge open after every waiter left. +export function abandonRemoteSessionScanOnCancel( + promise: Promise, + signal?: AbortSignal +): Promise { + if (!signal) { + return promise + } + return new Promise((resolve, reject) => { + if (signal.aborted) { + // The scanner promise already exists; observe it so its later failure is + // not an unhandled rejection after this caller walked away. + void promise.catch(() => undefined) + reject(createAiVaultScanCancelledError()) + return + } + const onAbort = (): void => reject(createAiVaultScanCancelledError()) + signal.addEventListener('abort', onAbort, { once: true }) + void promise.then( + (value) => { + signal.removeEventListener('abort', onAbort) + resolve(value) + }, + (error: unknown) => { + signal.removeEventListener('abort', onAbort) + reject(error) + } + ) + }) +} + +export function createAiVaultScanCancelledError(): Error { + const error = new Error(AI_VAULT_SCAN_CANCELLED_MESSAGE) + error.name = 'AbortError' + return error +} diff --git a/src/main/ai-vault/ai-vault-scan-coordinator.test.ts b/src/main/ai-vault/ai-vault-scan-coordinator.test.ts new file mode 100644 index 000000000..6464770d5 --- /dev/null +++ b/src/main/ai-vault/ai-vault-scan-coordinator.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it, vi } from 'vitest' +import { AiVaultScanCoordinator } from './ai-vault-scan-coordinator' + +const EMPTY_RESULT = { sessions: [], issues: [], scannedAt: '2026-07-27T00:00:00.000Z' } + +describe('AiVaultScanCoordinator', () => { + it('keeps a shared scan alive when only one caller cancels', async () => { + const coordinator = new AiVaultScanCoordinator() + let resolveScan: ((result: typeof EMPTY_RESULT) => void) | undefined + let sharedSignal: AbortSignal | undefined + const start = vi.fn( + (signal: AbortSignal) => + new Promise((resolve) => { + sharedSignal = signal + resolveScan = resolve + }) + ) + const controller = new AbortController() + const first = coordinator.run({ key: 'scope', signal: controller.signal, start }) + const second = coordinator.run({ key: 'scope', start }) + await Promise.resolve() + + controller.abort() + + await expect(first).rejects.toMatchObject({ name: 'AbortError' }) + expect(sharedSignal?.aborted).toBe(false) + resolveScan?.(EMPTY_RESULT) + await expect(second).resolves.toEqual(EMPTY_RESULT) + expect(start).toHaveBeenCalledTimes(1) + }) + + it('preempts a non-forced scan and re-joins its caller onto the forced scan', async () => { + const coordinator = new AiVaultScanCoordinator() + const signals: AbortSignal[] = [] + let resolveForced: ((result: typeof EMPTY_RESULT) => void) | undefined + const start = vi.fn((signal: AbortSignal) => { + signals.push(signal) + return new Promise((resolve) => { + if (signals.length === 1) { + signal.addEventListener('abort', () => resolve(EMPTY_RESULT), { once: true }) + } else { + resolveForced = resolve + } + }) + }) + const first = coordinator.run({ key: 'scope', start }) + await Promise.resolve() + + const forced = coordinator.run({ key: 'scope', force: true, start }) + const joined = coordinator.run({ key: 'scope', force: true, start }) + await Promise.resolve() + + expect(signals[0]?.aborted).toBe(true) + expect(start).toHaveBeenCalledTimes(2) + resolveForced?.(EMPTY_RESULT) + // The first caller never asked to cancel, so someone else's Refresh must + // hand it the replacement's result instead of a spurious cancellation. + await expect(Promise.all([first, forced, joined])).resolves.toEqual([ + EMPTY_RESULT, + EMPTY_RESULT, + EMPTY_RESULT + ]) + }) + + it('keeps coalescing forced callers onto a forced scan that is still fresh', async () => { + vi.useFakeTimers() + try { + const coordinator = new AiVaultScanCoordinator() + let resolveScan: ((result: typeof EMPTY_RESULT) => void) | undefined + const start = vi.fn( + () => + new Promise((resolve) => { + resolveScan = resolve + }) + ) + const first = coordinator.run({ key: 'scope', force: true, start }) + await Promise.resolve() + + vi.advanceTimersByTime(4_999) + const second = coordinator.run({ key: 'scope', force: true, start }) + await Promise.resolve() + + expect(start).toHaveBeenCalledTimes(1) + resolveScan?.(EMPTY_RESULT) + // Both callers settle off the single shared scan instead of preempting it. + await expect(Promise.all([first, second])).resolves.toEqual([EMPTY_RESULT, EMPTY_RESULT]) + } finally { + vi.useRealTimers() + } + }) + + it('lets a forced refresh preempt a forced scan that hung past the coalescing window', async () => { + vi.useFakeTimers() + try { + const coordinator = new AiVaultScanCoordinator() + const signals: AbortSignal[] = [] + let resolveSecond: ((result: typeof EMPTY_RESULT) => void) | undefined + const start = vi.fn((signal: AbortSignal) => { + signals.push(signal) + return new Promise((resolve) => { + if (signals.length > 1) { + resolveSecond = resolve + } + }) + }) + const stuck = coordinator.run({ key: 'scope', force: true, start }) + await Promise.resolve() + + vi.advanceTimersByTime(5_000) + const retry = coordinator.run({ key: 'scope', force: true, start }) + await Promise.resolve() + + expect(signals[0]?.aborted).toBe(true) + expect(start).toHaveBeenCalledTimes(2) + resolveSecond?.(EMPTY_RESULT) + // The caller stranded on the hung scan rides the replacement out rather + // than being told its own refresh was cancelled. + await expect(Promise.all([stuck, retry])).resolves.toEqual([EMPTY_RESULT, EMPTY_RESULT]) + } finally { + vi.useRealTimers() + } + }) + + it('starts a fresh scan after every waiter cancels', async () => { + const coordinator = new AiVaultScanCoordinator() + const signals: AbortSignal[] = [] + const start = vi.fn((signal: AbortSignal) => { + signals.push(signal) + if (signals.length > 1) { + return Promise.resolve(EMPTY_RESULT) + } + return new Promise((resolve) => { + signal.addEventListener('abort', () => resolve(EMPTY_RESULT), { once: true }) + }) + }) + const controller = new AbortController() + const first = coordinator.run({ key: 'scope', signal: controller.signal, start }) + await Promise.resolve() + controller.abort() + await expect(first).rejects.toMatchObject({ name: 'AbortError' }) + + const second = coordinator.run({ key: 'scope', start }) + + await expect(second).resolves.toEqual(EMPTY_RESULT) + expect(start).toHaveBeenCalledTimes(2) + expect(signals[0]?.aborted).toBe(true) + }) +}) diff --git a/src/main/ai-vault/ai-vault-scan-coordinator.ts b/src/main/ai-vault/ai-vault-scan-coordinator.ts new file mode 100644 index 000000000..7e704e4b8 --- /dev/null +++ b/src/main/ai-vault/ai-vault-scan-coordinator.ts @@ -0,0 +1,155 @@ +import { + AI_VAULT_SCAN_CANCELLED_MESSAGE, + type AiVaultListResult +} from '../../shared/ai-vault-types' + +// Matches the renderer's forced-rescan throttle: forced callers that arrive +// inside this window share one scan, later ones may preempt a scan that hung. +const FORCED_SCAN_PREEMPT_AFTER_MS = 5_000 + +type ScanEntry = { + controller: AbortController + force: boolean + startedAt: number + promise: Promise + waiterCount: number + // The last waiter leaving must not abort a scan that already finished. + settled: boolean + // Set when a forced caller replaced this entry, so its waiters re-join the + // replacement instead of surfacing someone else's refresh as their own cancel. + preemptedBy: ScanEntry | null +} + +export class AiVaultScanCoordinator { + private readonly entries = new Map() + + run(args: { + key: string + force?: boolean + signal?: AbortSignal + start: (signal: AbortSignal) => Promise + }): Promise { + if (args.signal?.aborted) { + return Promise.reject(scanCancellationError()) + } + let entry = this.entries.get(args.key) + const preempted = entry && args.force === true && canPreemptForForcedScan(entry) ? entry : null + if (preempted) { + this.removeEntry(args.key, preempted) + entry = undefined + } + if (!entry) { + entry = this.createEntry(args.key, args.force === true, args.start) + this.entries.set(args.key, entry) + } + if (preempted) { + // The replacement must be registered before the abort lands: waiters of + // the old scan re-join it synchronously from their abort listener. + preempted.preemptedBy = entry + preempted.controller.abort() + } + return this.attach(args.key, entry, args.signal) + } + + private createEntry( + key: string, + force: boolean, + start: (signal: AbortSignal) => Promise + ): ScanEntry { + const controller = new AbortController() + const entry: ScanEntry = { + controller, + force, + startedAt: Date.now(), + promise: Promise.resolve().then(() => { + if (controller.signal.aborted) { + throw scanCancellationError() + } + return start(controller.signal) + }), + waiterCount: 0, + settled: false, + preemptedBy: null + } + const onSettled = (): void => { + entry.settled = true + this.removeEntry(key, entry) + } + void entry.promise.then(onSettled, onSettled) + return entry + } + + private attach(key: string, entry: ScanEntry, signal?: AbortSignal): Promise { + entry.waiterCount++ + return new Promise((resolve, reject) => { + let attached = true + const detach = (): void => { + if (!attached) { + return + } + attached = false + signal?.removeEventListener('abort', onAbort) + entry.controller.signal.removeEventListener('abort', onAbort) + entry.waiterCount-- + if (entry.waiterCount === 0 && !entry.settled && !entry.controller.signal.aborted) { + this.removeEntry(key, entry) + entry.controller.abort() + } + } + const onAbort = (): void => { + if (!attached) { + return + } + // Why: a forced refresh aborts the shared entry, but the other waiters + // never asked to cancel — rejecting them turns one window's Refresh into + // a cancelled multi-host merge somewhere else. Re-join the replacement. + const replacement = signal?.aborted ? null : entry.preemptedBy + detach() + if (!replacement) { + reject(scanCancellationError()) + return + } + void this.attach(key, replacement, signal).then(resolve, reject) + } + signal?.addEventListener('abort', onAbort, { once: true }) + entry.controller.signal.addEventListener('abort', onAbort, { once: true }) + if (signal?.aborted || entry.controller.signal.aborted) { + onAbort() + return + } + void entry.promise.then( + (result) => { + if (attached) { + detach() + resolve(result) + } + }, + (error) => { + if (attached) { + detach() + reject(error) + } + } + ) + }) + } + + private removeEntry(key: string, entry: ScanEntry): void { + if (this.entries.get(key) === entry) { + this.entries.delete(key) + } + } +} + +// Why: without this a forced scan that never settles (no inactivity timer on the +// legacy SSH file-stream reader, see #11364) makes every later Refresh join the +// dead promise, so the panel stays empty until the app restarts. +function canPreemptForForcedScan(entry: ScanEntry): boolean { + return !entry.force || Date.now() - entry.startedAt >= FORCED_SCAN_PREEMPT_AFTER_MS +} + +function scanCancellationError(): Error { + const error = new Error(AI_VAULT_SCAN_CANCELLED_MESSAGE) + error.name = 'AbortError' + return error +} diff --git a/src/main/ai-vault/cached-session-list.ts b/src/main/ai-vault/cached-session-list.ts index 4b39e75eb..facf417cd 100644 --- a/src/main/ai-vault/cached-session-list.ts +++ b/src/main/ai-vault/cached-session-list.ts @@ -3,6 +3,13 @@ import { scanAiVaultSessions } from './session-scanner' import { getWslHomeAsync, listWslDistrosAsync } from '../wsl' import type { AiVaultListArgs, AiVaultListResult } from '../../shared/ai-vault-types' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' +import { AiVaultScanCoordinator } from './ai-vault-scan-coordinator' +import { + aiVaultSessionDepthCovers, + requestedAiVaultSessionDepth, + truncateAiVaultListResult, + type AiVaultSessionDepth +} from '../../shared/ai-vault-session-depth' // Why: ONE module owns the scan cache so the desktop IPC handler AND the runtime // RPC method share a single cache instance — opening the desktop panel and the @@ -19,65 +26,77 @@ export type AiVaultSessionSources = { type CachedAiVaultList = { key: string + depth: AiVaultSessionDepth result: AiVaultListResult expiresAt: number } let cachedList: CachedAiVaultList | null = null -let inflightList: Promise | null = null -let inflightKey: string | null = null +let scanCoordinator = new AiVaultScanCoordinator() let sources: AiVaultSessionSources = {} export function configureAiVaultSessionSources(next: AiVaultSessionSources): void { sources = next } -export async function listAiVaultSessions(args?: AiVaultListArgs): Promise { +export async function listAiVaultSessions( + args?: AiVaultListArgs, + options: { signal?: AbortSignal } = {} +): Promise { // Scope paths change the result set, so they must be part of the cache key. - const key = JSON.stringify({ - limit: args?.limit ?? 'default', - scopePaths: args?.scopePaths ?? [] - }) + const key = JSON.stringify({ scopePaths: [...new Set(args?.scopePaths ?? [])].sort() }) + const depth = requestedAiVaultSessionDepth(args) + const scanKey = JSON.stringify({ key, depth }) const now = Date.now() // Why: opening this panel repeatedly should not re-parse hundreds of JSONL - // transcripts; explicit refreshes bypass the cache but not an active scan. - if (args?.force !== true && cachedList?.key === key && cachedList.expiresAt > now) { - return cachedList.result + // transcripts; explicit refreshes bypass the cache and preempt stale scans. + if ( + args?.force !== true && + cachedList?.key === key && + cachedList.expiresAt > now && + aiVaultSessionDepthCovers(cachedList.depth, depth) + ) { + return truncateAiVaultListResult(cachedList.result, depth, args?.scopePaths) } - if (inflightList && inflightKey === key) { - return inflightList - } - - inflightKey = key - const additionalCodexSessionsDirs = - sources.getAdditionalCodexHomePaths?.().map((homePath) => join(homePath, 'sessions')) ?? [] - inflightList = (async () => - scanAiVaultSessions({ - limit: args?.limit, - scopePaths: args?.scopePaths, - additionalCodexSessionsDirs, - wslHomeDirs: await getAiVaultWslHomeDirs(), - // Why: this scan is always host-local; callers addressing this host by a - // runtime id get the result restamped at the RPC edge, never rescanned. - executionHostId: LOCAL_EXECUTION_HOST_ID - }))() - .then((result) => { - cachedList = { - key, - result, - expiresAt: Date.now() + AI_VAULT_CACHE_TTL_MS + return scanCoordinator.run({ + key: scanKey, + force: args?.force, + signal: options.signal, + start: async (scanSignal) => { + const additionalCodexSessionsDirs = + sources.getAdditionalCodexHomePaths?.().map((homePath) => join(homePath, 'sessions')) ?? [] + const result = await scanAiVaultSessions({ + limit: args?.limit, + unlimited: args?.unlimited, + scopePaths: args?.scopePaths, + additionalCodexSessionsDirs, + wslHomeDirs: await getAiVaultWslHomeDirs(), + // Cancelled/superseded callers must stop the parse, not just stop + // waiting for it — the scan owns hundreds of transcript reads. + signal: scanSignal, + // Why: this scan is always host-local; callers addressing this host by a + // runtime id get the result restamped at the RPC edge, never rescanned. + executionHostId: LOCAL_EXECUTION_HOST_ID + }) + if (!scanSignal.aborted) { + const current = cachedList + if ( + args?.force === true || + current?.key !== key || + current.expiresAt <= Date.now() || + !aiVaultSessionDepthCovers(current.depth, depth) + ) { + cachedList = { + key, + depth, + result, + expiresAt: Date.now() + AI_VAULT_CACHE_TTL_MS + } + } } return result - }) - .finally(() => { - // Only clear tracking if it still refers to this request: a concurrent - // different-key scan may have replaced it and must stay dedupable. - if (inflightKey === key) { - inflightKey = null - inflightList = null - } - }) - return inflightList + } + }) } // Exported for the subagent-transcript IPC path, which validates @@ -95,7 +114,6 @@ export async function getAiVaultWslHomeDirs(): Promise { // Why: tests reset module-level cache/source state between cases. export function resetAiVaultSessionListCacheForTests(): void { cachedList = null - inflightList = null - inflightKey = null + scanCoordinator = new AiVaultScanCoordinator() sources = {} } diff --git a/src/main/ai-vault/remote-session-content-lines.ts b/src/main/ai-vault/remote-session-content-lines.ts new file mode 100644 index 000000000..9a107fd1e --- /dev/null +++ b/src/main/ai-vault/remote-session-content-lines.ts @@ -0,0 +1,47 @@ +import { setImmediate as yieldToEventLoop } from 'node:timers/promises' +import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' + +const REMOTE_CONTENT_YIELD_LINE_COUNT = 200 +const REMOTE_CONTENT_YIELD_CHAR_COUNT = 256 * 1024 + +/** Splits transcript content into lines. Without a signal there is nothing to + * observe, so it splits in one pass; with one it yields to the event loop so a + * cancelled scan stops mid-transcript instead of parsing megabytes for a caller + * that already left. */ +export function remoteSessionContentLines( + content: string, + signal?: AbortSignal +): Iterable | AsyncIterable { + return signal ? cancellableContentLines(content, signal) : content.split(/\r?\n/) +} + +async function* cancellableContentLines( + content: string, + signal: AbortSignal +): AsyncGenerator { + // Content below one yield window would otherwise never observe cancellation. + throwIfAiVaultScanCancelled(signal) + let lineStart = 0 + let yieldStart = 0 + let linesSinceYield = 0 + + for (let index = 0; index <= content.length; index++) { + if (index < content.length && content.charCodeAt(index) !== 10) { + continue + } + const lineEnd = index > lineStart && content.charCodeAt(index - 1) === 13 ? index - 1 : index + yield content.slice(lineStart, lineEnd) + lineStart = index + 1 + linesSinceYield++ + if ( + linesSinceYield >= REMOTE_CONTENT_YIELD_LINE_COUNT || + index - yieldStart >= REMOTE_CONTENT_YIELD_CHAR_COUNT + ) { + throwIfAiVaultScanCancelled(signal) + await yieldToEventLoop() + throwIfAiVaultScanCancelled(signal) + linesSinceYield = 0 + yieldStart = index + } + } +} diff --git a/src/main/ai-vault/remote-session-file-stat.ts b/src/main/ai-vault/remote-session-file-stat.ts index 238ea13aa..aa5809a77 100644 --- a/src/main/ai-vault/remote-session-file-stat.ts +++ b/src/main/ai-vault/remote-session-file-stat.ts @@ -1,19 +1,24 @@ 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 { FileStat } from '../providers/types' +import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' +import { recordRemoteSessionScanIssue } from './remote-session-scan-issues' +import type { RemoteSessionFilesystemProvider } from './remote-session-scanner-types' import type { FileWithMtime } from './session-scanner-types' import { errorMessage } from './session-scanner-values' export async function statRemoteSessionFile( - provider: IFilesystemProvider, + provider: RemoteSessionFilesystemProvider, path: string, agent: AiVaultAgent, executionHostId: ExecutionHostId, issues: AiVaultScanIssue[], - options?: { missingIsExpected?: boolean } + options?: { missingIsExpected?: boolean; signal?: AbortSignal } ): Promise { try { + throwIfAiVaultScanCancelled(options?.signal) const stat = await provider.stat(path) + throwIfAiVaultScanCancelled(options?.signal) const mtimeMs = remoteSessionMtimeMs(stat) return { path, @@ -25,8 +30,14 @@ export async function statRemoteSessionFile( ...(typeof stat.nlink === 'number' ? { nlink: stat.nlink } : {}) } } catch (error) { + throwIfAiVaultScanCancelled(options?.signal) if (!options?.missingIsExpected || !isMissingRemoteSessionPathError(error)) { - issues.push({ executionHostId, agent, path, message: errorMessage(error) }) + recordRemoteSessionScanIssue(issues, { + executionHostId, + agent, + path, + message: errorMessage(error) + }) } return null } diff --git a/src/main/ai-vault/remote-session-scan-batching.test.ts b/src/main/ai-vault/remote-session-scan-batching.test.ts new file mode 100644 index 000000000..da9ee8760 --- /dev/null +++ b/src/main/ai-vault/remote-session-scan-batching.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { mapRemoteScanBatches } from './remote-session-scan-batching' + +describe('mapRemoteScanBatches', () => { + it('observes an abort that lands while the final batch yields', async () => { + const controller = new AbortController() + + await expect( + mapRemoteScanBatches( + ['a', 'b'], + 2, + async (item) => { + controller.abort() + return item + }, + controller.signal + ) + ).rejects.toMatchObject({ name: 'AbortError' }) + }) + + it('observes an abort for empty inputs that never enter the loop', async () => { + const controller = new AbortController() + controller.abort() + + await expect( + mapRemoteScanBatches([], 8, async (item) => item, controller.signal) + ).rejects.toMatchObject({ name: 'AbortError' }) + }) + + it('returns every batch result when nothing cancels', async () => { + expect(await mapRemoteScanBatches([1, 2, 3], 2, async (item) => item * 2)).toEqual([2, 4, 6]) + }) +}) diff --git a/src/main/ai-vault/remote-session-scan-batching.ts b/src/main/ai-vault/remote-session-scan-batching.ts new file mode 100644 index 000000000..d51703e1b --- /dev/null +++ b/src/main/ai-vault/remote-session-scan-batching.ts @@ -0,0 +1,22 @@ +import { setImmediate as yieldToEventLoop } from 'node:timers/promises' +import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' + +// Batching bounds concurrent remote round trips; the yield between batches keeps +// the process responsive, and the batch boundary is where cancellation lands. +export async function mapRemoteScanBatches( + items: readonly T[], + concurrency: number, + mapper: (item: T) => Promise, + signal?: AbortSignal +): Promise { + const results: U[] = [] + for (let index = 0; index < items.length; index += concurrency) { + throwIfAiVaultScanCancelled(signal) + results.push(...(await Promise.all(items.slice(index, index + concurrency).map(mapper)))) + await yieldToEventLoop() + } + // An abort can land while the last batch yields, and empty inputs never enter + // the loop at all — observe it here so neither path returns as a success. + throwIfAiVaultScanCancelled(signal) + return results +} diff --git a/src/main/ai-vault/remote-session-scan-concurrency.ts b/src/main/ai-vault/remote-session-scan-concurrency.ts new file mode 100644 index 000000000..77b8e371b --- /dev/null +++ b/src/main/ai-vault/remote-session-scan-concurrency.ts @@ -0,0 +1,47 @@ +import type { RemoteSessionFilesystemProvider } from './remote-session-scanner-types' + +// Why: discovery batches (8 sources) each stat in batches of 8, and parse +// batches read whole transcripts — nested fan-out put ~64 filesystem round +// trips on one SSH mux or relay event loop at once, blowing the scan budget and +// starving pty/fs/hook traffic. Every scan shares one in-flight ceiling. +const REMOTE_SCAN_FILESYSTEM_CONCURRENCY = 8 + +/** Wraps a provider so all of one scan's filesystem calls share a single + * in-flight ceiling, regardless of how the callers nest their batches. */ +export function limitRemoteScanFilesystemConcurrency( + provider: RemoteSessionFilesystemProvider, + maxInFlight: number = REMOTE_SCAN_FILESYSTEM_CONCURRENCY +): RemoteSessionFilesystemProvider { + const gate = createConcurrencyGate(maxInFlight) + return { + readDir: (dirPath) => gate(() => provider.readDir(dirPath)), + readFile: (filePath) => gate(() => provider.readFile(filePath)), + stat: (filePath) => gate(() => provider.stat(filePath)) + } +} + +function createConcurrencyGate(maxInFlight: number): (run: () => Promise) => Promise { + const limit = Math.max(1, Math.floor(maxInFlight)) + const waiting: (() => void)[] = [] + let inFlight = 0 + + return async (run: () => Promise): Promise => { + if (inFlight < limit) { + inFlight++ + } else { + // The releasing call hands its slot over directly, so a queued caller can + // never race a fresh one into an over-limit slot. + await new Promise((resolve) => waiting.push(resolve)) + } + try { + return await run() + } finally { + const next = waiting.shift() + if (next) { + next() + } else { + inFlight-- + } + } + } +} diff --git a/src/main/ai-vault/remote-session-scan-issues.ts b/src/main/ai-vault/remote-session-scan-issues.ts new file mode 100644 index 000000000..aee229f44 --- /dev/null +++ b/src/main/ai-vault/remote-session-scan-issues.ts @@ -0,0 +1,24 @@ +import type { AiVaultScanIssue } from '../../shared/ai-vault-types' + +const REMOTE_SCAN_ISSUE_LIMIT = 500 + +export function recordRemoteSessionScanIssue( + issues: AiVaultScanIssue[], + issue: AiVaultScanIssue +): void { + if (issues.length < REMOTE_SCAN_ISSUE_LIMIT - 1) { + issues.push(issue) + return + } + if (issues.length === REMOTE_SCAN_ISSUE_LIMIT - 1) { + // Kinded: this row is a scan notice, not a skipped transcript — the panel + // counts unkinded issues as skipped transcript files. + issues.push({ + executionHostId: issue.executionHostId, + agent: issue.agent, + kind: 'notice', + path: 'Agent Session History scan', + message: 'Additional scan issues were omitted.' + }) + } +} diff --git a/src/main/ai-vault/remote-session-scanner-codex-index.ts b/src/main/ai-vault/remote-session-scanner-codex-index.ts index d6a305ba4..8f5ec3c1b 100644 --- a/src/main/ai-vault/remote-session-scanner-codex-index.ts +++ b/src/main/ai-vault/remote-session-scanner-codex-index.ts @@ -1,39 +1,50 @@ -import type { IFilesystemProvider } from '../providers/types' import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' import { joinRemotePath } from '../ssh/ssh-remote-platform' import { extractString, normalizeTitleText, parseJsonObject } from './session-scanner-values' +import { remoteSessionContentLines } from './remote-session-content-lines' +import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' +import type { RemoteSessionFilesystemProvider } from './remote-session-scanner-types' const CODEX_SESSION_INDEX_FILE = 'session_index.jsonl' export async function remoteCodexIndexTitles(args: { - provider: IFilesystemProvider + provider: RemoteSessionFilesystemProvider codexHome: string hostPlatform: RemoteHostPlatform titleCaches: Map>> + signal?: AbortSignal }): Promise> { const cached = args.titleCaches.get(args.codexHome) if (cached) { return cached } - const pending = readRemoteCodexIndexTitles(args.provider, args.codexHome, args.hostPlatform) + const pending = readRemoteCodexIndexTitles( + args.provider, + args.codexHome, + args.hostPlatform, + args.signal + ) args.titleCaches.set(args.codexHome, pending) return pending } async function readRemoteCodexIndexTitles( - provider: IFilesystemProvider, + provider: RemoteSessionFilesystemProvider, codexHome: string, - hostPlatform: RemoteHostPlatform + hostPlatform: RemoteHostPlatform, + signal?: AbortSignal ): Promise> { const titleBySessionId = new Map() try { + throwIfAiVaultScanCancelled(signal) const { content, isBinary } = await provider.readFile( joinRemotePath(hostPlatform, codexHome, CODEX_SESSION_INDEX_FILE) ) + throwIfAiVaultScanCancelled(signal) if (isBinary) { return titleBySessionId } - for (const line of content.split(/\r?\n/)) { + for await (const line of remoteSessionContentLines(content, signal)) { const record = parseJsonObject(line) if (!record) { continue @@ -45,6 +56,7 @@ async function readRemoteCodexIndexTitles( } } } catch { + throwIfAiVaultScanCancelled(signal) // Codex indexes are opportunistic; raw transcripts remain sufficient. } return titleBySessionId diff --git a/src/main/ai-vault/remote-session-scanner-discovery.ts b/src/main/ai-vault/remote-session-scanner-discovery.ts index 14a47f365..a309f5107 100644 --- a/src/main/ai-vault/remote-session-scanner-discovery.ts +++ b/src/main/ai-vault/remote-session-scanner-discovery.ts @@ -6,6 +6,9 @@ import { isMissingRemoteSessionPathError, statRemoteSessionFile } from './remote import { partitionSubagentTranscriptPaths } from './session-scanner-subagent-transcripts' import type { FileWithMtime } from './session-scanner-types' import { errorMessage } from './session-scanner-values' +import { mapRemoteScanBatches } from './remote-session-scan-batching' +import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' +import { recordRemoteSessionScanIssue } from './remote-session-scan-issues' import type { RemoteScannerContext, RemoteSessionCandidate, @@ -26,15 +29,22 @@ export async function discoverRemoteSourceCandidates(args: { ? partitionSubagentTranscriptPaths(walked) : null const paths = partition ? partition.sessionFilePaths : walked - const files = await mapDiscoveryConcurrently(paths, (path) => - statRemoteSessionFile( - args.context.provider, - path, - args.source.agent, - args.context.executionHostId, - args.issues, - { missingIsExpected: Boolean(args.source.fixedChildFileSegments) } - ) + const files = await mapRemoteScanBatches( + paths, + REMOTE_DISCOVERY_CONCURRENCY, + (path) => + statRemoteSessionFile( + args.context.provider, + path, + args.source.agent, + args.context.executionHostId, + args.issues, + { + missingIsExpected: Boolean(args.source.fixedChildFileSegments), + signal: args.context.signal + } + ), + args.context.signal ) return files .filter((file): file is FileWithMtime => Boolean(file)) @@ -50,10 +60,12 @@ async function listRemoteFixedChildFiles( context: RemoteScannerContext, issues: AiVaultScanIssue[] ): Promise { + throwIfAiVaultScanCancelled(context.signal) let entries try { entries = await context.provider.readDir(source.rootDir) } catch (err) { + throwIfAiVaultScanCancelled(context.signal) recordRemoteDirectoryIssue(source, context.executionHostId, issues, source.rootDir, err) return [] } @@ -73,10 +85,12 @@ async function walkRemoteSessionFiles( dirPath = source.rootDir, depth = 0 ): Promise { + throwIfAiVaultScanCancelled(context.signal) let entries try { entries = await context.provider.readDir(dirPath) } catch (err) { + throwIfAiVaultScanCancelled(context.signal) recordRemoteDirectoryIssue(source, context.executionHostId, issues, dirPath, err) return [] } @@ -84,6 +98,7 @@ async function walkRemoteSessionFiles( const extensions = new Set(source.extensions) const files: string[] = [] for (const entry of entries) { + throwIfAiVaultScanCancelled(context.signal) const fullPath = joinRemotePath(context.hostPlatform, dirPath, entry.name) if ( entry.isDirectory && @@ -112,18 +127,12 @@ function recordRemoteDirectoryIssue( err: unknown ): void { if (!isMissingRemoteSessionPathError(err)) { - issues.push({ executionHostId, agent: source.agent, path, message: errorMessage(err) }) + recordRemoteSessionScanIssue(issues, { + executionHostId, + agent: source.agent, + kind: 'host', + path, + message: errorMessage(err) + }) } } - -async function mapDiscoveryConcurrently( - items: readonly T[], - mapper: (item: T) => Promise -): Promise { - const results: U[] = [] - for (let index = 0; index < items.length; index += REMOTE_DISCOVERY_CONCURRENCY) { - const batch = items.slice(index, index + REMOTE_DISCOVERY_CONCURRENCY) - results.push(...(await Promise.all(batch.map(mapper)))) - } - return results -} diff --git a/src/main/ai-vault/remote-session-scanner-sources.ts b/src/main/ai-vault/remote-session-scanner-sources.ts index 5f57fa47c..d704c2da6 100644 --- a/src/main/ai-vault/remote-session-scanner-sources.ts +++ b/src/main/ai-vault/remote-session-scanner-sources.ts @@ -25,7 +25,9 @@ type RemoteContentParser = ( file: FileWithMtime, content: string, platform: NodeJS.Platform, - options: RemoteParserOptions + options: RemoteParserOptions, + // Line-based parsers iterate cancellably; whole-document parsers ignore it. + signal?: AbortSignal ) => Promise | AiVaultSession | null export function remoteSessionSources( @@ -126,7 +128,8 @@ function remoteAntigravitySource( file, content, context.hostPlatform.os, - parserOptions(context) + parserOptions(context), + context.signal ) return session ? context.antigravityWorkspaceResolver.enrich(session, historyPath) : null } @@ -150,7 +153,9 @@ function source( filePredicate, directoryPredicate, parse: (file, content, context) => - Promise.resolve(parseContent(file, content, context.hostPlatform.os, parserOptions(context))) + Promise.resolve( + parseContent(file, content, context.hostPlatform.os, parserOptions(context), context.signal) + ) } } @@ -193,13 +198,15 @@ function remoteCodexSources( codexHome, executionHostId: context.executionHostId, executionHostPlatform: context.hostPlatform.os, + signal: context.signal, readIndexedTitle: async (sessionId) => ( await remoteCodexIndexTitles({ provider: context.provider, codexHome, hostPlatform, - titleCaches: context.titleCaches + titleCaches: context.titleCaches, + signal: context.signal }) ).get(sessionId) ?? null }) @@ -233,27 +240,30 @@ function piParser( file: FileWithMtime, content: string, platform: NodeJS.Platform, - options: RemoteParserOptions + options: RemoteParserOptions, + signal?: AbortSignal ): Promise { - return parseMessageGraphSessionContent('pi', file, content, platform, options) + return parseMessageGraphSessionContent('pi', file, content, platform, options, signal) } function ompParser( file: FileWithMtime, content: string, platform: NodeJS.Platform, - options: RemoteParserOptions + options: RemoteParserOptions, + signal?: AbortSignal ): Promise { - return parseMessageGraphSessionContent('omp', file, content, platform, options) + return parseMessageGraphSessionContent('omp', file, content, platform, options, signal) } function openClawParser( file: FileWithMtime, content: string, platform: NodeJS.Platform, - options: RemoteParserOptions + options: RemoteParserOptions, + signal?: AbortSignal ): Promise { - return parseMessageGraphSessionContent('openclaw', file, content, platform, options) + return parseMessageGraphSessionContent('openclaw', file, content, platform, options, signal) } function remotePathSegments(path: string): string[] { diff --git a/src/main/ai-vault/remote-session-scanner-types.ts b/src/main/ai-vault/remote-session-scanner-types.ts index 77806f38b..58fb255e3 100644 --- a/src/main/ai-vault/remote-session-scanner-types.ts +++ b/src/main/ai-vault/remote-session-scanner-types.ts @@ -6,13 +6,19 @@ import type { FileWithMtime } from './session-scanner-types' import type { AntigravityWorkspaceResolver } from './session-scanner-antigravity-history' export type RemoteScannerContext = { - provider: IFilesystemProvider + provider: RemoteSessionFilesystemProvider executionHostId: ExecutionHostId hostPlatform: RemoteHostPlatform + signal?: AbortSignal titleCaches: Map>> antigravityWorkspaceResolver: AntigravityWorkspaceResolver } +export type RemoteSessionFilesystemProvider = Pick< + IFilesystemProvider, + 'readDir' | 'readFile' | 'stat' +> + export type RemoteParserOptions = { executionHostId: ExecutionHostId executionHostPlatform: NodeJS.Platform diff --git a/src/main/ai-vault/remote-session-scanner.test.ts b/src/main/ai-vault/remote-session-scanner.test.ts index 4ba3bd4d6..a4705ee1b 100644 --- a/src/main/ai-vault/remote-session-scanner.test.ts +++ b/src/main/ai-vault/remote-session-scanner.test.ts @@ -153,7 +153,9 @@ describe('scanRemoteAiVaultSessions', () => { provider, executionHostId: 'ssh:dev-box', remoteHome: '/home/ada', - hostPlatform: getRemoteHostPlatform('linux-x64') + hostPlatform: getRemoteHostPlatform('linux-x64'), + limit: 1, + unlimited: true }) expect(result.issues).toEqual([]) @@ -404,11 +406,13 @@ describe('scanRemoteAiVaultSessions', () => { expect.arrayContaining([ expect.objectContaining({ agent: 'antigravity', + kind: 'host', path: brainDir, message: expect.stringContaining('EACCES') }), expect.objectContaining({ agent: 'claude', + kind: 'host', path: claudeProjectDir, message: expect.stringContaining('ECONNRESET') }) @@ -706,6 +710,93 @@ describe('scanRemoteAiVaultSessions', () => { 'scoped-session' ]) }) + + it('keeps looking past newer out-of-scope candidates during scoped backfill', async () => { + const provider = new MemoryRemoteProvider() + for (const [sessionId, mtimeMs, hour] of [ + ['other-newest', 50, '05'], + ['other-newer', 40, '04'] + ] as const) { + provider.addFile( + `/home/ada/.codex/sessions/${sessionId}.jsonl`, + codexTranscript({ + sessionId, + title: sessionId, + cwd: '/home/ada/other', + timestamp: `2026-07-04T${hour}:00:00.000Z` + }), + mtimeMs + ) + } + provider.addFile( + '/home/ada/.codex/sessions/scoped.jsonl', + codexTranscript({ + sessionId: 'scoped-session', + title: 'Scoped workspace', + cwd: '/home/ada/repo', + timestamp: '2026-07-04T01:00:00.000Z' + }), + 10 + ) + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:dev-box', + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64'), + limit: 1, + scopePaths: ['/home/ada/repo'] + }) + + expect(result.issues).toEqual([]) + expect(result.sessions.map((session) => session.sessionId)).toEqual([ + 'other-newest', + 'scoped-session' + ]) + }) + + it('caps scoped backfill at the requested limit', async () => { + const provider = new MemoryRemoteProvider() + provider.addFile( + '/home/ada/.codex/sessions/other.jsonl', + codexTranscript({ + sessionId: 'other-session', + title: 'Other workspace', + cwd: '/home/ada/other', + timestamp: '2026-07-04T05:00:00.000Z' + }), + 50 + ) + for (const [sessionId, mtimeMs] of [ + ['newer-scoped', 30], + ['older-scoped', 20] + ] as const) { + provider.addFile( + `/home/ada/.codex/sessions/${sessionId}.jsonl`, + codexTranscript({ + sessionId, + title: sessionId, + cwd: '/home/ada/repo', + timestamp: `2026-07-04T0${mtimeMs / 10}:00:00.000Z` + }), + mtimeMs + ) + } + + const result = await scanRemoteAiVaultSessions({ + provider, + executionHostId: 'ssh:dev-box', + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64'), + limit: 1, + scopePaths: ['/home/ada/repo'] + }) + + expect(result.sessions.map((session) => session.sessionId)).toEqual([ + 'other-session', + 'newer-scoped' + ]) + }) }) function codexTranscript(args: { diff --git a/src/main/ai-vault/remote-session-scanner.ts b/src/main/ai-vault/remote-session-scanner.ts index 0ad894214..2523cd22a 100644 --- a/src/main/ai-vault/remote-session-scanner.ts +++ b/src/main/ai-vault/remote-session-scanner.ts @@ -5,7 +5,7 @@ import type { } from '../../shared/ai-vault-types' import { isPathInsideOrEqual } from '../../shared/cross-platform-path' import type { ExecutionHostId } from '../../shared/execution-host' -import type { IFilesystemProvider } from '../providers/types' +import { setImmediate as yieldToEventLoop } from 'node:timers/promises' import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform' import { codexRolloutHardlinkIdentity, @@ -14,44 +14,71 @@ import { } from './codex-session-root-dedup' import { discoverRemoteSourceCandidates } from './remote-session-scanner-discovery' import { remoteSessionSources } from './remote-session-scanner-sources' -import type { RemoteScannerContext, RemoteSessionCandidate } from './remote-session-scanner-types' +import type { + RemoteScannerContext, + RemoteSessionCandidate, + RemoteSessionFilesystemProvider +} from './remote-session-scanner-types' import { sessionSortTime } from './session-scanner-accumulator' import { createAntigravityWorkspaceResolver } from './session-scanner-antigravity-history' import { errorMessage } from './session-scanner-values' +import { mapRemoteScanBatches } from './remote-session-scan-batching' +import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' +import { recordRemoteSessionScanIssue } from './remote-session-scan-issues' +import { limitRemoteScanFilesystemConcurrency } from './remote-session-scan-concurrency' +import { aiVaultScanLimit } from '../../shared/ai-vault-session-depth' -const DEFAULT_REMOTE_SCAN_LIMIT = 1000 const REMOTE_SCAN_CONCURRENCY = 8 -const REMOTE_SCOPE_PARSE_LIMIT = 2000 +const REMOTE_PARSE_CANDIDATE_MULTIPLIER = 2 +// Remote scope membership is only known after a transcript is read, so the scope +// backfill carries its own ceiling on remote reads instead of borrowing the +// recency cap — under the cap, newer out-of-scope files ate the scope budget and +// silently dropped older in-scope sessions the scope contract guarantees. +const REMOTE_SCOPE_PARSE_CANDIDATE_LIMIT = 1000 export async function scanRemoteAiVaultSessions(args: { - provider: IFilesystemProvider + provider: RemoteSessionFilesystemProvider executionHostId: ExecutionHostId remoteHome: string hostPlatform: RemoteHostPlatform limit?: number + unlimited?: boolean scopePaths?: readonly string[] + signal?: AbortSignal }): Promise { - const limit = args.limit && args.limit > 0 ? Math.floor(args.limit) : DEFAULT_REMOTE_SCAN_LIMIT + throwIfAiVaultScanCancelled(args.signal) + const limit = aiVaultScanLimit(args) const issues: AiVaultScanIssue[] = [] + // One ceiling for the whole scan: discovery walks, stats and transcript reads + // all queue behind it instead of multiplying into a nested fan-out. + const provider = limitRemoteScanFilesystemConcurrency(args.provider) const context: RemoteScannerContext = { - provider: args.provider, + provider, executionHostId: args.executionHostId, hostPlatform: args.hostPlatform, + signal: args.signal, titleCaches: new Map(), antigravityWorkspaceResolver: createAntigravityWorkspaceResolver(async (historyPath) => { try { - const read = await args.provider.readFile(historyPath) + throwIfAiVaultScanCancelled(args.signal) + const read = await provider.readFile(historyPath) + throwIfAiVaultScanCancelled(args.signal) return read.isBinary ? null : read.content - } catch { + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw error + } return null } }) } const candidates = dedupeCodexRolloutFileAliases( ( - await mapRemoteScanConcurrently( + await mapRemoteScanBatches( remoteSessionSources(args.remoteHome, args.hostPlatform), - (source) => discoverRemoteSourceCandidates({ source, context, issues }) + REMOTE_SCAN_CONCURRENCY, + (source) => discoverRemoteSourceCandidates({ source, context, issues }), + args.signal ) ) .flat() @@ -64,7 +91,12 @@ export async function scanRemoteAiVaultSessions(args: { } ) - const parsed = await parseRemoteSessionCandidates({ candidates, context, issues, limit }) + const parsed = await parseRemoteSessionCandidates({ + candidates: candidates.slice(0, limit * REMOTE_PARSE_CANDIDATE_MULTIPLIER), + context, + issues, + limit + }) const parsedSessions = dedupeCodexSessionsBySessionId(parsed.sessions) const cappedSessions = parsedSessions .sort((left, right) => sessionSortTime(right) - sessionSortTime(left)) @@ -78,12 +110,15 @@ export async function scanRemoteAiVaultSessions(args: { context, issues, scopePaths, + limit, alreadyParsedFilePaths: parsed.parsedFilePaths }) const scopeSessions = dedupeCodexSessionsBySessionId([ ...parsedScopeSessions, ...extraScopeSessions ]) + .sort((left, right) => sessionSortTime(right) - sessionSortTime(left)) + .slice(0, limit) return { sessions: mergeRemoteSessions(cappedSessions, scopeSessions), @@ -107,19 +142,27 @@ async function parseRemoteSessionCandidates(args: { break } - const batch = args.candidates.slice(index, index + REMOTE_SCAN_CONCURRENCY) + const remaining = args.candidates.length - index + const needed = Math.max(args.limit - sessions.length, 1) + const batchSize = Math.min(REMOTE_SCAN_CONCURRENCY, needed, remaining) + const batch = args.candidates.slice(index, index + batchSize) for (const candidate of batch) { parsedFilePaths.add(candidate.file.path) } + throwIfAiVaultScanCancelled(args.context.signal) const results = await Promise.all( batch.map((candidate) => parseRemoteSessionCandidate(candidate, args.context, args.issues)) ) sessions.push(...results.filter(isAiVaultSession)) const uniqueSessions = dedupeCodexSessionsBySessionId(sessions) sessions.splice(0, sessions.length, ...uniqueSessions) - index += batch.length + index += batchSize + await yieldToEventLoop() } + // The loop can terminate on the yield after its final batch, so re-check + // rather than letting a cancelled scan return a partial parse as a success. + throwIfAiVaultScanCancelled(args.context.signal) return { sessions, parsedFilePaths } } @@ -128,21 +171,29 @@ async function scanRemoteInScopeSessions(args: { context: RemoteScannerContext issues: AiVaultScanIssue[] scopePaths: readonly string[] + limit: number alreadyParsedFilePaths: ReadonlySet }): Promise { if (args.scopePaths.length === 0) { return [] } - const candidates = args.candidates - .filter((candidate) => !args.alreadyParsedFilePaths.has(candidate.file.path)) - .slice(0, REMOTE_SCOPE_PARSE_LIMIT) + const candidates = args.candidates.filter( + (candidate) => !args.alreadyParsedFilePaths.has(candidate.file.path) + ) + const bound = Math.min(candidates.length, REMOTE_SCOPE_PARSE_CANDIDATE_LIMIT) const sessions: AiVaultSession[] = [] + let index = 0 - for (let index = 0; index < candidates.length; index += REMOTE_SCAN_CONCURRENCY) { - const batch = candidates.slice(index, index + REMOTE_SCAN_CONCURRENCY) - const results = await Promise.all( - batch.map((candidate) => parseRemoteSessionCandidate(candidate, args.context, args.issues)) + // Keep reading newest-first until the scope has its requested number of + // sessions; out-of-scope candidates no longer end the search. + while (index < bound && sessions.length < args.limit) { + const batchEnd = Math.min(index + REMOTE_SCAN_CONCURRENCY, bound) + const results = await mapRemoteScanBatches( + candidates.slice(index, batchEnd), + REMOTE_SCAN_CONCURRENCY, + (candidate) => parseRemoteSessionCandidate(candidate, args.context, args.issues), + args.context.signal ) sessions.push( ...results.filter( @@ -150,6 +201,17 @@ async function scanRemoteInScopeSessions(args: { isAiVaultSession(session) && isRemoteSessionInScope(session, args.scopePaths) ) ) + index = batchEnd + } + + if (index < candidates.length && sessions.length < args.limit) { + recordRemoteSessionScanIssue(args.issues, { + executionHostId: args.context.executionHostId, + agent: 'codex', + kind: 'scope', + path: 'Agent Session History scan', + message: `Only the ${REMOTE_SCOPE_PARSE_CANDIDATE_LIMIT} most recent remote transcripts were checked for this workspace; older sessions may be missing.` + }) } return sessions @@ -161,11 +223,14 @@ async function parseRemoteSessionCandidate( issues: AiVaultScanIssue[] ): Promise { try { + throwIfAiVaultScanCancelled(context.signal) const read = await context.provider.readFile(candidate.file.path) + throwIfAiVaultScanCancelled(context.signal) if (read.isBinary) { return null } const session = await candidate.source.parse(candidate.file, read.content, context) + throwIfAiVaultScanCancelled(context.signal) // Mirror the local rule: every session carries its sibling subagent // transcript count (row badge; recoverable signal at zero turns). The // walk listing supplies it — the parser can't readdir a remote disk. @@ -175,7 +240,8 @@ async function parseRemoteSessionCandidate( } return session } catch (err) { - issues.push({ + throwIfAiVaultScanCancelled(context.signal) + recordRemoteSessionScanIssue(issues, { executionHostId: context.executionHostId, agent: candidate.source.agent, path: candidate.file.path, @@ -232,15 +298,3 @@ function canStopParsingRemoteSessions( function isAiVaultSession(session: AiVaultSession | null): session is AiVaultSession { return Boolean(session) } - -async function mapRemoteScanConcurrently( - items: readonly T[], - mapper: (item: T) => Promise -): Promise { - const results: U[] = [] - for (let index = 0; index < items.length; index += REMOTE_SCAN_CONCURRENCY) { - const batch = items.slice(index, index + REMOTE_SCAN_CONCURRENCY) - results.push(...(await Promise.all(batch.map(mapper)))) - } - return results -} diff --git a/src/main/ai-vault/runtime-session-scanner.test.ts b/src/main/ai-vault/runtime-session-scanner.test.ts index 1c77130c5..922a6af96 100644 --- a/src/main/ai-vault/runtime-session-scanner.test.ts +++ b/src/main/ai-vault/runtime-session-scanner.test.ts @@ -68,6 +68,21 @@ describe('runtime AI Vault session scanner', () => { ) }) + it('surfaces project scope truncation from the runtime transport bound', async () => { + const scopePaths = Array.from({ length: 80 }, (_, index) => `/srv/repo-${index}`) + + const scanResult = await scanRuntimeAiVaultSessions('/user-data', 'env-1', { + scopePaths + }) + + expect(scanResult.issues).toContainEqual( + expect.objectContaining({ + kind: 'scope', + message: expect.stringContaining('first 64 project paths') + }) + ) + }) + it('stamps sessions and issues returned for a different execution host', async () => { mocks.callRuntimeEnvironment.mockResolvedValueOnce({ ok: true, diff --git a/src/main/ai-vault/runtime-session-scanner.ts b/src/main/ai-vault/runtime-session-scanner.ts index 4ead287bb..1c1c248ee 100644 --- a/src/main/ai-vault/runtime-session-scanner.ts +++ b/src/main/ai-vault/runtime-session-scanner.ts @@ -1,18 +1,18 @@ import { z } from 'zod' import { - AI_VAULT_AGENTS, AI_VAULT_SCOPE_PATHS_MAX_COUNT, type AiVaultListArgs, type AiVaultListResult, type AiVaultSession } from '../../shared/ai-vault-types' -import { normalizeExecutionHostId, toRuntimeExecutionHostId } from '../../shared/execution-host' +import { toRuntimeExecutionHostId } from '../../shared/execution-host' import { listEnvironments } from '../../shared/runtime-environment-store' import { callRuntimeEnvironment } from '../ipc/runtime-environment-transport-routing' import type { AiVaultPrepareSessionResumeArgs, AiVaultPrepareSessionResumeResult } from '../../shared/ai-vault-resume-preparation' +import { parseAiVaultListResult } from './session-list-result-validation' export type RuntimeAiVaultHostInfo = { environmentId: string @@ -23,90 +23,6 @@ export type RuntimeAiVaultScanOptions = { timeoutMs?: number } -const nodePlatformSchema = z.enum([ - 'aix', - 'android', - 'darwin', - 'freebsd', - 'haiku', - 'linux', - 'openbsd', - 'sunos', - 'win32', - 'cygwin', - 'netbsd' -] satisfies NodeJS.Platform[]) - -const aiVaultSessionPreviewMessageSchema = z.object({ - role: z.enum(['user', 'assistant', 'system', 'tool', 'unknown']), - text: z.string(), - timestamp: z.string().nullable() -}) - -const executionHostIdSchema = z.string().transform((value, ctx) => { - const normalized = normalizeExecutionHostId(value) - if (normalized) { - return normalized - } - ctx.addIssue({ - code: 'custom', - message: 'Invalid execution host id' - }) - return z.NEVER -}) - -const aiVaultListResultSchema = z.object({ - sessions: z.array( - z.object({ - id: z.string(), - executionHostId: executionHostIdSchema, - executionHostPlatform: nodePlatformSchema.nullable().optional(), - agent: z.enum(AI_VAULT_AGENTS), - sessionId: z.string(), - title: z.string(), - cwd: z.string().nullable(), - branch: z.string().nullable(), - model: z.string().nullable(), - filePath: z.string(), - codexHome: z.string().nullable(), - createdAt: z.string().nullable(), - updatedAt: z.string().nullable(), - modifiedAt: z.string(), - messageCount: z.number(), - totalTokens: z.number(), - previewMessages: z.array(aiVaultSessionPreviewMessageSchema), - // Optional keeps paired hosts on older builds compatible. - previewMessagesTruncated: z.boolean().optional(), - firstUserPrompt: z.string().nullable().optional(), - lastUserPrompt: z.string().nullable().optional(), - // Default keeps remote hosts running an older build (no recoverable-signal - // fields) parseable; they simply report no recoverable-empty sessions. - queuedMessageCount: z.number().default(0), - subagentTranscriptCount: z.number().default(0), - resumeCommand: z.string(), - // The default keeps remote hosts running an older build (no subagent - // field) parseable; scanned top-level sessions carry null anyway. - subagent: z - .object({ - parentSessionId: z.string(), - agentType: z.string().nullable(), - status: z.enum(['running', 'completed', 'failed', 'stopped']).nullable() - }) - .nullable() - .default(null) - }) - ), - issues: z.array( - z.object({ - executionHostId: executionHostIdSchema.optional(), - agent: z.enum(AI_VAULT_AGENTS), - path: z.string(), - message: z.string() - }) - ), - scannedAt: z.string() -}) - // Why: zod strips unknown keys, so the repin home must be declared or the // parent would silently drop it and resume under the wrong account's home. const aiVaultPrepareSessionResumeResultSchema = z.object({ @@ -136,6 +52,7 @@ export async function scanRuntimeAiVaultSessions( 'aiVault.listSessions', { limit: args.limit, + unlimited: args.unlimited, force: args.force, // Why: cap here so the set of scanned paths is explicit on this side — // the RPC schema CLAMPS to the same bound anyway (older hosts had no @@ -147,17 +64,36 @@ export async function scanRuntimeAiVaultSessions( options.timeoutMs ) if (response.ok === true) { - const parsed = aiVaultListResultSchema.safeParse(response.result) - if (parsed.success) { - return withRuntimeExecutionHost(parsed.data, executionHostId) + try { + const result = withRuntimeExecutionHost( + parseAiVaultListResult(response.result), + executionHostId + ) + if (!args.scopePaths || args.scopePaths.length <= AI_VAULT_SCOPE_PATHS_MAX_COUNT) { + return result + } + return { + ...result, + issues: [ + ...result.issues, + { + executionHostId, + agent: 'codex', + kind: 'scope', + path: environmentId, + message: `Only the first ${AI_VAULT_SCOPE_PATHS_MAX_COUNT} project paths were scanned.` + } + ] + } + } catch (error) { + return runtimeScanIssueResult({ + executionHostId, + environmentId, + message: `Invalid aiVault.listSessions response: ${ + error instanceof Error ? error.message : 'unexpected result shape' + }` + }) } - return runtimeScanIssueResult({ - executionHostId, - environmentId, - message: `Invalid aiVault.listSessions response: ${ - parsed.error.issues[0]?.message ?? 'unexpected result shape' - }` - }) } return runtimeScanIssueResult({ executionHostId, @@ -224,6 +160,7 @@ function runtimeScanIssueResult(args: { { executionHostId: args.executionHostId, agent: 'codex', + kind: 'host', path: args.environmentId, message: args.message } diff --git a/src/main/ai-vault/session-list-result-validation.test.ts b/src/main/ai-vault/session-list-result-validation.test.ts new file mode 100644 index 000000000..9cdc22a8f --- /dev/null +++ b/src/main/ai-vault/session-list-result-validation.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest' +import { parseAiVaultListResult } from './session-list-result-validation' + +describe('parseAiVaultListResult', () => { + it('keeps valid sessions when another wire entry is malformed', () => { + const parsed = parseAiVaultListResult({ + sessions: [validSession(), { id: 42 }], + issues: [], + scannedAt: '2026-07-27T00:00:00.000Z' + }) + + expect(parsed.sessions).toHaveLength(1) + expect(parsed.sessions[0]?.sessionId).toBe('session-1') + expect(parsed.issues).toContainEqual( + expect.objectContaining({ + path: 'aiVault.listSessions', + message: expect.stringContaining('Skipped 1 invalid') + }) + ) + }) + + it('preserves the optional session fields the panel renders', () => { + const parsed = parseAiVaultListResult({ + sessions: [ + { + ...validSession(), + previewMessagesTruncated: true, + firstUserPrompt: 'first', + lastUserPrompt: 'last' + } + ], + issues: [], + scannedAt: '2026-07-27T00:00:00.000Z' + }) + + expect(parsed.sessions[0]).toMatchObject({ + previewMessagesTruncated: true, + firstUserPrompt: 'first', + lastUserPrompt: 'last' + }) + }) + + it('rejects a malformed result envelope', () => { + expect(() => parseAiVaultListResult({ sessions: [] })).toThrow() + }) + + it('rejects a nonempty sessions array when every row is invalid', () => { + expect(() => + parseAiVaultListResult({ + sessions: [{ id: 42 }, null], + issues: [], + scannedAt: '2026-07-27T00:00:00.000Z' + }) + ).toThrow('all supplied Agent Session History sessions were invalid') + }) +}) + +function validSession(): Record { + return { + id: 'local:codex:session-1:/tmp/session-1.jsonl', + executionHostId: 'local', + executionHostPlatform: 'linux', + agent: 'codex', + sessionId: 'session-1', + title: 'Session one', + cwd: '/repo', + branch: null, + model: null, + filePath: '/tmp/session-1.jsonl', + codexHome: null, + createdAt: null, + updatedAt: null, + modifiedAt: '2026-07-27T00:00:00.000Z', + messageCount: 1, + totalTokens: 0, + previewMessages: [], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: 'codex resume session-1', + subagent: null + } +} diff --git a/src/main/ai-vault/session-list-result-validation.ts b/src/main/ai-vault/session-list-result-validation.ts new file mode 100644 index 000000000..6d69a06af --- /dev/null +++ b/src/main/ai-vault/session-list-result-validation.ts @@ -0,0 +1,108 @@ +import { z } from 'zod' +import { AI_VAULT_AGENTS, type AiVaultListResult } from '../../shared/ai-vault-types' +import { normalizeExecutionHostId } from '../../shared/execution-host' + +const nodePlatformSchema = z.enum([ + 'aix', + 'android', + 'darwin', + 'freebsd', + 'haiku', + 'linux', + 'openbsd', + 'sunos', + 'win32', + 'cygwin', + 'netbsd' +] satisfies NodeJS.Platform[]) + +const executionHostIdSchema = z.string().transform((value, ctx) => { + const normalized = normalizeExecutionHostId(value) + if (normalized) { + return normalized + } + ctx.addIssue({ code: 'custom', message: 'Invalid execution host id' }) + return z.NEVER +}) + +const sessionPreviewMessageSchema = z.object({ + role: z.enum(['user', 'assistant', 'system', 'tool', 'unknown']), + text: z.string(), + timestamp: z.string().nullable() +}) + +const aiVaultSessionSchema = z.object({ + id: z.string(), + executionHostId: executionHostIdSchema, + executionHostPlatform: nodePlatformSchema.nullable().optional(), + agent: z.enum(AI_VAULT_AGENTS), + sessionId: z.string(), + title: z.string(), + cwd: z.string().nullable(), + branch: z.string().nullable(), + model: z.string().nullable(), + filePath: z.string(), + codexHome: z.string().nullable(), + createdAt: z.string().nullable(), + updatedAt: z.string().nullable(), + modifiedAt: z.string(), + messageCount: z.number(), + totalTokens: z.number(), + previewMessages: z.array(sessionPreviewMessageSchema), + previewMessagesTruncated: z.boolean().optional(), + firstUserPrompt: z.string().nullable().optional(), + lastUserPrompt: z.string().nullable().optional(), + queuedMessageCount: z.number().default(0), + subagentTranscriptCount: z.number().default(0), + resumeCommand: z.string(), + subagent: z + .object({ + parentSessionId: z.string(), + agentType: z.string().nullable(), + status: z.enum(['running', 'completed', 'failed', 'stopped']).nullable() + }) + .nullable() + .default(null) +}) + +const aiVaultScanIssueSchema = z.object({ + executionHostId: executionHostIdSchema.optional(), + agent: z.enum(AI_VAULT_AGENTS), + kind: z.enum(['host', 'scope', 'notice']).optional(), + path: z.string(), + message: z.string() +}) + +const aiVaultListResultEnvelopeSchema = z.object({ + sessions: z.array(z.unknown()), + issues: z.array(z.unknown()), + scannedAt: z.string() +}) + +export function parseAiVaultListResult(value: unknown): AiVaultListResult { + const envelope = aiVaultListResultEnvelopeSchema.safeParse(value) + if (!envelope.success) { + throw new Error(envelope.error.issues[0]?.message ?? 'unexpected result shape') + } + const sessions = envelope.data.sessions.flatMap((session) => { + const parsed = aiVaultSessionSchema.safeParse(session) + return parsed.success ? [parsed.data] : [] + }) + if (envelope.data.sessions.length > 0 && sessions.length === 0) { + throw new Error('all supplied Agent Session History sessions were invalid') + } + const issues = envelope.data.issues.flatMap((issue) => { + const parsed = aiVaultScanIssueSchema.safeParse(issue) + return parsed.success ? [parsed.data] : [] + }) + const invalidCount = + envelope.data.sessions.length - sessions.length + (envelope.data.issues.length - issues.length) + if (invalidCount > 0) { + issues.push({ + agent: 'codex', + path: 'aiVault.listSessions', + message: `Skipped ${invalidCount} invalid Agent Session History result ${invalidCount === 1 ? 'entry' : 'entries'}.` + }) + } + return { sessions, issues, scannedAt: envelope.data.scannedAt } +} diff --git a/src/main/ai-vault/session-list-results.test.ts b/src/main/ai-vault/session-list-results.test.ts new file mode 100644 index 000000000..ef0c578f6 --- /dev/null +++ b/src/main/ai-vault/session-list-results.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest' +import type { + AiVaultListResult, + AiVaultScanIssue, + AiVaultSession +} from '../../shared/ai-vault-types' +import { mergeAiVaultListResults } from './session-list-results' + +function listResult(issues: AiVaultScanIssue[]): AiVaultListResult { + return { sessions: [], issues, scannedAt: '2026-08-02T00:00:00.000Z' } +} + +function session(index: number): AiVaultSession { + const id = `session-${index}` + const timestamp = new Date(Date.UTC(2026, 7, 2, 0, 0, index)).toISOString() + return { + id, + executionHostId: 'local', + agent: 'codex', + sessionId: id, + title: id, + cwd: '/repo', + branch: null, + model: null, + filePath: `/sessions/${id}.jsonl`, + codexHome: null, + createdAt: timestamp, + updatedAt: timestamp, + modifiedAt: timestamp, + messageCount: 1, + totalTokens: 0, + previewMessages: [], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: id, + subagent: null + } +} + +const SCOPE_TRUNCATION: AiVaultScanIssue = { + executionHostId: 'ssh:dev-box', + agent: 'codex', + kind: 'scope', + path: '/home/ada', + message: 'Only the first 64 project paths were scanned.' +} + +describe('mergeAiVaultListResults', () => { + it('does not cap an Unlimited all-host merge', () => { + const sessions = Array.from({ length: 1001 }, (_, index) => session(index)) + const merged = mergeAiVaultListResults([{ ...listResult([]), sessions }], undefined, true) + + expect(merged.sessions).toHaveLength(1001) + }) + + it('keeps a per-host scope truncation notice when merging all-host results', () => { + const merged = mergeAiVaultListResults( + [listResult([]), listResult([SCOPE_TRUNCATION])], + undefined + ) + + expect(merged.issues).toEqual([SCOPE_TRUNCATION]) + }) + + it('keeps one scope notice per host rather than collapsing them', () => { + const otherHost: AiVaultScanIssue = { ...SCOPE_TRUNCATION, executionHostId: 'ssh:build-box' } + + const merged = mergeAiVaultListResults( + [listResult([SCOPE_TRUNCATION]), listResult([otherHost])], + undefined + ) + + expect(merged.issues.map((issue) => issue.executionHostId)).toEqual([ + 'ssh:dev-box', + 'ssh:build-box' + ]) + }) + + it('keeps a scope notice alongside a failing host so one bad host is not the whole story', () => { + const hostDown: AiVaultScanIssue = { + executionHostId: 'ssh:build-box', + agent: 'codex', + kind: 'host', + path: 'build-box', + message: 'Remote connection dropped.' + } + + const merged = mergeAiVaultListResults( + [listResult([SCOPE_TRUNCATION]), listResult([hostDown])], + undefined + ) + + expect(merged.issues).toEqual([SCOPE_TRUNCATION, hostDown]) + // Kinded issues render as their own banner rows, never as skipped transcripts. + expect(merged.issues.filter((issue) => !issue.kind)).toEqual([]) + }) +}) diff --git a/src/main/ai-vault/session-list-results.ts b/src/main/ai-vault/session-list-results.ts index 06f8df9c3..54e2fff7d 100644 --- a/src/main/ai-vault/session-list-results.ts +++ b/src/main/ai-vault/session-list-results.ts @@ -5,6 +5,7 @@ import type { } from '../../shared/ai-vault-types' import type { ExecutionHostId } from '../../shared/execution-host' import { sessionSortTime } from './session-scanner-accumulator' +import { aiVaultScanLimit } from '../../shared/ai-vault-session-depth' export function aiVaultScanIssueResult(args: { executionHostId?: ExecutionHostId @@ -17,6 +18,7 @@ export function aiVaultScanIssueResult(args: { { ...(args.executionHostId ? { executionHostId: args.executionHostId } : {}), agent: 'codex', + kind: 'host', path: args.path, message: args.message } @@ -25,6 +27,12 @@ export function aiVaultScanIssueResult(args: { } } +// A superseded scan has no findings to report; the flag tells the renderer to +// keep the list it already has rather than paint this empty body. +export function cancelledAiVaultListResult(): AiVaultListResult { + return { sessions: [], issues: [], scannedAt: new Date().toISOString(), cancelled: true } +} + // Why: the serving-side scan is host-local and cached once for every caller // (desktop parent, web, mobile), so callers that address this host by a runtime // id get the cached result restamped on the way out instead of a per-host scan. @@ -50,9 +58,10 @@ export function restampAiVaultListResult( export function mergeAiVaultListResults( results: readonly AiVaultListResult[], - rawLimit: number | undefined + rawLimit: number | undefined, + unlimited = false ): AiVaultListResult { - const limit = rawLimit && rawLimit > 0 ? Math.floor(rawLimit) : 1000 + const limit = aiVaultScanLimit({ limit: rawLimit, unlimited }) const byId = new Map() const issues: AiVaultScanIssue[] = [] for (const result of results) { diff --git a/src/main/ai-vault/session-scanner-antigravity-parser.ts b/src/main/ai-vault/session-scanner-antigravity-parser.ts index 2e3a3a08b..818167fbd 100644 --- a/src/main/ai-vault/session-scanner-antigravity-parser.ts +++ b/src/main/ai-vault/session-scanner-antigravity-parser.ts @@ -1,3 +1,4 @@ +import { remoteSessionContentLines } from './remote-session-content-lines' import { createReadStream } from 'node:fs' import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' @@ -36,11 +37,12 @@ export async function parseAntigravitySessionContent( file: FileWithMtime, content: string, platform: NodeJS.Platform = process.platform, - options: ParserSessionOptions = {} + options: ParserSessionOptions = {}, + signal?: AbortSignal ): Promise { return parseAntigravitySessionLines({ file, - lines: content.split(/\r?\n/), + lines: remoteSessionContentLines(content, signal), platform, options }) diff --git a/src/main/ai-vault/session-scanner-codex-parser.ts b/src/main/ai-vault/session-scanner-codex-parser.ts index 7e08ec9d9..74e92165c 100644 --- a/src/main/ai-vault/session-scanner-codex-parser.ts +++ b/src/main/ai-vault/session-scanner-codex-parser.ts @@ -31,6 +31,7 @@ import { parseJsonObject, subtractCodexUsage } from './session-scanner-values' +import { remoteSessionContentLines } from './remote-session-content-lines' export async function parseCodexSessionFile( file: FileWithMtime, @@ -61,10 +62,11 @@ export async function parseCodexSessionContent(args: { executionHostId?: ExecutionHostId executionHostPlatform?: NodeJS.Platform | null readIndexedTitle?: (sessionId: string) => Promise + signal?: AbortSignal }): Promise { return parseCodexSessionLines({ file: args.file, - lines: args.content.split(/\r?\n/), + lines: remoteSessionContentLines(args.content, args.signal), platform: args.platform ?? process.platform, codexHome: args.codexHome ?? null, executionHostId: args.executionHostId, diff --git a/src/main/ai-vault/session-scanner-copilot-parser.ts b/src/main/ai-vault/session-scanner-copilot-parser.ts index 3bb0b4ce1..4d0140fc3 100644 --- a/src/main/ai-vault/session-scanner-copilot-parser.ts +++ b/src/main/ai-vault/session-scanner-copilot-parser.ts @@ -1,3 +1,4 @@ +import { remoteSessionContentLines } from './remote-session-content-lines' import { createReadStream } from 'node:fs' import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' @@ -44,11 +45,12 @@ export async function parseCopilotSessionContent( file: FileWithMtime, content: string, platform: NodeJS.Platform = process.platform, - options: ParserSessionOptions = {} + options: ParserSessionOptions = {}, + signal?: AbortSignal ): Promise { return parseCopilotSessionLines({ file, - lines: content.split(/\r?\n/), + lines: remoteSessionContentLines(content, signal), platform, options }) diff --git a/src/main/ai-vault/session-scanner-cursor-parser.ts b/src/main/ai-vault/session-scanner-cursor-parser.ts index 44274d3b6..b18402584 100644 --- a/src/main/ai-vault/session-scanner-cursor-parser.ts +++ b/src/main/ai-vault/session-scanner-cursor-parser.ts @@ -1,3 +1,4 @@ +import { remoteSessionContentLines } from './remote-session-content-lines' import { createReadStream } from 'node:fs' import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' @@ -42,11 +43,12 @@ export async function parseCursorSessionContent( file: FileWithMtime, content: string, platform: NodeJS.Platform = process.platform, - options: ParserSessionOptions = {} + options: ParserSessionOptions = {}, + signal?: AbortSignal ): Promise { return parseCursorSessionLines({ file, - lines: content.split(/\r?\n/), + lines: remoteSessionContentLines(content, signal), platform, options }) diff --git a/src/main/ai-vault/session-scanner-droid-parser.ts b/src/main/ai-vault/session-scanner-droid-parser.ts index 2abaa7b81..557920e7f 100644 --- a/src/main/ai-vault/session-scanner-droid-parser.ts +++ b/src/main/ai-vault/session-scanner-droid-parser.ts @@ -1,3 +1,4 @@ +import { remoteSessionContentLines } from './remote-session-content-lines' import { createReadStream } from 'node:fs' import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' @@ -44,11 +45,12 @@ export async function parseDroidSessionContent( file: FileWithMtime, content: string, platform: NodeJS.Platform = process.platform, - options: ParserSessionOptions = {} + options: ParserSessionOptions = {}, + signal?: AbortSignal ): Promise { return parseDroidSessionLines({ file, - lines: content.split(/\r?\n/), + lines: remoteSessionContentLines(content, signal), platform, options }) diff --git a/src/main/ai-vault/session-scanner-gemini-parsers.ts b/src/main/ai-vault/session-scanner-gemini-parsers.ts index 6f0b9fdc7..c3cda8856 100644 --- a/src/main/ai-vault/session-scanner-gemini-parsers.ts +++ b/src/main/ai-vault/session-scanner-gemini-parsers.ts @@ -1,3 +1,4 @@ +import { remoteSessionContentLines } from './remote-session-content-lines' import { createReadStream } from 'node:fs' import { readFile } from 'node:fs/promises' import { createInterface } from 'node:readline' @@ -40,12 +41,13 @@ export async function parseGeminiSessionContent( file: FileWithMtime, content: string, platform: NodeJS.Platform = process.platform, - options: ResumableParseFinalizeOptions = {} + options: ResumableParseFinalizeOptions = {}, + signal?: AbortSignal ): Promise { if (file.path.endsWith('.jsonl')) { return parseGeminiJsonlSessionLines({ file, - lines: content.split(/\r?\n/), + lines: remoteSessionContentLines(content, signal), platform, options }) diff --git a/src/main/ai-vault/session-scanner-graph-parsers.ts b/src/main/ai-vault/session-scanner-graph-parsers.ts index 04601eacd..b4d0c1711 100644 --- a/src/main/ai-vault/session-scanner-graph-parsers.ts +++ b/src/main/ai-vault/session-scanner-graph-parsers.ts @@ -1,3 +1,4 @@ +import { remoteSessionContentLines } from './remote-session-content-lines' import { createReadStream } from 'node:fs' import { readFile } from 'node:fs/promises' import { basename, dirname, join } from 'node:path' @@ -184,12 +185,13 @@ export async function parseMessageGraphSessionContent( file: FileWithMtime, content: string, platform: NodeJS.Platform = process.platform, - options: ParserSessionOptions = {} + options: ParserSessionOptions = {}, + signal?: AbortSignal ): Promise { return parseMessageGraphSessionLines({ agent, file, - lines: content.split(/\r?\n/), + lines: remoteSessionContentLines(content, signal), platform, options }) diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-bounds.test.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-bounds.test.ts index 7f50a2202..87cc8eb2f 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-bounds.test.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-bounds.test.ts @@ -135,6 +135,22 @@ describe('listOpenCodeSqliteSessions — LIMIT-first discovery', () => { ]) }) + it('omits the SQL limit for an Unlimited scan', async () => { + const { db, path } = createTempDb() + applySchema(db) + for (let i = 0; i < 5; i++) { + insertSession(db, `ses_${i}`, 1_777_634_000_000 + i * 1000) + } + db.close() + + const candidates = await listOpenCodeSqliteSessions({ + dbPaths: [path], + limit: Number.POSITIVE_INFINITY, + issues: [] + }) + expect(candidates).toHaveLength(5) + }) + it('uses time_created recency when time_updated is not positive', async () => { const { db, path } = createTempDb() applySchema(db) diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite-list.ts b/src/main/ai-vault/session-scanner-opencode-sqlite-list.ts index 5d6c62028..4b86d0416 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite-list.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite-list.ts @@ -32,7 +32,7 @@ function canReadOpenCodeSessions(db: SyncDatabase): boolean { ) } -function buildSessionListQuery(db: SyncDatabase): string { +function buildSessionListQuery(db: SyncDatabase, limited: boolean): string { const parentIdPredicate = columnExists(db, 'session', 'parent_id') ? 'AND parent_id IS NULL' : '' const archivedPredicate = columnExists(db, 'session', 'time_archived') ? 'AND time_archived IS NULL' @@ -45,7 +45,7 @@ function buildSessionListQuery(db: SyncDatabase): string { FROM session WHERE 1=1 ${parentIdPredicate} ${archivedPredicate} ORDER BY CASE WHEN time_updated > 0 THEN time_updated ELSE time_created END DESC - LIMIT ?` + ${limited ? 'LIMIT ?' : ''}` } function rowToCandidate(row: SessionRow, dbPath: string): SessionFileCandidate { @@ -105,7 +105,9 @@ export async function listOpenCodeSqliteSessions(args: { if (!canReadOpenCodeSessions(db)) { continue } - const rows = db.prepare(buildSessionListQuery(db)).all(args.limit) as SessionRow[] + const limited = Number.isFinite(args.limit) + const statement = db.prepare(buildSessionListQuery(db, limited)) + const rows = (limited ? statement.all(args.limit) : statement.all()) as SessionRow[] for (const row of rows) { candidates.push(rowToCandidate(row, dbPath)) } diff --git a/src/main/ai-vault/session-scanner-primary-parsers.ts b/src/main/ai-vault/session-scanner-primary-parsers.ts index 537055d87..92db22413 100644 --- a/src/main/ai-vault/session-scanner-primary-parsers.ts +++ b/src/main/ai-vault/session-scanner-primary-parsers.ts @@ -1,3 +1,4 @@ +import { remoteSessionContentLines } from './remote-session-content-lines' import { createReadStream } from 'node:fs' import { createInterface } from 'node:readline' import type { AiVaultSession } from '../../shared/ai-vault-types' @@ -219,11 +220,12 @@ export async function parseClaudeSessionContent( file: FileWithMtime, content: string, platform: NodeJS.Platform = process.platform, - options: ParserSessionOptions = {} + options: ParserSessionOptions = {}, + signal?: AbortSignal ): Promise { return parseClaudeSessionLines({ file, - lines: content.split(/\r?\n/), + lines: remoteSessionContentLines(content, signal), platform, options }) diff --git a/src/main/ai-vault/session-scanner-types.ts b/src/main/ai-vault/session-scanner-types.ts index 7d4de3ef2..13b40698c 100644 --- a/src/main/ai-vault/session-scanner-types.ts +++ b/src/main/ai-vault/session-scanner-types.ts @@ -34,12 +34,16 @@ export type AiVaultScanOptions = { droidProjectsDir?: string kimiSessionsDir?: string limit?: number + unlimited?: boolean limitPerAgent?: number // Active workspace/project paths whose sessions must be included regardless of // the recency cap (see discoverInScopeClaudeFiles). scopePaths?: readonly string[] platform?: NodeJS.Platform executionHostId?: ExecutionHostId + // Superseded/cancelled scans stop between parse batches instead of parsing + // every remaining transcript for a caller that already left. + signal?: AbortSignal } export type FileWithMtime = { diff --git a/src/main/ai-vault/session-scanner.test.ts b/src/main/ai-vault/session-scanner.test.ts index 7c472d6f3..e6d49d1b9 100644 --- a/src/main/ai-vault/session-scanner.test.ts +++ b/src/main/ai-vault/session-scanner.test.ts @@ -154,7 +154,9 @@ describe('scanAiVaultSessions', () => { const result = await scanAiVaultSessions({ ...roots, - platform: 'darwin' + platform: 'darwin', + limit: 1, + unlimited: true }) expect(result.issues).toEqual([]) diff --git a/src/main/ai-vault/session-scanner.ts b/src/main/ai-vault/session-scanner.ts index f1979c676..3a98b7018 100644 --- a/src/main/ai-vault/session-scanner.ts +++ b/src/main/ai-vault/session-scanner.ts @@ -39,13 +39,11 @@ import type { SessionParseResult } from './session-scanner-types' import { clampPositiveInteger, errorMessage } from './session-scanner-values' +import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' +import { DEFAULT_AI_VAULT_SCAN_LIMIT } from '../../shared/ai-vault-session-depth' -const DEFAULT_LIMIT = 1000 -const DEFAULT_SCAN_LIMIT_PER_AGENT = 1000 const SESSION_PARSE_CONCURRENCY = 8 -// Upper bound on extra in-scope transcripts discovered and parsed past the -// recency cap; guards against a pathological scoped history directory. -const SCOPE_PARSE_LIMIT = 2000 +const SESSION_PARSE_CANDIDATE_MULTIPLIER = 2 /** * Scan all supported AI agent session stores and return a unified, sorted, @@ -63,8 +61,12 @@ export async function scanAiVaultSessions( // "one core pegged" reports need to show whether transcript scanning is the // subsystem burning CPU, and how much of each scan the cache absorbed. return withSpan('aiVault.scan', async (span) => { - const limit = clampPositiveInteger(options.limit, DEFAULT_LIMIT) - const limitPerAgent = clampPositiveInteger(options.limitPerAgent, DEFAULT_SCAN_LIMIT_PER_AGENT) + const limit = options.unlimited + ? Number.POSITIVE_INFINITY + : clampPositiveInteger(options.limit, DEFAULT_AI_VAULT_SCAN_LIMIT) + const limitPerAgent = options.unlimited + ? Number.POSITIVE_INFINITY + : clampPositiveInteger(options.limitPerAgent, limit * SESSION_PARSE_CANDIDATE_MULTIPLIER) const platform = options.platform ?? process.platform const executionHostId = options.executionHostId ?? LOCAL_EXECUTION_HOST_ID const issues: AiVaultScanIssue[] = [] @@ -72,8 +74,10 @@ export async function scanAiVaultSessions( const antigravityWorkspaceResolver = createAntigravityWorkspaceResolver(readOptionalTextFile) // Why: persisted entries must be seeded before any candidate is parsed, or // the cold scan gains nothing from the cache file (#9210). + throwIfAiVaultScanCancelled(options.signal) await ensureSessionParseCacheLoaded() const discoveries = await discoverAiVaultSessionSources({ options, limitPerAgent, issues }) + throwIfAiVaultScanCancelled(options.signal) const candidates = dedupeCodexRolloutFileAliases( discoveries @@ -106,12 +110,13 @@ export async function scanAiVaultSessions( ) const parsedSessions = await parseSessionCandidates({ - candidates, + candidates: candidates.slice(0, limit * SESSION_PARSE_CANDIDATE_MULTIPLIER), limit, platform, executionHostId, issues, parseStats, + signal: options.signal, antigravityWorkspaceResolver }) @@ -122,12 +127,17 @@ export async function scanAiVaultSessions( const scopeSessions = await scanInScopeSessions({ discoveries, scopePaths: options.scopePaths ?? [], + limit, alreadyParsedFilePaths: new Set(cappedSessions.map((session) => session.filePath)), platform, executionHostId, issues, - parseStats + parseStats, + signal: options.signal }) + // Scope discovery can return without parsing anything, so an abort landing + // here would otherwise persist and return a cancelled scan as complete. + throwIfAiVaultScanCancelled(options.signal) span.setAttribute('candidates', candidates.length) span.setAttribute('reused', parseStats.reused) @@ -169,11 +179,13 @@ function mergeSessions( async function scanInScopeSessions(args: { discoveries: SessionFileDiscovery[] scopePaths: readonly string[] + limit: number alreadyParsedFilePaths: ReadonlySet platform: NodeJS.Platform executionHostId: ExecutionHostId issues: AiVaultScanIssue[] parseStats: SessionParseStats + signal?: AbortSignal }): Promise { if (args.scopePaths.length === 0) { return [] @@ -184,7 +196,7 @@ async function scanInScopeSessions(args: { const files = await discoverInScopeClaudeFiles({ rootDirs: claudeRootDirs, scopePaths: args.scopePaths, - limit: SCOPE_PARSE_LIMIT, + limit: args.limit, excludedFilePaths: args.alreadyParsedFilePaths, issues: args.issues }) @@ -201,7 +213,8 @@ async function scanInScopeSessions(args: { platform: args.platform, executionHostId: args.executionHostId, issues: args.issues, - parseStats: args.parseStats + parseStats: args.parseStats, + signal: args.signal }) } @@ -212,12 +225,14 @@ async function parseSessionCandidates(args: { executionHostId: ExecutionHostId issues: AiVaultScanIssue[] parseStats: SessionParseStats + signal?: AbortSignal antigravityWorkspaceResolver?: AntigravityWorkspaceResolver }): Promise { const sessions: AiVaultSession[] = [] let index = 0 while (index < args.candidates.length) { + throwIfAiVaultScanCancelled(args.signal) if (canStopParsingSessions(sessions, args.limit, args.candidates[index]?.file.mtimeMs)) { break } @@ -255,6 +270,9 @@ async function parseSessionCandidates(args: { index += batchSize } + // An abort can land while the final batch settles; observe it here so a + // partial parse is never cached or returned as a complete scan. + throwIfAiVaultScanCancelled(args.signal) return sessions } diff --git a/src/main/ai-vault/ssh-session-list.test.ts b/src/main/ai-vault/ssh-session-list.test.ts new file mode 100644 index 000000000..4d1abd483 --- /dev/null +++ b/src/main/ai-vault/ssh-session-list.test.ts @@ -0,0 +1,249 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AiVaultListResult, AiVaultSession } from '../../shared/ai-vault-types' +import { SSH_MUX_REQUEST_TIMEOUT_CODE } from '../ssh/ssh-channel-multiplexer' + +const requestActiveSshAiVaultSessionList = vi.fn() +const getActiveSshAiVaultHostInfo = vi.fn() +const getSshFilesystemProvider = vi.fn() +const scanRemoteAiVaultSessions = vi.fn() + +vi.mock('../ipc/ssh', () => ({ + requestActiveSshAiVaultSessionList: (...args: unknown[]) => + requestActiveSshAiVaultSessionList(...args), + getActiveSshAiVaultHostInfo: (...args: unknown[]) => getActiveSshAiVaultHostInfo(...args) +})) + +vi.mock('../providers/ssh-filesystem-dispatch', () => ({ + getSshFilesystemProvider: (...args: unknown[]) => getSshFilesystemProvider(...args), + SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE: 'SSH filesystem is unavailable.' +})) + +vi.mock('./remote-session-scanner', () => ({ + scanRemoteAiVaultSessions: (...args: unknown[]) => scanRemoteAiVaultSessions(...args) +})) + +const { scanSshAiVaultSessions } = await import('./ssh-session-list') + +describe('scanSshAiVaultSessions', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useRealTimers() + getActiveSshAiVaultHostInfo.mockReturnValue({ remoteHome: '/home/dev', hostPlatform: 'linux' }) + getSshFilesystemProvider.mockReturnValue({}) + }) + + it('returns at the deadline when the legacy crawl ignores abort', async () => { + // Relay without the method resolves null, so the leg falls through to the + // desktop crawl — which used to run unbounded even under an all-host budget. + vi.useFakeTimers() + requestActiveSshAiVaultSessionList.mockResolvedValue(null) + let fallbackSignal: AbortSignal | undefined + scanRemoteAiVaultSessions.mockImplementation(({ signal }: { signal?: AbortSignal }) => { + fallbackSignal = signal + return new Promise(() => {}) + }) + + const resultPromise = scanSshAiVaultSessions('dev-box', undefined, { timeoutMs: 20 }) + await vi.advanceTimersByTimeAsync(20) + const result = await Promise.race([resultPromise, Promise.resolve('still-pending' as const)]) + + expect(result).not.toBe('still-pending') + expect(fallbackSignal?.aborted).toBe(true) + if (result === 'still-pending') { + return + } + expect(result.sessions).toEqual([]) + expect(result.issues).toEqual([ + expect.objectContaining({ + executionHostId: 'ssh:dev-box', + kind: 'host', + message: 'Agent Session History scan timed out after 20ms on this SSH host.' + }) + ]) + }) + + it('leaves the crawl unbounded when no budget was requested', async () => { + requestActiveSshAiVaultSessionList.mockResolvedValue(null) + scanRemoteAiVaultSessions.mockResolvedValue(emptyResult()) + + await scanSshAiVaultSessions('dev-box') + + expect(scanRemoteAiVaultSessions).toHaveBeenCalledWith( + expect.objectContaining({ signal: undefined }) + ) + }) + + it('reports an unexpected crawl failure as a host issue instead of rejecting', async () => { + // Why: `all` scope awaits every host leg together, so a throw here would + // discard the local sessions alongside this host's. + requestActiveSshAiVaultSessionList.mockResolvedValue(null) + scanRemoteAiVaultSessions.mockRejectedValue(new TypeError('provider blew up')) + + const result = await scanSshAiVaultSessions('dev-box') + + expect(result.sessions).toEqual([]) + expect(result.issues).toEqual([ + expect.objectContaining({ executionHostId: 'ssh:dev-box', message: 'provider blew up' }) + ]) + }) + + it('bounds the relay round trip separately from the whole leg', async () => { + // The relay needs seconds to walk a real remote home; only the leg total + // has to stay short enough that one host cannot hold the merge open. + requestActiveSshAiVaultSessionList.mockResolvedValue(null) + scanRemoteAiVaultSessions.mockResolvedValue(emptyResult()) + + await scanSshAiVaultSessions('dev-box', undefined, { + timeoutMs: 20_000, + relayTimeoutMs: 15_000 + }) + + expect(requestActiveSshAiVaultSessionList).toHaveBeenCalledWith( + 'dev-box', + expect.any(Object), + expect.objectContaining({ timeoutMs: 15_000 }) + ) + }) + + it('keeps a relay scan that outran the old three-second bound', async () => { + // Regression: the shared 3s budget emptied healthy hosts in the all-hosts + // view. A relay answering inside the scan budget must keep its sessions. + requestActiveSshAiVaultSessionList.mockImplementation( + (_targetId: string, _params: unknown, options: { timeoutMs?: number }) => + (options.timeoutMs ?? 0) > 3_000 + ? Promise.resolve({ + sessions: [remoteSession()], + issues: [], + scannedAt: '2026-08-02T00:00:00.000Z' + }) + : Promise.reject(relayTimeoutError()) + ) + + const result = await scanSshAiVaultSessions('dev-box', undefined, { + timeoutMs: 20_000, + relayTimeoutMs: 15_000 + }) + + expect(result.sessions).toEqual([expect.objectContaining({ sessionId: 'remote-session' })]) + expect(scanRemoteAiVaultSessions).not.toHaveBeenCalled() + }) + + it('reports a host issue when the relay timed out on a real scan budget', async () => { + // A relay that had a fair scan window will not answer faster over the far + // slower filesystem crawl, so retrying it only stalls the merge. + requestActiveSshAiVaultSessionList.mockRejectedValue(relayTimeoutError()) + + const result = await scanSshAiVaultSessions('dev-box', undefined, { + timeoutMs: 20_000, + relayTimeoutMs: 15_000 + }) + + expect(scanRemoteAiVaultSessions).not.toHaveBeenCalled() + expect(result.issues).toEqual([ + expect.objectContaining({ executionHostId: 'ssh:dev-box', kind: 'host' }) + ]) + }) + + it('still falls back when the relay budget was too short for a fair attempt', async () => { + requestActiveSshAiVaultSessionList.mockRejectedValue(relayTimeoutError()) + scanRemoteAiVaultSessions.mockResolvedValue({ + sessions: [remoteSession()], + issues: [], + scannedAt: '2026-08-02T00:00:00.000Z' + }) + + const result = await scanSshAiVaultSessions('dev-box', undefined, { + timeoutMs: 3_000, + relayTimeoutMs: 2_000 + }) + + expect(scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1) + expect(result.sessions).toEqual([expect.objectContaining({ sessionId: 'remote-session' })]) + }) + + it('does not treat an unrelated error mentioning a timeout as a relay timeout', async () => { + requestActiveSshAiVaultSessionList.mockRejectedValue( + new Error('the remote agent timed out after loading its index') + ) + scanRemoteAiVaultSessions.mockResolvedValue({ + sessions: [remoteSession()], + issues: [], + scannedAt: '2026-08-02T00:00:00.000Z' + }) + + const result = await scanSshAiVaultSessions('dev-box', undefined, { + timeoutMs: 20_000, + relayTimeoutMs: 15_000 + }) + + expect(scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1) + expect(result.sessions).toEqual([expect.objectContaining({ sessionId: 'remote-session' })]) + }) + + it('reports the relay error when the fallback crawl recovered nothing', async () => { + // Otherwise a broken relay over an empty crawl reads as a healthy but empty + // host, and the panel shows no reason for the missing sessions. + requestActiveSshAiVaultSessionList.mockRejectedValue(new Error('relay method exploded')) + scanRemoteAiVaultSessions.mockResolvedValue(emptyResult()) + + const result = await scanSshAiVaultSessions('dev-box') + + expect(result.issues).toEqual([ + expect.objectContaining({ executionHostId: 'ssh:dev-box', message: 'relay method exploded' }) + ]) + }) + + it('still propagates a caller cancellation', async () => { + const controller = new AbortController() + requestActiveSshAiVaultSessionList.mockResolvedValue(null) + scanRemoteAiVaultSessions.mockImplementation(() => new Promise(() => {})) + + const result = scanSshAiVaultSessions('dev-box', undefined, { + signal: controller.signal, + timeoutMs: 5_000 + }) + await vi.waitFor(() => expect(scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1)) + controller.abort() + + await expect(result).rejects.toMatchObject({ name: 'AbortError' }) + }) +}) + +function emptyResult(): AiVaultListResult { + return { sessions: [], issues: [], scannedAt: '2026-08-02T00:00:00.000Z' } +} + +/** Mirrors the multiplexer's typed timeout: the leg branches on the code, not + * on the message text, so a look-alike message must not take that branch. */ +function relayTimeoutError(): Error { + return Object.assign(new Error('Request "aiVault.listSessions" timed out after 15000ms'), { + code: SSH_MUX_REQUEST_TIMEOUT_CODE + }) +} + +// Relay rows are re-validated before they are trusted, so this has to satisfy +// the full session schema rather than a partial stub. +function remoteSession(): AiVaultSession { + return { + id: 'ssh:dev-box:codex:remote-session:/home/dev/remote-session.jsonl', + executionHostId: 'ssh:dev-box', + agent: 'codex', + sessionId: 'remote-session', + title: 'remote-session', + cwd: '/home/dev/repo', + branch: null, + model: null, + filePath: '/home/dev/remote-session.jsonl', + codexHome: null, + createdAt: null, + updatedAt: '2026-08-02T00:00:00.000Z', + modifiedAt: '2026-08-02T00:00:00.000Z', + messageCount: 1, + totalTokens: 0, + previewMessages: [], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: 'codex resume remote-session', + subagent: null + } +} diff --git a/src/main/ai-vault/ssh-session-list.ts b/src/main/ai-vault/ssh-session-list.ts new file mode 100644 index 000000000..7a02883f8 --- /dev/null +++ b/src/main/ai-vault/ssh-session-list.ts @@ -0,0 +1,241 @@ +import { + AI_VAULT_SCOPE_PATHS_MAX_COUNT, + type AiVaultListArgs, + type AiVaultListResult, + type AiVaultScanIssue +} from '../../shared/ai-vault-types' +import { toSshExecutionHostId } from '../../shared/execution-host' +import { + getSshFilesystemProvider, + SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE +} from '../providers/ssh-filesystem-dispatch' +import { getActiveSshAiVaultHostInfo, requestActiveSshAiVaultSessionList } from '../ipc/ssh' +import { isSshMuxRequestTimeoutError } from '../ssh/ssh-channel-multiplexer' +import { createAiVaultScanCancelledError } from './ai-vault-scan-cancellation' +import { scanRemoteAiVaultSessions } from './remote-session-scanner' +import { parseAiVaultListResult } from './session-list-result-validation' +import { aiVaultScanIssueResult, restampAiVaultListResult } from './session-list-results' + +/** Bounds one SSH host leg. `relayTimeoutMs` caps the relay round trip alone; + * `timeoutMs` caps the whole leg, including the legacy filesystem crawl. */ +export type SshAiVaultScanBudget = { + signal?: AbortSignal + timeoutMs?: number + relayTimeoutMs?: number +} + +// Why: a relay that was given a real scan budget and still timed out will not +// answer faster over the slower filesystem crawl, so that leg reports a host +// issue. A relay cut short below this never got a fair attempt — those fall +// back rather than emptying a healthy host's sessions (#12178 follow-up). +const MEANINGFUL_RELAY_SCAN_ATTEMPT_MS = 10_000 + +export async function scanSshAiVaultSessions( + targetId: string, + args?: AiVaultListArgs, + options: SshAiVaultScanBudget = {} +): Promise { + const executionHostId = toSshExecutionHostId(targetId) + // Why: in `all` scope every host leg is awaited together, so an unexpected + // throw here (not a caller cancellation) would discard the local results too. + try { + return await scanOneSshHost(targetId, executionHostId, args, options) + } catch (error) { + if (isAbortError(error)) { + throw error + } + return sshScanIssueResult(executionHostId, targetId, errorMessage(error)) + } +} + +async function scanOneSshHost( + targetId: string, + executionHostId: `ssh:${string}`, + args: AiVaultListArgs | undefined, + options: SshAiVaultScanBudget +): Promise { + const startedAt = Date.now() + // Both legs scan the same capped set, so the fallback can't quietly scan more + // paths — or skip the truncation notice — than the relay leg would. + const scopePaths = args?.scopePaths?.slice(0, AI_VAULT_SCOPE_PATHS_MAX_COUNT) + const scopePathsTruncated = (args?.scopePaths?.length ?? 0) > AI_VAULT_SCOPE_PATHS_MAX_COUNT + let relayError: unknown + const relayTimeoutMs = options.relayTimeoutMs ?? options.timeoutMs + try { + const params = { + limit: args?.limit, + ...(args?.unlimited === true ? { unlimited: true } : {}), + ...(args?.force === true ? { force: true } : {}), + scopePaths, + ...(scopePathsTruncated ? { scopePathsTruncated: true } : {}) + } + const relayResult = + options.signal || relayTimeoutMs !== undefined + ? await requestActiveSshAiVaultSessionList(targetId, params, { + signal: options.signal, + timeoutMs: relayTimeoutMs + }) + : await requestActiveSshAiVaultSessionList(targetId, params) + if (relayResult !== null) { + return restampAiVaultListResult(parseAiVaultListResult(relayResult), executionHostId) + } + } catch (error) { + if (isAbortError(error)) { + throw error + } + if ( + isSshMuxRequestTimeoutError(error) && + (relayTimeoutMs === undefined || relayTimeoutMs >= MEANINGFUL_RELAY_SCAN_ATTEMPT_MS) + ) { + return sshScanIssueResult(executionHostId, targetId, errorMessage(error)) + } + relayError = error + } + const hostInfo = getActiveSshAiVaultHostInfo(targetId) + const provider = getSshFilesystemProvider(targetId) + if (!hostInfo || !provider) { + return sshScanIssueResult( + executionHostId, + targetId, + relayError ? errorMessage(relayError) : SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE + ) + } + // Why: `timeoutMs` bounded only the relay round trip, so a host on a relay + // without the list method fell through to the unbounded desktop crawl and one + // stalled SSH file stream could hold every other host's results hostage. + const fallbackResult = await scanRemoteSessionsWithinBudget({ + scan: (signal) => + scanRemoteAiVaultSessions({ + provider, + executionHostId, + remoteHome: hostInfo.remoteHome, + hostPlatform: hostInfo.hostPlatform, + limit: args?.limit, + unlimited: args?.unlimited, + scopePaths, + signal + }), + signal: options.signal, + remainingMs: remainingScanBudgetMs(options.timeoutMs, startedAt) + }) + if (!fallbackResult) { + return sshScanIssueResult( + executionHostId, + targetId, + `Agent Session History scan timed out after ${options.timeoutMs}ms on this SSH host.` + ) + } + const scopeIssues = scopePathsTruncated + ? [scopeTruncationIssue(executionHostId, hostInfo.remoteHome)] + : [] + // An empty remote home and "the relay method failed and the crawl found + // nothing" look identical, so a fallback that recovered nothing still reports + // the relay error instead of presenting a broken relay as an empty host. + if (!relayError || fallbackResult.sessions.length > 0) { + return { ...fallbackResult, issues: [...fallbackResult.issues, ...scopeIssues] } + } + return { + ...fallbackResult, + issues: [ + ...sshScanIssueResult(executionHostId, targetId, errorMessage(relayError)).issues, + ...fallbackResult.issues, + ...scopeIssues + ] + } +} + +/** Budget left for the legacy crawl after the relay attempt spent part of it. */ +function remainingScanBudgetMs(timeoutMs: number | undefined, startedAt: number): number | null { + if (timeoutMs === undefined) { + return null + } + return Math.max(0, timeoutMs - (Date.now() - startedAt)) +} + +/** Runs the legacy crawl under `remainingMs`; resolves null once the budget is + * spent. Caller cancellation still propagates as an AbortError. */ +async function scanRemoteSessionsWithinBudget(args: { + scan: (signal?: AbortSignal) => Promise + signal?: AbortSignal + remainingMs: number | null +}): Promise { + const remainingMs = args.remainingMs + if (remainingMs === null) { + return args.scan(args.signal) + } + if (args.signal?.aborted) { + throw createAiVaultScanCancelledError() + } + if (remainingMs === 0) { + return null + } + const controller = new AbortController() + return new Promise((resolve, reject) => { + let settled = false + const finish = (settle: () => void): void => { + if (settled) { + return + } + settled = true + clearTimeout(timer) + args.signal?.removeEventListener('abort', onCallerAbort) + settle() + } + const onCallerAbort = (): void => { + controller.abort() + finish(() => reject(createAiVaultScanCancelledError())) + } + const timer = setTimeout(() => { + controller.abort() + finish(() => resolve(null)) + }, remainingMs) + args.signal?.addEventListener('abort', onCallerAbort, { once: true }) + if (args.signal?.aborted) { + onCallerAbort() + return + } + try { + const scan = args.scan(controller.signal) + // The SSH provider may ignore abort; observe any eventual rejection after + // this caller has already returned its timeout or cancellation result. + void scan.then( + (result) => finish(() => resolve(result)), + (error: unknown) => finish(() => reject(error)) + ) + } catch (error) { + finish(() => reject(error)) + } + }) +} + +// Mirrors the notice the relay leg appends so both legs report the same cap. +function scopeTruncationIssue( + executionHostId: `ssh:${string}`, + remoteHome: string +): AiVaultScanIssue { + return { + executionHostId, + agent: 'codex', + kind: 'scope', + path: remoteHome, + message: `Only the first ${AI_VAULT_SCOPE_PATHS_MAX_COUNT} project paths were scanned.` + } +} + +function sshScanIssueResult( + executionHostId: `ssh:${string}`, + targetId: string, + message: string +): AiVaultListResult { + return aiVaultScanIssueResult({ executionHostId, path: targetId, message }) +} + +function isAbortError(error: unknown): boolean { + return error instanceof Error && error.name === 'AbortError' +} + +function errorMessage(error: unknown): string { + return error instanceof Error + ? error.message + : 'Agent Session History scan failed on the SSH target.' +} diff --git a/src/main/ipc/ai-vault-host-discovery.ts b/src/main/ipc/ai-vault-host-discovery.ts new file mode 100644 index 000000000..e8f1f51fb --- /dev/null +++ b/src/main/ipc/ai-vault-host-discovery.ts @@ -0,0 +1,29 @@ +import type { AiVaultListResult } from '../../shared/ai-vault-types' +import { aiVaultScanIssueResult } from '../ai-vault/session-list-results' + +export type AiVaultHostDiscoveryResult = { + hostInfos: readonly T[] + issue?: AiVaultListResult +} + +/** + * Why: enumerating runtime/SSH hosts reads live session state and can throw. In + * 'all' scope that rejection would take down the whole Promise.all and drop the + * local sessions too, so a broken enumerator degrades to one issue row instead. + */ +export function discoverAiVaultHosts( + enumerate: () => readonly T[], + args: { path: string; fallbackMessage: string } +): AiVaultHostDiscoveryResult { + try { + return { hostInfos: enumerate() } + } catch (error) { + return { + hostInfos: [], + issue: aiVaultScanIssueResult({ + path: args.path, + message: error instanceof Error ? error.message : args.fallbackMessage + }) + } + } +} diff --git a/src/main/ipc/ai-vault-host-leg-cache.ts b/src/main/ipc/ai-vault-host-leg-cache.ts new file mode 100644 index 000000000..9911e81e9 --- /dev/null +++ b/src/main/ipc/ai-vault-host-leg-cache.ts @@ -0,0 +1,78 @@ +import type { AiVaultListResult } from '../../shared/ai-vault-types' +import { + aiVaultSessionDepthCovers, + truncateAiVaultListResult, + type AiVaultSessionDepth +} from '../../shared/ai-vault-session-depth' + +export const AI_VAULT_CACHE_TTL_MS = 15_000 + +type CachedHostLeg = { + depth: AiVaultSessionDepth + result: AiVaultListResult + expiresAt: number +} + +// Why: the merged result is only cacheable when every host answered, so one +// flaky host used to force a full rescan of every healthy host on each panel +// open. Legs are cached individually and the failing host is the only one +// rescanned. +const cachedHostLegs = new Map() + +/** Serves one host's leg from the per-host TTL cache, and stores it only when + * that host answered without a host issue — a failing host is retried on the + * next open while its healthy neighbours stay cached. */ +export async function scanHostLegWithCache(args: { + cacheKey: string + depth: AiVaultSessionDepth + scopePaths?: readonly string[] + force: boolean + scan: () => Promise +}): Promise { + const now = Date.now() + const cached = cachedHostLegs.get(args.cacheKey) + if ( + !args.force && + cached && + cached.expiresAt > now && + aiVaultSessionDepthCovers(cached.depth, args.depth) + ) { + return truncateAiVaultListResult(cached.result, args.depth, args.scopePaths) + } + const result = await args.scan() + if (result.issues.some((issue) => issue.kind === 'host')) { + const current = cachedHostLegs.get(args.cacheKey) + if (!current || current.expiresAt <= Date.now()) { + cachedHostLegs.delete(args.cacheKey) + } + return result + } + pruneExpiredHostLegs(now) + const current = cachedHostLegs.get(args.cacheKey) + if ( + !args.force && + current && + current.expiresAt > Date.now() && + aiVaultSessionDepthCovers(current.depth, args.depth) + ) { + return result + } + cachedHostLegs.set(args.cacheKey, { + depth: args.depth, + result, + expiresAt: Date.now() + AI_VAULT_CACHE_TTL_MS + }) + return result +} + +function pruneExpiredHostLegs(now: number): void { + for (const [cacheKey, entry] of cachedHostLegs) { + if (entry.expiresAt <= now) { + cachedHostLegs.delete(cacheKey) + } + } +} + +export function resetAiVaultHostLegCacheForTests(): void { + cachedHostLegs.clear() +} diff --git a/src/main/ipc/ai-vault-runtime-scan.ts b/src/main/ipc/ai-vault-runtime-scan.ts new file mode 100644 index 000000000..f9432f143 --- /dev/null +++ b/src/main/ipc/ai-vault-runtime-scan.ts @@ -0,0 +1,95 @@ +import { + isAiVaultScanCancelledError, + type AiVaultListArgs, + type AiVaultListResult +} from '../../shared/ai-vault-types' +import { + abandonRemoteSessionScanOnCancel, + throwIfAiVaultScanCancelled +} from '../ai-vault/ai-vault-scan-cancellation' +import { aiVaultScanIssueResult } from '../ai-vault/session-list-results' + +export type RuntimeAiVaultHostInfo = { + environmentId: string + executionHostId: `runtime:${string}` +} + +export type RuntimeAiVaultScanOptions = { + timeoutMs?: number +} + +export type RuntimeAiVaultScanner = ( + environmentId: string, + args: AiVaultListArgs, + options?: RuntimeAiVaultScanOptions +) => Promise + +/** + * Why: an unreachable Orca server must cost this host's row, not the whole + * multi-host list, so every failure except cancellation degrades to an issue. + */ +export async function scanRuntimeAiVaultSessions(args: { + hostInfo: RuntimeAiVaultHostInfo + scanner: RuntimeAiVaultScanner | undefined + listArgs?: AiVaultListArgs + options?: RuntimeAiVaultScanOptions & { signal?: AbortSignal } +}): Promise { + const { signal, ...scannerOptions } = args.options ?? {} + throwIfAiVaultScanCancelled(signal) + if (!args.scanner) { + return runtimeScanIssueResult( + args.hostInfo, + 'Agent Session History is not available for this execution host.' + ) + } + try { + return await abandonRemoteSessionScanOnCancel( + args.scanner(args.hostInfo.environmentId, runtimeScanArgs(args.hostInfo, args.listArgs), { + ...scannerOptions + }), + signal + ) + } catch (error) { + // RPC rejections keep the message but lose Error.name, so classify on both. + if (isAiVaultScanCancelledError(error)) { + throw error + } + return runtimeScanIssueResult( + args.hostInfo, + error instanceof Error ? error.message : 'Remote Orca server is unavailable.' + ) + } +} + +// Optional keys are copied only when present so the RPC schema never sees an +// explicit undefined for limit/force/scopePaths. +function runtimeScanArgs( + hostInfo: RuntimeAiVaultHostInfo, + listArgs: AiVaultListArgs | undefined +): AiVaultListArgs { + const scanArgs: AiVaultListArgs = { executionHostScope: hostInfo.executionHostId } + if (listArgs?.limit !== undefined) { + scanArgs.limit = listArgs.limit + } + if (listArgs?.unlimited !== undefined) { + scanArgs.unlimited = listArgs.unlimited + } + if (listArgs?.force !== undefined) { + scanArgs.force = listArgs.force + } + if (listArgs?.scopePaths !== undefined) { + scanArgs.scopePaths = listArgs.scopePaths + } + return scanArgs +} + +function runtimeScanIssueResult( + hostInfo: RuntimeAiVaultHostInfo, + message: string +): AiVaultListResult { + return aiVaultScanIssueResult({ + executionHostId: hostInfo.executionHostId, + path: hostInfo.environmentId, + message + }) +} diff --git a/src/main/ipc/ai-vault-scan-coalescing.test.ts b/src/main/ipc/ai-vault-scan-coalescing.test.ts new file mode 100644 index 000000000..3a8cd3bd1 --- /dev/null +++ b/src/main/ipc/ai-vault-scan-coalescing.test.ts @@ -0,0 +1,230 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AiVaultListResult } from '../../shared/ai-vault-types' +import type { IFilesystemProvider } from '../providers/types' +import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' + +const mocks = vi.hoisted(() => ({ + scanAiVaultSessions: vi.fn(), + scanRemoteAiVaultSessions: vi.fn(), + scanRuntimeAiVaultSessions: vi.fn(), + getSshFilesystemProvider: vi.fn(), + getActiveSshAiVaultHostInfo: vi.fn(), + getActiveSshAiVaultHostInfos: vi.fn(), + requestActiveSshAiVaultSessionList: vi.fn(), + ipcHandle: vi.fn() +})) + +vi.mock('electron', () => ({ app: { on: vi.fn() }, ipcMain: { handle: mocks.ipcHandle } })) +vi.mock('../ai-vault/session-scanner', () => ({ + scanAiVaultSessions: mocks.scanAiVaultSessions +})) +vi.mock('../ai-vault/remote-session-scanner', () => ({ + scanRemoteAiVaultSessions: mocks.scanRemoteAiVaultSessions +})) +vi.mock('../wsl', () => ({ + getWslHomeAsync: vi.fn(), + listWslDistrosAsync: vi.fn().mockResolvedValue([]) +})) +vi.mock('../providers/ssh-filesystem-dispatch', () => ({ + SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE: 'SSH unavailable', + getSshFilesystemProvider: mocks.getSshFilesystemProvider +})) +vi.mock('./ssh', () => ({ + getActiveSshAiVaultHostInfo: mocks.getActiveSshAiVaultHostInfo, + getActiveSshAiVaultHostInfos: mocks.getActiveSshAiVaultHostInfos, + requestActiveSshAiVaultSessionList: mocks.requestActiveSshAiVaultSessionList +})) + +const { _internals, registerAiVaultHandlers } = await import('./ai-vault') +const EMPTY_RESULT: AiVaultListResult = { + sessions: [], + issues: [], + scannedAt: '2026-07-27T00:00:00.000Z' +} + +beforeEach(() => { + vi.clearAllMocks() + _internals.resetAiVaultCacheForTests() + mocks.scanAiVaultSessions.mockResolvedValue(EMPTY_RESULT) + mocks.scanRemoteAiVaultSessions.mockResolvedValue(EMPTY_RESULT) + mocks.scanRuntimeAiVaultSessions.mockResolvedValue(EMPTY_RESULT) + mocks.getSshFilesystemProvider.mockReturnValue({} as IFilesystemProvider) + mocks.getActiveSshAiVaultHostInfo.mockReturnValue(hostInfo()) + mocks.getActiveSshAiVaultHostInfos.mockReturnValue([hostInfo()]) + mocks.requestActiveSshAiVaultSessionList.mockResolvedValue(null) +}) + +describe('Agent Session History scan coalescing', () => { + it.each([ + ['local', mocks.scanAiVaultSessions], + ['runtime:remote-server', mocks.scanRuntimeAiVaultSessions] + ] as const)('coalesces %s scans while isolating caller cancellation', async (scope, scan) => { + let resolveScan: ((result: AiVaultListResult) => void) | undefined + scan.mockImplementation( + () => + new Promise((resolve) => { + resolveScan = resolve + }) + ) + registerRuntimeHost() + const firstController = new AbortController() + const first = _internals.listAiVaultSessions( + { executionHostScope: scope }, + { signal: firstController.signal } + ) + const second = _internals.listAiVaultSessions({ executionHostScope: scope }) + await vi.waitFor(() => expect(resolveScan).toBeDefined()) + + firstController.abort() + + await expect(first).rejects.toMatchObject({ name: 'AbortError' }) + expect(scan).toHaveBeenCalledTimes(1) + resolveScan?.(EMPTY_RESULT) + await expect(second).resolves.toEqual(EMPTY_RESULT) + }) + + it('coalesces every all-host leg while isolating caller cancellation', async () => { + let resolveRuntime: ((result: AiVaultListResult) => void) | undefined + mocks.scanRuntimeAiVaultSessions.mockImplementation( + () => + new Promise((resolve) => { + resolveRuntime = resolve + }) + ) + registerRuntimeHost() + const controller = new AbortController() + + const first = _internals.listAiVaultSessions( + { executionHostScope: 'all' }, + { signal: controller.signal } + ) + const firstRejection = expect(first).rejects.toMatchObject({ name: 'AbortError' }) + const second = _internals.listAiVaultSessions({ executionHostScope: 'all' }) + await vi.waitFor(() => expect(resolveRuntime).toBeDefined()) + + expect(mocks.scanAiVaultSessions).toHaveBeenCalledTimes(1) + expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1) + expect(mocks.scanRuntimeAiVaultSessions).toHaveBeenCalledTimes(1) + controller.abort() + await firstRejection + resolveRuntime?.(EMPTY_RESULT) + await expect(second).resolves.toMatchObject({ sessions: [], issues: [] }) + }) + + it('keeps a shared multi-window scan alive when one window cancels', async () => { + let resolveRelay: ((result: AiVaultListResult) => void) | undefined + mocks.requestActiveSshAiVaultSessionList.mockImplementation( + () => + new Promise((resolve) => { + resolveRelay = resolve + }) + ) + registerAiVaultHandlers() + const list = ipcHandler('aiVault:listSessions') + const cancel = ipcHandler('aiVault:cancelListSessions') + const firstEvent = { sender: { id: 1 } } + const secondEvent = { sender: { id: 2 } } + const first = list(firstEvent, { + executionHostScope: 'ssh:dev-box', + requestToken: 'scan' + }) as Promise + const second = list(secondEvent, { + executionHostScope: 'ssh:dev-box', + requestToken: 'scan' + }) as Promise + await vi.waitFor(() => expect(resolveRelay).toBeDefined()) + + cancel(firstEvent, { requestToken: 'scan' }) + + // Electron logs every rejected handler, so a cancelled scan resolves instead. + await expect(first).resolves.toMatchObject({ cancelled: true, sessions: [], issues: [] }) + expect(mocks.requestActiveSshAiVaultSessionList).toHaveBeenCalledTimes(1) + resolveRelay?.(EMPTY_RESULT) + await expect(second).resolves.toEqual(EMPTY_RESULT) + }) + + it('reports a real scan failure as a host issue rather than cancellation', async () => { + mocks.requestActiveSshAiVaultSessionList.mockRejectedValue(new Error('relay socket closed')) + mocks.scanRemoteAiVaultSessions.mockRejectedValue(new Error('relay socket closed')) + registerAiVaultHandlers() + const list = ipcHandler('aiVault:listSessions') + + // SSH host legs convert unexpected throws into scan issues so an `all` + // multi-host list still returns the other hosts' sessions. + const result = await list( + { sender: { id: 1 } }, + { executionHostScope: 'ssh:dev-box', requestToken: 'scan' } + ) + expect(result).toMatchObject({ + sessions: [], + issues: [expect.objectContaining({ message: 'relay socket closed', kind: 'host' })] + }) + expect(result).not.toHaveProperty('cancelled') + }) + + it('still rejects the handler when a scan fails for a non-cancellation reason', async () => { + mocks.scanAiVaultSessions.mockRejectedValue(new Error('transcript root is unreadable')) + registerAiVaultHandlers() + const list = ipcHandler('aiVault:listSessions') + + await expect( + list({ sender: { id: 1 } }, { executionHostScope: 'local', requestToken: 'scan' }) + ).rejects.toThrow('transcript root is unreadable') + }) + + it('re-joins a preempted same-scope caller onto the forced refresh', async () => { + const signals: AbortSignal[] = [] + let resolveForced: ((result: AiVaultListResult) => void) | undefined + mocks.requestActiveSshAiVaultSessionList.mockImplementation( + (_targetId, _params, options: { signal: AbortSignal }) => { + signals.push(options.signal) + return new Promise((resolve) => { + if (signals.length === 1) { + options.signal.addEventListener('abort', () => resolve(EMPTY_RESULT), { once: true }) + } else { + resolveForced = resolve + } + }) + } + ) + const first = _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box' }) + await vi.waitFor(() => expect(signals).toHaveLength(1)) + + const forced = _internals.listAiVaultSessions({ + executionHostScope: 'ssh:dev-box', + force: true + }) + await vi.waitFor(() => expect(signals).toHaveLength(2)) + + expect(signals[0]?.aborted).toBe(true) + resolveForced?.(EMPTY_RESULT) + // Another window's Refresh must not surface as this caller's cancellation. + await expect(Promise.all([first, forced])).resolves.toEqual([EMPTY_RESULT, EMPTY_RESULT]) + }) +}) + +function registerRuntimeHost(): void { + registerAiVaultHandlers({ + getActiveRuntimeAiVaultHostInfos: () => [ + { environmentId: 'remote-server', executionHostId: 'runtime:remote-server' } + ], + scanRuntimeAiVaultSessions: mocks.scanRuntimeAiVaultSessions + }) +} + +function hostInfo() { + return { + targetId: 'dev-box', + executionHostId: 'ssh:dev-box' as const, + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64') + } +} + +function ipcHandler(channel: string): (...args: unknown[]) => unknown { + const registration = mocks.ipcHandle.mock.calls.find(([registered]) => registered === channel) + if (!registration) { + throw new Error(`${channel} was not registered`) + } + return registration[1] +} diff --git a/src/main/ipc/ai-vault.test.ts b/src/main/ipc/ai-vault.test.ts index 5f4153106..1ecc3bb0a 100644 --- a/src/main/ipc/ai-vault.test.ts +++ b/src/main/ipc/ai-vault.test.ts @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AiVaultListResult, AiVaultSession } from '../../shared/ai-vault-types' import type { IFilesystemProvider } from '../providers/types' import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' +import { SSH_MUX_REQUEST_TIMEOUT_CODE } from '../ssh/ssh-channel-multiplexer' const mocks = vi.hoisted(() => ({ scanAiVaultSessions: vi.fn(), @@ -14,6 +15,7 @@ const mocks = vi.hoisted(() => ({ getSshFilesystemProvider: vi.fn(), getActiveSshAiVaultHostInfo: vi.fn(), getActiveSshAiVaultHostInfos: vi.fn(), + requestActiveSshAiVaultSessionList: vi.fn(), ipcHandle: vi.fn() })) @@ -47,7 +49,8 @@ vi.mock('../providers/ssh-filesystem-dispatch', () => ({ vi.mock('./ssh', () => ({ getActiveSshAiVaultHostInfo: mocks.getActiveSshAiVaultHostInfo, - getActiveSshAiVaultHostInfos: mocks.getActiveSshAiVaultHostInfos + getActiveSshAiVaultHostInfos: mocks.getActiveSshAiVaultHostInfos, + requestActiveSshAiVaultSessionList: mocks.requestActiveSshAiVaultSessionList })) const { _internals, registerAiVaultHandlers } = await import('./ai-vault') @@ -66,6 +69,7 @@ beforeEach(() => { result([session('runtime:remote-server', 'runtime-session')]) ) mocks.getSshFilesystemProvider.mockReturnValue(provider) + mocks.requestActiveSshAiVaultSessionList.mockResolvedValue(null) mocks.getActiveSshAiVaultHostInfo.mockReturnValue(hostInfo('dev-box')) mocks.getActiveSshAiVaultHostInfos.mockReturnValue([hostInfo('dev-box')]) }) @@ -101,11 +105,160 @@ describe('listAiVaultSessions host routing', () => { ) }) + it('uses one target-side relay scan when the SSH relay supports it', async () => { + mocks.requestActiveSshAiVaultSessionList.mockResolvedValue( + result([session('local', 'remote-session')]) + ) + + const scanned = await _internals.listAiVaultSessions({ + executionHostScope: 'ssh:dev-box', + scopePaths: ['/home/ada/repo'] + }) + + expect(mocks.requestActiveSshAiVaultSessionList).toHaveBeenCalledWith( + 'dev-box', + { + limit: undefined, + scopePaths: ['/home/ada/repo'] + }, + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ) + expect(mocks.scanRemoteAiVaultSessions).not.toHaveBeenCalled() + expect(scanned.sessions[0]).toMatchObject({ + executionHostId: 'ssh:dev-box', + id: expect.stringContaining('ssh:dev-box:') + }) + }) + + it('marks oversized project scopes when sending their bounded relay form', async () => { + const scopePaths = Array.from({ length: 80 }, (_, index) => `/repo/${index}`) + mocks.requestActiveSshAiVaultSessionList.mockResolvedValue(result([])) + + await _internals.listAiVaultSessions({ + executionHostScope: 'ssh:dev-box', + scopePaths + }) + + expect(mocks.requestActiveSshAiVaultSessionList).toHaveBeenCalledWith( + 'dev-box', + { + limit: undefined, + scopePaths: scopePaths.slice(0, 64), + scopePathsTruncated: true + }, + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ) + }) + + it('caps and reports oversized project scopes on the SSH filesystem fallback', async () => { + const scopePaths = Array.from({ length: 80 }, (_, index) => `/repo/${index}`) + + const scanned = await _internals.listAiVaultSessions({ + executionHostScope: 'ssh:dev-box', + scopePaths + }) + + expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledWith( + expect.objectContaining({ scopePaths: scopePaths.slice(0, 64) }) + ) + expect(scanned.issues).toContainEqual( + expect.objectContaining({ + kind: 'scope', + message: expect.stringContaining('first 64 project paths') + }) + ) + }) + + it('coalesces concurrent cancellable requests into one scan', async () => { + let resolveRelay: (() => void) | undefined + mocks.requestActiveSshAiVaultSessionList.mockImplementation( + () => + new Promise((resolve) => { + resolveRelay = () => resolve(result([])) + }) + ) + const args = { executionHostScope: 'ssh:dev-box' as const } + + const first = _internals.listAiVaultSessions(args, { signal: new AbortController().signal }) + const second = _internals.listAiVaultSessions(args, { signal: new AbortController().signal }) + await vi.waitFor(() => expect(resolveRelay).toBeDefined()) + + expect(mocks.requestActiveSshAiVaultSessionList).toHaveBeenCalledTimes(1) + resolveRelay?.() + await expect(Promise.all([first, second])).resolves.toHaveLength(2) + }) + + it('does not start a second remote crawl after the relay scan budget expires', async () => { + mocks.requestActiveSshAiVaultSessionList.mockRejectedValue(relayTimeoutError()) + + const scanned = await _internals.listAiVaultSessions({ + executionHostScope: 'ssh:dev-box' + }) + + expect(mocks.scanRemoteAiVaultSessions).not.toHaveBeenCalled() + expect(scanned.sessions).toEqual([]) + expect(scanned.issues[0]?.message).toContain('timed out') + }) + + it('does not cache a host-level relay failure', async () => { + mocks.requestActiveSshAiVaultSessionList.mockRejectedValue(relayTimeoutError()) + + await _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box' }) + await _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box' }) + + expect(mocks.requestActiveSshAiVaultSessionList).toHaveBeenCalledTimes(2) + }) + + it('falls back to the filesystem crawl after a non-timeout relay failure', async () => { + mocks.requestActiveSshAiVaultSessionList.mockRejectedValue( + new Error('Invalid aiVault.listSessions response') + ) + + const scanned = await _internals.listAiVaultSessions({ + executionHostScope: 'ssh:dev-box' + }) + + expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1) + expect(scanned.sessions).toEqual([expect.objectContaining({ sessionId: 'remote-session' })]) + }) + + it('falls back when a nonempty relay sessions array contains no valid rows', async () => { + mocks.requestActiveSshAiVaultSessionList.mockResolvedValue({ + sessions: [{ id: 42 }], + issues: [], + scannedAt: '2026-07-27T00:00:00.000Z' + }) + + const scanned = await _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box' }) + + expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1) + expect(scanned.sessions).toEqual([expect.objectContaining({ sessionId: 'remote-session' })]) + }) + + it('uses the relay scan without requiring the fallback filesystem provider', async () => { + mocks.getSshFilesystemProvider.mockReturnValue(undefined) + mocks.requestActiveSshAiVaultSessionList.mockResolvedValue( + result([session('local', 'remote-session')]) + ) + + const scanned = await _internals.listAiVaultSessions({ + executionHostScope: 'ssh:dev-box' + }) + + expect(scanned.sessions).toHaveLength(1) + expect(mocks.scanRemoteAiVaultSessions).not.toHaveBeenCalled() + }) + it('merges local plus connected SSH targets for all hosts', async () => { const result = await _internals.listAiVaultSessions({ executionHostScope: 'all' }) expect(mocks.scanAiVaultSessions).toHaveBeenCalledTimes(1) expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1) + expect(mocks.requestActiveSshAiVaultSessionList).toHaveBeenCalledWith( + 'dev-box', + expect.any(Object), + expect.objectContaining({ timeoutMs: 15_000 }) + ) expect(result.sessions.map((entry) => entry.executionHostId)).toEqual(['ssh:dev-box', 'local']) }) @@ -159,6 +312,50 @@ describe('listAiVaultSessions host routing', () => { ]) }) + it('keeps SSH results when the local scan itself throws', async () => { + // Why: `all` awaits every leg together, so an unguarded local throw (parse + // cache load, WSL home resolution) would discard every host's sessions. + mocks.scanAiVaultSessions.mockRejectedValue(new Error('session parse cache is corrupt')) + registerAiVaultHandlers({ + getActiveRuntimeAiVaultHostInfos: () => [], + scanRuntimeAiVaultSessions: mocks.scanRuntimeAiVaultSessions + }) + + const result = await _internals.listAiVaultSessions({ executionHostScope: 'all' }) + + expect(result.sessions.map((entry) => entry.executionHostId)).toEqual(['ssh:dev-box']) + expect(result.issues).toEqual([ + expect.objectContaining({ + executionHostId: 'local', + kind: 'host', + path: 'this computer', + message: 'session parse cache is corrupt' + }) + ]) + }) + + it('keeps local results when SSH host discovery fails', async () => { + mocks.getActiveSshAiVaultHostInfos.mockImplementation(() => { + throw new Error('relay session map is unavailable') + }) + registerAiVaultHandlers({ + getActiveRuntimeAiVaultHostInfos: () => [], + scanRuntimeAiVaultSessions: mocks.scanRuntimeAiVaultSessions + }) + + const result = await _internals.listAiVaultSessions({ executionHostScope: 'all' }) + + expect(mocks.scanAiVaultSessions).toHaveBeenCalledTimes(1) + expect(result.sessions.map((entry) => entry.executionHostId)).toEqual(['local']) + expect(result.issues).toEqual([ + expect.objectContaining({ + agent: 'codex', + path: 'SSH hosts', + message: 'relay session map is unavailable' + }) + ]) + }) + it('keeps direct runtime host scans on the normal runtime timeout', async () => { registerAiVaultHandlers({ getActiveRuntimeAiVaultHostInfos: () => [], @@ -205,6 +402,63 @@ describe('listAiVaultSessions host routing', () => { expect(mocks.scanAiVaultSessions).toHaveBeenCalledTimes(1) expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1) }) + + it('caches completed SSH scans by host and workspace scope', async () => { + await _internals.listAiVaultSessions({ + executionHostScope: 'ssh:dev-box', + scopePaths: ['/home/ada/repo-a', '/home/ada/repo-b'] + }) + await _internals.listAiVaultSessions({ + executionHostScope: 'ssh:dev-box', + scopePaths: ['/home/ada/repo-b', '/home/ada/repo-a'] + }) + + expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1) + }) + + it('serves lower SSH depths from a larger completed scan', async () => { + const base = { executionHostScope: 'ssh:dev-box' as const, scopePaths: ['/home/ada/repo'] } + await _internals.listAiVaultSessions({ ...base, limit: 1000 }) + await _internals.listAiVaultSessions({ ...base, limit: 250 }) + await _internals.listAiVaultSessions({ ...base, limit: 500 }) + + expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1) + }) + + it('threads renderer cancellation into the SSH relay request', async () => { + let relaySignal: AbortSignal | undefined + mocks.requestActiveSshAiVaultSessionList.mockImplementation( + (_targetId, _params, options: { signal?: AbortSignal }) => + new Promise((_resolve, reject) => { + relaySignal = options.signal + options.signal?.addEventListener( + 'abort', + () => { + const error = new Error('cancelled') + error.name = 'AbortError' + reject(error) + }, + { once: true } + ) + }) + ) + registerAiVaultHandlers() + const event = { sender: { id: 7 } } + const pending = getIpcHandler('aiVault:listSessions')(event, { + executionHostScope: 'ssh:dev-box', + requestToken: 'scan-1' + }) + await vi.waitFor(() => expect(relaySignal).toBeDefined()) + + await getIpcHandler('aiVault:cancelListSessions')(event, { + requestToken: 'scan-1' + }) + + expect(relaySignal?.aborted).toBe(true) + // Resolved, not rejected: Electron logs every rejected handler, and a + // superseded scan is normal control flow rather than a failure. + await expect(pending).resolves.toMatchObject({ cancelled: true, sessions: [] }) + }) }) describe('prepareSessionResume IPC', () => { @@ -280,6 +534,14 @@ function getPrepareSessionResumeHandler(): ( return registration[1] } +function getIpcHandler(channel: string): (...args: unknown[]) => unknown { + const registration = mocks.ipcHandle.mock.calls.find(([registered]) => registered === channel) + if (!registration) { + throw new Error(`${channel} was not registered`) + } + return registration[1] +} + describe('listAiVaultSubagentSessions gating', () => { const claudeRoot = join(homedir(), '.claude', 'projects') @@ -365,6 +627,14 @@ function hostInfo(targetId: string) { } } +/** Mirrors the multiplexer's typed timeout: callers branch on the code, not on + * the message text. */ +function relayTimeoutError(): Error { + return Object.assign(new Error('Request "aiVault.listSessions" timed out after 130000ms'), { + code: SSH_MUX_REQUEST_TIMEOUT_CODE + }) +} + function result(sessions: AiVaultSession[]): AiVaultListResult { return { sessions, issues: [], scannedAt: new Date().toISOString() } } diff --git a/src/main/ipc/ai-vault.ts b/src/main/ipc/ai-vault.ts index e103b7b5c..5d3b0bd2e 100644 --- a/src/main/ipc/ai-vault.ts +++ b/src/main/ipc/ai-vault.ts @@ -7,17 +7,24 @@ import { resetAiVaultSessionListCacheForTests, type AiVaultSessionSources } from '../ai-vault/cached-session-list' -import { scanRemoteAiVaultSessions } from '../ai-vault/remote-session-scanner' import { listClaudeSubagentSessions } from '../ai-vault/session-scanner-claude-subagents' import { claudeProjectsRootDirs } from '../ai-vault/session-scanner-source-discovery' import { isPathInsideOrEqual } from '../../shared/cross-platform-path' -import { aiVaultScanIssueResult, mergeAiVaultListResults } from '../ai-vault/session-list-results' -import type { - AiVaultFirstUserPromptArgs, - AiVaultListArgs, - AiVaultListResult, - AiVaultSubagentListArgs, - AiVaultSubagentListResult +import { + aiVaultScanIssueResult, + cancelledAiVaultListResult, + mergeAiVaultListResults +} from '../ai-vault/session-list-results' +import { scanSshAiVaultSessions } from '../ai-vault/ssh-session-list' +import { AiVaultScanCoordinator } from '../ai-vault/ai-vault-scan-coordinator' +import { + AI_VAULT_SCOPE_PATHS_MAX_COUNT, + isAiVaultScanCancelledError, + type AiVaultFirstUserPromptArgs, + type AiVaultListArgs, + type AiVaultListResult, + type AiVaultSubagentListArgs, + type AiVaultSubagentListResult } from '../../shared/ai-vault-types' import { handleAiVaultGetFirstUserPrompt } from '../ai-vault/session-first-user-prompt-read' import { registerAiVaultResumeHandler, type AiVaultResumeHandlerOptions } from './ai-vault-resume' @@ -29,126 +36,150 @@ import { toSshExecutionHostId, type ExecutionHostScope } from '../../shared/execution-host' +import { getActiveSshAiVaultHostInfos } from './ssh' +import { createSenderScopedRequestCancellations } from './sender-scoped-request-cancellation' +import { discoverAiVaultHosts, type AiVaultHostDiscoveryResult } from './ai-vault-host-discovery' import { - getSshFilesystemProvider, - SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE -} from '../providers/ssh-filesystem-dispatch' -import { getActiveSshAiVaultHostInfo, getActiveSshAiVaultHostInfos } from './ssh' + scanRuntimeAiVaultSessions, + type RuntimeAiVaultHostInfo, + type RuntimeAiVaultScanner +} from './ai-vault-runtime-scan' +import { resetAiVaultHostLegCacheForTests, scanHostLegWithCache } from './ai-vault-host-leg-cache' +import { requestedAiVaultSessionDepth } from '../../shared/ai-vault-session-depth' -const AI_VAULT_CACHE_TTL_MS = 15_000 const AI_VAULT_ALL_HOST_RUNTIME_TIMEOUT_MS = 3_000 +// Why: a remote home with many agent roots routinely needs seconds to walk, +// stat and parse. The old shared 3s bound emptied healthy SSH hosts in the +// all-hosts view; the relay gets a real scan budget and the whole leg (relay +// attempt plus any legacy crawl) stays bounded so one host can't hold the +// merge open. +const AI_VAULT_ALL_HOST_SSH_RELAY_TIMEOUT_MS = 15_000 +const AI_VAULT_ALL_HOST_SSH_TIMEOUT_MS = 20_000 type AiVaultHandlerOptions = AiVaultSessionSources & AiVaultResumeHandlerOptions & { getActiveRuntimeAiVaultHostInfos?: () => readonly RuntimeAiVaultHostInfo[] - scanRuntimeAiVaultSessions?: ( - environmentId: string, - args: AiVaultListArgs, - options?: RuntimeAiVaultScanOptions - ) => Promise + scanRuntimeAiVaultSessions?: RuntimeAiVaultScanner } -type RuntimeAiVaultScanOptions = { - timeoutMs?: number -} - -type CachedAiVaultList = { - key: string - result: AiVaultListResult - expiresAt: number -} - -type RuntimeAiVaultHostInfo = { - environmentId: string - executionHostId: `runtime:${string}` -} - -let cachedList: CachedAiVaultList | null = null -let inflightList: Promise | null = null -let inflightKey: string | null = null +let scanCoordinator = new AiVaultScanCoordinator() let handlerOptions: AiVaultHandlerOptions = {} +const listCancellations = createSenderScopedRequestCancellations() -async function listAiVaultSessions(args?: AiVaultListArgs): Promise { +async function listAiVaultSessions( + args?: AiVaultListArgs, + options: { signal?: AbortSignal } = {} +): Promise { const executionHostScope = normalizeExecutionHostScope( args?.executionHostScope ?? LOCAL_EXECUTION_HOST_ID ) - // Why: local-scope scans go straight to the shared cache module (also used by - // the runtime RPC method), so the desktop panel and a paired mobile client - // never double-scan the same transcripts; the cache below only has to dedupe - // the multi-host (ssh/runtime/all) merges that exist on the desktop side. - if (executionHostScope === LOCAL_EXECUTION_HOST_ID) { - return scanLocalAiVaultSessions(args) - } // Scope paths change the result set, so they must be part of the cache key. + // A scanner consumes at most 64 paths, so smaller equivalent workspace sets + // can share a snapshot regardless of which worktree was selected first. + const scopePaths = args?.scopePaths ?? [] const key = JSON.stringify({ - limit: args?.limit ?? 'default', - scopePaths: args?.scopePaths ?? [], + scopePaths: + scopePaths.length <= AI_VAULT_SCOPE_PATHS_MAX_COUNT + ? [...new Set(scopePaths)].sort() + : scopePaths, executionHostScope }) - const now = Date.now() - // Why: opening this panel repeatedly should not re-parse hundreds of JSONL - // transcripts; explicit refreshes bypass the cache but not an active scan. - if (args?.force !== true && cachedList?.key === key && cachedList.expiresAt > now) { - return cachedList.result - } - if (inflightList && inflightKey === key) { - return inflightList - } - - inflightKey = key - inflightList = scanAiVaultSessionsByHostScope(args, executionHostScope) - .then((result) => { - cachedList = { - key, - result, - expiresAt: Date.now() + AI_VAULT_CACHE_TTL_MS + const depth = requestedAiVaultSessionDepth(args) + const scanKey = JSON.stringify({ key, depth }) + // Why: every renderer request carries its own cancellation signal, so + // coalescing has to survive them — the coordinator hands all same-key callers + // one scan and only aborts it once every one of them has cancelled. + return scanCoordinator.run({ + key: scanKey, + force: args?.force, + signal: options.signal, + start: (scanSignal) => { + const scan = () => scanAiVaultSessionsByHostScope(args, executionHostScope, scanSignal, key) + if (executionHostScope === LOCAL_EXECUTION_HOST_ID) { + return scan() } - return result - }) - .finally(() => { - // Only clear tracking if it still refers to this request: a concurrent - // different-scope scan may have replaced it and must stay dedupable. - if (inflightKey === key) { - inflightKey = null - inflightList = null - } - }) - return inflightList + return scanHostLegWithCache({ + cacheKey: key, + depth, + scopePaths, + force: args?.force === true, + scan + }) + } + }) } async function scanAiVaultSessionsByHostScope( args: AiVaultListArgs | undefined, - executionHostScope: ExecutionHostScope + executionHostScope: ExecutionHostScope, + signal?: AbortSignal, + cacheKey = '' ): Promise { + const depth = requestedAiVaultSessionDepth(args) + const scopePaths = args?.scopePaths ?? [] + if (executionHostScope === LOCAL_EXECUTION_HOST_ID) { + return scanLocalAiVaultSessions(args, signal) + } if (executionHostScope === 'all') { const runtimeHosts = getActiveRuntimeAiVaultHostInfosResult() - const runtimeResults = runtimeHosts.issue ? [runtimeHosts.issue] : [] + const sshHosts = getActiveSshAiVaultHostInfosResult() + const runtimeResults = [ + ...(runtimeHosts.issue ? [runtimeHosts.issue] : []), + ...(sshHosts.issue ? [sshHosts.issue] : []) + ] const scannedResults = await Promise.all([ - scanLocalAiVaultSessions(args), - ...getActiveSshAiVaultHostInfos().map((hostInfo) => - scanSshAiVaultSessions(hostInfo.targetId, args) + scanLocalAiVaultSessionsForAllScope(args, signal), + ...sshHosts.hostInfos.map((hostInfo) => + scanHostLegWithCache({ + cacheKey: `${cacheKey}|${toSshExecutionHostId(hostInfo.targetId)}`, + depth, + scopePaths, + force: args?.force === true, + scan: () => + scanSshAiVaultSessions(hostInfo.targetId, args, { + signal, + timeoutMs: AI_VAULT_ALL_HOST_SSH_TIMEOUT_MS, + relayTimeoutMs: AI_VAULT_ALL_HOST_SSH_RELAY_TIMEOUT_MS + }) + }) ), ...runtimeHosts.hostInfos.map((hostInfo) => - scanRuntimeAiVaultSessions(hostInfo, args, { - timeoutMs: AI_VAULT_ALL_HOST_RUNTIME_TIMEOUT_MS + scanHostLegWithCache({ + cacheKey: `${cacheKey}|${hostInfo.executionHostId}`, + depth, + scopePaths, + force: args?.force === true, + scan: () => + scanRuntimeAiVaultSessions({ + hostInfo, + scanner: handlerOptions.scanRuntimeAiVaultSessions, + listArgs: args, + options: { signal, timeoutMs: AI_VAULT_ALL_HOST_RUNTIME_TIMEOUT_MS } + }) }) ) ]) - return mergeAiVaultListResults([...scannedResults, ...runtimeResults], args?.limit) + return mergeAiVaultListResults( + [...scannedResults, ...runtimeResults], + args?.limit, + args?.unlimited + ) } const parsed = parseExecutionHostId(executionHostScope) if (parsed?.kind === 'ssh') { - return scanSshAiVaultSessions(parsed.targetId, args) + return scanSshAiVaultSessions(parsed.targetId, args, { signal }) } if (parsed?.kind === 'runtime') { - return scanRuntimeAiVaultSessions( - { + return scanRuntimeAiVaultSessions({ + hostInfo: { environmentId: parsed.environmentId, executionHostId: toRuntimeExecutionHostId(parsed.environmentId) }, - args - ) + scanner: handlerOptions.scanRuntimeAiVaultSessions, + listArgs: args, + options: { signal } + }) } return aiVaultScanIssueResult({ @@ -158,118 +189,57 @@ async function scanAiVaultSessionsByHostScope( }) } -function getActiveRuntimeAiVaultHostInfos(): readonly RuntimeAiVaultHostInfo[] { - return handlerOptions.getActiveRuntimeAiVaultHostInfos?.() ?? [] -} - -function getActiveRuntimeAiVaultHostInfosResult(): { - hostInfos: readonly RuntimeAiVaultHostInfo[] - issue?: AiVaultListResult -} { - try { - return { hostInfos: getActiveRuntimeAiVaultHostInfos() } - } catch (error) { - return { - hostInfos: [], - issue: runtimeHostDiscoveryIssueResult( - error instanceof Error ? error.message : 'Runtime hosts are unavailable.' - ) - } - } -} - -async function scanRuntimeAiVaultSessions( - hostInfo: RuntimeAiVaultHostInfo, - args?: AiVaultListArgs, - options: RuntimeAiVaultScanOptions = {} -): Promise { - const scanner = handlerOptions.scanRuntimeAiVaultSessions - if (!scanner) { - return runtimeScanIssueResult( - hostInfo, - 'Agent Session History is not available for this execution host.' - ) - } - const scanArgs: AiVaultListArgs = { executionHostScope: hostInfo.executionHostId } - if (args?.limit !== undefined) { - scanArgs.limit = args.limit - } - if (args?.force !== undefined) { - scanArgs.force = args.force - } - if (args?.scopePaths !== undefined) { - scanArgs.scopePaths = args.scopePaths - } - try { - return await scanner(hostInfo.environmentId, scanArgs, options) - } catch (error) { - return runtimeScanIssueResult( - hostInfo, - error instanceof Error ? error.message : 'Remote Orca server is unavailable.' - ) - } -} - -function runtimeScanIssueResult( - hostInfo: RuntimeAiVaultHostInfo, - message: string -): AiVaultListResult { - return aiVaultScanIssueResult({ - executionHostId: hostInfo.executionHostId, - path: hostInfo.environmentId, - message +function getActiveRuntimeAiVaultHostInfosResult(): AiVaultHostDiscoveryResult { + return discoverAiVaultHosts(() => handlerOptions.getActiveRuntimeAiVaultHostInfos?.() ?? [], { + path: 'runtime environments', + fallbackMessage: 'Runtime hosts are unavailable.' }) } -function runtimeHostDiscoveryIssueResult(message: string): AiVaultListResult { - return aiVaultScanIssueResult({ path: 'runtime environments', message }) +function getActiveSshAiVaultHostInfosResult(): AiVaultHostDiscoveryResult<{ targetId: string }> { + return discoverAiVaultHosts(getActiveSshAiVaultHostInfos, { + path: 'SSH hosts', + fallbackMessage: 'SSH hosts are unavailable.' + }) } -async function scanLocalAiVaultSessions(args?: AiVaultListArgs): Promise { +// Why: the SSH legs already degrade to an issue row so one bad host can't take +// the shared Promise.all down; the local leg can throw too (parse-cache load, +// WSL home resolution) and would otherwise discard every host's sessions. +async function scanLocalAiVaultSessionsForAllScope( + args: AiVaultListArgs | undefined, + signal: AbortSignal | undefined +): Promise { + try { + return await scanLocalAiVaultSessions(args, signal) + } catch (error) { + if (isAiVaultScanCancelledError(error)) { + throw error + } + return aiVaultScanIssueResult({ + executionHostId: LOCAL_EXECUTION_HOST_ID, + path: 'this computer', + message: error instanceof Error ? error.message : 'Local session scan failed.' + }) + } +} + +async function scanLocalAiVaultSessions( + args?: AiVaultListArgs, + signal?: AbortSignal +): Promise { // Why: the shared cache module owns codex-home/WSL sourcing and the local // scan cache, so the desktop IPC path and the runtime RPC method (mobile) // share one cache instance and one source of managed-Codex homes. - return listCachedLocalAiVaultSessions({ - limit: args?.limit, - force: args?.force, - scopePaths: args?.scopePaths - }) -} - -async function scanSshAiVaultSessions( - targetId: string, - args?: AiVaultListArgs -): Promise { - const executionHostId = toSshExecutionHostId(targetId) - const hostInfo = getActiveSshAiVaultHostInfo(targetId) - const provider = getSshFilesystemProvider(targetId) - if (!hostInfo || !provider) { - return sshScanIssueResult({ - executionHostId, - targetId, - message: SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE - }) - } - return scanRemoteAiVaultSessions({ - provider, - executionHostId: hostInfo.executionHostId, - remoteHome: hostInfo.remoteHome, - hostPlatform: hostInfo.hostPlatform, - limit: args?.limit, - scopePaths: args?.scopePaths - }) -} - -function sshScanIssueResult(args: { - executionHostId: `ssh:${string}` - targetId: string - message: string -}): AiVaultListResult { - return aiVaultScanIssueResult({ - executionHostId: args.executionHostId, - path: args.targetId, - message: args.message - }) + return listCachedLocalAiVaultSessions( + { + limit: args?.limit, + unlimited: args?.unlimited, + force: args?.force, + scopePaths: args?.scopePaths + }, + { signal } + ) } export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): void { @@ -279,8 +249,32 @@ export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): vo // WSL injection. The runtime also configures these sources from its deps // (serve-mode reachable); this desktop path supplies the same source. configureAiVaultSessionSources(options) - ipcMain.handle('aiVault:listSessions', (_event, args?: AiVaultListArgs) => - listAiVaultSessions(args) + ipcMain.handle('aiVault:listSessions', async (event, args?: AiVaultListArgs) => { + const requestToken = + typeof args?.requestToken === 'string' && args.requestToken.length <= 128 + ? args.requestToken + : undefined + const controller = listCancellations.begin(event, requestToken) + try { + return await listAiVaultSessions(args, { signal: controller?.signal }) + } catch (error) { + // Why: superseding a scan is normal control flow, but Electron logs every + // rejected handler — report it as a result so the log stays truthful. + if (!isAiVaultScanCancelledError(error)) { + throw error + } + return cancelledAiVaultListResult() + } finally { + listCancellations.finish(event, requestToken, controller) + } + }) + ipcMain.handle( + 'aiVault:cancelListSessions', + (event, args: { requestToken?: string } | undefined): void => { + if (typeof args?.requestToken === 'string' && args.requestToken.length <= 128) { + listCancellations.cancel(event, args.requestToken) + } + } ) registerAiVaultResumeHandler(options) ipcMain.handle( @@ -335,9 +329,8 @@ async function listAiVaultSubagentSessions( } function resetAiVaultCacheForTests(): void { - cachedList = null - inflightList = null - inflightKey = null + resetAiVaultHostLegCacheForTests() + scanCoordinator = new AiVaultScanCoordinator() handlerOptions = {} // The local leg delegates to the shared cache module; reset it too so tests // never see a scan cached by an earlier case. diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index 995d3e88b..6bae75a44 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -7,6 +7,7 @@ import type { SshConnectionCallbacks } from '../ssh/ssh-connection' import { SshConnectionManager } from '../ssh/ssh-connection-manager' import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer' import { SshRelaySession, type SshRelayAiVaultHostInfo } from '../ssh/ssh-relay-session' +import type { SshAiVaultRelayListParams } from '../../shared/ssh-ai-vault-relay' import { SshPortForwardManager } from '../ssh/ssh-port-forward' import type { DetectedPort, @@ -161,6 +162,21 @@ export function getActiveSshAiVaultHostInfos(): SshRelayAiVaultHostInfo[] { }) } +export async function requestActiveSshAiVaultSessionList( + targetId: string, + params: SshAiVaultRelayListParams, + options: { signal?: AbortSignal; timeoutMs?: number } = {} +): Promise { + if (isRuntimeOwnedSshTargetId(targetId)) { + return null + } + const session = activeSessions.get(targetId) + if (!session) { + throw new Error('SSH relay is not ready') + } + return session.requestAiVaultSessionList(params, options) +} + function runTargetLifecycle(targetId: string, operation: () => Promise): Promise { const prior = targetLifecycleInFlight.get(targetId) const operationPromise = (async () => { diff --git a/src/main/runtime/rpc/methods/ai-vault.test.ts b/src/main/runtime/rpc/methods/ai-vault.test.ts index a622ea4f8..606972f42 100644 --- a/src/main/runtime/rpc/methods/ai-vault.test.ts +++ b/src/main/runtime/rpc/methods/ai-vault.test.ts @@ -85,6 +85,11 @@ describe('aiVault.listSessions params schema', () => { expect(parsed.success).toBe(false) }) + it('accepts Unlimited without applying the numeric cap', () => { + const parsed = AiVaultListSessionsParams.safeParse({ limit: 5000, unlimited: true }) + expect(parsed.success).toBe(true) + }) + it('clamps scopePaths past the cap instead of rejecting', () => { // Why: uncapped producers (web client, pre-cap desktop parents) may exceed // the bound; scope paths only widen discovery, so truncation is safe. @@ -181,6 +186,33 @@ describe('aiVault.listSessions handler + shared cache', () => { expect(scanAiVaultSessions).toHaveBeenCalledTimes(1) }) + it('serves lower depths from a larger completed scan', async () => { + await listAiVaultSessions({ limit: 1000 }) + await listAiVaultSessions({ limit: 250 }) + await listAiVaultSessions({ limit: 500 }) + + expect(scanAiVaultSessions).toHaveBeenCalledTimes(1) + }) + + it('shares a cache entry across equivalent scope path ordering', async () => { + await listAiVaultSessions({ limit: 500, scopePaths: ['/repo/a', '/repo/b'] }) + await listAiVaultSessions({ limit: 500, scopePaths: ['/repo/b', '/repo/a'] }) + + expect(scanAiVaultSessions).toHaveBeenCalledTimes(1) + }) + + it('forwards Unlimited without a numeric limit', async () => { + const dispatcher = makeDispatcher() + const response = await dispatcher.dispatch( + makeRequest('aiVault.listSessions', { limit: 5000, unlimited: true }) + ) + + expect(response).toMatchObject({ ok: true }) + expect(scanAiVaultSessions).toHaveBeenCalledWith( + expect.objectContaining({ limit: undefined, unlimited: true }) + ) + }) + it('keeps a newer different-key scan dedupable after an older scan resolves', async () => { // Why: the resolving scan's cleanup must not clear tracking a concurrent // different-key scan replaced, or re-requests start a duplicate rescan. diff --git a/src/main/runtime/rpc/methods/ai-vault.ts b/src/main/runtime/rpc/methods/ai-vault.ts index e494148ef..70acf5dab 100644 --- a/src/main/runtime/rpc/methods/ai-vault.ts +++ b/src/main/runtime/rpc/methods/ai-vault.ts @@ -23,27 +23,34 @@ const executionHostIdSchema = z.string().transform((value, ctx): `runtime:${stri return z.NEVER }) -export const AiVaultListSessionsParams = z.object({ - limit: z - .unknown() - .transform((value) => - typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined - ) - .pipe(z.union([z.number().int().max(AI_VAULT_LIMIT_MAX), z.undefined()])) - .optional(), - force: OptionalBoolean, - scopePaths: z - .array(z.string().min(1).max(AI_VAULT_SCOPE_PATH_MAX_LENGTH)) - // Why: clamp instead of reject — scope paths only ever widen discovery, and - // rejecting would hard-break older/uncapped producers (web client, pre-cap - // desktop parents) that send more than the bound. - .transform((paths) => paths.slice(0, AI_VAULT_SCOPE_PATHS_MAX_COUNT)) - .optional(), - // Why: desktop/web callers name the runtime host they are addressing; mobile - // omits it. The scan itself is host-local either way, so the id must never - // change what is scanned — it only restamps the shared cached result. - executionHostId: executionHostIdSchema.optional() -}) +export const AiVaultListSessionsParams = z + .object({ + limit: z + .unknown() + .transform((value) => + typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined + ) + .pipe(z.union([z.number().int(), z.undefined()])) + .optional(), + unlimited: OptionalBoolean, + force: OptionalBoolean, + scopePaths: z + .array(z.string().min(1).max(AI_VAULT_SCOPE_PATH_MAX_LENGTH)) + // Why: clamp instead of reject — scope paths only ever widen discovery, and + // rejecting would hard-break older/uncapped producers (web client, pre-cap + // desktop parents) that send more than the bound. + .transform((paths) => paths.slice(0, AI_VAULT_SCOPE_PATHS_MAX_COUNT)) + .optional(), + // Why: desktop/web callers name the runtime host they are addressing; mobile + // omits it. The scan itself is host-local either way, so the id must never + // change what is scanned — it only restamps the shared cached result. + executionHostId: executionHostIdSchema.optional() + }) + .superRefine((params, ctx) => { + if (params.unlimited !== true && params.limit && params.limit > AI_VAULT_LIMIT_MAX) { + ctx.addIssue({ code: 'custom', path: ['limit'], message: 'Limit exceeds maximum' }) + } + }) export const AiVaultPrepareSessionResumeParams = z.object({ agent: z.enum(AI_VAULT_AGENTS), @@ -58,7 +65,8 @@ export const AI_VAULT_METHODS: RpcMethod[] = [ params: AiVaultListSessionsParams, handler: async (params, { runtime }) => { const result = await runtime.listAiVaultSessions({ - limit: params.limit, + limit: params.unlimited ? undefined : params.limit, + unlimited: params.unlimited, force: params.force, scopePaths: params.scopePaths }) diff --git a/src/main/ssh/ssh-channel-multiplexer.ts b/src/main/ssh/ssh-channel-multiplexer.ts index 575decba7..206d008c1 100644 --- a/src/main/ssh/ssh-channel-multiplexer.ts +++ b/src/main/ssh/ssh-channel-multiplexer.ts @@ -48,6 +48,24 @@ const MAX_UNACKED_TIMESTAMPS = MAX_ORDINARY_UNACKED_TIMESTAMPS + 1 // (system sleep, App Nap timer throttling) — not that the link is dead (#7773). const WAKE_GAP_MS = KEEPALIVE_SEND_MS * 3 +// Why: callers branch on "the request timed out" (fall back to a slower path, +// report a host issue). Matching the message text made every unrelated error +// carrying the same phrase take the timeout branch. +export const SSH_MUX_REQUEST_TIMEOUT_CODE = 'SSH_MUX_REQUEST_TIMEOUT' + +function sshMuxRequestTimeoutError(method: string, timeoutMs: number): Error { + return Object.assign(new Error(`Request "${method}" timed out after ${timeoutMs}ms`), { + code: SSH_MUX_REQUEST_TIMEOUT_CODE + }) +} + +export function isSshMuxRequestTimeoutError(error: unknown): boolean { + return ( + error instanceof Error && + (error as Error & { code?: unknown }).code === SSH_MUX_REQUEST_TIMEOUT_CODE + ) +} + export class SshChannelMultiplexer { private decoder: FrameDecoder private transport: MultiplexerTransport @@ -231,7 +249,7 @@ export class SshChannelMultiplexer { this.notify('rpc.cancel', { id }) } this.pendingRequests.delete(id) - reject(new Error(`Request "${method}" timed out after ${timeoutMs}ms`)) + reject(sshMuxRequestTimeoutError(method, timeoutMs)) }, timeoutMs) if (options?.signal) { diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index d413f45f0..c8440f6c2 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -15,6 +15,7 @@ import type { SshPtyRecoveryActivationLease } from '../providers/ssh-pty-notific import { isSshPtyIdentityMismatchError, isSshPtyNotFoundError } from '../providers/ssh-pty-errors' import { toAppSshPtyId, toRelaySshPtyId } from '../providers/ssh-pty-id' import { SshFilesystemProvider } from '../providers/ssh-filesystem-provider' +import { isMethodNotFoundError } from './ssh-filesystem-stream-reader' import { SshGitProvider } from '../providers/ssh-git-provider' import { agentHookServer } from '../agent-hooks/server' import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' @@ -86,6 +87,11 @@ import { parseRemoteOrcaCliPostOutput } from './ssh-remote-orchestration-post-output' import { toSshExecutionHostId, type ExecutionHostId } from '../../shared/execution-host' +import { + SSH_AI_VAULT_LIST_SESSIONS_METHOD, + SSH_AI_VAULT_LIST_SESSIONS_TIMEOUT_MS, + type SshAiVaultRelayListParams +} from '../../shared/ssh-ai-vault-relay' import { isTerminalLeafId, makePaneKey } from '../../shared/stable-pane-id' import { isValidTerminalTabId } from '../../shared/terminal-tab-id' import { @@ -272,6 +278,7 @@ export class SshRelaySession { private currentConnection: SshConnection | null = null private hostPlatform: RemoteHostPlatform | null = null private remoteCliBridgeEnv: RemoteCliBridgeEnv | null = null + private aiVaultListMethodSupported: boolean | null = null private pendingPtyReattaches = new Map() private readonly ptyRecoveryRetention = new SshPtyRecoveryRetentionBudget() private activePtyProviderGeneration: number | null = null @@ -377,6 +384,33 @@ export class SshRelaySession { } } + async requestAiVaultSessionList( + params: SshAiVaultRelayListParams, + options: { signal?: AbortSignal; timeoutMs?: number } = {} + ): Promise { + if (this.aiVaultListMethodSupported === false) { + return null + } + const mux = this.mux + if (!mux || mux.isDisposed() || this._state !== 'ready') { + throw new Error('SSH relay is not ready') + } + try { + const result = await mux.request(SSH_AI_VAULT_LIST_SESSIONS_METHOD, params, { + signal: options.signal, + timeoutMs: options.timeoutMs ?? SSH_AI_VAULT_LIST_SESSIONS_TIMEOUT_MS + }) + this.aiVaultListMethodSupported = true + return result + } catch (error) { + if (isMethodNotFoundError(error)) { + this.aiVaultListMethodSupported = false + return null + } + throw error + } + } + getPortScanner(): PortScanner | null { return this.portScanner } @@ -395,6 +429,7 @@ export class SshRelaySession { throw new Error(`Cannot establish relay session in state: ${this._state}`) } this._state = 'deploying' + this.aiVaultListMethodSupported = null this.currentConnection = conn try { @@ -528,6 +563,7 @@ export class SshRelaySession { this.abortController = abortController this._state = 'reconnecting' + this.aiVaultListMethodSupported = null this.currentConnection = conn // Why: stop scanning before teardownProviders so the poll timer can't fire against a disposed multiplexer. diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 0675e398d..1fd19f8f7 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -890,6 +890,7 @@ export type OpenCodeUsageApi = { export type AiVaultApi = { listSessions: (args?: AiVaultListArgs) => Promise + cancelListSessions: (args: { requestToken: string }) => Promise prepareSessionResume: ( args: AiVaultPrepareSessionResumeArgs ) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index aaeb54038..329449c6e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -4164,6 +4164,8 @@ const api = { aiVault: { listSessions: (args?: AiVaultListArgs): Promise => ipcRenderer.invoke('aiVault:listSessions', args), + cancelListSessions: (args: { requestToken: string }): Promise => + ipcRenderer.invoke('aiVault:cancelListSessions', args), prepareSessionResume: (args: AiVaultPrepareSessionResumeArgs): Promise => ipcRenderer.invoke('aiVault:prepareSessionResume', args), listSubagentSessions: (args: AiVaultSubagentListArgs): Promise => diff --git a/src/relay/ai-vault-handler.test.ts b/src/relay/ai-vault-handler.test.ts new file mode 100644 index 000000000..6a04b5a0b --- /dev/null +++ b/src/relay/ai-vault-handler.test.ts @@ -0,0 +1,260 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AiVaultListResult } from '../shared/ai-vault-types' +import { SSH_AI_VAULT_LIST_SESSIONS_METHOD } from '../shared/ssh-ai-vault-relay' +import { getRemoteHostPlatform } from '../main/ssh/ssh-remote-platform' +import type { RelayDispatcher, RequestContext } from './dispatcher' +import { AiVaultHandler } from './ai-vault-handler' + +type RequestHandler = (params: Record, context: RequestContext) => Promise + +const temporaryHomes: string[] = [] + +afterEach(async () => { + await Promise.all( + temporaryHomes.splice(0).map((path) => rm(path, { recursive: true, force: true })) + ) +}) + +describe('AiVaultHandler', () => { + it('discovers and parses sessions entirely on the relay host', async () => { + const remoteHome = await makeTemporaryHome() + const transcriptPath = join( + remoteHome, + '.codex', + 'sessions', + '2026', + '07', + '26', + 'rollout-test.jsonl' + ) + await mkdir(dirname(transcriptPath), { recursive: true }) + await writeFile( + transcriptPath, + [ + JSON.stringify({ + timestamp: '2026-07-26T01:00:00.000Z', + type: 'session_meta', + payload: { id: 'ssh-session', cwd: join(remoteHome, 'repo') } + }), + JSON.stringify({ + timestamp: '2026-07-26T01:00:01.000Z', + type: 'response_item', + payload: { + type: 'message', + role: 'user', + content: [{ type: 'text', text: 'Scan on the SSH target' }] + } + }) + ].join('\n') + ) + const dispatcher = createMockDispatcher() + new AiVaultHandler(dispatcher.value, { + remoteHome, + hostPlatform: getRemoteHostPlatform('linux-x64') + }) + + const result = (await dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, { + limit: 20 + })) as AiVaultListResult + + expect(result.issues).toEqual([]) + expect(result.sessions).toHaveLength(1) + expect(result.sessions[0]).toMatchObject({ + executionHostId: 'local', + executionHostPlatform: 'linux', + sessionId: 'ssh-session', + title: 'Scan on the SSH target', + filePath: transcriptPath + }) + }) + + it('bounds relay scan parameters before touching the target filesystem', async () => { + const scanRemoteSessions = vi.fn().mockResolvedValue(emptyResult()) + const dispatcher = createMockDispatcher() + new AiVaultHandler(dispatcher.value, { + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64'), + scanRemoteSessions + }) + const scopePaths = Array.from({ length: 80 }, (_, index) => `/repo/${index}`) + + await dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, { + limit: 50_000, + scopePaths + }) + + expect(scanRemoteSessions).toHaveBeenCalledWith( + expect.objectContaining({ + executionHostId: 'local', + limit: 1000, + scopePaths: scopePaths.slice(0, 64) + }) + ) + const result = (await dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, { + scopePaths + })) as AiVaultListResult + expect(result.issues).toContainEqual( + expect.objectContaining({ + kind: 'scope', + message: expect.stringContaining('first 64 project paths') + }) + ) + }) + + it('forwards Unlimited without the relay numeric cap', async () => { + const scanRemoteSessions = vi.fn().mockResolvedValue(emptyResult()) + const dispatcher = createMockDispatcher() + new AiVaultHandler(dispatcher.value, { + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64'), + scanRemoteSessions + }) + + await dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, { + limit: 50_000, + unlimited: true + }) + + expect(scanRemoteSessions).toHaveBeenCalledWith( + expect.objectContaining({ limit: undefined, unlimited: true }) + ) + }) + + it('coalesces identical in-flight scans without coupling caller cancellation', async () => { + let resolveScan: ((result: AiVaultListResult) => void) | undefined + let sharedSignal: AbortSignal | undefined + const scanRemoteSessions = vi.fn( + (args: { signal?: AbortSignal }) => + new Promise((resolve) => { + sharedSignal = args.signal + resolveScan = resolve + }) + ) + const dispatcher = createMockDispatcher() + new AiVaultHandler(dispatcher.value, { + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64'), + scanRemoteSessions: scanRemoteSessions as never + }) + const firstController = new AbortController() + const first = dispatcher.call( + SSH_AI_VAULT_LIST_SESSIONS_METHOD, + { limit: 20 }, + firstController.signal + ) + const second = dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, { limit: 20 }) + await vi.waitFor(() => expect(scanRemoteSessions).toHaveBeenCalledTimes(1)) + + firstController.abort() + await expect(first).rejects.toMatchObject({ name: 'AbortError' }) + expect(sharedSignal?.aborted).toBe(false) + + resolveScan?.(emptyResult()) + await expect(second).resolves.toEqual(emptyResult()) + }) + + it('re-joins a preempted relay caller onto the forced refresh', async () => { + const signals: AbortSignal[] = [] + let resolveForced: ((result: AiVaultListResult) => void) | undefined + const scanRemoteSessions = vi.fn((args: { signal: AbortSignal }) => { + signals.push(args.signal) + return new Promise((resolve) => { + if (signals.length === 1) { + args.signal.addEventListener('abort', () => resolve(emptyResult()), { once: true }) + } else { + resolveForced = resolve + } + }) + }) + const dispatcher = createMockDispatcher() + new AiVaultHandler(dispatcher.value, { + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64'), + scanRemoteSessions: scanRemoteSessions as never + }) + const first = dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, { limit: 20 }) + await vi.waitFor(() => expect(signals).toHaveLength(1)) + + const forced = dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, { + limit: 20, + force: true + }) + await vi.waitFor(() => expect(signals).toHaveLength(2)) + + expect(signals[0]?.aborted).toBe(true) + resolveForced?.(emptyResult()) + // The desktop caller that did not ask for a refresh still gets sessions. + await expect(Promise.all([first, forced])).resolves.toEqual([emptyResult(), emptyResult()]) + }) + + it('soft-disables the method instead of aborting relay startup on an unsupported platform', () => { + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { value: 'freebsd', configurable: true }) + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + try { + const dispatcher = createMockDispatcher() + + expect(() => new AiVaultHandler(dispatcher.value)).not.toThrow() + + expect(() => dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, {})).toThrow(/No handler/) + } finally { + stderr.mockRestore() + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + }) + + it('stops a relay-local scan when the owning request is cancelled', async () => { + const dispatcher = createMockDispatcher() + new AiVaultHandler(dispatcher.value, { + remoteHome: '/home/ada', + hostPlatform: getRemoteHostPlatform('linux-x64') + }) + const controller = new AbortController() + controller.abort() + + await expect( + dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, {}, controller.signal) + ).rejects.toMatchObject({ name: 'AbortError' }) + }) +}) + +async function makeTemporaryHome(): Promise { + const path = await mkdtemp(join(tmpdir(), 'orca-relay-ai-vault-')) + temporaryHomes.push(path) + return path +} + +function emptyResult(): AiVaultListResult { + return { sessions: [], issues: [], scannedAt: '2026-07-26T00:00:00.000Z' } +} + +function createMockDispatcher(): { + value: RelayDispatcher + call: (method: string, params: Record, signal?: AbortSignal) => Promise +} { + const handlers = new Map() + const value = { + onRequest(method: string, handler: RequestHandler) { + handlers.set(method, handler) + } + } as RelayDispatcher + return { + value, + call(method, params, signal) { + const handler = handlers.get(method) + if (!handler) { + throw new Error(`No handler for ${method}`) + } + return handler(params, { + clientId: 1, + isStale: () => signal?.aborted ?? false, + signal + }) + } + } +} diff --git a/src/relay/ai-vault-handler.ts b/src/relay/ai-vault-handler.ts new file mode 100644 index 000000000..80feae8ea --- /dev/null +++ b/src/relay/ai-vault-handler.ts @@ -0,0 +1,159 @@ +import { lstat, readdir } from 'node:fs/promises' +import { homedir } from 'node:os' +import { AI_VAULT_SCOPE_PATHS_MAX_COUNT, type AiVaultListResult } from '../shared/ai-vault-types' +import { LOCAL_EXECUTION_HOST_ID } from '../shared/execution-host' +import { + SSH_AI_VAULT_LIST_LIMIT_MAX, + SSH_AI_VAULT_LIST_SESSIONS_METHOD, + SSH_AI_VAULT_SCOPE_PATH_MAX_LENGTH, + type SshAiVaultRelayListParams +} from '../shared/ssh-ai-vault-relay' +import { scanRemoteAiVaultSessions } from '../main/ai-vault/remote-session-scanner' +import type { RemoteSessionFilesystemProvider } from '../main/ai-vault/remote-session-scanner-types' +import { getRemoteHostPlatform, type RemoteHostPlatform } from '../main/ssh/ssh-remote-platform' +import { parseUnameToRelayPlatform } from '../main/ssh/relay-protocol' +import { readRelayFileContent } from './fs-handler-file-read' +import { relayLogLine } from './relay-diagnostic-log' +import type { RelayDispatcher } from './dispatcher' +import { AiVaultScanCoordinator } from '../main/ai-vault/ai-vault-scan-coordinator' + +type ScanRemoteSessions = typeof scanRemoteAiVaultSessions + +type AiVaultHandlerOptions = { + remoteHome?: string + hostPlatform?: RemoteHostPlatform + scanRemoteSessions?: ScanRemoteSessions +} + +export class AiVaultHandler { + private readonly remoteHome: string + private readonly scanRemoteSessions: ScanRemoteSessions + private readonly provider: RemoteSessionFilesystemProvider + private readonly scanCoordinator = new AiVaultScanCoordinator() + + constructor(dispatcher: RelayDispatcher, options: AiVaultHandlerOptions = {}) { + this.remoteHome = options.remoteHome ?? homedir() + this.scanRemoteSessions = options.scanRemoteSessions ?? scanRemoteAiVaultSessions + this.provider = createRelayAiVaultFilesystemProvider() + const hostPlatform = options.hostPlatform ?? currentRelayHostPlatform() + // Why: an OS/arch this build has no path flavor for must not abort relay + // startup — leaving the method unregistered soft-disables the feature and + // the host falls back to its own SSH filesystem scan. + if (!hostPlatform) { + relayLogLine( + `[relay] Agent Session History disabled: unsupported platform ${process.platform}-${process.arch}` + ) + return + } + dispatcher.onRequest(SSH_AI_VAULT_LIST_SESSIONS_METHOD, (params, context) => + this.listSessions(hostPlatform, params, context.signal) + ) + } + + private async listSessions( + hostPlatform: RemoteHostPlatform, + rawParams: Record, + signal?: AbortSignal + ): Promise { + const params = normalizeSshAiVaultRelayListParams(rawParams) + const result = await this.scanCoordinator.run({ + key: JSON.stringify({ + limit: params.limit, + unlimited: params.unlimited, + scopePaths: params.scopePaths, + scopePathsTruncated: params.scopePathsTruncated + }), + force: params.force, + signal, + start: (scanSignal) => + this.scanRemoteSessions({ + provider: this.provider, + executionHostId: LOCAL_EXECUTION_HOST_ID, + remoteHome: this.remoteHome, + hostPlatform, + limit: params.limit, + unlimited: params.unlimited, + scopePaths: params.scopePaths, + signal: scanSignal + }) + }) + if (!params.scopePathsTruncated) { + return result + } + return { + ...result, + issues: [ + ...result.issues, + { + executionHostId: LOCAL_EXECUTION_HOST_ID, + agent: 'codex', + kind: 'scope', + path: this.remoteHome, + message: `Only the first ${AI_VAULT_SCOPE_PATHS_MAX_COUNT} project paths were scanned.` + } + ] + } + } +} + +export function normalizeSshAiVaultRelayListParams( + params: Record +): SshAiVaultRelayListParams { + const rawLimit = params.limit + const unlimited = params.unlimited === true + const limit = + !unlimited && typeof rawLimit === 'number' && Number.isFinite(rawLimit) && rawLimit > 0 + ? Math.min(Math.floor(rawLimit), SSH_AI_VAULT_LIST_LIMIT_MAX) + : undefined + const scopePaths = Array.isArray(params.scopePaths) + ? params.scopePaths + .slice(0, AI_VAULT_SCOPE_PATHS_MAX_COUNT) + .filter( + (path): path is string => + typeof path === 'string' && + path.trim().length > 0 && + path.length <= SSH_AI_VAULT_SCOPE_PATH_MAX_LENGTH + ) + : undefined + const scopePathsTruncated = + params.scopePathsTruncated === true || + (Array.isArray(params.scopePaths) && params.scopePaths.length > AI_VAULT_SCOPE_PATHS_MAX_COUNT) + return { + ...(unlimited ? { unlimited: true } : {}), + ...(limit === undefined ? {} : { limit }), + ...(params.force === true ? { force: true } : {}), + ...(scopePaths === undefined ? {} : { scopePaths }), + ...(scopePathsTruncated ? { scopePathsTruncated: true } : {}) + } +} + +function currentRelayHostPlatform(): RemoteHostPlatform | null { + const relayPlatform = parseUnameToRelayPlatform(process.platform, process.arch) + return relayPlatform ? getRemoteHostPlatform(relayPlatform) : null +} + +function createRelayAiVaultFilesystemProvider(): RemoteSessionFilesystemProvider { + return { + async readDir(dirPath) { + const entries = await readdir(dirPath, { withFileTypes: true }) + return entries.map((entry) => ({ + name: entry.name, + isDirectory: entry.isDirectory(), + isSymlink: entry.isSymbolicLink() + })) + }, + readFile: readRelayFileContent, + async stat(filePath) { + const stats = await lstat(filePath) + return { + size: stats.size, + type: stats.isDirectory() ? 'directory' : stats.isSymbolicLink() ? 'symlink' : 'file', + mtime: stats.mtimeMs, + mtimeMs: stats.mtimeMs, + dev: stats.dev, + ino: stats.ino, + nlink: stats.nlink + } + } + } +} diff --git a/src/relay/relay.ts b/src/relay/relay.ts index 4bfc8bf3e..d5003bdf6 100644 --- a/src/relay/relay.ts +++ b/src/relay/relay.ts @@ -32,6 +32,7 @@ import { ExternalAutomationsHandler } from './external-automations-handler' import { PortScanHandler } from './port-scan-handler' import { AgentExecHandler } from './agent-exec-handler' import { WorkspaceSessionHandler } from './workspace-session-handler' +import { AiVaultHandler } from './ai-vault-handler' import { endpointDirForRelaySocket, RelayAgentHookServer } from './agent-hook-server' import { PluginOverlayManager } from './plugin-overlay' import { @@ -663,6 +664,9 @@ async function main(): Promise { const _workspaceSessionHandler = new WorkspaceSessionHandler(dispatcher) void _workspaceSessionHandler + const _aiVaultHandler = new AiVaultHandler(dispatcher) + void _aiVaultHandler + // Why: relay-hosted plugin provisioning is a later phase. Register the // enforcement boundary now with no consented identities or runtime services. registerRelayPluginHostCallHandlers( diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx index 0113f0d22..e048925c7 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx @@ -48,6 +48,7 @@ import { } from './ai-vault-host-scope' import { usePersistedAiVaultViewOptions } from './use-persisted-ai-vault-view-options' import { AgentSessionContinuationDialog } from '@/components/agent-session-continuation/AgentSessionContinuationDialog' +import { AiVaultScanIssueBanners } from './AiVaultScanIssueBanners' export default function AiVaultPanel(): React.JSX.Element { const activeWorktreeId = useActiveWorktreeId() @@ -77,9 +78,11 @@ export default function AiVaultPanel(): React.JSX.Element { sort, group, hideEmptySessions, + sessionLimit, setSort, setGroup, setHideEmptySessions, + setSessionLimit, setAgentEnabled, setAllAgentsEnabled, resetViewOptions @@ -141,9 +144,10 @@ export default function AiVaultPanel(): React.JSX.Element { ) const { error, loading, refresh, scanResult, sessions } = useAiVaultSessionRefresh( scopePaths, - executionHostScope + executionHostScope, + sessionLimit ) - // Deliberately blind to the active repo/worktree: rebuilding these ~500-entry + // Deliberately blind to the active repo/worktree: rebuilding these session // maps on every worktree switch is what made switching visibly slow (#10841 era). const sessionProjectById = useMemo( () => @@ -180,7 +184,8 @@ export default function AiVaultPanel(): React.JSX.Element { agents, sort, group, - hideEmptySessions + hideEmptySessions, + sessionLimit }) // Workspace is the preferred default, but unavailable context still falls back to All. @@ -316,6 +321,7 @@ export default function AiVaultPanel(): React.JSX.Element { sort={sort} group={group} hideEmptySessions={hideEmptySessions} + sessionLimit={sessionLimit} adjustmentCount={viewAdjustmentCount} onQueryChange={setQuery} onScopeChange={handleScopeChange} @@ -325,6 +331,7 @@ export default function AiVaultPanel(): React.JSX.Element { onSortChange={setSort} onGroupChange={setGroup} onHideEmptySessionsChange={setHideEmptySessions} + onSessionLimitChange={setSessionLimit} onReset={resetViewOptions} onRefresh={() => void refresh({ force: true })} /> @@ -335,15 +342,7 @@ export default function AiVaultPanel(): React.JSX.Element { ) : null} - {scanResult && scanResult.issues.length > 0 ? ( -
- {translate( - 'auto.components.right.sidebar.AiVaultPanel.transcriptsSkipped', - '{{count}} transcript skipped', - { count: scanResult.issues.length } - )} -
- ) : null} + -
- - - {translate( - 'auto.components.right.sidebar.AiVaultPanelControls.scanningSessions', - 'Scanning sessions' - )} - -
-
- {Array.from({ length: 6 }, (_, index) => ( -
-
-
-
-
-
-
-
- ))} -
-
- ) -} - export function VaultScopeSwitch({ scope, workspaceAvailable, @@ -235,24 +208,28 @@ export function VaultViewMenu({ sort, group, hideEmptySessions, + sessionLimit, adjustmentCount, onAgentEnabledChange, onAllAgentsEnabledChange, onSortChange, onGroupChange, onHideEmptySessionsChange, + onSessionLimitChange, onReset }: { agents: readonly AiVaultAgent[] sort: AiVaultSort group: AiVaultGroup hideEmptySessions: boolean + sessionLimit: AiVaultSessionLimit adjustmentCount: number onAgentEnabledChange: (agent: AiVaultAgent, enabled: boolean) => void onAllAgentsEnabledChange: (enabled: boolean) => void onSortChange: (sort: AiVaultSort) => void onGroupChange: (group: AiVaultGroup) => void onHideEmptySessionsChange: (hideEmptySessions: boolean) => void + onSessionLimitChange: (limit: AiVaultSessionLimit) => void onReset: () => void }): React.JSX.Element { const allAgentsSelected = agents.length === AI_VAULT_AGENTS.length @@ -388,6 +365,10 @@ export function VaultViewMenu({ 'Hide empty sessions' )} + {adjustmentCount > 0 ? ( <> @@ -403,12 +384,3 @@ export function VaultViewMenu({ ) } - -export function EmptyState({ title }: { title: string }): React.JSX.Element { - return ( -
- -

{title}

-
- ) -} diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx index 86c728aa8..83b190a08 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanelHeader.tsx @@ -10,6 +10,7 @@ import type { import type { ExecutionHostScope } from '../../../../shared/execution-host' import { VaultHostScopeMenu, VaultScopeSwitch, VaultViewMenu } from './AiVaultPanelControls' import type { AiVaultHostScopeOption } from './ai-vault-host-scope' +import type { AiVaultSessionLimit } from './ai-vault-session-limit' type AiVaultPanelHeaderProps = { query: string @@ -26,6 +27,7 @@ type AiVaultPanelHeaderProps = { sort: AiVaultSort group: AiVaultGroup hideEmptySessions: boolean + sessionLimit: AiVaultSessionLimit adjustmentCount: number onQueryChange: (query: string) => void onScopeChange: (scope: AiVaultScope) => void @@ -35,6 +37,7 @@ type AiVaultPanelHeaderProps = { onSortChange: (sort: AiVaultSort) => void onGroupChange: (group: AiVaultGroup) => void onHideEmptySessionsChange: (hideEmptySessions: boolean) => void + onSessionLimitChange: (limit: AiVaultSessionLimit) => void onReset: () => void onRefresh: () => void } @@ -54,6 +57,7 @@ export function AiVaultPanelHeader({ sort, group, hideEmptySessions, + sessionLimit, adjustmentCount, onQueryChange, onScopeChange, @@ -63,6 +67,7 @@ export function AiVaultPanelHeader({ onSortChange, onGroupChange, onHideEmptySessionsChange, + onSessionLimitChange, onReset, onRefresh }: AiVaultPanelHeaderProps): React.JSX.Element { @@ -119,12 +124,14 @@ export function AiVaultPanelHeader({ sort={sort} group={group} hideEmptySessions={hideEmptySessions} + sessionLimit={sessionLimit} adjustmentCount={adjustmentCount} onAgentEnabledChange={onAgentEnabledChange} onAllAgentsEnabledChange={onAllAgentsEnabledChange} onSortChange={onSortChange} onGroupChange={onGroupChange} onHideEmptySessionsChange={onHideEmptySessionsChange} + onSessionLimitChange={onSessionLimitChange} onReset={onReset} />