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