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.
This commit is contained in:
Brennan Benson
2026-08-24 00:10:36 -07:00
parent afd76a4df9
commit cc732bbf00
11 changed files with 242 additions and 18 deletions
+4
View File
@@ -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 (
@@ -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
@@ -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[]
@@ -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'])
@@ -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<void> {
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
}
@@ -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.')
@@ -85,6 +85,11 @@ export function invalidateAiVaultServiceCache(paths: string[]): Promise<void> {
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
+6 -1
View File
@@ -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)
})
}
}
@@ -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
@@ -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')
})
})
+50
View File
@@ -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
}