From cc732bbf00a38903dbf8d21344d52932da3415b9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 24 Aug 2026 00:10:21 -0700 Subject: [PATCH] fix(ai-vault): replace scanner internals with actionable panel copy Agent Session History painted the scanner's supervision errors verbatim: "AI Vault service restart circuit is open." and "AI Vault service timed out after 130000ms." Neither tells a user what happened or what to do. Add a shared mapper that rewrites the supervision family into copy tied to an action, passing anything unrecognized through so scanner-authored messages (host name, remote path, cap) keep their own wording. It also strips Electron's `Error invoking remote method` wrapper, which this path never handled. Applied at both surfaces the panel paints: the local leg's scan-issue row and the thrown-rejection banner. Humanizing in main covers remote clients too; the raw text moves to the main log. Also make "Refresh to try again" true. The relay path lets a forced refresh reopen the restart circuit, but on the local path `force` stopped at the cache layer and never reached the supervisor, so the refresh button was inert for the full 60s fault window. The client file sat at 299/300 lines, so extract the child-listener and init-frame wiring into `attachAiVaultServiceChild`, next to the ready-waiter and retirement helpers it belongs with, rather than bumping the max-lines ceiling. --- src/main/ai-vault/cached-session-list.ts | 4 + .../ai-vault/session-scanner-background.ts | 11 +++ .../session-scanner-service-client-state.ts | 31 +++++++- .../session-scanner-service-client.test.ts | 47 ++++++++++++ .../session-scanner-service-client.ts | 22 +++--- .../session-scanner-service-restart-policy.ts | 5 ++ .../ai-vault/session-scanner-service-spawn.ts | 5 ++ src/main/ipc/ai-vault.ts | 7 +- .../right-sidebar/ai-vault-session-refresh.ts | 3 +- .../ai-vault-scan-error-message.test.ts | 75 +++++++++++++++++++ src/shared/ai-vault-scan-error-message.ts | 50 +++++++++++++ 11 files changed, 242 insertions(+), 18 deletions(-) create mode 100644 src/shared/ai-vault-scan-error-message.test.ts create mode 100644 src/shared/ai-vault-scan-error-message.ts diff --git a/src/main/ai-vault/cached-session-list.ts b/src/main/ai-vault/cached-session-list.ts index 960bbc5576c..5ecb45f3469 100644 --- a/src/main/ai-vault/cached-session-list.ts +++ b/src/main/ai-vault/cached-session-list.ts @@ -1,5 +1,6 @@ import { join } from 'node:path' import { + clearAiVaultBackgroundRestartCircuit, resetAiVaultScannerBackgroundForTests, scanAiVaultSessionsInBackground } from './session-scanner-background' @@ -56,6 +57,9 @@ export async function listAiVaultSessions( const depth = requestedAiVaultSessionDepth(args) const scanKey = JSON.stringify({ key, depth }) const now = Date.now() + if (args?.force === true) { + clearAiVaultBackgroundRestartCircuit() + } // Why: opening this panel repeatedly should not re-parse hundreds of JSONL // transcripts; explicit refreshes bypass the cache and preempt stale scans. if ( diff --git a/src/main/ai-vault/session-scanner-background.ts b/src/main/ai-vault/session-scanner-background.ts index fb80afa2def..e1f7b76b971 100644 --- a/src/main/ai-vault/session-scanner-background.ts +++ b/src/main/ai-vault/session-scanner-background.ts @@ -9,6 +9,7 @@ import { type ReadAiVaultFirstUserPromptResult } from './session-first-user-prompt-read' import { + clearAiVaultServiceRestartCircuit, invalidateAiVaultServiceCache, listAiVaultSubagentSessionsInService, readAiVaultFirstUserPromptInService, @@ -36,6 +37,16 @@ export function shouldUseAiVaultServiceProcess(): boolean { return process.env.NODE_ENV !== 'test' } +/** + * Without this a scan that tripped the circuit leaves the panel refusing every + * refresh for the rest of the fault window, with no way for the user to retry. + */ +export function clearAiVaultBackgroundRestartCircuit(): void { + if (shouldUseAiVaultServiceProcess()) { + clearAiVaultServiceRestartCircuit() + } +} + export function scanAiVaultSessionsInBackground( options: AiVaultWorkerScanOptions, signal?: AbortSignal diff --git a/src/main/ai-vault/session-scanner-service-client-state.ts b/src/main/ai-vault/session-scanner-service-client-state.ts index ea56bd534f5..9b64431e219 100644 --- a/src/main/ai-vault/session-scanner-service-client-state.ts +++ b/src/main/ai-vault/session-scanner-service-client-state.ts @@ -1,8 +1,9 @@ import type { ChildProcess } from 'node:child_process' -import type { - AiVaultServiceInit, - AiVaultServiceLane, - AiVaultServiceRequest +import { + AI_VAULT_SERVICE_PROTOCOL_VERSION, + type AiVaultServiceInit, + type AiVaultServiceLane, + type AiVaultServiceRequest } from './session-scanner-service-protocol' export const AI_VAULT_SERVICE_READY_TIMEOUT_MS = 5_000 @@ -110,6 +111,28 @@ export function armAiVaultServiceCancellationTimeout( * A cold start that faults before the request reached the child self-heals on * the scheduled respawn. Requeue once; the caller rejects when this returns false. */ +/** Wires a freshly forked child to the client's callbacks and hands it the init frame. */ +export function attachAiVaultServiceChild( + child: ChildProcess, + init: AiVaultServiceClientOptions['init'], + handlers: { + onMessage: (message: unknown) => void + onFault: (error: Error) => void + onStderr?: (text: string) => void + } +): void { + child.on('message', handlers.onMessage) + child.on('error', handlers.onFault) + child.on('disconnect', () => handlers.onFault(new Error('AI Vault service disconnected.'))) + child.on('exit', (code) => handlers.onFault(new Error(`AI Vault service exited (${code}).`))) + child.stderr?.on('data', (chunk: Buffer) => handlers.onStderr?.(String(chunk))) + child.send({ + type: 'init', + protocol: AI_VAULT_SERVICE_PROTOCOL_VERSION, + ...init + } satisfies AiVaultServiceInit) +} + export function requeueAiVaultServiceStart( call: AiVaultServicePendingCall, queue: AiVaultServicePendingCall[] diff --git a/src/main/ai-vault/session-scanner-service-client.test.ts b/src/main/ai-vault/session-scanner-service-client.test.ts index 6dd415cafa5..69248d5fc21 100644 --- a/src/main/ai-vault/session-scanner-service-client.test.ts +++ b/src/main/ai-vault/session-scanner-service-client.test.ts @@ -181,6 +181,53 @@ describe('AiVaultScannerServiceClient', () => { client.dispose() }) + it('lets a forced refresh reopen the circuit instead of waiting out the window', async () => { + vi.useFakeTimers() + const children: AiVaultServiceTestChild[] = [] + const client = new AiVaultScannerServiceClient({ + processFactory: () => { + const child = new AiVaultServiceTestChild(22_345 + children.length) + children.push(child) + return child.asChildProcess() + }, + init: { sessionParseCache: null } + }) + // Each request retries its cold start once, so two requests spend the three + // faults the circuit breaker needs. + const first = client.request({ type: 'request', operation: 'titles', requests: [] }) + vi.advanceTimersByTime(AI_VAULT_SERVICE_READY_TIMEOUT_MS) + await Promise.resolve() + vi.advanceTimersByTime(250) + await Promise.resolve() + vi.advanceTimersByTime(AI_VAULT_SERVICE_READY_TIMEOUT_MS) + await expect(first).rejects.toThrow('did not become ready') + vi.advanceTimersByTime(1_000) + await Promise.resolve() + + const blocked = client.request({ type: 'request', operation: 'titles', requests: [] }) + vi.advanceTimersByTime(AI_VAULT_SERVICE_READY_TIMEOUT_MS) + await Promise.resolve() + vi.advanceTimersByTime(5_000) + await expect(blocked).rejects.toThrow('circuit is open') + expect(children).toHaveLength(3) + + client.clearRestartCircuit() + const retried = client.request({ type: 'request', operation: 'titles', requests: [] }) + await vi.waitFor(() => expect(children).toHaveLength(4)) + readyAiVaultServiceChild(children[3]!) + await vi.waitFor(() => + expect(children[3]!.sent).toContainEqual(expect.objectContaining({ operation: 'titles' })) + ) + children[3]!.emit('message', { + type: 'result', + id: aiVaultServiceRequestId(children[3]!, 'titles'), + operation: 'titles', + value: { titles: [] } + }) + await expect(retried).resolves.toEqual({ titles: [] }) + client.dispose() + }) + it('acknowledges cache invalidation through the running child', async () => { const { child, client } = setup() const invalidation = client.invalidate(['/tmp/deleted.jsonl']) diff --git a/src/main/ai-vault/session-scanner-service-client.ts b/src/main/ai-vault/session-scanner-service-client.ts index 4f46dc643c5..2e7d169fde8 100644 --- a/src/main/ai-vault/session-scanner-service-client.ts +++ b/src/main/ai-vault/session-scanner-service-client.ts @@ -9,6 +9,7 @@ import { AiVaultServiceIdleRetirement, AiVaultServiceInvalidations, armAiVaultServiceCancellationTimeout, + attachAiVaultServiceChild, clearAiVaultServiceCall, createAiVaultServiceReadyWaiter, rejectAiVaultServiceCall, @@ -20,11 +21,9 @@ import { } from './session-scanner-service-client-state' import { AiVaultServiceRestartPolicy } from './session-scanner-service-restart-policy' import { - AI_VAULT_SERVICE_PROTOCOL_VERSION, aiVaultServiceLane, isAiVaultServiceChildMessage, type AiVaultServiceChildMessage, - type AiVaultServiceInit, type AiVaultServiceRequest, type AiVaultServiceRequestBody, type AiVaultServiceResultValue @@ -77,6 +76,10 @@ export class AiVaultScannerServiceClient { }) } + clearRestartCircuit(): void { + this.restartPolicy.clearCircuit() + } + async invalidate(paths: string[]): Promise { if (paths.length === 0 || this.disposed) { return @@ -199,16 +202,11 @@ export class AiVaultScannerServiceClient { this.onFault(new Error('AI Vault service did not become ready.')) ) this.readyWaiter = waiter - child.on('message', (message) => this.onMessage(message)) - child.on('error', (error) => this.onFault(error)) - child.on('disconnect', () => this.onFault(new Error('AI Vault service disconnected.'))) - child.on('exit', (code) => this.onFault(new Error(`AI Vault service exited (${code}).`))) - child.stderr?.on('data', (chunk: Buffer) => this.options.onStderr?.(String(chunk))) - child.send({ - type: 'init', - protocol: AI_VAULT_SERVICE_PROTOCOL_VERSION, - ...this.options.init - } satisfies AiVaultServiceInit) + attachAiVaultServiceChild(child, this.options.init, { + onMessage: (message) => this.onMessage(message), + onFault: (error) => this.onFault(error), + onStderr: this.options.onStderr + }) return waiter.promise } diff --git a/src/main/ai-vault/session-scanner-service-restart-policy.ts b/src/main/ai-vault/session-scanner-service-restart-policy.ts index 605e6c9731b..9d5e1452acc 100644 --- a/src/main/ai-vault/session-scanner-service-restart-policy.ts +++ b/src/main/ai-vault/session-scanner-service-restart-policy.ts @@ -12,6 +12,11 @@ export class AiVaultServiceRestartPolicy { return this.timer !== null } + /** A forced refresh is a deliberate user action, so it reopens the circuit. */ + clearCircuit(): void { + this.circuitUntil = 0 + } + startError(): Error | null { return this.now() < this.circuitUntil ? new Error('AI Vault service restart circuit is open.') diff --git a/src/main/ai-vault/session-scanner-service-spawn.ts b/src/main/ai-vault/session-scanner-service-spawn.ts index 9fd20cfe57e..3ba12322734 100644 --- a/src/main/ai-vault/session-scanner-service-spawn.ts +++ b/src/main/ai-vault/session-scanner-service-spawn.ts @@ -85,6 +85,11 @@ export function invalidateAiVaultServiceCache(paths: string[]): Promise { return sharedClient?.invalidate(paths) ?? Promise.resolve() } +/** A forced refresh is a deliberate user action, so it reopens the restart circuit. */ +export function clearAiVaultServiceRestartCircuit(): void { + sharedClient?.clearRestartCircuit() +} + export function resetAiVaultScannerServiceForTests(): void { sharedClient?.dispose() sharedClient = null diff --git a/src/main/ipc/ai-vault.ts b/src/main/ipc/ai-vault.ts index f54a8edbf23..198c5295662 100644 --- a/src/main/ipc/ai-vault.ts +++ b/src/main/ipc/ai-vault.ts @@ -15,6 +15,7 @@ import { import { scanSshAiVaultSessions } from '../ai-vault/ssh-session-list' import { AiVaultScanCoordinator } from '../ai-vault/ai-vault-scan-coordinator' import type { AiVaultDeleteSessionArgs } from '../../shared/ai-vault-session-deletion' +import { describeAiVaultScanError } from '../../shared/ai-vault-scan-error-message' import { AI_VAULT_SCOPE_PATHS_MAX_COUNT, isAiVaultScanCancelledError, @@ -237,10 +238,14 @@ async function scanLocalAiVaultSessionsAsIssue( if (isAiVaultScanCancelledError(error)) { throw error } + // Raw supervision text ("restart circuit is open") means nothing to a user, + // so the row carries actionable copy and the log keeps the original. + const raw = error instanceof Error ? error.message : 'Local session scan failed.' + console.error('[ai-vault] local session scan failed:', raw) return aiVaultScanIssueResult({ executionHostId: LOCAL_EXECUTION_HOST_ID, path: 'this computer', - message: error instanceof Error ? error.message : 'Local session scan failed.' + message: describeAiVaultScanError(raw) }) } } diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts index 0c64607853e..b1f72178b24 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts @@ -4,6 +4,7 @@ import { type AiVaultListResult, type AiVaultSession } from '../../../../shared/ai-vault-types' +import { describeAiVaultScanError } from '../../../../shared/ai-vault-scan-error-message' import { ALL_EXECUTION_HOSTS_SCOPE, requestedExecutionHostScope, @@ -193,7 +194,7 @@ export function useAiVaultSessionRefresh( refreshIdRef.current === refreshId && scanKey === currentScanScopeKey() ) { - setError(err instanceof Error ? err.message : String(err)) + setError(describeAiVaultScanError(err instanceof Error ? err.message : String(err))) } } finally { refreshInFlightRef.current = false diff --git a/src/shared/ai-vault-scan-error-message.test.ts b/src/shared/ai-vault-scan-error-message.test.ts new file mode 100644 index 00000000000..44124df72b5 --- /dev/null +++ b/src/shared/ai-vault-scan-error-message.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest' +import { describeAiVaultScanError } from './ai-vault-scan-error-message' + +describe('describeAiVaultScanError', () => { + it('replaces the restart-circuit wording with an actionable line', () => { + expect(describeAiVaultScanError('AI Vault service restart circuit is open.')).toBe( + 'Session scanning paused after repeated failures. Refresh to try again.' + ) + }) + + it('renders the scan timeout in seconds', () => { + expect(describeAiVaultScanError('AI Vault service timed out after 130000ms.')).toBe( + 'Session scan timed out after 130s — the machine may be busy. Refresh to try again.' + ) + }) + + it('keeps the SSH host in the timeout copy', () => { + expect( + describeAiVaultScanError( + 'Agent Session History scan timed out after 130000ms on this SSH host.' + ) + ).toBe('Session scan timed out after 130s on this SSH host. Refresh to try again.') + }) + + it('humanizes the relay-hosted variant of the same fault', () => { + expect(describeAiVaultScanError('Relay AI Vault service restart circuit is open.')).toBe( + 'Session scanning paused after repeated failures. Refresh to try again.' + ) + }) + + it('strips the Electron invoke wrapper before matching', () => { + expect( + describeAiVaultScanError( + "Error invoking remote method 'aiVault:listSessions': Error: AI Vault service did not become ready." + ) + ).toBe('The session scanner stopped unexpectedly. Refresh to try again.') + }) + + it.each([ + ['AI Vault service disconnected.', 'The session scanner stopped unexpectedly.'], + ['AI Vault service exited (null).', 'The session scanner stopped unexpectedly.'], + ['AI Vault scanner worker exited with code 1.', 'The session scanner stopped unexpectedly.'], + ['AI Vault service sent a malformed message.', 'The session scanner stopped unexpectedly.'] + ])('covers the supervision fault %s', (raw, expected) => { + expect(describeAiVaultScanError(raw)).toBe(`${expected} Refresh to try again.`) + }) + + it('routes a missing scanner entry to install guidance, not a retry', () => { + const described = describeAiVaultScanError('AI Vault service entry not found: /a/b.js') + expect(described).toBe( + 'The session scanner is missing from this Orca install. Reinstalling Orca restores it.' + ) + expect(described).not.toContain('Refresh') + }) + + it('never leaks internal vocabulary a user cannot act on', () => { + for (const raw of [ + 'AI Vault service restart circuit is open.', + 'AI Vault service timed out after 130000ms.', + 'AI Vault service queue is full.', + 'AI Vault service cache invalidation timed out.', + 'AI Vault service did not cancel within 2000ms.', + 'AI Vault service client was disposed.' + ]) { + const described = describeAiVaultScanError(raw) + expect(described).not.toMatch(/circuit|ms\.|AI Vault service|scanner worker/) + } + }) + + it('passes scanner-authored messages through untouched', () => { + const authored = 'Could not read /Users/x/.codex/sessions: EACCES' + expect(describeAiVaultScanError(authored)).toBe(authored) + expect(describeAiVaultScanError('SSH relay is not ready')).toBe('SSH relay is not ready') + }) +}) diff --git a/src/shared/ai-vault-scan-error-message.ts b/src/shared/ai-vault-scan-error-message.ts new file mode 100644 index 00000000000..80c0c0e484b --- /dev/null +++ b/src/shared/ai-vault-scan-error-message.ts @@ -0,0 +1,50 @@ +/** + * Maps the session scanner's internal supervision errors onto copy a user can + * act on. Applied at both surfaces the panel paints: the local leg's scan-issue + * row and the thrown-rejection banner. Anything unrecognized passes through, so + * scanner-authored messages (host name, remote path, cap) keep their own wording. + */ +const RETRY = 'Refresh to try again.' + +/** Electron wraps every rejected `ipcMain.handle` before the renderer sees it. */ +const IPC_INVOKE_PREFIX = /^Error invoking remote method '[^']*': (?:\w*Error: )?/ +/** Relay-hosted scans reuse the local wording behind a `Relay ` prefix. */ +const RELAY_PREFIX = /^Relay (?=AI Vault )/ +/** Both the fork-based service and the legacy worker thread emit this family. */ +const SCANNER = String.raw`AI Vault (?:service|scanner worker)` + +function toSeconds(ms: string): number { + return Math.max(1, Math.round(Number(ms) / 1000)) +} + +function humanize(text: string): string | null { + const sshTimeout = /^Agent Session History scan timed out after (\d+)ms on this SSH host\.$/.exec( + text + ) + if (sshTimeout) { + return `Session scan timed out after ${toSeconds(sshTimeout[1]!)}s on this SSH host. ${RETRY}` + } + const timeout = new RegExp(String.raw`^${SCANNER} timed out after (\d+)ms\.$`).exec(text) + if (timeout) { + return `Session scan timed out after ${toSeconds(timeout[1]!)}s — the machine may be busy. ${RETRY}` + } + if (text === 'AI Vault service restart circuit is open.') { + return `Session scanning paused after repeated failures. ${RETRY}` + } + if (new RegExp(String.raw`^${SCANNER} (?:client )?queue is full\.$`).test(text)) { + return 'Too many session scans in flight. Wait a moment, then refresh.' + } + if (new RegExp(String.raw`^${SCANNER} entry not found: `).test(text)) { + return 'The session scanner is missing from this Orca install. Reinstalling Orca restores it.' + } + if (new RegExp(String.raw`^${SCANNER}\b`).test(text)) { + return `The session scanner stopped unexpectedly. ${RETRY}` + } + return null +} + +/** Returns user-facing copy, or the original text when it is already meaningful. */ +export function describeAiVaultScanError(raw: string): string { + const text = raw.replace(IPC_INVOKE_PREFIX, '').replace(RELAY_PREFIX, '') + return humanize(text) ?? raw +}