mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
feat(ai-vault): isolate scanning from terminal workloads (#13411)
* feat(ai-vault): isolate scanning in service processes * fix(ai-vault): retire idle service processes * fix(ai-vault): discard unverified cache processes * fix(ai-vault): clear relay sidecar cancel watchdog on acknowledgement A cancelled relay call is settled before its 2s cancel watchdog is armed, so the acknowledgement path bailed out of settle() before clearing the timer. The watchdog then faulted a healthy sidecar two seconds after every aborted scan, killing whatever request had since become active. * fix(ai-vault): clear the pending restart before scheduling another recordFault overwrote this.timer, stranding a restart that dispose() could no longer cancel. * refactor(ai-vault): drop the orphaned first-prompt IPC wrapper session-first-user-prompt-handler.ts now owns this entry point and routes through the service; the copy left in the read module had no callers. * fix(ai-vault): retry a faulted cold start before surfacing it A slow first start surfaced a raw 'did not become ready' error to the caller even though the supervisor was already respawning. Requeue an unsent call once onto the scheduled respawn instead. Also stop arming the cancellation watchdog for a call the child never received: no acknowledgement is coming, so it killed a healthy service and stalled the lane. Invalidation bookkeeping and ready-waiter construction move to the state module to stay under the max-lines cap. * fix(ai-vault): give relay title reads their own lane Before this branch the relay read title files directly, concurrently with scans. Routing both through one sidecar lane put title resolution behind a list scan that may run up to 130s, so SSH tab titles could lag minutes behind. Split cache and interactive lanes in both the relay client and the sidecar entry, mirroring the desktop service. Also: clear the ready deadline on fault, so a sidecar that dies before ready cannot fault its healthy replacement five seconds later; retry an unsent call once across a respawn; and skip the cancellation watchdog for a call the sidecar never received. Restart/circuit bookkeeping moves to its own module, mirroring the desktop policy, to stay under the max-lines cap. * fix(ai-vault): degrade relay title resolution on sidecar failure listSessions already returns a host issue when the sidecar is unavailable; titles propagated the raw RPC error instead. Return no titles so callers fall back to preview text, and keep cancellation propagating. * fix(ai-vault): scrub the service child environment The children are forked with a 384 MiB heap cap and no loader, but both spawn sites handed them the full parent environment, so an exported NODE_OPTIONS silently raised the cap or --require'd code into them. Allowlist both, following the plugin worker. The desktop child keeps the eleven agent-root overrides it resolves its own roots from; the relay sidecar takes remoteHome and hostPlatform from its init message and so needs none of them. Both children share one priority module while they share this one. * fix(ai-vault): soft-disable relay vault when the service is missing A missing service threw out of the constructor, so a Vault wiring bug would abort relay startup and take every PTY on the host with it. The unsupported-platform branch three lines above already treats a Vault failure as a soft disable; do the same here. Threading the service through the two handlers instead of a field also retires the definite-assignment assertion the throw was propping up. * fix(ai-vault): drain consumed cache invalidations invalidatedPaths was re-applied in every request's finally and never drained, so once N paths had been invalidated every later request paid N evictions for the life of the process; the 4096 cap only bounded how bad that got. The re-apply exists to cover a read that overlapped the invalidation, so drain once nothing is executing. Clearing unconditionally would drop the re-apply for a request still running on the other lane. * fix(ai-vault): keep a busy child through slow invalidation acks invalidate() reused the 5s ready budget as its acknowledgement deadline and killed the child on expiry, so a delete issued during a large scan could kill a healthy process mid-scan and burn a slot toward the restart circuit. Fault only when nothing is executing. Fork IPC ordering already puts the invalidation ahead of any later request, so a busy child owes no ack here, and the 130s/15s request deadlines still catch a wedged one. The start-retry predicate moves to the state module to stay under the line cap, matching the shape the relay client already uses. * fix(ai-vault): report a failed local scan as a host issue A local-scope scan let its error escape to the renderer, which paints it over the session list. Service supervision now produces those errors, so "AI Vault service restart circuit is open." replaced the list. Route local scope through the degradation the all-hosts leg and every SSH leg already use, so it lands as a retryable host issue row instead. Same result shape either way, so no IPC or wire contract changes. * test(ai-vault): cover the relay restart circuit transitions The relay policy shipped without tests. Pin both circuit edges, the aging-out case, the forced-refresh reopen the relay has and the desktop does not, and the backoff schedule. * fix(ai-vault): keep the OpenCode roots in the service child env The scrubbed allowlist dropped XDG_DATA_HOME and OPENCODE_DB, which the child reads to locate the OpenCode store and database. The pre-PR worker thread inherited them, so a user who sets either lost every OpenCode session. * test(ai-vault): anchor the service spawn env assertion
This commit is contained in:
@@ -94,6 +94,7 @@ docs/**
|
||||
!docs/readme/
|
||||
!docs/readme/**
|
||||
!docs/STYLEGUIDE.md
|
||||
!docs/ai-vault-process-isolation-plan.md
|
||||
!docs/mobile-terminal-shortcut-bar.md
|
||||
!docs/reference/
|
||||
!docs/reference/git-compatibility.md
|
||||
|
||||
@@ -182,6 +182,7 @@ module.exports = {
|
||||
'out/main/grok/**',
|
||||
'out/main/hermes/**',
|
||||
'out/main/daemon-entry.js',
|
||||
'out/main/session-scanner-service-entry.js',
|
||||
'out/main/plugin-host-entry.js',
|
||||
'out/main/computer-sidecar.js',
|
||||
'out/main/parcel-watcher-process-entry.js',
|
||||
|
||||
@@ -18,6 +18,7 @@ const __dirname = import.meta.dirname
|
||||
const ROOT = join(__dirname, '..', '..')
|
||||
const RELAY_ENTRY = join(ROOT, 'src', 'relay', 'relay.ts')
|
||||
const WATCHER_ENTRY = join(ROOT, 'src', 'main', 'ipc', 'parcel-watcher-process-entry.ts')
|
||||
const AI_VAULT_SERVICE_ENTRY = join(ROOT, 'src', 'relay', 'ai-vault-service-entry.ts')
|
||||
const MANAGED_HOOK_RUNTIME_ENTRY = join(
|
||||
ROOT,
|
||||
'src',
|
||||
@@ -88,6 +89,21 @@ for (const platform of PLATFORMS) {
|
||||
}
|
||||
})
|
||||
|
||||
await build({
|
||||
entryPoints: [AI_VAULT_SERVICE_ENTRY],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
target: 'node18',
|
||||
format: 'cjs',
|
||||
outfile: join(outDir, 'relay-ai-vault-service.js'),
|
||||
external: ['electron'],
|
||||
sourcemap: false,
|
||||
minify: true,
|
||||
define: {
|
||||
'process.env.NODE_ENV': '"production"'
|
||||
}
|
||||
})
|
||||
|
||||
await build({
|
||||
entryPoints: [MANAGED_HOOK_RUNTIME_ENTRY],
|
||||
bundle: true,
|
||||
@@ -110,10 +126,12 @@ for (const platform of PLATFORMS) {
|
||||
// so a companion-only change always deploys beside the matching relay host.
|
||||
const relayContent = readFileSync(join(outDir, 'relay.js'))
|
||||
const watcherContent = readFileSync(join(outDir, 'relay-watcher.js'))
|
||||
const aiVaultServiceContent = readFileSync(join(outDir, 'relay-ai-vault-service.js'))
|
||||
const managedHookRuntimeContent = readFileSync(join(outDir, 'managed-hook-runtime.js'))
|
||||
const hash = createHash('sha256')
|
||||
.update(relayContent)
|
||||
.update(watcherContent)
|
||||
.update(aiVaultServiceContent)
|
||||
.update(managedHookRuntimeContent)
|
||||
// Why: changing the remote node-pty patch must select a fresh immutable Windows relay directory.
|
||||
if (platform.startsWith('win32-')) {
|
||||
|
||||
@@ -197,8 +197,13 @@ describe('Electron runtime package contract', () => {
|
||||
expect(relayBuild).toContain("'parcel-watcher-process-entry.ts'")
|
||||
expect(relayBuild).toContain("outfile: join(outDir, 'relay-watcher.js')")
|
||||
expect(relayBuild).toContain("readFileSync(join(outDir, 'relay-watcher.js'))")
|
||||
expect(relayBuild).toContain("outfile: join(outDir, 'relay-ai-vault-service.js')")
|
||||
expect(relayBuild).toContain("readFileSync(join(outDir, 'relay-ai-vault-service.js'))")
|
||||
expect(builderConfig).toContain("from: 'out/relay'")
|
||||
expect(remoteCommands).toContain("joinRemotePath(host, remoteRelayDir, 'relay-watcher.js')")
|
||||
expect(remoteCommands).toContain(
|
||||
"joinRemotePath(host, remoteRelayDir, 'relay-ai-vault-service.js')"
|
||||
)
|
||||
|
||||
const assertRelayGate = (steps, publishStepName) => {
|
||||
const names = steps.map((step) => step.name)
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
|
||||
const npxCommand = process.platform === 'win32' ? 'npx.cmd' : 'npx'
|
||||
const knobByFlag = {
|
||||
'--iterations': 'ORCA_AI_VAULT_BENCH_ITERATIONS',
|
||||
'--sessions': 'ORCA_AI_VAULT_BENCH_SESSIONS',
|
||||
'--payload-kib': 'ORCA_AI_VAULT_BENCH_PAYLOAD_KIB',
|
||||
'--keys': 'ORCA_AI_VAULT_BENCH_KEYS',
|
||||
'--cadence-ms': 'ORCA_AI_VAULT_BENCH_CADENCE_MS',
|
||||
'--label': 'ORCA_AI_VAULT_BENCH_LABEL'
|
||||
}
|
||||
|
||||
const env = { ...process.env, ORCA_AI_VAULT_TYPING_BENCH: '1' }
|
||||
const passthroughArgs = []
|
||||
const argv = process.argv.slice(2)
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
if (argv[index] === '--') {
|
||||
continue
|
||||
}
|
||||
const knob = knobByFlag[argv[index]]
|
||||
if (knob) {
|
||||
env[knob] = argv[++index]
|
||||
} else {
|
||||
passthroughArgs.push(argv[index])
|
||||
}
|
||||
}
|
||||
|
||||
const child = spawn(
|
||||
npxCommand,
|
||||
[
|
||||
'playwright',
|
||||
'test',
|
||||
'tests/e2e/terminal-ai-vault-typing-latency.spec.ts',
|
||||
'--config',
|
||||
'tests/playwright.config.ts',
|
||||
'--project',
|
||||
'electron-headless',
|
||||
'--workers=1',
|
||||
...passthroughArgs
|
||||
],
|
||||
{ stdio: 'inherit', env }
|
||||
)
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal)
|
||||
return
|
||||
}
|
||||
process.exit(code ?? 1)
|
||||
})
|
||||
@@ -153,6 +153,7 @@ function prepareRelayTree(runDir, nodePtyDir) {
|
||||
for (const filename of [
|
||||
'relay.js',
|
||||
'relay-watcher.js',
|
||||
'relay-ai-vault-service.js',
|
||||
'managed-hook-runtime.js',
|
||||
NODE_PTY_PATCH_FILENAME,
|
||||
'.version'
|
||||
|
||||
@@ -0,0 +1,596 @@
|
||||
# AI Vault process isolation architecture and implementation plan
|
||||
|
||||
Status: implemented behind the documented desktop/runtime kill switch, with local build, parity, Electron, and A/B validation complete.
|
||||
|
||||
Last updated: 2026-08-09.
|
||||
|
||||
## Decision summary
|
||||
|
||||
AI Vault will remain part of the integrated Orca renderer, but its host-side work will move behind a persistent service-process boundary.
|
||||
|
||||
The target has three rules:
|
||||
|
||||
1. The desktop and Orca runtime route local Vault scans and title resolution to one lazy, supervised Vault service process per host process.
|
||||
2. The SSH relay routes Vault work to a relay-side Vault service process. The relay event loop that handles PTYs must not scan or parse Vault data.
|
||||
3. The renderer publishes completed Vault results at low priority after terminal input is quiet. xterm and the Vault panel remain in the same renderer.
|
||||
|
||||
This is deliberately not a separate `WebContentsView`, window, or embedded app. The measurements below show bounded CPU contention under an exaggerated scan, but no renderer long tasks and no evidence that a second Chromium renderer is justified.
|
||||
|
||||
The public Electron IPC, runtime RPC, and SSH relay method names and result meanings remain unchanged in the initial migration. The process boundary is host-internal.
|
||||
|
||||
## Goals
|
||||
|
||||
- A Vault scan, parser fault, cache fault, or memory spike cannot crash the terminal daemon, relay PTY loop, Electron main process, or runtime host.
|
||||
- Terminal input remains responsive while a local, runtime, or SSH Vault refresh is active.
|
||||
- Vault work has bounded concurrency, queue depth, memory, result size, cancellation latency, and wall time.
|
||||
- The service can be restarted without restarting the app, runtime, relay, or terminals.
|
||||
- Existing local, folder-workspace, WSL, runtime, and SSH behavior remains compatible.
|
||||
- Mixed-version remote peers continue to work.
|
||||
- The architecture is conventional and debuggable: integrated UI, thin routers, dedicated background services.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Treating the service process as a security sandbox. It runs trusted Orca code with the same user identity.
|
||||
- Rewriting the scanner or changing session discovery semantics during the process migration.
|
||||
- Moving xterm into another renderer.
|
||||
- Adding a process per window, worktree, repository, or SSH request.
|
||||
- Removing the old-relay compatibility fallback in the same change.
|
||||
- Making pagination a public wire dependency before mixed-version fallback behavior exists.
|
||||
|
||||
## Current architecture and coupling
|
||||
|
||||
| Path | Current execution | Remaining coupling |
|
||||
| --------------------------------------- | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Desktop local scan and title resolution | Persistent `worker_threads.Worker` from `session-scanner-worker-spawn.ts` | Separate V8 isolate, but the same OS process, priority class, failure domain, and overall memory accounting as Electron main |
|
||||
| Orca runtime scan and title resolution | The same worker client used by runtime RPC methods | Same-process CPU, memory, and lifecycle coupling with the runtime host |
|
||||
| SSH relay scan and title resolution | `AiVaultHandler` calls the remote scanner and title reader inside `relay.ts` | Vault discovery, reads, parsing, cache work, and PTY routing share one event loop and process |
|
||||
| Old SSH relay fallback | Desktop main crawls the host through the SSH filesystem provider | Compatibility path can consume desktop and SSH multiplexer work; complete remote isolation is impossible without a new relay |
|
||||
| Renderer result publication | `setScanResult(result)` and `setSessions(result.sessions)` immediately | Deserialization, projection, filtering, grouping, and React rendering share the renderer thread with xterm |
|
||||
|
||||
The current local worker already provides meaningful isolation. It allows one active request, bounds the queue at 16, supports cancellation, and uses 130-second scan and 15-second title timeouts. The migration should preserve those properties instead of replacing them with an unbounded child-process API.
|
||||
|
||||
The terminal backend is already a detached daemon process. A Vault service beside it fits the existing process model; it does not require a terminal redesign.
|
||||
|
||||
## Feature-parity contract
|
||||
|
||||
The migration changes execution ownership only. Unless a separate product change is approved, the service-on and worker-fallback paths must be observably equivalent after normalizing `scannedAt` and process metrics.
|
||||
|
||||
| Capability | Required behavior after migration | Owner |
|
||||
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
|
||||
| Agent discovery | Preserve every `AI_VAULT_AGENTS` source: Claude, Codex, Hermes, Pi, OMP, Prime Agent, Cursor, Gemini, Antigravity, Rovo Dev, Copilot, OpenCode, Grok, OpenClaw, Devin, Droid, and Kimi | Service scanner, using existing source adapters |
|
||||
| Session result | Preserve every `AiVaultSession` field, including host/platform identity, paths, Codex home, timestamps, previews, token/message counts, queued/recoverable state, subagent count, resume command, and subagent metadata | Service result builder; router validates/restamps only where it does today |
|
||||
| Scope and depth | Preserve workspace/project/all scoping, guaranteed older in-scope sessions, 250/500/1000/unlimited depth, sorting, deduplication, and issue rows | Service per-host scan; router multi-host merge; renderer view projection |
|
||||
| Host routing | Preserve local, all-host, individual SSH, and individual runtime selection with the same per-host time budgets and partial-failure issue behavior | Router |
|
||||
| Dynamic roots | Preserve environment overrides, managed/per-account Codex homes, runtime Codex homes, WSL default and Orca-owned homes, and every platform-specific agent root | Router resolves dynamic homes per request; service discovers within them |
|
||||
| Refresh semantics | Preserve TTL reuse, force refresh, request-token cancellation, force preemption, coalescing, window-focus refresh, and new-agent-session refresh throttling | Router coordinator plus service cancellation |
|
||||
| Title synchronization | Preserve local, SSH, and runtime Claude/Codex title resolution and the existing input-quiet tab-title gate | Service title operation plus existing host routing |
|
||||
| Subagent expansion | Preserve local-only Claude and OMP child listing, path containment, status, issue rows, and retry behavior. Runtime/SSH/web remain empty until a separately negotiated feature exists | Service interactive operation; router retains current host gate |
|
||||
| First-prompt copy | Preserve local-only on-demand full prompt parsing, OpenCode row lookup, truncation safety, injected-turn suppression, and preview fallback. Runtime/SSH/web remain preview-only | Service interactive operation; router retains current host gate |
|
||||
| Resume | Preserve provider-specific resume commands, Codex-home preparation, account targeting, execution-host targeting, platform quoting, OMP path resume, drag/drop, and new-tab launch | Existing router/renderer paths; they consume an unchanged session result |
|
||||
| Logs and live state | Preserve View Log/Open Log, reveal/copy path, working-directory action, read-only editor behavior, live tail, original-pane jump, and worktree jump | Existing filesystem/editor/native-chat paths; outside the service migration |
|
||||
| Delete | Preserve local-only gating, liveness checks, OS trash/WSL deletion, companion artifacts, idempotency, and rejection reasons | Trusted router executor; invalidate router and service caches on success |
|
||||
| View behavior | Preserve search, agent toggles, sort/group, hide-empty/recoverable-empty logic, persisted view options, collapsed groups, and host/scope controls | Renderer; unchanged apart from publication scheduling |
|
||||
| Compatibility | Preserve current web/runtime limitations, old-relay fallback, mixed-version result meaning, and unavailable-host issue rows | Router and existing public transports |
|
||||
|
||||
Add a backend-equivalence suite that runs the same golden corpus through the current worker and the service process, normalizes only `scannedAt`, and deep-compares the complete results. The corpus must cover every agent, OpenCode SQLite and legacy formats, append-only incremental parsing, recoverable empty sessions, Claude/OMP subagents, managed Codex homes, duplicate Codex roots, WSL-shaped roots, scope inclusion, corrupt/partial files, and issue overflow.
|
||||
|
||||
No field may be dropped to reduce IPC size during the migration. Any later payload reduction requires an independently versioned compatibility plan because resume, drag/drop, log actions, and project ownership consume fields that may not be visibly rendered in the list.
|
||||
|
||||
## Target topology
|
||||
|
||||
```text
|
||||
Desktop / runtime host
|
||||
|
||||
Integrated renderer
|
||||
├─ xterm UI ─────────── terminal IPC/router ───────── terminal daemon
|
||||
└─ AI Vault panel ───── Vault IPC/RPC router ──────── Vault service process
|
||||
│
|
||||
└─ host merge + compatibility routing
|
||||
|
||||
SSH execution host
|
||||
|
||||
Desktop ── existing SSH Vault RPC ── relay router ───── Vault service sidecar
|
||||
└────────────────── PTY handler/event loop
|
||||
```
|
||||
|
||||
The two relay branches share only the relay's bounded request/response routing. Filesystem discovery, transcript reads, JSON parsing, SQLite access, parse caches, and title caches execute in the sidecar.
|
||||
|
||||
## Process ownership
|
||||
|
||||
- Desktop: one Vault service per Electron main process, shared by every window and workspace.
|
||||
- Orca runtime: one Vault service per runtime host process, shared by all connected clients.
|
||||
- SSH relay: one Vault service per live relay daemon, shared across relay reconnects and requests.
|
||||
- WSL: preserve current source ownership initially. The Windows local service invokes the existing WSL-aware adapters; do not add a process per distro in this migration.
|
||||
- Old relay: retain the bounded desktop fallback only when the relay method is unavailable. A new relay whose sidecar fails must return an issue instead of scanning inline on the relay loop.
|
||||
|
||||
## Service responsibilities
|
||||
|
||||
The service owns:
|
||||
|
||||
- Source discovery and filesystem metadata reads.
|
||||
- Transcript streaming and parsing for all supported agents.
|
||||
- OpenCode SQLite reads and its nested worker lifecycle.
|
||||
- In-memory and persisted session parse caches.
|
||||
- The bounded title index and transcript title resolution.
|
||||
- On-demand Claude/OMP subagent listing and full first-user-prompt extraction.
|
||||
- Scan parse concurrency and cooperative cancellation.
|
||||
- Result construction, deduplication, sorting, and internal paging.
|
||||
- Per-request metrics that contain counts and timings, not session content.
|
||||
|
||||
The router owns:
|
||||
|
||||
- Electron IPC, runtime RPC, or relay method registration.
|
||||
- Authentication and connection ownership already enforced by that transport.
|
||||
- Public argument validation and execution-host selection.
|
||||
- Resolution of dynamic host inputs such as managed Codex homes and WSL distro homes; resolved roots are passed with each scan so a long-lived service cannot retain stale account configuration.
|
||||
- Multi-host result merging.
|
||||
- Request coalescing and mapping caller cancellation to the service request.
|
||||
- Validation of service responses before publishing them across a public boundary.
|
||||
- Compatibility fallback selection.
|
||||
- Local deletion, liveness checks, OS trash integration, and cache-invalidation commands sent to the service after a successful mutation.
|
||||
|
||||
The renderer owns only view state, filtering/grouping of the returned page, user actions, and low-priority result publication.
|
||||
|
||||
## Internal process protocol
|
||||
|
||||
Add a versioned, private protocol in `src/main/ai-vault/session-scanner-service-protocol.ts`. It is not a remote wire protocol.
|
||||
|
||||
Parent to service:
|
||||
|
||||
```ts
|
||||
type ServiceRequest =
|
||||
| { type: 'init'; protocol: 1; host: HostDescriptor; cache: CacheOptions }
|
||||
| { type: 'request'; id: number; operation: 'scan'; options: ScanOptions }
|
||||
| { type: 'request'; id: number; operation: 'titles'; requests: TitleRequest[] }
|
||||
| { type: 'request'; id: number; operation: 'subagents'; request: SubagentRequest }
|
||||
| { type: 'request'; id: number; operation: 'firstPrompt'; request: FirstPromptRequest }
|
||||
| { type: 'invalidate'; paths: string[]; generation: number }
|
||||
| { type: 'cancel'; id: number }
|
||||
| { type: 'shutdown' }
|
||||
```
|
||||
|
||||
Service to parent:
|
||||
|
||||
```ts
|
||||
type ServiceResponse =
|
||||
| { type: 'ready'; protocol: 1; pid: number; capabilities: ServiceCapabilities }
|
||||
| { type: 'result'; id: number; operation: 'scan'; value: ScanPage; metrics: ScanMetrics }
|
||||
| { type: 'result'; id: number; operation: 'titles'; value: TitlesResult }
|
||||
| { type: 'result'; id: number; operation: 'subagents'; value: SubagentListResult }
|
||||
| { type: 'result'; id: number; operation: 'firstPrompt'; value: FirstPromptResult }
|
||||
| { type: 'invalidated'; generation: number }
|
||||
| { type: 'error'; id: number; code: ServiceErrorCode; message: string; retryable: boolean }
|
||||
| { type: 'fatal'; code: ServiceErrorCode; message: string }
|
||||
```
|
||||
|
||||
Protocol rules:
|
||||
|
||||
- Validate every incoming and outgoing message. Ignore an unknown response ID and terminate on a protocol-version mismatch.
|
||||
- Use monotonically increasing safe-integer request IDs scoped to the child lifetime.
|
||||
- Preserve the existing shared limits for request counts, scope-path count and length, title requests, and session depth.
|
||||
- Keep one cache-mutating lane for scans and title resolution, plus one independent interactive-read lane for subagent and first-prompt reads. Title resolution stays serialized with scans because both mutate the shared parse/title caches; the independent operations do not. Queue at most 16 calls across both lanes.
|
||||
- Coalesce equivalent scans. Replace an older queued background scan with the newest one.
|
||||
- Queue priority is first-prompt/subagent reads, queued title reads, forced foreground refresh, initial foreground load, then background refresh. Priority never preempts an active request; explicit cancellation does.
|
||||
- Keep the existing 130-second scan and 15-second title deadlines. A timeout terminates and replaces the service because a timed-out parser cannot be assumed healthy.
|
||||
- Cancellation rejects the caller immediately, sends `cancel`, and gives the service 2 seconds to acknowledge or finish. The service is terminated if it remains stuck.
|
||||
- Continue returning the last good renderer result when a refresh fails.
|
||||
- Invalidation is a control message, not queued scan work. The parent invalidates its result/host caches immediately and waits for the service acknowledgement before allowing a non-forced post-delete list result to reuse service cache state.
|
||||
- Instrument serialized response size. Warn at 8 MiB. Do not reject legacy unlimited results until internal paging and public fallback are implemented.
|
||||
|
||||
The first implementation may reuse the current plain-object Node IPC serialization. The payload does not require handles, sockets, or transferable buffers.
|
||||
|
||||
## Lifecycle and supervision
|
||||
|
||||
### Startup
|
||||
|
||||
- Start lazily on the first scan or title request.
|
||||
- Resolve an explicit entry path; packaged Electron must use `app.asar.unpacked` because `ELECTRON_RUN_AS_NODE=1` bypasses Electron's asar loader.
|
||||
- Fork with `stdio: ['ignore', 'ignore', 'pipe', 'ipc']`, `ELECTRON_RUN_AS_NODE=1`, and `windowsHide: true` on Windows.
|
||||
- Require a `ready` message within 5 seconds. A request does not enter its operation timeout until the service is ready.
|
||||
- Pipe a bounded stderr tail into the existing diagnostic log. Never allow child output backpressure to stall the child.
|
||||
|
||||
### Steady state
|
||||
|
||||
- Reuse the process and caches across calls.
|
||||
- Keep current parse concurrency at eight for the first A/B. Process isolation is not permission to increase concurrency.
|
||||
- Set best-effort below-normal priority with `os.setPriority(child.pid, PRIORITY_BELOW_NORMAL)`. Failure to lower priority is observable but not fatal.
|
||||
- Start with `--max-old-space-size=384`. Treat an out-of-memory exit as a service failure; keep terminals and the last Vault snapshot alive.
|
||||
- Exit after 10 minutes with no active or queued request. Persisted parse-cache state makes a later cold process cheaper.
|
||||
|
||||
### Faults and restart
|
||||
|
||||
- Reject the active request on exit, disconnect, malformed protocol, or timeout.
|
||||
- Retry queued work in a new process only when it is safe and has not been cancelled.
|
||||
- Back off at 250 ms, 1 second, then 5 seconds.
|
||||
- Open a 60-second circuit breaker after three unexpected exits in 60 seconds. Manual refresh may make one explicit restart attempt.
|
||||
- On idle or orderly app/runtime/relay shutdown, flush any debounced parse-cache persistence, send the final acknowledgement, and exit. The parent waits at most 2 seconds, then terminates. Never hold host shutdown indefinitely.
|
||||
|
||||
## Resource and quality-of-service policy
|
||||
|
||||
The process boundary supplies crash and heap isolation. Priority and bounds supply responsiveness.
|
||||
|
||||
- CPU: below-normal process priority, scan concurrency eight, one scan at a time.
|
||||
- Memory: 384 MiB V8 old-space starting cap, 4,096 title-cache entries, current parse-cache eviction, and response-size telemetry.
|
||||
- Queue: 16 calls, coalescing, background replacement, explicit priority.
|
||||
- I/O: stream transcripts; do not read an entire store into memory. Preserve current per-file streaming behavior.
|
||||
- UI: defer result publication until terminal input has been quiet for 100 ms, with a 1-second maximum deferral so the panel cannot starve.
|
||||
- React: publish inside `startTransition`; keep the previous snapshot visible while the transition is pending.
|
||||
- Unlimited history: retain behavior during migration. Add internal pages before enforcing a hard serialized-result cap.
|
||||
|
||||
Process priority is best effort and cross-platform. Correctness must not depend on a particular scheduler implementation.
|
||||
|
||||
## Renderer publication
|
||||
|
||||
`ai-vault-session-refresh.ts` should stop applying a completed result immediately.
|
||||
|
||||
Add `ai-vault-session-publication-gate.ts` with this behavior:
|
||||
|
||||
1. Cache the validated result immediately so another caller can reuse it.
|
||||
2. If no terminal has received input in the last 100 ms, publish now inside `startTransition`.
|
||||
3. Otherwise retain only the newest pending result and wait for quiet.
|
||||
4. Publish after 1 second even if input continues, using a transition and one bounded page.
|
||||
5. Cancel a pending publication when the scope, host, or component request token changes.
|
||||
|
||||
Reuse the existing terminal-input quiet signal used by AI Vault tab-title synchronization rather than adding global key listeners per panel.
|
||||
|
||||
A separate Vault renderer remains a contingency only if post-service measurements show repeated renderer tasks over 50 ms that cannot be removed with publication scheduling, paging, and list virtualization.
|
||||
|
||||
## Desktop and runtime implementation
|
||||
|
||||
Create these concrete modules:
|
||||
|
||||
- `src/main/ai-vault/session-scanner-service-entry.ts`: process entry, init/ready handshake, request dispatch, cancellation, cache initialization, and shutdown.
|
||||
- `src/main/ai-vault/session-scanner-service-client.ts`: queue, deadlines, cancellation, validation, and restart policy.
|
||||
- `src/main/ai-vault/session-scanner-service-spawn.ts`: lazy shared client and public scan/title functions.
|
||||
- `src/main/ai-vault/session-scanner-service-entry-path.ts`: dev, E2E, and packaged path resolution.
|
||||
- `src/main/ai-vault/session-scanner-service-protocol.ts`: message types, schemas, and error codes.
|
||||
- `src/main/ai-vault/session-scanner-service-priority.ts`: best-effort process priority policy.
|
||||
|
||||
Reuse the scanner and cache modules without moving their business logic. Replace imports in:
|
||||
|
||||
- `src/main/ai-vault/cached-session-list.ts`
|
||||
- `src/main/ai-vault/session-title-resolver.ts`
|
||||
- `src/main/ipc/ai-vault-subagent-list.ts`
|
||||
- `src/main/ai-vault/session-first-user-prompt-read.ts`
|
||||
- `src/main/ipc/ai-vault-delete.ts` for acknowledged service-cache invalidation after a successful delete
|
||||
- `src/main/ipc/ai-vault.ts`
|
||||
- `src/main/runtime/rpc/methods/ai-vault.ts`
|
||||
|
||||
Promote the parse-cache flush currently exposed only for tests into an internal production shutdown function. Idle and orderly service exits must await it within the shutdown grace period.
|
||||
|
||||
Keep the current worker implementation behind the rollout switch until the new process passes packaging and fault-injection coverage. Remove it in a later cleanup change, not in the initial migration.
|
||||
|
||||
## SSH relay sidecar implementation
|
||||
|
||||
Create:
|
||||
|
||||
- `src/relay/ai-vault-service-entry.ts`: bundled Node sidecar entry using the remote scanner and title reader.
|
||||
- `src/relay/ai-vault-service-client.ts`: relay-owned supervisor and internal protocol adapter.
|
||||
- `src/relay/ai-vault-service-priority.ts`: the same best-effort QoS policy without Electron imports.
|
||||
|
||||
Change `src/relay/ai-vault-handler.ts` so it validates public params and forwards them to the sidecar. It must no longer import or call `scanRemoteAiVaultSessions` or `readAiVaultSessionTitlesFromFiles` in the relay process.
|
||||
|
||||
`relay.ts` creates the client once, injects it into `AiVaultHandler`, and shuts it down with relay lifecycle. The PTY handler does not depend on sidecar readiness.
|
||||
|
||||
If the sidecar cannot start or crashes:
|
||||
|
||||
- Return a normal `AiVaultListResult` containing a host issue and no new sessions.
|
||||
- Do not scan inline in the relay.
|
||||
- Keep all PTYs, reconnect state, and other relay methods available.
|
||||
- Allow the next manual refresh to cross the restart circuit breaker once.
|
||||
|
||||
Build `relay-ai-vault-service.js` for every current relay platform in `config/scripts/build-relay.mjs`. Include its bytes in the immutable relay content hash so a sidecar-only change deploys a new relay directory. The bundle must target Node 18 and rely only on Node built-ins plus already supported optional externals.
|
||||
|
||||
## Remote compatibility
|
||||
|
||||
The first implementation adds no public method, stream opcode, required field, or changed result meaning.
|
||||
|
||||
- Current desktop with new relay: calls the existing Vault relay methods; the new relay handles them through its sidecar.
|
||||
- New desktop with current relay: calls the same methods and receives the same result shape.
|
||||
- New desktop with an older relay lacking the method: uses the existing budgeted SSH filesystem fallback.
|
||||
- Old desktop with new relay: unknown internal sidecar details never cross the wire.
|
||||
|
||||
Do not make a new client depend on internal paging fields from an older host. If public paging is added later, use optional request/result fields and fall back to the existing complete-result call when the host does not return paging capability.
|
||||
|
||||
The old-relay fallback is the explicit isolation exception. Keep its total budget and cancellation. Do not broaden when it is selected. Once relay adoption data shows the fallback is rare, it can be reduced or removed in a separate compatibility decision.
|
||||
|
||||
## Folder workspaces, WSL, and paths
|
||||
|
||||
- Scope paths remain opaque filesystem paths. Do not require a `.git` directory or worktree metadata.
|
||||
- Continue using `path.join` and the existing host-platform path adapters.
|
||||
- Keep Windows batch and shell selection unrelated to terminal-shell preference.
|
||||
- Preserve WSL host identity and cache scoping; capability and cache state must not leak between distros or native Windows.
|
||||
- An SSH service reads only the remote host's paths. A desktop service must not interpret remote paths as local paths.
|
||||
- Title requests must continue through the existing readable-path resolution and allowed-root validation before opening a transcript.
|
||||
- Subagent and first-prompt requests remain local-only and must validate renderer-provided paths against the same agent-source roots used for discovery, including managed and WSL roots. Tightening path validation must ship with parity fixtures so legitimate current roots are not rejected.
|
||||
|
||||
## Security boundary
|
||||
|
||||
The service is a performance and reliability boundary, not a privilege boundary.
|
||||
|
||||
- It has the same OS user and must access the same agent stores.
|
||||
- Do not expose its IPC channel to renderer code or the network.
|
||||
- The parent passes validated options; the service validates again before filesystem access.
|
||||
- Do not log prompts, titles, transcript paths, environment values, or raw service messages.
|
||||
- Preserve symlink and allowed-root checks for title reads.
|
||||
- Inherit only the environment needed for current agent-home discovery and process operation. Document any removed variables with cross-platform tests before tightening further.
|
||||
|
||||
## Packaging
|
||||
|
||||
Desktop packaging changes:
|
||||
|
||||
- Add `session-scanner-service-entry` to the main inputs in `electron.vite.config.ts`.
|
||||
- Add `out/main/session-scanner-service-entry.js` to `asarUnpack` in `config/electron-builder.config.cjs`.
|
||||
- Reuse the existing unpacked entry-path pattern used by other forked processes.
|
||||
- Add a packaged smoke test that resolves the real unpacked path, starts the service, scans one fixture, and shuts down.
|
||||
|
||||
Relay packaging changes:
|
||||
|
||||
- Bundle the sidecar beside `relay.js` and `relay-watcher.js` for every supported OS/architecture.
|
||||
- Include it in `.version` content hashing and deploy completeness assertions.
|
||||
- Verify reconnect and versioned-install cleanup keep the matching relay and sidecar together.
|
||||
- No new native module is required, so the Linux glibc floor should remain unchanged. Continue running the packaging verifier.
|
||||
|
||||
## Observability
|
||||
|
||||
Add spans and counters for:
|
||||
|
||||
- Service start, ready latency, PID, restart reason, circuit-breaker state, and idle exit.
|
||||
- Queue depth, queue wait, priority class, coalesced calls, and dropped background calls.
|
||||
- Discovery duration, parse duration, candidates, bytes read, cache reuse, incremental parses, full parses, and result count.
|
||||
- Parent/child serialization duration and serialized bytes.
|
||||
- Child RSS and heap-used snapshots at request completion.
|
||||
- Renderer result receipt, quiet-wait duration, transition publication duration, and displayed session count.
|
||||
- Relay sidecar failures separately from PTY and relay-loop health.
|
||||
|
||||
Metrics must use counts, byte sizes, and durations only. Paths and transcript-derived content stay out of logs.
|
||||
|
||||
## Verification matrix
|
||||
|
||||
The design is not ready for default-on until each layer below passes with both the worker fallback and process backend where applicable.
|
||||
|
||||
| Layer | Required coverage |
|
||||
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Pure parser | Existing fixtures for every agent and format; incremental append, corrupt/partial input, title precedence, usage, previews, queued prompts, recoverable empties, and subagent counts |
|
||||
| Backend equivalence | Worker versus process deep equality for complete list, title, subagent, and first-prompt results; only `scannedAt` and internal metrics may differ |
|
||||
| Cache | Cold/warm/incremental scans, persistence restart, atomic save failure, corrupt cache, app-version mismatch, delete generation guard, service invalidation acknowledgement, and idle-exit flush |
|
||||
| Queue/lifecycle | Both logical lanes, priority, coalescing, 16-call bound, cancellation before/while queued/active, deadlines, malformed IPC, crash, OOM, backoff, circuit breaker, and shutdown |
|
||||
| Desktop Electron | Visible list, scope/host/depth controls, refresh/cancel, first-prompt copy, Claude/OMP expansion, delete, View/Open Log, live tail, resume, drag/drop, and service-crash terminal typing through Electron/CDP |
|
||||
| Runtime/web | List/title/resume parity, host restamping, managed Codex homes, unchanged no-op cancellation limitation, and unchanged unavailable subagent/first-prompt/delete behavior |
|
||||
| SSH relay | All-agent remote list, title resolution, scope truncation, sidecar crash/timeout/OOM, PTY typing/output/reconnect continuity, old-relay fallback, and both mixed-version directions |
|
||||
| Windows/WSL | Native and per-distro roots, UNC paths, WSL deletion, priority failure fallback, hidden child windows, packaged entry path, and host-isolated cache/source state |
|
||||
| Linux/package | Ubuntu 20.04/glibc floor, Node 18 relay bundle, packaged entry smoke, sidecar deploy/hash completeness, and no undeclared native dependency |
|
||||
| Folder workspace | Workspace/project scope, ownership mapping, resume target, path actions, and deletion without assuming Git metadata |
|
||||
|
||||
The Electron feature-parity run must assert visible behavior rather than only inspecting the store. Service fault tests additionally assert PTY markers so a Vault failure cannot pass while silently disrupting terminals.
|
||||
|
||||
## Failure behavior
|
||||
|
||||
| Failure | User-visible Vault behavior | Terminal behavior |
|
||||
| ---------------------------------- | ------------------------------------------------------------------------------ | ----------------------------------------- |
|
||||
| Service fails to start | Keep last result and show a retryable host issue | Unchanged |
|
||||
| Service crashes during scan | Reject refresh, restart under backoff, keep last result | Unchanged |
|
||||
| Service exceeds memory cap | Treat as crash and reduce confidence telemetry; do not remove the cap silently | Unchanged |
|
||||
| Scan or cancellation hangs | Kill and replace service after the deadline/grace | Unchanged |
|
||||
| Queue is full | Coalesce or drop background work; foreground call gets a clear retryable error | Unchanged |
|
||||
| Relay sidecar unavailable | Remote Vault host shows an issue | Relay PTYs and reconnect remain available |
|
||||
| Renderer publication is superseded | Drop the stale result and publish the newest scope only | Unchanged |
|
||||
| Old relay has no Vault method | Use the existing bounded desktop fallback | Existing compatibility limitation remains |
|
||||
|
||||
## Benchmark methodology
|
||||
|
||||
The new `bench:ai-vault-typing` command launches a fresh Electron app through Playwright's Electron/CDP path and uses an isolated home.
|
||||
|
||||
Each iteration:
|
||||
|
||||
1. Keeps the Vault panel mounted in both arms.
|
||||
2. Seeds 300 new, newer-mtime Codex JSONL transcripts in the isolated home.
|
||||
3. Alternates arm order to reduce warm-up bias.
|
||||
4. Runs paced raw-mode terminal echo typing as the control.
|
||||
5. Runs the same typing while programmatically starting a forced visible Vault refresh as treatment.
|
||||
6. Verifies the newest seeded title is visible, proving the full scan and UI path completed.
|
||||
|
||||
The renderer records keydown-to-xterm-parse, keydown-to-xterm-render, long tasks, timer drift, animation-frame gaps, refresh duration, and missing echoes without polling or serializing the terminal buffer for each key.
|
||||
|
||||
Raw reports are written to the ignored `tests/tools/benchmarks/results/` directory.
|
||||
|
||||
## Current measurements
|
||||
|
||||
Machine: Apple M5 Pro, 64 GiB RAM, macOS 26.5.1. These are local-host measurements on the current worker-thread architecture after the recently merged performance work.
|
||||
|
||||
Pre-implementation parity baseline:
|
||||
|
||||
- 90 Vault-focused unit/integration test files passed: 778 tests covering parsers, caches, routing, deletion, resume, renderer projection, subagents, first prompts, and runtime contracts.
|
||||
- Electron/CDP parity spot-check passed for long View Log tail stability, single-file session deletion, and Claude companion-directory deletion: 3 tests.
|
||||
- The representative, stress, and final benchmark smoke runs all completed with visible Vault results and zero missing terminal echoes.
|
||||
|
||||
### Representative pass
|
||||
|
||||
Configuration: 3 iterations, 300 new sessions per iteration, 128 KiB assistant payload per session, 100 keys per arm at 30 ms cadence, 118.4 MB total seeded data.
|
||||
|
||||
| Metric | Control | Refresh active |
|
||||
| ------------------------------ | -------------------: | ------------------: |
|
||||
| Parse p50 / p95 / max | 1.3 / 2.5 / 29.8 ms | 1.3 / 2.0 / 5.4 ms |
|
||||
| Render p50 / p95 / max | 5.9 / 10.0 / 35.4 ms | 5.8 / 9.5 / 10.6 ms |
|
||||
| Worst timer drift | 14.2 ms | 24.5 ms |
|
||||
| Worst frame gap | 9.4 ms | 24.9 ms |
|
||||
| Renderer long tasks over 50 ms | 0 | 0 |
|
||||
| Missing echoes | 0 / 300 | 0 / 300 |
|
||||
| Refresh duration p50 / max | — | 103.5 / 204.2 ms |
|
||||
|
||||
There is no treatment-side typing regression in this pass. The current worker is doing useful work and should remain the rollback path during migration.
|
||||
|
||||
### Stress pass
|
||||
|
||||
Configuration: 2 iterations, 300 new sessions per iteration, 512 KiB assistant payload per session, 120 keys per arm at 30 ms cadence, 314.9 MB total seeded data.
|
||||
|
||||
| Metric | Control | Refresh active |
|
||||
| ------------------------------ | ------------------: | -------------------: |
|
||||
| Parse p50 / p95 / max | 1.3 / 1.9 / 19.7 ms | 1.4 / 4.7 / 13.8 ms |
|
||||
| Render p50 / p95 / max | 5.6 / 9.4 / 23.8 ms | 6.0 / 11.2 / 16.3 ms |
|
||||
| Worst timer drift | 9.3 ms | 43.6 ms |
|
||||
| Worst frame gap | 9.4 ms | 50.0 ms |
|
||||
| Renderer long tasks over 50 ms | 0 | 0 |
|
||||
| Missing echoes | 0 / 240 | 0 / 240 |
|
||||
| Refresh duration p50 / max | — | 276.3 / 453.5 ms |
|
||||
|
||||
The exaggerated workload causes measurable but bounded whole-system contention. It does not show renderer result application becoming a long task. This is evidence for a lower-priority service process and against a second Chromium renderer.
|
||||
|
||||
### Measurement limits
|
||||
|
||||
- One high-end macOS machine does not establish Windows, Linux, low-core, battery, or memory-pressure behavior.
|
||||
- The benchmark covers the local service path, not the currently unisolated SSH relay path.
|
||||
- Raw shell echo is a stable latency probe, not a full-screen TUI workload.
|
||||
- A child process still competes for machine-wide CPU; the expected benefit comes from priority, heap/failure isolation, and supervision, not from eliminating CPU cost.
|
||||
|
||||
## Post-implementation A/B
|
||||
|
||||
Run the same build with the rollout switch off and on, alternating launch order:
|
||||
|
||||
```bash
|
||||
ORCA_AI_VAULT_SERVICE_PROCESS=0 pnpm bench:ai-vault-typing -- --label worker-control
|
||||
ORCA_AI_VAULT_SERVICE_PROCESS=1 pnpm bench:ai-vault-typing -- --label process-treatment
|
||||
```
|
||||
|
||||
Run at least five representative and five stress passes on:
|
||||
|
||||
- macOS Apple Silicon.
|
||||
- Windows x64 with a folder workspace and a WSL-backed source present.
|
||||
- Ubuntu 20.04-compatible x64 packaging/runtime.
|
||||
- A 4-core or smaller machine or constrained CI runner.
|
||||
|
||||
Add a Docker SSH variant that runs the same terminal probe while the remote sidecar scans. A fault arm kills the sidecar during typing and verifies the PTY marker stream and reconnect remain healthy.
|
||||
|
||||
## Implemented result
|
||||
|
||||
The implementation now matches the target topology:
|
||||
|
||||
- Desktop and runtime list scans, title resolution, subagent reads, and first-prompt reads use a lazy supervised child process by default outside unit tests. `ORCA_AI_VAULT_SERVICE_PROCESS=0` retains the worker fallback.
|
||||
- Successful local deletion remains in the trusted main process and acknowledges child parse-cache invalidation. In-flight parses cannot restore an invalidated entry.
|
||||
- The SSH relay handler no longer imports the remote scanner, transcript title reader, or filesystem provider. It forwards existing public methods to `relay-ai-vault-service.js`; sidecar failure returns a normal host issue.
|
||||
- All six relay platform bundles include the sidecar, hash its bytes into `.version`, and require it in remote-install completeness probes.
|
||||
- Renderer results cache immediately, retain only the newest pending publication, wait for 100 ms of input quiet, publish in a React transition, and cannot defer beyond one second.
|
||||
- Desktop and relay supervisors bound the queue at 16, cap old-space at 384 MiB, use below-normal priority where supported, enforce ready/operation/cancellation/shutdown deadlines, and restart with bounded backoff plus a three-fault circuit breaker.
|
||||
|
||||
Local validation on 2026-08-09:
|
||||
|
||||
- Electron E2E build emitted `out/main/session-scanner-service-entry.js`; every relay target built `relay-ai-vault-service.js` successfully.
|
||||
- Real built-child smoke checks passed for the desktop service title lane and relay sidecar list lane.
|
||||
- Vault, runtime, renderer, relay, and remote-install focused tests passed 900/901 in one loaded run. The single unrelated 150 ms map micro-benchmark measured 184 ms during that parallel run and passed at 30 ms alone.
|
||||
- Electron/CDP feature checks passed for long View Log stability, single-file deletion, and Claude companion-directory deletion with the process backend enabled.
|
||||
|
||||
Representative A/B configuration: 3 iterations, 300 sessions per iteration, 128 KiB payloads, and 100 keys at 30 ms cadence.
|
||||
|
||||
| Metric | Worker control | Worker refresh | Process control | Process refresh |
|
||||
| --------------------- | -------------: | -------------: | --------------: | --------------: |
|
||||
| Parse p95 | 2.0 ms | 1.9 ms | 6.2 ms | 6.4 ms |
|
||||
| Render p95 | 9.6 ms | 9.6 ms | 11.0 ms | 12.3 ms |
|
||||
| Worst timer drift | 9.4 ms | 17.4 ms | 10.4 ms | 12.0 ms |
|
||||
| Worst frame gap | 9.4 ms | 9.4 ms | 16.8 ms | 16.2 ms |
|
||||
| Long tasks over 50 ms | 0 | 0 | 0 | 0 |
|
||||
| Missing echoes | 0 / 300 | 0 / 300 | 0 / 300 | 0 / 300 |
|
||||
| Refresh p50 / max | — | 74.9 / 76.9 ms | — | 83.2 / 165.1 ms |
|
||||
|
||||
The process arm stayed well inside every acceptance budget. It did not outperform the already-optimized worker on raw parse latency; its value is the intended heap, crash, priority, relay-loop, and lifecycle isolation while preserving terminal responsiveness.
|
||||
|
||||
Windows/WSL, Ubuntu 20.04, constrained-core, packaged-app launch, and Docker SSH fault measurements remain release/CI matrix work because they cannot be truthfully produced on this macOS host. The implementation includes their path, platform, packaging, Node 18, and mixed-version compatibility requirements; default rollback remains one environment variable.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
Correctness:
|
||||
|
||||
- Zero missing terminal echoes.
|
||||
- The latest seeded Vault session is visibly published in every treatment arm.
|
||||
- Local, runtime, SSH, WSL, and folder-workspace session fixtures remain equivalent to the current scanner.
|
||||
- Cancellation, force-preemption, host merging, title resolution, and unlimited history preserve current semantics.
|
||||
|
||||
Performance:
|
||||
|
||||
- Treatment parse p95 is no more than 10 ms above its paired control.
|
||||
- Treatment render p95 is no more than 15 ms above its paired control.
|
||||
- Median active-key render latency remains at or below 75 ms; worst remains below 300 ms.
|
||||
- No renderer task attributable to Vault result publication exceeds 50 ms in the representative run.
|
||||
- No relay PTY input/output stall exceeds the terminal reliability budget while its sidecar scans or crashes.
|
||||
|
||||
Isolation:
|
||||
|
||||
- Killing or exhausting the desktop service does not crash Electron main, runtime RPC, renderer, or terminal daemon.
|
||||
- Killing or exhausting the relay sidecar does not interrupt PTYs, relay reconnect, or non-Vault methods.
|
||||
- The service respects queue, timeout, idle, and heap bounds.
|
||||
- Packaged app and relay artifacts contain and launch the correct entry for every supported platform.
|
||||
|
||||
## Phased implementation plan
|
||||
|
||||
### Phase 0 — measurement harness (completed in this change)
|
||||
|
||||
- Add `terminal-ai-vault-typing-latency.spec.ts` and its focused corpus/renderer probes.
|
||||
- Add `run-ai-vault-typing-bench.mjs` and `pnpm bench:ai-vault-typing`.
|
||||
- Capture representative and stress baselines.
|
||||
|
||||
Exit: reproducible JSON report, visible Vault completion assertion, zero missing echoes.
|
||||
|
||||
### Phase 1 — desktop and runtime service behind a switch
|
||||
|
||||
- Add the private service protocol, entry-path resolver, process entry, client, priority policy, and supervisor.
|
||||
- Reuse current scanner/cache implementations.
|
||||
- Route desktop IPC and runtime RPC through the service when `ORCA_AI_VAULT_SERVICE_PROCESS` is enabled.
|
||||
- Keep the worker path as the disabled-switch fallback.
|
||||
- Add unit tests for ready timeout, queue priority, coalescing, cancellation, operation timeout, crash, restart backoff, circuit breaker, idle exit, malformed messages, and packaged path resolution.
|
||||
- Add E2E fault injection that kills the service during active terminal typing.
|
||||
|
||||
Exit: desktop/runtime correctness parity, packaging smoke test, representative/stress A/B within budgets.
|
||||
|
||||
### Phase 2 — make the desktop/runtime process the default
|
||||
|
||||
- Enable the process by default in development, E2E, and canary builds.
|
||||
- Collect process RSS, restart, timeout, and latency telemetry without content.
|
||||
- Validate Windows, Linux, WSL, and constrained-runner results.
|
||||
- Keep `ORCA_AI_VAULT_SERVICE_PROCESS=0` as a release kill switch for one stable cycle.
|
||||
|
||||
Exit: no unexplained crash or timeout increase and all acceptance gates green.
|
||||
|
||||
### Phase 3 — SSH relay sidecar
|
||||
|
||||
- Add the relay sidecar entry and supervisor.
|
||||
- Remove scanner/parser/cache imports from the relay handler.
|
||||
- Add the sidecar to every relay bundle, content hash, deploy manifest, and completeness test.
|
||||
- Add Docker SSH scan, typing, sidecar-crash, reconnect, old-relay fallback, and mixed-version tests.
|
||||
- Make inline relay scanning impossible; sidecar failure returns a host issue.
|
||||
|
||||
Exit: remote typing and fault gates green; current public relay methods unchanged.
|
||||
|
||||
### Phase 4 — renderer quiet publication
|
||||
|
||||
- Add the shared quiet-publication gate and React transition.
|
||||
- Retain only the newest pending result per scope/host.
|
||||
- Add supersession, starvation timeout, unmount, and rapid-host-switch tests.
|
||||
- Re-run the Electron benchmark with large result counts and active filtering/grouping.
|
||||
|
||||
Exit: no Vault-attributable renderer long task over 50 ms in the representative gate.
|
||||
|
||||
### Phase 5 — hardening and cleanup
|
||||
|
||||
- Remove the worker implementation after one stable release if the kill switch was unused.
|
||||
- Decide whether public optional paging is necessary from serialized-size and renderer-publication telemetry.
|
||||
- Promote `bench:ai-vault-typing` from an experimental benchmark to the terminal reliability gate.
|
||||
- Document operational recovery and service diagnostics.
|
||||
|
||||
Exit: kill switch and dead worker code removed only after evidence supports it.
|
||||
|
||||
## Rollback
|
||||
|
||||
- Desktop/runtime: set `ORCA_AI_VAULT_SERVICE_PROCESS=0` to restore the current worker path without changing public APIs or stored data.
|
||||
- Renderer: disable quiet publication and apply the validated complete result directly.
|
||||
- Relay: deploy the prior immutable relay version. A new relay must never fall back to inline scanning when its sidecar fails.
|
||||
- Parse-cache format remains unchanged during migration, so rollback does not require cache conversion.
|
||||
|
||||
## Confidence and remaining questions
|
||||
|
||||
Confidence in the overall topology is about 90%. Confidence that a separate Vault renderer is unnecessary is higher because both baseline passes recorded zero renderer long tasks. Confidence that the local child process alone will materially improve normal typing latency is lower: the current worker already performs well, and the process migration is primarily buying priority, heap, crash, and lifecycle isolation.
|
||||
|
||||
The remaining implementation questions are deliberately narrow:
|
||||
|
||||
1. Is 384 MiB the right old-space cap on Windows/Linux and for unlimited histories? Resolve with packaged stress runs before default-on.
|
||||
2. Does the 10-minute idle exit improve retained memory without creating noticeable cold-refresh churn? Resolve with startup/RSS telemetry.
|
||||
3. Is public paging necessary? Add it only if response-size or renderer-publication evidence crosses the stated budgets.
|
||||
4. How large is the current SSH relay impact on low-core hosts? Resolve with the Phase 3 Docker and constrained-runner typing gate; it does not change the decision to remove scans from the relay loop.
|
||||
@@ -220,6 +220,9 @@ export const electronViteConfig: UserConfig = {
|
||||
'session-scanner-worker-entry': resolve(
|
||||
'src/main/ai-vault/session-scanner-worker-entry.ts'
|
||||
),
|
||||
'session-scanner-service-entry': resolve(
|
||||
'src/main/ai-vault/session-scanner-service-entry.ts'
|
||||
),
|
||||
// Why: libuv spawns processes inline on the calling loop, so the port
|
||||
// scan's probe commands run on a worker thread instead of the UI one.
|
||||
'port-scan-command-worker-entry': resolve(
|
||||
|
||||
@@ -117,6 +117,7 @@
|
||||
"bench:zustand-selector-fanout": "node config/scripts/zustand-selector-fanout-benchmark.mjs",
|
||||
"bench:worktree-refresh-churn": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON config/scripts/worktree-refresh-churn-benchmark.mjs",
|
||||
"bench:multi-workspace-typing": "pnpm run ensure:electron-runtime && node config/scripts/run-multi-workspace-typing-bench.mjs",
|
||||
"bench:ai-vault-typing": "pnpm run ensure:electron-runtime && node config/scripts/run-ai-vault-typing-bench.mjs",
|
||||
"bench:cold-park-reveal": "pnpm run ensure:electron-runtime && node tests/tools/benchmarks/terminal-cold-park-reveal-bench.mjs",
|
||||
"bench:cold-park-resource": "pnpm run ensure:electron-runtime && node tests/tools/benchmarks/terminal-cold-park-resource-bench.mjs",
|
||||
"bench:compare": "node config/scripts/compare-benchmark-artifacts.mjs",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
resetAiVaultScannerWorkerForTests,
|
||||
scanAiVaultSessionsInWorker
|
||||
} from './session-scanner-worker-spawn'
|
||||
resetAiVaultScannerBackgroundForTests,
|
||||
scanAiVaultSessionsInBackground
|
||||
} from './session-scanner-background'
|
||||
import { getWslHomeAsync, listWslDistrosAsync } from '../wsl'
|
||||
import type { AiVaultListArgs, AiVaultListResult } from '../../shared/ai-vault-types'
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
|
||||
@@ -77,7 +77,7 @@ export async function listAiVaultSessions(
|
||||
start: async (scanSignal) => {
|
||||
const additionalCodexSessionsDirs =
|
||||
sources.getAdditionalCodexHomePaths?.().map((homePath) => join(homePath, 'sessions')) ?? []
|
||||
const result = await scanAiVaultSessionsInWorker(
|
||||
const result = await scanAiVaultSessionsInBackground(
|
||||
{
|
||||
limit: args?.limit,
|
||||
unlimited: args?.unlimited,
|
||||
@@ -142,5 +142,5 @@ export function resetAiVaultSessionListCacheForTests(): void {
|
||||
invalidateAiVaultSessionListCache()
|
||||
scanCoordinator = new AiVaultScanCoordinator()
|
||||
sources = {}
|
||||
resetAiVaultScannerWorkerForTests()
|
||||
resetAiVaultScannerBackgroundForTests()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type {
|
||||
AiVaultFirstUserPromptArgs,
|
||||
AiVaultFirstUserPromptResult
|
||||
} from '../../shared/ai-vault-types'
|
||||
import { readAiVaultFirstUserPromptInBackground } from './session-scanner-background'
|
||||
|
||||
export function handleAiVaultGetFirstUserPrompt(
|
||||
args?: AiVaultFirstUserPromptArgs
|
||||
): Promise<AiVaultFirstUserPromptResult> {
|
||||
if (!args || typeof args.filePath !== 'string' || typeof args.agent !== 'string') {
|
||||
return Promise.resolve({ prompt: null })
|
||||
}
|
||||
return readAiVaultFirstUserPromptInBackground({
|
||||
agent: args.agent,
|
||||
filePath: args.filePath,
|
||||
sessionId: typeof args.sessionId === 'string' ? args.sessionId : undefined,
|
||||
executionHostId: args.executionHostId,
|
||||
codexHome: args.codexHome
|
||||
})
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { stat } from 'node:fs/promises'
|
||||
import type {
|
||||
AiVaultAgent,
|
||||
AiVaultFirstUserPromptArgs,
|
||||
AiVaultFirstUserPromptResult,
|
||||
AiVaultSession
|
||||
} from '../../shared/ai-vault-types'
|
||||
@@ -22,22 +21,6 @@ export type ReadAiVaultFirstUserPromptArgs = {
|
||||
|
||||
export type ReadAiVaultFirstUserPromptResult = AiVaultFirstUserPromptResult
|
||||
|
||||
/** IPC-safe entry: validates untyped payload then reads the full first prompt. */
|
||||
export async function handleAiVaultGetFirstUserPrompt(
|
||||
args?: AiVaultFirstUserPromptArgs
|
||||
): Promise<AiVaultFirstUserPromptResult> {
|
||||
if (!args || typeof args.filePath !== 'string' || typeof args.agent !== 'string') {
|
||||
return { prompt: null }
|
||||
}
|
||||
return readAiVaultFirstUserPrompt({
|
||||
agent: args.agent,
|
||||
filePath: args.filePath,
|
||||
sessionId: typeof args.sessionId === 'string' ? args.sessionId : undefined,
|
||||
executionHostId: args.executionHostId,
|
||||
codexHome: args.codexHome
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-parse one session transcript under full first-prompt capture and return
|
||||
* the untruncated first real user ask for copy/reuse.
|
||||
|
||||
@@ -86,8 +86,8 @@ export function scheduleSessionParseCachePersist(stats: SessionParseStats): void
|
||||
}
|
||||
}
|
||||
|
||||
/** Run any pending debounced save immediately and wait for it. Test-only. */
|
||||
export async function flushSessionParseCachePersistForTests(): Promise<void> {
|
||||
/** Run any pending debounced save immediately and wait for it before process exit. */
|
||||
export async function flushSessionParseCachePersist(): Promise<void> {
|
||||
if (saveTimer) {
|
||||
clearTimeout(saveTimer)
|
||||
saveTimer = null
|
||||
@@ -99,6 +99,8 @@ export async function flushSessionParseCachePersistForTests(): Promise<void> {
|
||||
await lastSave
|
||||
}
|
||||
|
||||
export const flushSessionParseCachePersistForTests = flushSessionParseCachePersist
|
||||
|
||||
async function loadPersistedEntries(current: SessionParseCachePersistenceOptions): Promise<void> {
|
||||
await sweepOrphanedTempFiles(current.filePath)
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { shouldUseAiVaultServiceProcess } from './session-scanner-background'
|
||||
|
||||
const originalBackend = process.env.ORCA_AI_VAULT_SERVICE_PROCESS
|
||||
const originalNodeEnv = process.env.NODE_ENV
|
||||
|
||||
afterEach(() => {
|
||||
if (originalBackend === undefined) {
|
||||
delete process.env.ORCA_AI_VAULT_SERVICE_PROCESS
|
||||
} else {
|
||||
process.env.ORCA_AI_VAULT_SERVICE_PROCESS = originalBackend
|
||||
}
|
||||
process.env.NODE_ENV = originalNodeEnv
|
||||
})
|
||||
|
||||
describe('shouldUseAiVaultServiceProcess', () => {
|
||||
it('keeps unit tests on the worker fallback by default', () => {
|
||||
delete process.env.ORCA_AI_VAULT_SERVICE_PROCESS
|
||||
process.env.NODE_ENV = 'test'
|
||||
expect(shouldUseAiVaultServiceProcess()).toBe(false)
|
||||
})
|
||||
|
||||
it('defaults non-test hosts to the service process', () => {
|
||||
delete process.env.ORCA_AI_VAULT_SERVICE_PROCESS
|
||||
process.env.NODE_ENV = 'production'
|
||||
expect(shouldUseAiVaultServiceProcess()).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['1', true],
|
||||
['0', false]
|
||||
] as const)('honors the explicit %s kill switch', (value, expected) => {
|
||||
process.env.ORCA_AI_VAULT_SERVICE_PROCESS = value
|
||||
expect(shouldUseAiVaultServiceProcess()).toBe(expected)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { AiVaultListResult, AiVaultSubagentListResult } from '../../shared/ai-vault-types'
|
||||
import type {
|
||||
AiVaultSessionTitleRequest,
|
||||
AiVaultSessionTitlesResult
|
||||
} from '../../shared/ai-vault-session-title'
|
||||
import {
|
||||
readAiVaultFirstUserPrompt,
|
||||
type ReadAiVaultFirstUserPromptArgs,
|
||||
type ReadAiVaultFirstUserPromptResult
|
||||
} from './session-first-user-prompt-read'
|
||||
import {
|
||||
invalidateAiVaultServiceCache,
|
||||
listAiVaultSubagentSessionsInService,
|
||||
readAiVaultFirstUserPromptInService,
|
||||
resetAiVaultScannerServiceForTests,
|
||||
resolveAiVaultSessionTitlesInService,
|
||||
scanAiVaultSessionsInService
|
||||
} from './session-scanner-service-spawn'
|
||||
import type { AiVaultServiceSubagentRequest } from './session-scanner-service-protocol'
|
||||
import {
|
||||
resetAiVaultScannerWorkerForTests,
|
||||
resolveAiVaultSessionTitlesInWorker,
|
||||
scanAiVaultSessionsInWorker
|
||||
} from './session-scanner-worker-spawn'
|
||||
import type { AiVaultWorkerScanOptions } from './session-scanner-worker-protocol'
|
||||
import { listLocalAiVaultSubagentSessions } from './session-subagent-reader'
|
||||
|
||||
export function shouldUseAiVaultServiceProcess(): boolean {
|
||||
const configured = process.env.ORCA_AI_VAULT_SERVICE_PROCESS
|
||||
if (configured === '1') {
|
||||
return true
|
||||
}
|
||||
if (configured === '0') {
|
||||
return false
|
||||
}
|
||||
return process.env.NODE_ENV !== 'test'
|
||||
}
|
||||
|
||||
export function scanAiVaultSessionsInBackground(
|
||||
options: AiVaultWorkerScanOptions,
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultListResult> {
|
||||
return shouldUseAiVaultServiceProcess()
|
||||
? scanAiVaultSessionsInService(options, signal)
|
||||
: scanAiVaultSessionsInWorker(options, signal)
|
||||
}
|
||||
|
||||
export function resolveAiVaultSessionTitlesInBackground(
|
||||
requests: AiVaultSessionTitleRequest[],
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultSessionTitlesResult> {
|
||||
return shouldUseAiVaultServiceProcess()
|
||||
? resolveAiVaultSessionTitlesInService(requests, signal)
|
||||
: resolveAiVaultSessionTitlesInWorker(requests, signal)
|
||||
}
|
||||
|
||||
export function listAiVaultSubagentSessionsInBackground(
|
||||
request: AiVaultServiceSubagentRequest
|
||||
): Promise<AiVaultSubagentListResult> {
|
||||
return shouldUseAiVaultServiceProcess()
|
||||
? listAiVaultSubagentSessionsInService(request)
|
||||
: listLocalAiVaultSubagentSessions(request)
|
||||
}
|
||||
|
||||
export function readAiVaultFirstUserPromptInBackground(
|
||||
request: ReadAiVaultFirstUserPromptArgs
|
||||
): Promise<ReadAiVaultFirstUserPromptResult> {
|
||||
return shouldUseAiVaultServiceProcess()
|
||||
? readAiVaultFirstUserPromptInService(request)
|
||||
: readAiVaultFirstUserPrompt(request)
|
||||
}
|
||||
|
||||
export function invalidateAiVaultBackgroundCache(paths: string[]): Promise<void> {
|
||||
return shouldUseAiVaultServiceProcess() ? invalidateAiVaultServiceCache(paths) : Promise.resolve()
|
||||
}
|
||||
|
||||
export function resetAiVaultScannerBackgroundForTests(): void {
|
||||
resetAiVaultScannerServiceForTests()
|
||||
resetAiVaultScannerWorkerForTests()
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import type { ChildProcess } from 'node:child_process'
|
||||
import type {
|
||||
AiVaultServiceInit,
|
||||
AiVaultServiceLane,
|
||||
AiVaultServiceRequest
|
||||
} from './session-scanner-service-protocol'
|
||||
|
||||
export const AI_VAULT_SERVICE_READY_TIMEOUT_MS = 5_000
|
||||
export const AI_VAULT_SERVICE_SCAN_TIMEOUT_MS = 130_000
|
||||
export const AI_VAULT_SERVICE_INTERACTIVE_TIMEOUT_MS = 15_000
|
||||
export const AI_VAULT_SERVICE_MAX_CALLS = 16
|
||||
export const AI_VAULT_SERVICE_IDLE_TIMEOUT_MS = 10 * 60_000
|
||||
export const AI_VAULT_SERVICE_SHUTDOWN_TIMEOUT_MS = 2_000
|
||||
|
||||
export type AiVaultServiceProcessFactory = () => ChildProcess
|
||||
export type AiVaultServiceClientOptions = {
|
||||
processFactory: AiVaultServiceProcessFactory
|
||||
init: Omit<AiVaultServiceInit, 'type' | 'protocol'>
|
||||
idleTimeoutMs?: number
|
||||
onStderr?: (text: string) => void
|
||||
}
|
||||
|
||||
export type AiVaultServiceInvalidation = {
|
||||
resolve: () => void
|
||||
reject: (error: Error) => void
|
||||
timer: NodeJS.Timeout
|
||||
}
|
||||
|
||||
export class AiVaultServiceInvalidations {
|
||||
private readonly pending = new Map<number, AiVaultServiceInvalidation>()
|
||||
private generation = 0
|
||||
|
||||
get size(): number {
|
||||
return this.pending.size
|
||||
}
|
||||
|
||||
open(
|
||||
timeoutMs: number,
|
||||
onTimeout: (generation: number) => void,
|
||||
send: (generation: number) => void
|
||||
): Promise<void> {
|
||||
const generation = ++this.generation
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => onTimeout(generation), timeoutMs)
|
||||
timer.unref?.()
|
||||
this.pending.set(generation, { resolve, reject, timer })
|
||||
send(generation)
|
||||
})
|
||||
}
|
||||
|
||||
settle(generation: number): boolean {
|
||||
const entry = this.pending.get(generation)
|
||||
if (!entry) {
|
||||
return false
|
||||
}
|
||||
clearTimeout(entry.timer)
|
||||
this.pending.delete(generation)
|
||||
entry.resolve()
|
||||
return true
|
||||
}
|
||||
|
||||
rejectAll(error: Error): void {
|
||||
for (const entry of this.pending.values()) {
|
||||
clearTimeout(entry.timer)
|
||||
entry.reject(error)
|
||||
}
|
||||
this.pending.clear()
|
||||
}
|
||||
}
|
||||
|
||||
export function createAiVaultServiceReadyWaiter(
|
||||
timeoutMs: number,
|
||||
onTimeout: () => void
|
||||
): AiVaultServiceReadyWaiter {
|
||||
let resolve!: (child: ChildProcess) => void
|
||||
let reject!: (error: Error) => void
|
||||
const promise = new Promise<ChildProcess>((resolveReady, rejectReady) => {
|
||||
resolve = resolveReady
|
||||
reject = rejectReady
|
||||
})
|
||||
const timer = setTimeout(onTimeout, timeoutMs)
|
||||
timer.unref?.()
|
||||
return { promise, resolve, reject, timer }
|
||||
}
|
||||
|
||||
export function retireAiVaultServiceChild(child: ChildProcess): void {
|
||||
child.removeAllListeners('message')
|
||||
child.removeAllListeners('disconnect')
|
||||
child.removeAllListeners('error')
|
||||
child.removeAllListeners('exit')
|
||||
const killTimer = setTimeout(() => child.kill(), AI_VAULT_SERVICE_SHUTDOWN_TIMEOUT_MS)
|
||||
killTimer.unref?.()
|
||||
child.once('exit', () => clearTimeout(killTimer))
|
||||
child.send({ type: 'shutdown' }, () => undefined)
|
||||
child.unref()
|
||||
}
|
||||
|
||||
export function armAiVaultServiceCancellationTimeout(
|
||||
call: AiVaultServicePendingCall,
|
||||
onExpired: () => void
|
||||
): void {
|
||||
if (call.timer) {
|
||||
clearTimeout(call.timer)
|
||||
}
|
||||
call.timer = setTimeout(onExpired, AI_VAULT_SERVICE_SHUTDOWN_TIMEOUT_MS)
|
||||
call.timer.unref?.()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function requeueAiVaultServiceStart(
|
||||
call: AiVaultServicePendingCall,
|
||||
queue: AiVaultServicePendingCall[]
|
||||
): boolean {
|
||||
if (call.sent || call.cancelled || call.startRetried) {
|
||||
return false
|
||||
}
|
||||
call.startRetried = true
|
||||
queue.unshift(call)
|
||||
return true
|
||||
}
|
||||
|
||||
export function clearAiVaultServiceCall(call: AiVaultServicePendingCall): void {
|
||||
if (call.timer) {
|
||||
clearTimeout(call.timer)
|
||||
call.timer = null
|
||||
}
|
||||
if (call.signal && call.onAbort) {
|
||||
call.signal.removeEventListener('abort', call.onAbort)
|
||||
call.onAbort = null
|
||||
}
|
||||
}
|
||||
|
||||
export function rejectAiVaultServiceCall(call: AiVaultServicePendingCall, error: Error): void {
|
||||
clearAiVaultServiceCall(call)
|
||||
if (!call.cancelled) {
|
||||
call.reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
export type AiVaultServicePendingCall = {
|
||||
request: AiVaultServiceRequest
|
||||
lane: AiVaultServiceLane
|
||||
signal?: AbortSignal
|
||||
resolve: (value: unknown) => void
|
||||
reject: (error: Error) => void
|
||||
timer: NodeJS.Timeout | null
|
||||
onAbort: (() => void) | null
|
||||
cancelled: boolean
|
||||
/** Whether the child received the request; an unsent call gets no reply. */
|
||||
sent: boolean
|
||||
startRetried: boolean
|
||||
}
|
||||
|
||||
export type AiVaultServiceReadyWaiter = {
|
||||
promise: Promise<ChildProcess>
|
||||
resolve: (child: ChildProcess) => void
|
||||
reject: (error: Error) => void
|
||||
timer: NodeJS.Timeout
|
||||
}
|
||||
|
||||
export class AiVaultServiceIdleRetirement {
|
||||
private timer: NodeJS.Timeout | null = null
|
||||
|
||||
clear(): void {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = null
|
||||
}
|
||||
}
|
||||
|
||||
schedule(busy: boolean, timeoutMs: number, retire: () => void): void {
|
||||
if (busy || this.timer) {
|
||||
return
|
||||
}
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = null
|
||||
retire()
|
||||
}, timeoutMs)
|
||||
this.timer.unref?.()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AiVaultScannerServiceClient } from './session-scanner-service-client'
|
||||
import { AI_VAULT_SERVICE_READY_TIMEOUT_MS } from './session-scanner-service-client-state'
|
||||
import {
|
||||
AiVaultServiceTestChild,
|
||||
aiVaultServiceRequestId,
|
||||
readyAiVaultServiceChild
|
||||
} from './session-scanner-service-test-child'
|
||||
|
||||
function setup(idleTimeoutMs?: number): {
|
||||
child: AiVaultServiceTestChild
|
||||
client: AiVaultScannerServiceClient
|
||||
} {
|
||||
const child = new AiVaultServiceTestChild()
|
||||
const client = new AiVaultScannerServiceClient({
|
||||
processFactory: () => child.asChildProcess(),
|
||||
init: { sessionParseCache: null },
|
||||
idleTimeoutMs
|
||||
})
|
||||
return { child, client }
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('AiVaultScannerServiceClient', () => {
|
||||
it('waits for ready and runs cache and interactive lanes independently', async () => {
|
||||
const { child, client } = setup()
|
||||
const titles = client.request({ type: 'request', operation: 'titles', requests: [] })
|
||||
const subagents = client.request({
|
||||
type: 'request',
|
||||
operation: 'subagents',
|
||||
request: { agent: 'claude', parentFilePath: '/tmp/parent.jsonl' }
|
||||
})
|
||||
|
||||
expect(child.sent).toEqual([expect.objectContaining({ type: 'init', protocol: 1 })])
|
||||
readyAiVaultServiceChild(child)
|
||||
await Promise.resolve()
|
||||
expect(child.sent).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ operation: 'titles' }),
|
||||
expect.objectContaining({ operation: 'subagents' })
|
||||
])
|
||||
)
|
||||
|
||||
child.emit('message', {
|
||||
type: 'result',
|
||||
id: aiVaultServiceRequestId(child, 'titles'),
|
||||
operation: 'titles',
|
||||
value: { titles: [] }
|
||||
})
|
||||
child.emit('message', {
|
||||
type: 'result',
|
||||
id: aiVaultServiceRequestId(child, 'subagents'),
|
||||
operation: 'subagents',
|
||||
value: { sessions: [], issues: [] }
|
||||
})
|
||||
await expect(titles).resolves.toEqual({ titles: [] })
|
||||
await expect(subagents).resolves.toEqual({ sessions: [], issues: [] })
|
||||
client.dispose()
|
||||
})
|
||||
|
||||
it('bounds active and queued calls together at sixteen', async () => {
|
||||
const { child, client } = setup()
|
||||
const calls = Array.from({ length: 16 }, () =>
|
||||
client.request({ type: 'request', operation: 'titles', requests: [] })
|
||||
)
|
||||
|
||||
await expect(
|
||||
client.request({ type: 'request', operation: 'titles', requests: [] })
|
||||
).rejects.toThrow('queue is full')
|
||||
readyAiVaultServiceChild(child)
|
||||
client.dispose()
|
||||
await Promise.all(calls.map((call) => expect(call).rejects.toThrow('disposed')))
|
||||
})
|
||||
|
||||
it('cancels active work and kills a child that ignores cancellation', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { child, client } = setup()
|
||||
const controller = new AbortController()
|
||||
const request = client.request(
|
||||
{ type: 'request', operation: 'titles', requests: [] },
|
||||
controller.signal
|
||||
)
|
||||
readyAiVaultServiceChild(child)
|
||||
await Promise.resolve()
|
||||
const id = aiVaultServiceRequestId(child, 'titles')
|
||||
|
||||
controller.abort()
|
||||
await expect(request).rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(child.sent).toContainEqual({ type: 'cancel', id })
|
||||
vi.advanceTimersByTime(1_999)
|
||||
expect(child.killed).toBe(false)
|
||||
vi.advanceTimersByTime(1)
|
||||
expect(child.killed).toBe(true)
|
||||
client.dispose()
|
||||
})
|
||||
|
||||
it('drops a call cancelled before the child received it', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { child, client } = setup()
|
||||
const controller = new AbortController()
|
||||
const cancelled = client.request(
|
||||
{ type: 'request', operation: 'titles', requests: [] },
|
||||
controller.signal
|
||||
)
|
||||
|
||||
controller.abort()
|
||||
await expect(cancelled).rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(child.sent).not.toContainEqual(expect.objectContaining({ type: 'cancel' }))
|
||||
|
||||
readyAiVaultServiceChild(child)
|
||||
const next = client.request({ type: 'request', operation: 'titles', requests: [] })
|
||||
await Promise.resolve()
|
||||
child.emit('message', {
|
||||
type: 'result',
|
||||
id: aiVaultServiceRequestId(child, 'titles'),
|
||||
operation: 'titles',
|
||||
value: { titles: [] }
|
||||
})
|
||||
await expect(next).resolves.toEqual({ titles: [] })
|
||||
vi.advanceTimersByTime(2_000)
|
||||
|
||||
expect(child.killed).toBe(false)
|
||||
client.dispose()
|
||||
})
|
||||
|
||||
it('retries once when the first cold start misses the ready deadline', async () => {
|
||||
vi.useFakeTimers()
|
||||
const children: AiVaultServiceTestChild[] = []
|
||||
const client = new AiVaultScannerServiceClient({
|
||||
processFactory: () => {
|
||||
const child = new AiVaultServiceTestChild(12_345 + children.length)
|
||||
children.push(child)
|
||||
return child.asChildProcess()
|
||||
},
|
||||
init: { sessionParseCache: null }
|
||||
})
|
||||
const titles = client.request({ type: 'request', operation: 'titles', requests: [] })
|
||||
expect(children).toHaveLength(1)
|
||||
|
||||
vi.advanceTimersByTime(AI_VAULT_SERVICE_READY_TIMEOUT_MS)
|
||||
expect(children[0]!.killed).toBe(true)
|
||||
await Promise.resolve()
|
||||
vi.advanceTimersByTime(250)
|
||||
expect(children).toHaveLength(2)
|
||||
readyAiVaultServiceChild(children[1]!)
|
||||
await Promise.resolve()
|
||||
children[1]!.emit('message', {
|
||||
type: 'result',
|
||||
id: aiVaultServiceRequestId(children[1]!, 'titles'),
|
||||
operation: 'titles',
|
||||
value: { titles: [] }
|
||||
})
|
||||
|
||||
await expect(titles).resolves.toEqual({ titles: [] })
|
||||
client.dispose()
|
||||
})
|
||||
|
||||
it('surfaces the startup error when the retried cold start also fails', async () => {
|
||||
vi.useFakeTimers()
|
||||
const children: AiVaultServiceTestChild[] = []
|
||||
const client = new AiVaultScannerServiceClient({
|
||||
processFactory: () => {
|
||||
const child = new AiVaultServiceTestChild(12_345 + children.length)
|
||||
children.push(child)
|
||||
return child.asChildProcess()
|
||||
},
|
||||
init: { sessionParseCache: null }
|
||||
})
|
||||
const titles = client.request({ type: 'request', operation: 'titles', requests: [] })
|
||||
|
||||
vi.advanceTimersByTime(AI_VAULT_SERVICE_READY_TIMEOUT_MS)
|
||||
await Promise.resolve()
|
||||
vi.advanceTimersByTime(250)
|
||||
expect(children).toHaveLength(2)
|
||||
vi.advanceTimersByTime(AI_VAULT_SERVICE_READY_TIMEOUT_MS)
|
||||
|
||||
await expect(titles).rejects.toThrow('did not become ready')
|
||||
client.dispose()
|
||||
})
|
||||
|
||||
it('acknowledges cache invalidation through the running child', async () => {
|
||||
const { child, client } = setup()
|
||||
const invalidation = client.invalidate(['/tmp/deleted.jsonl'])
|
||||
readyAiVaultServiceChild(child)
|
||||
await vi.waitFor(() =>
|
||||
expect(child.sent).toContainEqual({
|
||||
type: 'invalidate',
|
||||
generation: 1,
|
||||
paths: ['/tmp/deleted.jsonl']
|
||||
})
|
||||
)
|
||||
child.emit('message', { type: 'invalidated', generation: 1 })
|
||||
|
||||
await expect(invalidation).resolves.toBeUndefined()
|
||||
client.dispose()
|
||||
})
|
||||
|
||||
it('leaves a scanning child alone when cache invalidation is slow to acknowledge', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { child, client } = setup()
|
||||
const scan = client.request({ type: 'request', operation: 'scan', options: {} })
|
||||
readyAiVaultServiceChild(child)
|
||||
await Promise.resolve()
|
||||
|
||||
const invalidation = client.invalidate(['/tmp/deleted.jsonl'])
|
||||
await Promise.resolve()
|
||||
vi.advanceTimersByTime(AI_VAULT_SERVICE_READY_TIMEOUT_MS)
|
||||
|
||||
// The scan owns liveness through its own 130s deadline; killing the child
|
||||
// here would abort it and burn a slot toward the restart circuit.
|
||||
await expect(invalidation).resolves.toBeUndefined()
|
||||
expect(child.killed).toBe(false)
|
||||
|
||||
child.emit('message', {
|
||||
type: 'result',
|
||||
id: aiVaultServiceRequestId(child, 'scan'),
|
||||
operation: 'scan',
|
||||
value: { result: { sessions: [], issues: [], scannedAt: '2026-08-10' }, durationMs: 1 }
|
||||
})
|
||||
await expect(scan).resolves.toMatchObject({ result: { sessions: [] } })
|
||||
client.dispose()
|
||||
})
|
||||
|
||||
it('replaces a child that does not acknowledge cache invalidation', async () => {
|
||||
vi.useFakeTimers()
|
||||
const children: AiVaultServiceTestChild[] = []
|
||||
const client = new AiVaultScannerServiceClient({
|
||||
processFactory: () => {
|
||||
const child = new AiVaultServiceTestChild(12_345 + children.length)
|
||||
children.push(child)
|
||||
return child.asChildProcess()
|
||||
},
|
||||
init: { sessionParseCache: null }
|
||||
})
|
||||
const invalidation = client.invalidate(['/tmp/deleted.jsonl'])
|
||||
readyAiVaultServiceChild(children[0]!)
|
||||
await Promise.resolve()
|
||||
|
||||
vi.advanceTimersByTime(AI_VAULT_SERVICE_READY_TIMEOUT_MS)
|
||||
|
||||
await expect(invalidation).rejects.toThrow('cache invalidation timed out')
|
||||
expect(children[0]!.killed).toBe(true)
|
||||
const titles = client.request({ type: 'request', operation: 'titles', requests: [] })
|
||||
expect(children).toHaveLength(1)
|
||||
vi.advanceTimersByTime(250)
|
||||
expect(children).toHaveLength(2)
|
||||
readyAiVaultServiceChild(children[1]!)
|
||||
await Promise.resolve()
|
||||
children[1]!.emit('message', {
|
||||
type: 'result',
|
||||
id: aiVaultServiceRequestId(children[1]!, 'titles'),
|
||||
operation: 'titles',
|
||||
value: { titles: [] }
|
||||
})
|
||||
|
||||
await expect(titles).resolves.toEqual({ titles: [] })
|
||||
client.dispose()
|
||||
})
|
||||
|
||||
it('keeps invalidation-only children alive through acknowledgement, then retires them', async () => {
|
||||
vi.useFakeTimers()
|
||||
const children: AiVaultServiceTestChild[] = []
|
||||
const client = new AiVaultScannerServiceClient({
|
||||
processFactory: () => {
|
||||
const child = new AiVaultServiceTestChild(12_345 + children.length)
|
||||
children.push(child)
|
||||
return child.asChildProcess()
|
||||
},
|
||||
init: { sessionParseCache: null },
|
||||
idleTimeoutMs: 100
|
||||
})
|
||||
|
||||
const first = client.request({ type: 'request', operation: 'titles', requests: [] })
|
||||
readyAiVaultServiceChild(children[0]!)
|
||||
await Promise.resolve()
|
||||
children[0]!.emit('message', {
|
||||
type: 'result',
|
||||
id: aiVaultServiceRequestId(children[0]!, 'titles'),
|
||||
operation: 'titles',
|
||||
value: { titles: [] }
|
||||
})
|
||||
await first
|
||||
vi.advanceTimersByTime(100)
|
||||
|
||||
const invalidation = client.invalidate(['/tmp/deleted.jsonl'])
|
||||
readyAiVaultServiceChild(children[1]!)
|
||||
await Promise.resolve()
|
||||
vi.advanceTimersByTime(100)
|
||||
expect(children[1]!.sent).not.toContainEqual({ type: 'shutdown' })
|
||||
children[1]!.emit('message', { type: 'invalidated', generation: 1 })
|
||||
await invalidation
|
||||
vi.advanceTimersByTime(100)
|
||||
|
||||
expect(children[1]!.sent).toContainEqual({ type: 'shutdown' })
|
||||
client.dispose()
|
||||
})
|
||||
|
||||
it('faults the child on malformed output without affecting later processes', async () => {
|
||||
const { child, client } = setup()
|
||||
const request = client.request({ type: 'request', operation: 'titles', requests: [] })
|
||||
readyAiVaultServiceChild(child)
|
||||
await Promise.resolve()
|
||||
|
||||
child.emit('message', { nope: true })
|
||||
|
||||
await expect(request).rejects.toThrow('malformed')
|
||||
expect(child.killed).toBe(true)
|
||||
client.dispose()
|
||||
})
|
||||
|
||||
it('retires an idle child gracefully, then kills it after the shutdown bound', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { child, client } = setup(100)
|
||||
const request = client.request({ type: 'request', operation: 'titles', requests: [] })
|
||||
readyAiVaultServiceChild(child)
|
||||
await Promise.resolve()
|
||||
child.emit('message', {
|
||||
type: 'result',
|
||||
id: aiVaultServiceRequestId(child, 'titles'),
|
||||
operation: 'titles',
|
||||
value: { titles: [] }
|
||||
})
|
||||
await request
|
||||
|
||||
vi.advanceTimersByTime(100)
|
||||
expect(child.sent).toContainEqual({ type: 'shutdown' })
|
||||
vi.advanceTimersByTime(2_000)
|
||||
expect(child.killed).toBe(true)
|
||||
client.dispose()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,325 @@
|
||||
import type { ChildProcess } from 'node:child_process'
|
||||
import { createAiVaultScanCancelledError } from './ai-vault-scan-cancellation'
|
||||
import {
|
||||
AI_VAULT_SERVICE_IDLE_TIMEOUT_MS,
|
||||
AI_VAULT_SERVICE_INTERACTIVE_TIMEOUT_MS,
|
||||
AI_VAULT_SERVICE_MAX_CALLS,
|
||||
AI_VAULT_SERVICE_READY_TIMEOUT_MS,
|
||||
AI_VAULT_SERVICE_SCAN_TIMEOUT_MS,
|
||||
AiVaultServiceIdleRetirement,
|
||||
AiVaultServiceInvalidations,
|
||||
armAiVaultServiceCancellationTimeout,
|
||||
clearAiVaultServiceCall,
|
||||
createAiVaultServiceReadyWaiter,
|
||||
rejectAiVaultServiceCall,
|
||||
requeueAiVaultServiceStart,
|
||||
retireAiVaultServiceChild,
|
||||
type AiVaultServiceClientOptions,
|
||||
type AiVaultServicePendingCall,
|
||||
type AiVaultServiceReadyWaiter
|
||||
} 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
|
||||
} from './session-scanner-service-protocol'
|
||||
|
||||
export class AiVaultScannerServiceClient {
|
||||
private child: ChildProcess | null = null
|
||||
private readyWaiter: AiVaultServiceReadyWaiter | null = null
|
||||
private readonly active = new Map<AiVaultServicePendingCall['lane'], AiVaultServicePendingCall>()
|
||||
private readonly queue: AiVaultServicePendingCall[] = []
|
||||
private readonly invalidations = new AiVaultServiceInvalidations()
|
||||
private nextId = 1
|
||||
private readonly idleRetirement = new AiVaultServiceIdleRetirement()
|
||||
private readonly restartPolicy = new AiVaultServiceRestartPolicy()
|
||||
private disposed = false
|
||||
|
||||
constructor(private readonly options: AiVaultServiceClientOptions) {}
|
||||
|
||||
request<T>(body: AiVaultServiceRequestBody, signal?: AbortSignal): Promise<T> {
|
||||
if (this.disposed) {
|
||||
return Promise.reject(new Error('AI Vault service client was disposed.'))
|
||||
}
|
||||
if (signal?.aborted) {
|
||||
return Promise.reject(createAiVaultScanCancelledError())
|
||||
}
|
||||
if (this.queue.length + this.active.size >= AI_VAULT_SERVICE_MAX_CALLS) {
|
||||
return Promise.reject(new Error('AI Vault service queue is full.'))
|
||||
}
|
||||
const request = { ...body, id: this.nextId++ } as AiVaultServiceRequest
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const call: AiVaultServicePendingCall = {
|
||||
request,
|
||||
lane: aiVaultServiceLane(request.operation),
|
||||
signal,
|
||||
resolve: resolve as (value: unknown) => void,
|
||||
reject,
|
||||
timer: null,
|
||||
onAbort: null,
|
||||
cancelled: false,
|
||||
sent: false,
|
||||
startRetried: false
|
||||
}
|
||||
if (signal) {
|
||||
call.onAbort = () => this.cancel(call)
|
||||
signal.addEventListener('abort', call.onAbort, { once: true })
|
||||
}
|
||||
this.queue.push(call)
|
||||
this.idleRetirement.clear()
|
||||
this.pump()
|
||||
})
|
||||
}
|
||||
|
||||
async invalidate(paths: string[]): Promise<void> {
|
||||
if (paths.length === 0 || this.disposed) {
|
||||
return
|
||||
}
|
||||
this.idleRetirement.clear()
|
||||
const child = await this.ensureChild()
|
||||
return this.invalidations.open(
|
||||
AI_VAULT_SERVICE_READY_TIMEOUT_MS,
|
||||
(generation) => this.onInvalidationDeadline(generation),
|
||||
(generation) => child.send({ type: 'invalidate', generation, paths })
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The deadline is a startup-sized budget, but a child mid-scan can be slow to
|
||||
* turn the channel around. Fork IPC ordering already guarantees the child
|
||||
* applies the invalidation before any request sent after it, so a busy child
|
||||
* owes nothing here — only an idle one that misses the deadline is wedged.
|
||||
*/
|
||||
private onInvalidationDeadline(generation: number): void {
|
||||
if (this.active.size > 0) {
|
||||
this.invalidations.settle(generation)
|
||||
return
|
||||
}
|
||||
this.onFault(new Error('AI Vault service cache invalidation timed out.'))
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.disposed) {
|
||||
return
|
||||
}
|
||||
this.disposed = true
|
||||
this.restartPolicy.dispose()
|
||||
this.idleRetirement.clear()
|
||||
const error = new Error('AI Vault service client was disposed.')
|
||||
for (const call of [...this.active.values(), ...this.queue]) {
|
||||
rejectAiVaultServiceCall(call, error)
|
||||
}
|
||||
this.active.clear()
|
||||
this.queue.length = 0
|
||||
this.invalidations.rejectAll(error)
|
||||
this.retireChild()
|
||||
}
|
||||
|
||||
private pump(): void {
|
||||
if (this.restartPolicy.restartScheduled) {
|
||||
return
|
||||
}
|
||||
for (const lane of ['cache', 'interactive'] as const) {
|
||||
if (this.active.has(lane)) {
|
||||
continue
|
||||
}
|
||||
const index = this.queue.findIndex((call) => call.lane === lane)
|
||||
if (index < 0) {
|
||||
continue
|
||||
}
|
||||
const call = this.queue.splice(index, 1)[0]!
|
||||
this.active.set(lane, call)
|
||||
void this.ensureChild().then(
|
||||
(child) => this.sendCall(child, call),
|
||||
(error: Error) => {
|
||||
if (this.active.get(lane) !== call) {
|
||||
return
|
||||
}
|
||||
this.active.delete(lane)
|
||||
this.retryStartOrReject(call, error)
|
||||
this.pump()
|
||||
}
|
||||
)
|
||||
}
|
||||
this.scheduleIdleIfNeeded()
|
||||
}
|
||||
|
||||
private sendCall(child: ChildProcess, call: AiVaultServicePendingCall): void {
|
||||
if (call.cancelled || this.active.get(call.lane) !== call) {
|
||||
return
|
||||
}
|
||||
const timeoutMs =
|
||||
call.request.operation === 'scan'
|
||||
? AI_VAULT_SERVICE_SCAN_TIMEOUT_MS
|
||||
: AI_VAULT_SERVICE_INTERACTIVE_TIMEOUT_MS
|
||||
call.timer = setTimeout(() => {
|
||||
this.onFault(new Error(`AI Vault service timed out after ${timeoutMs}ms.`))
|
||||
}, timeoutMs)
|
||||
call.timer.unref?.()
|
||||
call.sent = true
|
||||
child.send(call.request)
|
||||
}
|
||||
|
||||
private retryStartOrReject(call: AiVaultServicePendingCall, error: Error): void {
|
||||
if (
|
||||
this.disposed ||
|
||||
!this.restartPolicy.restartScheduled ||
|
||||
!requeueAiVaultServiceStart(call, this.queue)
|
||||
) {
|
||||
rejectAiVaultServiceCall(call, error)
|
||||
}
|
||||
}
|
||||
|
||||
private ensureChild(): Promise<ChildProcess> {
|
||||
if (this.child && !this.readyWaiter) {
|
||||
return Promise.resolve(this.child)
|
||||
}
|
||||
if (this.readyWaiter) {
|
||||
return this.readyWaiter.promise
|
||||
}
|
||||
const startError = this.restartPolicy.startError()
|
||||
if (startError) {
|
||||
return Promise.reject(startError)
|
||||
}
|
||||
let child: ChildProcess
|
||||
try {
|
||||
child = this.options.processFactory()
|
||||
} catch (error) {
|
||||
this.restartPolicy.recordFault(() => this.pump())
|
||||
return Promise.reject(error instanceof Error ? error : new Error(String(error)))
|
||||
}
|
||||
this.child = child
|
||||
const waiter = createAiVaultServiceReadyWaiter(AI_VAULT_SERVICE_READY_TIMEOUT_MS, () =>
|
||||
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)
|
||||
return waiter.promise
|
||||
}
|
||||
|
||||
private onMessage(raw: unknown): void {
|
||||
if (!isAiVaultServiceChildMessage(raw)) {
|
||||
this.onFault(new Error('AI Vault service sent a malformed message.'))
|
||||
return
|
||||
}
|
||||
const message = raw as AiVaultServiceChildMessage
|
||||
if (message.type === 'ready') {
|
||||
const waiter = this.readyWaiter
|
||||
if (!waiter || !this.child) {
|
||||
return
|
||||
}
|
||||
clearTimeout(waiter.timer)
|
||||
this.readyWaiter = null
|
||||
waiter.resolve(this.child)
|
||||
return
|
||||
}
|
||||
if (message.type === 'invalidated') {
|
||||
if (this.invalidations.settle(message.generation)) {
|
||||
this.scheduleIdleIfNeeded()
|
||||
}
|
||||
return
|
||||
}
|
||||
const call = [...this.active.values()].find((entry) => entry.request.id === message.id)
|
||||
if (!call) {
|
||||
return
|
||||
}
|
||||
this.active.delete(call.lane)
|
||||
clearAiVaultServiceCall(call)
|
||||
if (!call.cancelled) {
|
||||
if (message.type === 'error') {
|
||||
call.reject(new Error(message.message))
|
||||
} else {
|
||||
call.resolve((message as { value: AiVaultServiceResultValue['value'] }).value)
|
||||
}
|
||||
}
|
||||
this.pump()
|
||||
}
|
||||
|
||||
private cancel(call: AiVaultServicePendingCall): void {
|
||||
if (call.cancelled) {
|
||||
return
|
||||
}
|
||||
call.cancelled = true
|
||||
call.reject(createAiVaultScanCancelledError())
|
||||
const queuedIndex = this.queue.indexOf(call)
|
||||
if (queuedIndex >= 0) {
|
||||
this.queue.splice(queuedIndex, 1)
|
||||
clearAiVaultServiceCall(call)
|
||||
this.pump()
|
||||
return
|
||||
}
|
||||
if (this.active.get(call.lane) === call) {
|
||||
// Why: a call cancelled before it reached the child gets no acknowledgement,
|
||||
// so waiting on one would kill a healthy service and stall the lane.
|
||||
if (!call.sent) {
|
||||
this.active.delete(call.lane)
|
||||
clearAiVaultServiceCall(call)
|
||||
this.pump()
|
||||
return
|
||||
}
|
||||
this.child?.send({ type: 'cancel', id: call.request.id })
|
||||
armAiVaultServiceCancellationTimeout(call, () =>
|
||||
this.onFault(new Error('AI Vault service did not cancel within 2000ms.'))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private onFault(error: Error): void {
|
||||
const child = this.child
|
||||
if (!child) {
|
||||
return
|
||||
}
|
||||
this.child = null
|
||||
child.removeAllListeners()
|
||||
child.kill()
|
||||
if (this.readyWaiter) {
|
||||
clearTimeout(this.readyWaiter.timer)
|
||||
this.readyWaiter.reject(error)
|
||||
this.readyWaiter = null
|
||||
}
|
||||
// Recorded before the pending calls are settled so retryStartOrReject can see
|
||||
// whether a respawn is actually coming.
|
||||
this.restartPolicy.recordFault(() => this.pump())
|
||||
const active = [...this.active.values()]
|
||||
this.active.clear()
|
||||
for (const call of active) {
|
||||
this.retryStartOrReject(call, error)
|
||||
}
|
||||
this.invalidations.rejectAll(error)
|
||||
}
|
||||
|
||||
private scheduleIdleIfNeeded(): void {
|
||||
this.idleRetirement.schedule(
|
||||
this.active.size > 0 || this.queue.length > 0 || this.invalidations.size > 0 || !this.child,
|
||||
this.options.idleTimeoutMs ?? AI_VAULT_SERVICE_IDLE_TIMEOUT_MS,
|
||||
() => this.retireChild()
|
||||
)
|
||||
}
|
||||
|
||||
private retireChild(): void {
|
||||
this.idleRetirement.clear()
|
||||
const child = this.child
|
||||
this.child = null
|
||||
if (!child) {
|
||||
return
|
||||
}
|
||||
retireAiVaultServiceChild(child)
|
||||
}
|
||||
}
|
||||
|
||||
export type { AiVaultServiceProcessFactory } from './session-scanner-service-client-state'
|
||||
@@ -0,0 +1,58 @@
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
resolveAiVaultServiceEntryPath,
|
||||
resolveAiVaultServiceEntryPathWithoutApp
|
||||
} from './session-scanner-service-entry-path'
|
||||
|
||||
describe('resolveAiVaultServiceEntryPath', () => {
|
||||
it('uses the adjacent electron-vite output when present', () => {
|
||||
const outMain = join(process.cwd(), 'out', 'main')
|
||||
const adjacent = join(outMain, 'session-scanner-service-entry.js')
|
||||
|
||||
expect(resolveAiVaultServiceEntryPath(outMain, false, (path) => path === adjacent)).toBe(
|
||||
adjacent
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the nested output from a project root', () => {
|
||||
expect(resolveAiVaultServiceEntryPath(process.cwd(), false, () => false)).toBe(
|
||||
join(process.cwd(), 'out', 'main', 'session-scanner-service-entry.js')
|
||||
)
|
||||
})
|
||||
|
||||
it('uses app.asar.unpacked for packaged Electron', () => {
|
||||
const appPath = join('C:', 'Orca', 'resources', 'app.asar')
|
||||
|
||||
expect(resolveAiVaultServiceEntryPath(appPath, true)).toBe(
|
||||
join(
|
||||
'C:',
|
||||
'Orca',
|
||||
'resources',
|
||||
'app.asar.unpacked',
|
||||
'out',
|
||||
'main',
|
||||
'session-scanner-service-entry.js'
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('uses resourcesPath from packaged Electron-as-Node runtimes', () => {
|
||||
const resourcesPath = join('Applications', 'Orca.app', 'Contents', 'Resources')
|
||||
const entry = join(
|
||||
resourcesPath,
|
||||
'app.asar.unpacked',
|
||||
'out',
|
||||
'main',
|
||||
'session-scanner-service-entry.js'
|
||||
)
|
||||
|
||||
expect(
|
||||
resolveAiVaultServiceEntryPathWithoutApp(
|
||||
'/unrelated/cwd',
|
||||
resourcesPath,
|
||||
(path) => path === entry
|
||||
)
|
||||
).toBe(entry)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
type ElectronAppPath = { getAppPath(): string; isPackaged: boolean }
|
||||
|
||||
function loadElectronApp(): ElectronAppPath | null {
|
||||
try {
|
||||
return require('electron').app ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveAiVaultServiceEntryPath(
|
||||
appPath: string,
|
||||
isPackaged: boolean,
|
||||
pathExists: (candidate: string) => boolean = existsSync
|
||||
): string {
|
||||
const basePath = isPackaged ? appPath.replace('app.asar', 'app.asar.unpacked') : appPath
|
||||
const adjacentEntry = join(basePath, 'session-scanner-service-entry.js')
|
||||
if (!isPackaged && pathExists(adjacentEntry)) {
|
||||
return adjacentEntry
|
||||
}
|
||||
return join(basePath, 'out', 'main', 'session-scanner-service-entry.js')
|
||||
}
|
||||
|
||||
export function resolveAiVaultServiceEntryPathWithoutApp(
|
||||
cwd: string,
|
||||
resourcesPath: string | undefined,
|
||||
pathExists: (candidate: string) => boolean = existsSync
|
||||
): string {
|
||||
if (resourcesPath) {
|
||||
const packagedEntry = join(
|
||||
resourcesPath,
|
||||
'app.asar.unpacked',
|
||||
'out',
|
||||
'main',
|
||||
'session-scanner-service-entry.js'
|
||||
)
|
||||
if (pathExists(packagedEntry)) {
|
||||
return packagedEntry
|
||||
}
|
||||
}
|
||||
return resolveAiVaultServiceEntryPath(cwd, false, pathExists)
|
||||
}
|
||||
|
||||
export function getAiVaultServiceEntryPath(): string {
|
||||
const app = loadElectronApp()
|
||||
return app
|
||||
? resolveAiVaultServiceEntryPath(app.getAppPath(), app.isPackaged)
|
||||
: resolveAiVaultServiceEntryPathWithoutApp(process.cwd(), process.resourcesPath)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AI_VAULT_SERVICE_PROTOCOL_VERSION } from './session-scanner-service-protocol'
|
||||
|
||||
const invalidateSessionParseCacheEntry = vi.hoisted(() => vi.fn())
|
||||
const scanAiVaultSessions = vi.hoisted(() => vi.fn())
|
||||
|
||||
// Only the invalidation hook is replaced; the title reader shares this module.
|
||||
vi.mock('./session-scanner-parse-cache', async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
invalidateSessionParseCacheEntry
|
||||
}))
|
||||
vi.mock('./session-scanner', () => ({ scanAiVaultSessions }))
|
||||
vi.mock('./session-parse-cache-persistence', () => ({
|
||||
flushSessionParseCachePersist: vi.fn(() => Promise.resolve()),
|
||||
initSessionParseCachePersistence: vi.fn()
|
||||
}))
|
||||
vi.mock('./session-subagent-reader', () => ({
|
||||
listLocalAiVaultSubagentSessions: vi.fn(() => Promise.resolve({ sessions: [], issues: [] }))
|
||||
}))
|
||||
|
||||
const sent: { type: string; id?: number }[] = []
|
||||
|
||||
function emit(message: unknown): void {
|
||||
process.emit('message', message as never, undefined as never)
|
||||
}
|
||||
|
||||
async function runScan(id: number): Promise<void> {
|
||||
emit({ type: 'request', id, operation: 'scan', options: {} })
|
||||
await vi.waitFor(() => expect(sent.some((message) => message.id === id)).toBe(true))
|
||||
}
|
||||
|
||||
function invalidationsFor(path: string): number {
|
||||
return invalidateSessionParseCacheEntry.mock.calls.filter((call) => call[0] === path).length
|
||||
}
|
||||
|
||||
describe('AI Vault service entry cache invalidation', () => {
|
||||
beforeAll(async () => {
|
||||
process.send = ((message: { type: string; id?: number }) => {
|
||||
sent.push(message)
|
||||
return true
|
||||
}) as typeof process.send
|
||||
await import('./session-scanner-service-entry')
|
||||
emit({ type: 'init', protocol: AI_VAULT_SERVICE_PROTOCOL_VERSION })
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
sent.length = 0
|
||||
invalidateSessionParseCacheEntry.mockClear()
|
||||
scanAiVaultSessions.mockResolvedValue({ sessions: [], issues: [], scannedAt: '2026-08-10' })
|
||||
})
|
||||
|
||||
it('re-applies an invalidation once and then stops re-evicting the path', async () => {
|
||||
emit({ type: 'invalidate', generation: 1, paths: ['/transcripts/a.jsonl'] })
|
||||
expect(invalidationsFor('/transcripts/a.jsonl')).toBe(1)
|
||||
|
||||
// The request that overlapped the invalidation still gets the re-apply, so a
|
||||
// read that started before it cannot leave pre-edit content cached.
|
||||
await runScan(1)
|
||||
expect(invalidationsFor('/transcripts/a.jsonl')).toBe(2)
|
||||
|
||||
// Every later request must not keep paying for a consumed invalidation.
|
||||
await runScan(2)
|
||||
expect(invalidationsFor('/transcripts/a.jsonl')).toBe(2)
|
||||
})
|
||||
|
||||
it('keeps re-applying while another request is still executing', async () => {
|
||||
let releaseSlowScan: (() => void) | undefined
|
||||
scanAiVaultSessions.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
releaseSlowScan = () =>
|
||||
resolve({ sessions: [], issues: [], scannedAt: '2026-08-10' } as never)
|
||||
})
|
||||
)
|
||||
// 'subagents' runs on the interactive lane, so it overlaps the cache-lane
|
||||
// scan; 'titles' would not, it shares the cache lane with scans.
|
||||
emit({ type: 'request', id: 10, operation: 'scan', options: {} })
|
||||
await vi.waitFor(() => expect(releaseSlowScan).toBeDefined())
|
||||
|
||||
emit({ type: 'invalidate', generation: 2, paths: ['/transcripts/b.jsonl'] })
|
||||
emit({ type: 'request', id: 11, operation: 'subagents', request: {} })
|
||||
await vi.waitFor(() => expect(sent.some((message) => message.id === 11)).toBe(true))
|
||||
|
||||
// The scan is still reading, so the path stays armed rather than draining.
|
||||
expect(invalidationsFor('/transcripts/b.jsonl')).toBe(2)
|
||||
|
||||
releaseSlowScan?.()
|
||||
await vi.waitFor(() => expect(sent.some((message) => message.id === 10)).toBe(true))
|
||||
expect(invalidationsFor('/transcripts/b.jsonl')).toBe(3)
|
||||
|
||||
await runScan(12)
|
||||
expect(invalidationsFor('/transcripts/b.jsonl')).toBe(3)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,204 @@
|
||||
import type { AiVaultSessionTitle } from '../../shared/ai-vault-session-title'
|
||||
import { readAiVaultFirstUserPrompt } from './session-first-user-prompt-read'
|
||||
import {
|
||||
flushSessionParseCachePersist,
|
||||
initSessionParseCachePersistence
|
||||
} from './session-parse-cache-persistence'
|
||||
import { scanAiVaultSessions } from './session-scanner'
|
||||
import { invalidateSessionParseCacheEntry } from './session-scanner-parse-cache'
|
||||
import {
|
||||
AI_VAULT_SERVICE_PROTOCOL_VERSION,
|
||||
aiVaultServiceLane,
|
||||
cacheServiceTitle,
|
||||
isAiVaultServiceRequest,
|
||||
type AiVaultServiceChildMessage,
|
||||
type AiVaultServiceParentMessage,
|
||||
type AiVaultServiceRequest,
|
||||
type AiVaultServiceResultValue
|
||||
} from './session-scanner-service-protocol'
|
||||
import { readAiVaultSessionTitlesFromFiles } from './session-title-file-reader'
|
||||
import { resolveHostReadableAiVaultTitleRequests } from './session-title-request-paths'
|
||||
import { listLocalAiVaultSubagentSessions } from './session-subagent-reader'
|
||||
|
||||
if (!process.send) {
|
||||
throw new Error('AI Vault service requires a parent IPC channel.')
|
||||
}
|
||||
|
||||
const controllers = new Map<number, AbortController>()
|
||||
const cancelled = new Set<number>()
|
||||
const pending = new Set<number>()
|
||||
const titleIndex = new Map<string, AiVaultSessionTitle>()
|
||||
const invalidatedPaths = new Set<string>()
|
||||
let initialized = false
|
||||
let shuttingDown = false
|
||||
let cacheLane = Promise.resolve()
|
||||
let interactiveLane = Promise.resolve()
|
||||
|
||||
function send(message: AiVaultServiceChildMessage): void {
|
||||
process.send?.(message)
|
||||
}
|
||||
|
||||
function titleKey(request: { agent: string; sessionId: string }): string {
|
||||
return `${request.agent}\0${request.sessionId}`
|
||||
}
|
||||
|
||||
async function executeRequest(request: AiVaultServiceRequest): Promise<AiVaultServiceResultValue> {
|
||||
const controller = new AbortController()
|
||||
controllers.set(request.id, controller)
|
||||
try {
|
||||
if (cancelled.delete(request.id)) {
|
||||
controller.abort()
|
||||
}
|
||||
if (request.operation === 'titles') {
|
||||
const requests = await resolveHostReadableAiVaultTitleRequests(
|
||||
request.requests,
|
||||
controller.signal
|
||||
)
|
||||
return {
|
||||
operation: 'titles',
|
||||
value: await readAiVaultSessionTitlesFromFiles(requests, {
|
||||
signal: controller.signal,
|
||||
cache: {
|
||||
get: (entry) => titleIndex.get(titleKey(entry)) ?? null,
|
||||
set: (title) => cacheServiceTitle(titleIndex, title)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
if (request.operation === 'subagents') {
|
||||
return {
|
||||
operation: 'subagents',
|
||||
value: await listLocalAiVaultSubagentSessions(request.request)
|
||||
}
|
||||
}
|
||||
if (request.operation === 'firstPrompt') {
|
||||
return {
|
||||
operation: 'firstPrompt',
|
||||
value: await readAiVaultFirstUserPrompt(request.request)
|
||||
}
|
||||
}
|
||||
const startedAt = performance.now()
|
||||
const result = await scanAiVaultSessions({ ...request.options, signal: controller.signal })
|
||||
for (const session of result.sessions) {
|
||||
if ((session.agent === 'claude' || session.agent === 'codex') && session.title.trim()) {
|
||||
cacheServiceTitle(titleIndex, {
|
||||
agent: session.agent,
|
||||
sessionId: session.sessionId,
|
||||
title: session.title.trim()
|
||||
})
|
||||
}
|
||||
}
|
||||
return {
|
||||
operation: 'scan',
|
||||
value: { result, durationMs: performance.now() - startedAt }
|
||||
}
|
||||
} finally {
|
||||
controllers.delete(request.id)
|
||||
cancelled.delete(request.id)
|
||||
for (const path of invalidatedPaths) {
|
||||
invalidateSessionParseCacheEntry(path)
|
||||
}
|
||||
// Why: the re-apply only protects reads that overlapped the invalidation.
|
||||
// Once nothing else is executing it has done its job, and holding the paths
|
||||
// would re-evict them on every later request for the life of the process.
|
||||
if (controllers.size === 0) {
|
||||
invalidatedPaths.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRequest(request: AiVaultServiceRequest): Promise<void> {
|
||||
try {
|
||||
const value = await executeRequest(request)
|
||||
send({ type: 'result', id: request.id, ...value })
|
||||
} catch (error) {
|
||||
send({
|
||||
type: 'error',
|
||||
id: request.id,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
retryable: true
|
||||
})
|
||||
} finally {
|
||||
pending.delete(request.id)
|
||||
}
|
||||
}
|
||||
|
||||
function queueRequest(request: AiVaultServiceRequest): void {
|
||||
if (pending.size >= 16) {
|
||||
send({
|
||||
type: 'error',
|
||||
id: request.id,
|
||||
message: 'AI Vault service queue is full.',
|
||||
retryable: true
|
||||
})
|
||||
return
|
||||
}
|
||||
pending.add(request.id)
|
||||
if (aiVaultServiceLane(request.operation) === 'interactive') {
|
||||
interactiveLane = interactiveLane.then(() => handleRequest(request))
|
||||
return
|
||||
}
|
||||
cacheLane = cacheLane.then(() => handleRequest(request))
|
||||
}
|
||||
|
||||
async function shutdown(): Promise<void> {
|
||||
if (shuttingDown) {
|
||||
return
|
||||
}
|
||||
shuttingDown = true
|
||||
for (const controller of controllers.values()) {
|
||||
controller.abort()
|
||||
}
|
||||
await Promise.allSettled([cacheLane, interactiveLane])
|
||||
await flushSessionParseCachePersist()
|
||||
process.disconnect?.()
|
||||
}
|
||||
|
||||
process.on('message', (raw: AiVaultServiceParentMessage) => {
|
||||
if (raw?.type === 'init') {
|
||||
if (initialized || raw.protocol !== AI_VAULT_SERVICE_PROTOCOL_VERSION) {
|
||||
void shutdown()
|
||||
return
|
||||
}
|
||||
initialized = true
|
||||
if (raw.sessionParseCache) {
|
||||
initSessionParseCachePersistence(raw.sessionParseCache)
|
||||
}
|
||||
send({ type: 'ready', protocol: AI_VAULT_SERVICE_PROTOCOL_VERSION, pid: process.pid })
|
||||
return
|
||||
}
|
||||
if (!initialized || shuttingDown) {
|
||||
return
|
||||
}
|
||||
if (raw?.type === 'cancel') {
|
||||
cancelled.add(raw.id)
|
||||
controllers.get(raw.id)?.abort()
|
||||
return
|
||||
}
|
||||
if (raw?.type === 'invalidate') {
|
||||
for (const path of raw.paths) {
|
||||
invalidatedPaths.delete(path)
|
||||
invalidatedPaths.add(path)
|
||||
invalidateSessionParseCacheEntry(path)
|
||||
}
|
||||
while (invalidatedPaths.size > 4_096) {
|
||||
const oldest = invalidatedPaths.values().next().value
|
||||
if (oldest === undefined) {
|
||||
break
|
||||
}
|
||||
invalidatedPaths.delete(oldest)
|
||||
}
|
||||
titleIndex.clear()
|
||||
send({ type: 'invalidated', generation: raw.generation })
|
||||
return
|
||||
}
|
||||
if (raw?.type === 'shutdown') {
|
||||
void shutdown()
|
||||
return
|
||||
}
|
||||
if (isAiVaultServiceRequest(raw)) {
|
||||
queueRequest(raw)
|
||||
}
|
||||
})
|
||||
|
||||
process.on('disconnect', () => void shutdown())
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildAiVaultServiceEnv, buildRelayAiVaultServiceEnv } from './session-scanner-service-env'
|
||||
|
||||
describe('buildAiVaultServiceEnv', () => {
|
||||
it('drops Node flag injection variables so the forked heap cap and loader stand', () => {
|
||||
const env = buildAiVaultServiceEnv(
|
||||
{
|
||||
NODE_OPTIONS: '--max-old-space-size=8192 --require=/tmp/evil.js',
|
||||
NODE_REPL_EXTERNAL_MODULE: '/tmp/evil.js',
|
||||
NODE_PATH: '/tmp/evil-modules',
|
||||
PATH: '/usr/bin'
|
||||
},
|
||||
'linux'
|
||||
)
|
||||
|
||||
expect(env.NODE_OPTIONS).toBeUndefined()
|
||||
expect(env.NODE_REPL_EXTERNAL_MODULE).toBeUndefined()
|
||||
expect(env.NODE_PATH).toBeUndefined()
|
||||
expect(env.PATH).toBe('/usr/bin')
|
||||
})
|
||||
|
||||
it('drops an unrecognised variable rather than carrying a shell-exported secret', () => {
|
||||
const env = buildAiVaultServiceEnv(
|
||||
{ AWS_SECRET_ACCESS_KEY: 'shhh', HOME: '/home/dev' },
|
||||
'linux'
|
||||
)
|
||||
|
||||
expect(env.AWS_SECRET_ACCESS_KEY).toBeUndefined()
|
||||
expect(env.HOME).toBe('/home/dev')
|
||||
})
|
||||
|
||||
it('keeps the agent-home variables the scanner discovers sessions through', () => {
|
||||
const env = buildAiVaultServiceEnv(
|
||||
{
|
||||
CODEX_HOME: '/home/dev/.codex',
|
||||
COPILOT_HOME: '/home/dev/.copilot',
|
||||
DEVIN_HOME: '/home/dev/.devin',
|
||||
GROK_HOME: '/home/dev/.grok',
|
||||
KIMI_CODE_HOME: '/home/dev/.kimi-code',
|
||||
OMP_CODING_AGENT_DIR: '/home/dev/.omp/agent/sessions',
|
||||
OPENCLAW_STATE_DIR: '/home/dev/.openclaw',
|
||||
PI_CODING_AGENT_DIR: '/home/dev/.pi/agent/sessions',
|
||||
PRIME_AGENT_CODING_AGENT_DIR: '/home/dev/.prime/agent',
|
||||
PRIME_AGENT_CODING_AGENT_SESSION_DIR: '/home/dev/.prime/legacy-sessions',
|
||||
PRIME_AGENT_SESSION_DIR: '/home/dev/.prime/sessions'
|
||||
},
|
||||
'linux'
|
||||
)
|
||||
|
||||
expect(env).toEqual({
|
||||
CODEX_HOME: '/home/dev/.codex',
|
||||
COPILOT_HOME: '/home/dev/.copilot',
|
||||
DEVIN_HOME: '/home/dev/.devin',
|
||||
GROK_HOME: '/home/dev/.grok',
|
||||
KIMI_CODE_HOME: '/home/dev/.kimi-code',
|
||||
OMP_CODING_AGENT_DIR: '/home/dev/.omp/agent/sessions',
|
||||
OPENCLAW_STATE_DIR: '/home/dev/.openclaw',
|
||||
PI_CODING_AGENT_DIR: '/home/dev/.pi/agent/sessions',
|
||||
PRIME_AGENT_CODING_AGENT_DIR: '/home/dev/.prime/agent',
|
||||
PRIME_AGENT_CODING_AGENT_SESSION_DIR: '/home/dev/.prime/legacy-sessions',
|
||||
PRIME_AGENT_SESSION_DIR: '/home/dev/.prime/sessions',
|
||||
ELECTRON_RUN_AS_NODE: '1'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the OpenCode data-directory and database overrides', () => {
|
||||
const env = buildAiVaultServiceEnv(
|
||||
{ XDG_DATA_HOME: '/home/dev/data', OPENCODE_DB: 'opencode-alt.db' },
|
||||
'linux'
|
||||
)
|
||||
|
||||
expect(env.XDG_DATA_HOME).toBe('/home/dev/data')
|
||||
expect(env.OPENCODE_DB).toBe('opencode-alt.db')
|
||||
})
|
||||
|
||||
it('runs the forked Electron binary as plain Node', () => {
|
||||
expect(buildAiVaultServiceEnv({}, 'linux').ELECTRON_RUN_AS_NODE).toBe('1')
|
||||
})
|
||||
|
||||
it('ignores a POSIX variable that only matches an allowed name by case', () => {
|
||||
expect(buildAiVaultServiceEnv({ codex_home: '/tmp/spoof' }, 'linux')).toEqual({
|
||||
ELECTRON_RUN_AS_NODE: '1'
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves a lowercased Windows variable the OS would still honour', () => {
|
||||
const env = buildAiVaultServiceEnv({ codex_home: 'C:\\codex', Path: 'C:\\bin' }, 'win32')
|
||||
|
||||
expect(env.CODEX_HOME).toBe('C:\\codex')
|
||||
expect(env.PATH).toBe('C:\\bin')
|
||||
})
|
||||
|
||||
it('spells SystemRoot the way Windows Node expects', () => {
|
||||
expect(buildAiVaultServiceEnv({ SystemRoot: 'C:\\Windows' }, 'win32').SystemRoot).toBe(
|
||||
'C:\\Windows'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not mutate the caller environment', () => {
|
||||
const baseEnv = { NODE_OPTIONS: '--inspect', HOME: '/home/dev' }
|
||||
buildAiVaultServiceEnv(baseEnv, 'linux')
|
||||
|
||||
expect(baseEnv.NODE_OPTIONS).toBe('--inspect')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildRelayAiVaultServiceEnv', () => {
|
||||
it('drops Node flag injection variables', () => {
|
||||
const env = buildRelayAiVaultServiceEnv(
|
||||
{ NODE_OPTIONS: '--max-old-space-size=8192', NODE_PATH: '/tmp/evil', HOME: '/home/ada' },
|
||||
'linux'
|
||||
)
|
||||
|
||||
expect(env.NODE_OPTIONS).toBeUndefined()
|
||||
expect(env.NODE_PATH).toBeUndefined()
|
||||
expect(env.HOME).toBe('/home/ada')
|
||||
})
|
||||
|
||||
// The sidecar takes remoteHome and hostPlatform from its init message, so an
|
||||
// agent-home override on the remote host is not part of how it finds roots.
|
||||
it('withholds the agent-home variables the desktop child needs', () => {
|
||||
const env = buildRelayAiVaultServiceEnv(
|
||||
{ CODEX_HOME: '/remote/.codex', PATH: '/usr/bin' },
|
||||
'linux'
|
||||
)
|
||||
|
||||
expect(env.CODEX_HOME).toBeUndefined()
|
||||
expect(env.PATH).toBe('/usr/bin')
|
||||
})
|
||||
|
||||
it('stays plain Node rather than an Electron child', () => {
|
||||
expect(buildRelayAiVaultServiceEnv({}, 'linux').ELECTRON_RUN_AS_NODE).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Environments for the AI Vault service children.
|
||||
*
|
||||
* Deliberately allowlists, never `...process.env` (the plugin worker takes the
|
||||
* same stance): the children are forked with a heap cap and no loader, and an
|
||||
* ambient NODE_OPTIONS would raise the cap or `--require` code straight into
|
||||
* them. Shell-exported secrets have no business in a transcript reader either.
|
||||
*/
|
||||
|
||||
// What Node and libuv need to start and resolve a home, temp dir and locale.
|
||||
const RUNTIME_ENV_ALLOWLIST = [
|
||||
'PATH',
|
||||
'HOME',
|
||||
'USERPROFILE',
|
||||
'LANG',
|
||||
'LC_ALL',
|
||||
'LC_CTYPE',
|
||||
'TZ',
|
||||
'TMPDIR',
|
||||
'TEMP',
|
||||
'TMP',
|
||||
// Why: Windows Node/libuv need these to resolve DLLs and the machine root.
|
||||
'SYSTEMROOT',
|
||||
'SYSTEMDRIVE',
|
||||
'WINDIR',
|
||||
'COMSPEC',
|
||||
'PATHEXT',
|
||||
'PROCESSOR_ARCHITECTURE',
|
||||
'NUMBER_OF_PROCESSORS'
|
||||
] as const
|
||||
|
||||
// Why: the desktop child resolves agent roots from its own environment, so
|
||||
// dropping one hides every session of a user who relocated that agent's home.
|
||||
const AGENT_ROOT_ENV_ALLOWLIST = [
|
||||
'CODEX_HOME',
|
||||
'COPILOT_HOME',
|
||||
'DEVIN_HOME',
|
||||
'GROK_HOME',
|
||||
'KIMI_CODE_HOME',
|
||||
'OMP_CODING_AGENT_DIR',
|
||||
'OPENCLAW_STATE_DIR',
|
||||
'OPENCODE_DB',
|
||||
'PI_CODING_AGENT_DIR',
|
||||
'PRIME_AGENT_CODING_AGENT_DIR',
|
||||
'PRIME_AGENT_CODING_AGENT_SESSION_DIR',
|
||||
'PRIME_AGENT_SESSION_DIR',
|
||||
// Why: OpenCode has no home variable — its store hangs off the XDG data dir,
|
||||
// so this one is an agent root here rather than generic runtime state.
|
||||
'XDG_DATA_HOME'
|
||||
] as const
|
||||
|
||||
function pickAllowedEnv(
|
||||
keys: readonly string[],
|
||||
baseEnv: NodeJS.ProcessEnv,
|
||||
platform: NodeJS.Platform
|
||||
): NodeJS.ProcessEnv {
|
||||
const windowsLookup = new Map<string, string>()
|
||||
if (platform === 'win32') {
|
||||
// Why: Windows resolves env names case-insensitively, so a lowercased
|
||||
// `codex_home` still reaches the child; folding on POSIX instead would
|
||||
// promote an attacker-set `path` over the real one.
|
||||
for (const [key, value] of Object.entries(baseEnv)) {
|
||||
if (typeof value === 'string') {
|
||||
windowsLookup.set(key.toUpperCase(), value)
|
||||
}
|
||||
}
|
||||
}
|
||||
const env: NodeJS.ProcessEnv = {}
|
||||
for (const key of keys) {
|
||||
const value = platform === 'win32' ? windowsLookup.get(key) : baseEnv[key]
|
||||
if (value !== undefined) {
|
||||
env[key === 'SYSTEMROOT' ? 'SystemRoot' : key] = value
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
/** Desktop: forked from the Electron binary, so it also needs run-as-node. */
|
||||
export function buildAiVaultServiceEnv(
|
||||
baseEnv: NodeJS.ProcessEnv = process.env,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): NodeJS.ProcessEnv {
|
||||
const env = pickAllowedEnv(
|
||||
[...RUNTIME_ENV_ALLOWLIST, ...AGENT_ROOT_ENV_ALLOWLIST],
|
||||
baseEnv,
|
||||
platform
|
||||
)
|
||||
env.ELECTRON_RUN_AS_NODE = '1'
|
||||
return env
|
||||
}
|
||||
|
||||
/** Relay: the sidecar takes every root from its init message, not the environment. */
|
||||
export function buildRelayAiVaultServiceEnv(
|
||||
baseEnv: NodeJS.ProcessEnv = process.env,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): NodeJS.ProcessEnv {
|
||||
return pickAllowedEnv(RUNTIME_ENV_ALLOWLIST, baseEnv, platform)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { constants, setPriority } from 'node:os'
|
||||
|
||||
export function lowerAiVaultServicePriority(pid: number | undefined): boolean {
|
||||
if (pid === undefined || !Number.isSafeInteger(pid) || pid <= 0) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
setPriority(pid, constants.priority.PRIORITY_BELOW_NORMAL)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { AiVaultListResult, AiVaultSubagentListResult } from '../../shared/ai-vault-types'
|
||||
import type {
|
||||
AiVaultSessionTitle,
|
||||
AiVaultSessionTitleRequest,
|
||||
AiVaultSessionTitlesResult
|
||||
} from '../../shared/ai-vault-session-title'
|
||||
import type { ReadAiVaultFirstUserPromptArgs } from './session-first-user-prompt-read'
|
||||
import type { SessionParseCachePersistenceOptions } from './session-parse-cache-persistence'
|
||||
import type { AiVaultWorkerScanOptions } from './session-scanner-worker-protocol'
|
||||
|
||||
export const AI_VAULT_SERVICE_PROTOCOL_VERSION = 1
|
||||
|
||||
export type AiVaultServiceLane = 'cache' | 'interactive'
|
||||
export type AiVaultServiceOperation = 'scan' | 'titles' | 'subagents' | 'firstPrompt'
|
||||
|
||||
export type AiVaultServiceSubagentRequest = {
|
||||
agent: 'claude' | 'omp'
|
||||
parentFilePath: string
|
||||
}
|
||||
|
||||
export type AiVaultServiceInit = {
|
||||
type: 'init'
|
||||
protocol: typeof AI_VAULT_SERVICE_PROTOCOL_VERSION
|
||||
sessionParseCache: SessionParseCachePersistenceOptions | null
|
||||
}
|
||||
|
||||
export type AiVaultServiceRequestBody =
|
||||
| { type: 'request'; operation: 'scan'; options: AiVaultWorkerScanOptions }
|
||||
| {
|
||||
type: 'request'
|
||||
operation: 'titles'
|
||||
requests: AiVaultSessionTitleRequest[]
|
||||
}
|
||||
| {
|
||||
type: 'request'
|
||||
operation: 'subagents'
|
||||
request: AiVaultServiceSubagentRequest
|
||||
}
|
||||
| {
|
||||
type: 'request'
|
||||
operation: 'firstPrompt'
|
||||
request: ReadAiVaultFirstUserPromptArgs
|
||||
}
|
||||
|
||||
export type AiVaultServiceRequest = AiVaultServiceRequestBody & { id: number }
|
||||
|
||||
export type AiVaultServiceParentMessage =
|
||||
| AiVaultServiceInit
|
||||
| AiVaultServiceRequest
|
||||
| { type: 'cancel'; id: number }
|
||||
| { type: 'invalidate'; generation: number; paths: string[] }
|
||||
| { type: 'shutdown' }
|
||||
|
||||
export type AiVaultServiceResultValue =
|
||||
| { operation: 'scan'; value: { result: AiVaultListResult; durationMs: number } }
|
||||
| { operation: 'titles'; value: AiVaultSessionTitlesResult }
|
||||
| { operation: 'subagents'; value: AiVaultSubagentListResult }
|
||||
| { operation: 'firstPrompt'; value: { prompt: string | null } }
|
||||
|
||||
export type AiVaultServiceChildMessage =
|
||||
| {
|
||||
type: 'ready'
|
||||
protocol: typeof AI_VAULT_SERVICE_PROTOCOL_VERSION
|
||||
pid: number
|
||||
}
|
||||
| ({ type: 'result'; id: number } & AiVaultServiceResultValue)
|
||||
| { type: 'error'; id: number; message: string; retryable: boolean }
|
||||
| { type: 'invalidated'; generation: number }
|
||||
|
||||
export function aiVaultServiceLane(operation: AiVaultServiceOperation): AiVaultServiceLane {
|
||||
return operation === 'subagents' || operation === 'firstPrompt' ? 'interactive' : 'cache'
|
||||
}
|
||||
|
||||
export function isAiVaultServiceRequest(value: unknown): value is AiVaultServiceRequest {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false
|
||||
}
|
||||
const message = value as Record<string, unknown>
|
||||
return (
|
||||
message.type === 'request' &&
|
||||
Number.isSafeInteger(message.id) &&
|
||||
(message.operation === 'scan' ||
|
||||
message.operation === 'titles' ||
|
||||
message.operation === 'subagents' ||
|
||||
message.operation === 'firstPrompt')
|
||||
)
|
||||
}
|
||||
|
||||
export function isAiVaultServiceChildMessage(value: unknown): value is AiVaultServiceChildMessage {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false
|
||||
}
|
||||
const message = value as Record<string, unknown>
|
||||
if (message.type === 'ready') {
|
||||
return message.protocol === AI_VAULT_SERVICE_PROTOCOL_VERSION && Number.isInteger(message.pid)
|
||||
}
|
||||
if (message.type === 'invalidated') {
|
||||
return Number.isSafeInteger(message.generation)
|
||||
}
|
||||
return (message.type === 'result' || message.type === 'error') && Number.isSafeInteger(message.id)
|
||||
}
|
||||
|
||||
export function cacheServiceTitle(
|
||||
titleIndex: Map<string, AiVaultSessionTitle>,
|
||||
title: AiVaultSessionTitle,
|
||||
maxEntries = 4_096
|
||||
): void {
|
||||
const key = `${title.agent}\0${title.sessionId}`
|
||||
titleIndex.delete(key)
|
||||
titleIndex.set(key, title)
|
||||
while (titleIndex.size > maxEntries) {
|
||||
const oldest = titleIndex.keys().next().value
|
||||
if (oldest === undefined) {
|
||||
break
|
||||
}
|
||||
titleIndex.delete(oldest)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AiVaultServiceRestartPolicy } from './session-scanner-service-restart-policy'
|
||||
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
describe('AiVaultServiceRestartPolicy', () => {
|
||||
it('keeps one pending restart when faults overlap', () => {
|
||||
vi.useFakeTimers()
|
||||
const policy = new AiVaultServiceRestartPolicy()
|
||||
const restart = vi.fn()
|
||||
|
||||
policy.recordFault(restart)
|
||||
policy.recordFault(restart)
|
||||
vi.advanceTimersByTime(10_000)
|
||||
|
||||
expect(restart).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('cancels the pending restart on dispose after overlapping faults', () => {
|
||||
vi.useFakeTimers()
|
||||
const policy = new AiVaultServiceRestartPolicy()
|
||||
const restart = vi.fn()
|
||||
|
||||
policy.recordFault(restart)
|
||||
policy.recordFault(restart)
|
||||
policy.dispose()
|
||||
vi.advanceTimersByTime(10_000)
|
||||
|
||||
expect(restart).not.toHaveBeenCalled()
|
||||
expect(policy.restartScheduled).toBe(false)
|
||||
})
|
||||
|
||||
it('opens the circuit after three faults inside the window', () => {
|
||||
let now = 0
|
||||
const policy = new AiVaultServiceRestartPolicy(() => now)
|
||||
|
||||
expect(policy.startError()).toBeNull()
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
policy.recordFault(() => undefined)
|
||||
now += 1_000
|
||||
}
|
||||
|
||||
expect(policy.startError()?.message).toContain('circuit is open')
|
||||
now += 60_000
|
||||
expect(policy.startError()).toBeNull()
|
||||
policy.dispose()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
const AI_VAULT_SERVICE_FAULT_WINDOW_MS = 60_000
|
||||
const AI_VAULT_SERVICE_RESTART_DELAYS_MS = [250, 1_000, 5_000] as const
|
||||
|
||||
export class AiVaultServiceRestartPolicy {
|
||||
private faults: number[] = []
|
||||
private circuitUntil = 0
|
||||
private timer: NodeJS.Timeout | null = null
|
||||
|
||||
constructor(private readonly now: () => number = Date.now) {}
|
||||
|
||||
get restartScheduled(): boolean {
|
||||
return this.timer !== null
|
||||
}
|
||||
|
||||
startError(): Error | null {
|
||||
return this.now() < this.circuitUntil
|
||||
? new Error('AI Vault service restart circuit is open.')
|
||||
: null
|
||||
}
|
||||
|
||||
recordFault(restart: () => void): void {
|
||||
const now = this.now()
|
||||
this.faults = [
|
||||
...this.faults.filter((time) => now - time < AI_VAULT_SERVICE_FAULT_WINDOW_MS),
|
||||
now
|
||||
]
|
||||
if (this.faults.length >= 3) {
|
||||
this.circuitUntil = now + AI_VAULT_SERVICE_FAULT_WINDOW_MS
|
||||
}
|
||||
const delay = AI_VAULT_SERVICE_RESTART_DELAYS_MS[Math.min(this.faults.length - 1, 2)]
|
||||
// Why: a second fault before the pending restart fires would otherwise strand
|
||||
// the old timer, leaving a restart that dispose() can no longer cancel.
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer)
|
||||
}
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = null
|
||||
restart()
|
||||
}, delay)
|
||||
this.timer.unref?.()
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { ChildProcess } from 'node:child_process'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const forkMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('node:child_process', () => ({ fork: forkMock }))
|
||||
vi.mock('node:fs', () => ({ existsSync: () => true }))
|
||||
|
||||
const { spawnAiVaultServiceProcess } = await import('./session-scanner-service-spawn')
|
||||
|
||||
function forkOptions(): { env?: NodeJS.ProcessEnv; execArgv?: string[] } {
|
||||
return forkMock.mock.calls.at(-1)?.[2] ?? {}
|
||||
}
|
||||
|
||||
describe('spawnAiVaultServiceProcess', () => {
|
||||
beforeEach(() => {
|
||||
forkMock.mockReset()
|
||||
forkMock.mockReturnValue({ pid: undefined, unref: vi.fn() } as unknown as ChildProcess)
|
||||
})
|
||||
|
||||
it('keeps NODE_OPTIONS out of the child so the heap cap and loader stand', () => {
|
||||
vi.stubEnv('NODE_OPTIONS', '--max-old-space-size=8192 --require=/tmp/evil.js')
|
||||
spawnAiVaultServiceProcess()
|
||||
const options = forkOptions()
|
||||
|
||||
// Asserted first: omitting `env` entirely inherits everything, and would
|
||||
// leave the NODE_OPTIONS assertion below passing for the wrong reason.
|
||||
expect(options.env).toBeDefined()
|
||||
expect(options.env?.NODE_OPTIONS).toBeUndefined()
|
||||
expect(options.execArgv).toEqual(['--max-old-space-size=384'])
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('still passes through a relocated agent home', () => {
|
||||
vi.stubEnv('CODEX_HOME', '/home/dev/elsewhere/.codex')
|
||||
spawnAiVaultServiceProcess()
|
||||
|
||||
expect(forkOptions().env?.CODEX_HOME).toBe('/home/dev/elsewhere/.codex')
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('runs the forked Electron binary as plain Node', () => {
|
||||
spawnAiVaultServiceProcess()
|
||||
|
||||
expect(forkOptions().env?.ELECTRON_RUN_AS_NODE).toBe('1')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import { fork, type ChildProcess } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import type { AiVaultListResult, AiVaultSubagentListResult } from '../../shared/ai-vault-types'
|
||||
import type {
|
||||
AiVaultSessionTitleRequest,
|
||||
AiVaultSessionTitlesResult
|
||||
} from '../../shared/ai-vault-session-title'
|
||||
import { withSpan } from '../observability/tracer'
|
||||
import type {
|
||||
ReadAiVaultFirstUserPromptArgs,
|
||||
ReadAiVaultFirstUserPromptResult
|
||||
} from './session-first-user-prompt-read'
|
||||
import { getSessionParseCachePersistenceOptions } from './session-parse-cache-persistence'
|
||||
import { buildAiVaultServiceEnv } from './session-scanner-service-env'
|
||||
import { AiVaultScannerServiceClient } from './session-scanner-service-client'
|
||||
import { getAiVaultServiceEntryPath } from './session-scanner-service-entry-path'
|
||||
import { lowerAiVaultServicePriority } from './session-scanner-service-priority'
|
||||
import type { AiVaultServiceSubagentRequest } from './session-scanner-service-protocol'
|
||||
import type { AiVaultWorkerScanOptions } from './session-scanner-worker-protocol'
|
||||
|
||||
export function spawnAiVaultServiceProcess(): ChildProcess {
|
||||
const entryPath = getAiVaultServiceEntryPath()
|
||||
if (!existsSync(entryPath)) {
|
||||
throw new Error(`AI Vault service entry not found: ${entryPath}`)
|
||||
}
|
||||
const child = fork(entryPath, [], {
|
||||
stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
|
||||
execArgv: ['--max-old-space-size=384'],
|
||||
env: buildAiVaultServiceEnv(),
|
||||
...(process.platform === 'win32' ? { windowsHide: true } : {})
|
||||
})
|
||||
lowerAiVaultServicePriority(child.pid)
|
||||
child.unref()
|
||||
return child
|
||||
}
|
||||
|
||||
let sharedClient: AiVaultScannerServiceClient | null = null
|
||||
|
||||
function getSharedClient(): AiVaultScannerServiceClient {
|
||||
sharedClient ??= new AiVaultScannerServiceClient({
|
||||
processFactory: spawnAiVaultServiceProcess,
|
||||
init: { sessionParseCache: getSessionParseCachePersistenceOptions() },
|
||||
onStderr: (text) => console.error('[ai-vault-service]', text.trimEnd())
|
||||
})
|
||||
return sharedClient
|
||||
}
|
||||
|
||||
export function scanAiVaultSessionsInService(
|
||||
options: AiVaultWorkerScanOptions,
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultListResult> {
|
||||
return withSpan('aiVault.scan.service', async (span) => {
|
||||
const value = await getSharedClient().request<{
|
||||
result: AiVaultListResult
|
||||
durationMs: number
|
||||
}>({ type: 'request', operation: 'scan', options }, signal)
|
||||
span.setAttribute('serviceDurationMs', value.durationMs)
|
||||
span.setAttribute('sessions', value.result.sessions.length)
|
||||
return value.result
|
||||
})
|
||||
}
|
||||
|
||||
export function resolveAiVaultSessionTitlesInService(
|
||||
requests: AiVaultSessionTitleRequest[],
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultSessionTitlesResult> {
|
||||
return getSharedClient().request({ type: 'request', operation: 'titles', requests }, signal)
|
||||
}
|
||||
|
||||
export function listAiVaultSubagentSessionsInService(
|
||||
request: AiVaultServiceSubagentRequest,
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultSubagentListResult> {
|
||||
return getSharedClient().request({ type: 'request', operation: 'subagents', request }, signal)
|
||||
}
|
||||
|
||||
export function readAiVaultFirstUserPromptInService(
|
||||
request: ReadAiVaultFirstUserPromptArgs,
|
||||
signal?: AbortSignal
|
||||
): Promise<ReadAiVaultFirstUserPromptResult> {
|
||||
return getSharedClient().request({ type: 'request', operation: 'firstPrompt', request }, signal)
|
||||
}
|
||||
|
||||
export function invalidateAiVaultServiceCache(paths: string[]): Promise<void> {
|
||||
return sharedClient?.invalidate(paths) ?? Promise.resolve()
|
||||
}
|
||||
|
||||
export function resetAiVaultScannerServiceForTests(): void {
|
||||
sharedClient?.dispose()
|
||||
sharedClient = null
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { EventEmitter } from 'node:events'
|
||||
import type { ChildProcess } from 'node:child_process'
|
||||
|
||||
export class AiVaultServiceTestChild extends EventEmitter {
|
||||
readonly sent: unknown[] = []
|
||||
readonly stderr = new EventEmitter()
|
||||
readonly pid: number
|
||||
killed = false
|
||||
unrefed = false
|
||||
|
||||
constructor(pid = 12_345) {
|
||||
super()
|
||||
this.pid = pid
|
||||
}
|
||||
|
||||
send(message: unknown, callback?: (error: Error | null) => void): boolean {
|
||||
this.sent.push(message)
|
||||
callback?.(null)
|
||||
return true
|
||||
}
|
||||
|
||||
kill(): boolean {
|
||||
this.killed = true
|
||||
return true
|
||||
}
|
||||
|
||||
unref(): this {
|
||||
this.unrefed = true
|
||||
return this
|
||||
}
|
||||
|
||||
asChildProcess(): ChildProcess {
|
||||
return this as unknown as ChildProcess
|
||||
}
|
||||
}
|
||||
|
||||
export function readyAiVaultServiceChild(child: AiVaultServiceTestChild): void {
|
||||
child.emit('message', { type: 'ready', protocol: 1, pid: child.pid })
|
||||
}
|
||||
|
||||
export function aiVaultServiceRequestId(child: AiVaultServiceTestChild, operation: string): number {
|
||||
const request = child.sent.find(
|
||||
(message) =>
|
||||
typeof message === 'object' &&
|
||||
message !== null &&
|
||||
(message as { operation?: string }).operation === operation
|
||||
) as { id?: number } | undefined
|
||||
if (request?.id === undefined) {
|
||||
throw new Error(`No ${operation} request was sent.`)
|
||||
}
|
||||
return request.id
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { AiVaultSubagentListResult } from '../../shared/ai-vault-types'
|
||||
import { listClaudeSubagentSessions } from './session-scanner-claude-subagents'
|
||||
import { listOmpSubagentSessions } from './session-scanner-omp-subagent-listing'
|
||||
import type { AiVaultServiceSubagentRequest } from './session-scanner-service-protocol'
|
||||
|
||||
export function listLocalAiVaultSubagentSessions(
|
||||
request: AiVaultServiceSubagentRequest
|
||||
): Promise<AiVaultSubagentListResult> {
|
||||
return request.agent === 'claude'
|
||||
? listClaudeSubagentSessions({ parentFilePath: request.parentFilePath })
|
||||
: listOmpSubagentSessions({ parentFilePath: request.parentFilePath })
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
type AiVaultSessionTitleRequest,
|
||||
type AiVaultSessionTitlesResult
|
||||
} from '../../shared/ai-vault-session-title'
|
||||
import { resolveAiVaultSessionTitlesInWorker } from './session-scanner-worker-spawn'
|
||||
import { resolveAiVaultSessionTitlesInBackground } from './session-scanner-background'
|
||||
|
||||
const TRANSCRIPT_PATH_MAX_LENGTH = 32_768
|
||||
|
||||
@@ -42,5 +42,5 @@ export async function resolveLocalAiVaultSessionTitles(
|
||||
deduped.set(key, normalized)
|
||||
}
|
||||
}
|
||||
return resolveAiVaultSessionTitlesInWorker([...deduped.values()], signal)
|
||||
return resolveAiVaultSessionTitlesInBackground([...deduped.values()], signal)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
} from '../ai-vault/cached-session-list'
|
||||
import { deleteAiVaultSessionFile } from '../ai-vault/session-delete'
|
||||
import { invalidateSessionParseCacheEntry } from '../ai-vault/session-scanner-parse-cache'
|
||||
import { invalidateAiVaultBackgroundCache } from '../ai-vault/session-scanner-background'
|
||||
import type { AiVaultAgent } from '../../shared/ai-vault-types'
|
||||
import type {
|
||||
AiVaultDeleteSessionArgs,
|
||||
@@ -15,6 +16,7 @@ import type {
|
||||
// invalidation is injected rather than reached into from here.
|
||||
type AiVaultDeleteDeps = {
|
||||
invalidateMultiHostListCache: () => void
|
||||
invalidateBackgroundCache?: (paths: string[]) => Promise<void>
|
||||
}
|
||||
|
||||
// Binds the delete orchestration to the caller's cache-invalidation seam.
|
||||
@@ -52,6 +54,11 @@ export async function deleteAiVaultSession(
|
||||
// what the renderer echoes back as filePath), so invalidate with that exact
|
||||
// key — resolve() could normalise it away from the stored key and miss.
|
||||
invalidateSessionParseCacheEntry(args?.filePath ?? '')
|
||||
await (deps.invalidateBackgroundCache ?? invalidateAiVaultBackgroundCache)([
|
||||
args?.filePath ?? ''
|
||||
]).catch((error) => {
|
||||
console.warn('[ai-vault] background cache invalidation failed:', error)
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
@@ -169,14 +169,22 @@ describe('Agent Session History scan coalescing', () => {
|
||||
expect(result).not.toHaveProperty('cancelled')
|
||||
})
|
||||
|
||||
it('still rejects the handler when a scan fails for a non-cancellation reason', async () => {
|
||||
it('reports a failed local scan as a host issue rather than rejecting', async () => {
|
||||
mocks.scanAiVaultSessionsInWorker.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')
|
||||
// The local leg degrades like the SSH legs above: a rejection reaches the
|
||||
// renderer as a raw string painted over the list instead of an issue row.
|
||||
const result = await list(
|
||||
{ sender: { id: 1 } },
|
||||
{ executionHostScope: 'local', requestToken: 'scan' }
|
||||
)
|
||||
expect(result).toMatchObject({
|
||||
sessions: [],
|
||||
issues: [expect.objectContaining({ message: 'transcript root is unreadable', kind: 'host' })]
|
||||
})
|
||||
expect(result).not.toHaveProperty('cancelled')
|
||||
})
|
||||
|
||||
it('re-joins a preempted same-scope caller onto the forced refresh', async () => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { resolve } from 'node:path'
|
||||
import { getAiVaultWslHomeDirs } from '../ai-vault/cached-session-list'
|
||||
import { listClaudeSubagentSessions } from '../ai-vault/session-scanner-claude-subagents'
|
||||
import { listOmpSubagentSessions } from '../ai-vault/session-scanner-omp-subagent-listing'
|
||||
import { listAiVaultSubagentSessionsInBackground } from '../ai-vault/session-scanner-background'
|
||||
import { claudeProjectsRootDirs, ompSessionsRootDirs } from '../ai-vault/session-scanner-roots'
|
||||
import { isPathInsideOrEqual } from '../../shared/cross-platform-path'
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
|
||||
@@ -45,7 +44,5 @@ export async function listAiVaultSubagentSessions(
|
||||
if (!roots.some((root) => isPathInsideOrEqual(resolve(root), parentFilePath))) {
|
||||
return { sessions: [], issues: [] }
|
||||
}
|
||||
return args.agent === 'claude'
|
||||
? listClaudeSubagentSessions({ parentFilePath })
|
||||
: listOmpSubagentSessions({ parentFilePath })
|
||||
return listAiVaultSubagentSessionsInBackground({ agent: args.agent, parentFilePath })
|
||||
}
|
||||
|
||||
@@ -93,6 +93,7 @@ vi.mock('./ssh', () => ({
|
||||
|
||||
const { OMP_SESSIONS_DIR } = await import('../ai-vault/session-scanner-roots')
|
||||
const { _internals, registerAiVaultHandlers } = await import('./ai-vault')
|
||||
const { deleteAiVaultSession: deleteAiVaultSessionWithDeps } = await import('./ai-vault-delete')
|
||||
|
||||
const provider = {} as IFilesystemProvider
|
||||
|
||||
@@ -789,9 +790,14 @@ describe('deleteAiVaultSession', () => {
|
||||
}
|
||||
|
||||
it('invalidates every AI Vault cache after a real delete', async () => {
|
||||
const invalidateMultiHostListCache = vi.fn()
|
||||
const invalidateBackgroundCache = vi.fn().mockResolvedValue(undefined)
|
||||
mocks.deleteAiVaultSessionFile.mockResolvedValue({ outcome: 'deleted' })
|
||||
|
||||
const result = await _internals.deleteAiVaultSession(args)
|
||||
const result = await deleteAiVaultSessionWithDeps(args, {
|
||||
invalidateMultiHostListCache,
|
||||
invalidateBackgroundCache
|
||||
})
|
||||
|
||||
expect(result).toEqual({ outcome: 'deleted' })
|
||||
expect(mocks.deleteAiVaultSessionFile).toHaveBeenCalledWith(
|
||||
@@ -802,8 +808,10 @@ describe('deleteAiVaultSession', () => {
|
||||
executionHostId: 'local'
|
||||
})
|
||||
)
|
||||
expect(invalidateMultiHostListCache).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.invalidateAiVaultSessionListCache).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.invalidateSessionParseCacheEntry).toHaveBeenCalledWith(args.filePath)
|
||||
expect(invalidateBackgroundCache).toHaveBeenCalledWith([args.filePath])
|
||||
})
|
||||
|
||||
it('does not invalidate any cache when the executor rejects (e.g. non-local host)', async () => {
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
type AiVaultSubagentListArgs,
|
||||
type AiVaultSubagentListResult
|
||||
} from '../../shared/ai-vault-types'
|
||||
import { handleAiVaultGetFirstUserPrompt } from '../ai-vault/session-first-user-prompt-read'
|
||||
import { handleAiVaultGetFirstUserPrompt } from '../ai-vault/session-first-user-prompt-handler'
|
||||
import { registerAiVaultResumeHandler, type AiVaultResumeHandlerOptions } from './ai-vault-resume'
|
||||
import {
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
@@ -139,7 +139,7 @@ async function scanAiVaultSessionsByHostScope(
|
||||
const depth = requestedAiVaultSessionDepth(args)
|
||||
const scopePaths = args?.scopePaths ?? []
|
||||
if (executionHostScope === LOCAL_EXECUTION_HOST_ID) {
|
||||
return scanLocalAiVaultSessions(args, signal)
|
||||
return scanLocalAiVaultSessionsAsIssue(args, signal)
|
||||
}
|
||||
if (executionHostScope === 'all') {
|
||||
const runtimeHosts = getActiveRuntimeAiVaultHostInfosResult()
|
||||
@@ -149,7 +149,7 @@ async function scanAiVaultSessionsByHostScope(
|
||||
...(sshHosts.issue ? [sshHosts.issue] : [])
|
||||
]
|
||||
const scannedResults = await Promise.all([
|
||||
scanLocalAiVaultSessionsForAllScope(args, signal),
|
||||
scanLocalAiVaultSessionsAsIssue(args, signal),
|
||||
...sshHosts.hostInfos.map((hostInfo) =>
|
||||
scanHostLegWithCache({
|
||||
cacheKey: `${cacheKey}|${toSshExecutionHostId(hostInfo.targetId)}`,
|
||||
@@ -226,8 +226,10 @@ function getActiveSshAiVaultHostInfosResult(): AiVaultHostDiscoveryResult<{ targ
|
||||
|
||||
// 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(
|
||||
// WSL home resolution, scanner service supervision) and would otherwise discard
|
||||
// every host's sessions under 'all', or replace the list with a raw error string
|
||||
// under single-host scope.
|
||||
async function scanLocalAiVaultSessionsAsIssue(
|
||||
args: AiVaultListArgs | undefined,
|
||||
signal: AbortSignal | undefined
|
||||
): Promise<AiVaultListResult> {
|
||||
|
||||
@@ -134,6 +134,7 @@ describe('isRelayAlreadyInstalled', () => {
|
||||
const cmd = mockExec.mock.calls.at(-1)?.[1] ?? ''
|
||||
expect(cmd).toContain('relay.js')
|
||||
expect(cmd).toContain('relay-watcher.js')
|
||||
expect(cmd).toContain('relay-ai-vault-service.js')
|
||||
expect(cmd).toContain('managed-hook-runtime.js')
|
||||
expect(cmd).toContain('.install-complete')
|
||||
})
|
||||
|
||||
@@ -117,6 +117,7 @@ describe('ssh remote command builders', () => {
|
||||
const probe = probeRelayInstalledCommand(posix, '/home/me/relay')
|
||||
expect(probe).toContain('test -d')
|
||||
expect(probe).toContain('managed-hook-runtime.js')
|
||||
expect(probe).toContain('relay-ai-vault-service.js')
|
||||
})
|
||||
|
||||
it('uses encoded PowerShell for Windows deploy commands', () => {
|
||||
@@ -127,6 +128,7 @@ describe('ssh remote command builders', () => {
|
||||
const probe = probeRelayInstalledCommand(windows, 'C:/Users/me/relay')
|
||||
expect(probe).toContain('-EncodedCommand')
|
||||
expect(decodePowerShellCommand(probe)).toContain('managed-hook-runtime.js')
|
||||
expect(decodePowerShellCommand(probe)).toContain('relay-ai-vault-service.js')
|
||||
})
|
||||
|
||||
it('uses a legacy-visible Windows lock directory with an exclusive owner file', () => {
|
||||
|
||||
@@ -91,6 +91,7 @@ export function probeRelayInstalledCommand(
|
||||
): string {
|
||||
const relayJs = joinRemotePath(host, remoteRelayDir, 'relay.js')
|
||||
const relayWatcherJs = joinRemotePath(host, remoteRelayDir, 'relay-watcher.js')
|
||||
const relayAiVaultServiceJs = joinRemotePath(host, remoteRelayDir, 'relay-ai-vault-service.js')
|
||||
const managedHookRuntimeJs = joinRemotePath(host, remoteRelayDir, 'managed-hook-runtime.js')
|
||||
const installComplete = joinRemotePath(host, remoteRelayDir, '.install-complete')
|
||||
if (!isWindowsRemoteHost(host)) {
|
||||
@@ -98,6 +99,7 @@ export function probeRelayInstalledCommand(
|
||||
`test -d ${shellEscape(remoteRelayDir)} ` +
|
||||
`&& test -f ${shellEscape(relayJs)} ` +
|
||||
`&& test -f ${shellEscape(relayWatcherJs)} ` +
|
||||
`&& test -f ${shellEscape(relayAiVaultServiceJs)} ` +
|
||||
`&& test -f ${shellEscape(managedHookRuntimeJs)} ` +
|
||||
`&& test -f ${shellEscape(installComplete)} ` +
|
||||
`&& echo OK || echo MISSING`
|
||||
@@ -108,9 +110,10 @@ export function probeRelayInstalledCommand(
|
||||
`$dir = ${powerShellLiteral(remoteRelayDir)}`,
|
||||
`$relay = ${powerShellLiteral(relayJs)}`,
|
||||
`$watcher = ${powerShellLiteral(relayWatcherJs)}`,
|
||||
`$aiVaultService = ${powerShellLiteral(relayAiVaultServiceJs)}`,
|
||||
`$managedHooks = ${powerShellLiteral(managedHookRuntimeJs)}`,
|
||||
`$complete = ${powerShellLiteral(installComplete)}`,
|
||||
"if ((Test-Path -LiteralPath $dir -PathType Container) -and (Test-Path -LiteralPath $relay -PathType Leaf) -and (Test-Path -LiteralPath $watcher -PathType Leaf) -and (Test-Path -LiteralPath $managedHooks -PathType Leaf) -and (Test-Path -LiteralPath $complete -PathType Leaf)) { 'OK' } else { 'MISSING' }"
|
||||
"if ((Test-Path -LiteralPath $dir -PathType Container) -and (Test-Path -LiteralPath $relay -PathType Leaf) -and (Test-Path -LiteralPath $watcher -PathType Leaf) -and (Test-Path -LiteralPath $aiVaultService -PathType Leaf) -and (Test-Path -LiteralPath $managedHooks -PathType Leaf) -and (Test-Path -LiteralPath $complete -PathType Leaf)) { 'OK' } else { 'MISSING' }"
|
||||
].join('; ')
|
||||
)
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ exec /bin/sh -c "$cmd"
|
||||
|
||||
function writeFakeRelay(dir: string): void {
|
||||
writeFileSync(join(dir, 'relay-watcher.js'), '')
|
||||
writeFileSync(join(dir, 'relay-ai-vault-service.js'), '')
|
||||
writeFileSync(join(dir, 'managed-hook-runtime.js'), '')
|
||||
writeFileSync(
|
||||
join(dir, 'relay.js'),
|
||||
|
||||
@@ -8,8 +8,13 @@ import {
|
||||
SSH_AI_VAULT_RESOLVE_SESSION_TITLES_METHOD
|
||||
} from '../shared/ssh-ai-vault-relay'
|
||||
import { getRemoteHostPlatform } from '../main/ssh/ssh-remote-platform'
|
||||
import type { RemoteHostPlatform } from '../main/ssh/ssh-remote-platform'
|
||||
import { scanRemoteAiVaultSessions } from '../main/ai-vault/remote-session-scanner'
|
||||
import { readAiVaultSessionTitlesFromFiles } from '../main/ai-vault/session-title-file-reader'
|
||||
import type { RelayDispatcher, RequestContext } from './dispatcher'
|
||||
import { AiVaultHandler } from './ai-vault-handler'
|
||||
import { createRelayAiVaultFilesystemProvider } from './ai-vault-service-filesystem'
|
||||
import type { RelayAiVaultServiceApi } from './ai-vault-service-client-state'
|
||||
|
||||
type RequestHandler = (params: Record<string, unknown>, context: RequestContext) => Promise<unknown>
|
||||
|
||||
@@ -49,7 +54,7 @@ describe('AiVaultHandler', () => {
|
||||
new AiVaultHandler(dispatcher.value, {
|
||||
remoteHome,
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64'),
|
||||
scanRemoteSessions
|
||||
service: createTestService(remoteHome, getRemoteHostPlatform('linux-x64'), scanRemoteSessions)
|
||||
})
|
||||
|
||||
await expect(
|
||||
@@ -99,7 +104,8 @@ describe('AiVaultHandler', () => {
|
||||
const dispatcher = createMockDispatcher()
|
||||
new AiVaultHandler(dispatcher.value, {
|
||||
remoteHome,
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64')
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64'),
|
||||
service: createTestService(remoteHome, getRemoteHostPlatform('linux-x64'))
|
||||
})
|
||||
|
||||
const result = (await dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, {
|
||||
@@ -123,7 +129,11 @@ describe('AiVaultHandler', () => {
|
||||
new AiVaultHandler(dispatcher.value, {
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64'),
|
||||
scanRemoteSessions
|
||||
service: createTestService(
|
||||
'/home/ada',
|
||||
getRemoteHostPlatform('linux-x64'),
|
||||
scanRemoteSessions
|
||||
)
|
||||
})
|
||||
const scopePaths = Array.from({ length: 80 }, (_, index) => `/repo/${index}`)
|
||||
|
||||
@@ -156,7 +166,11 @@ describe('AiVaultHandler', () => {
|
||||
new AiVaultHandler(dispatcher.value, {
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64'),
|
||||
scanRemoteSessions
|
||||
service: createTestService(
|
||||
'/home/ada',
|
||||
getRemoteHostPlatform('linux-x64'),
|
||||
scanRemoteSessions
|
||||
)
|
||||
})
|
||||
|
||||
await dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, {
|
||||
@@ -183,7 +197,11 @@ describe('AiVaultHandler', () => {
|
||||
new AiVaultHandler(dispatcher.value, {
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64'),
|
||||
scanRemoteSessions: scanRemoteSessions as never
|
||||
service: createTestService(
|
||||
'/home/ada',
|
||||
getRemoteHostPlatform('linux-x64'),
|
||||
scanRemoteSessions as never
|
||||
)
|
||||
})
|
||||
const firstController = new AbortController()
|
||||
const first = dispatcher.call(
|
||||
@@ -219,7 +237,11 @@ describe('AiVaultHandler', () => {
|
||||
new AiVaultHandler(dispatcher.value, {
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64'),
|
||||
scanRemoteSessions: scanRemoteSessions as never
|
||||
service: createTestService(
|
||||
'/home/ada',
|
||||
getRemoteHostPlatform('linux-x64'),
|
||||
scanRemoteSessions as never
|
||||
)
|
||||
})
|
||||
const first = dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, { limit: 20 })
|
||||
await vi.waitFor(() => expect(signals).toHaveLength(1))
|
||||
@@ -254,11 +276,31 @@ describe('AiVaultHandler', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('soft-disables the method instead of aborting relay startup when the service is missing', () => {
|
||||
const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true)
|
||||
try {
|
||||
const dispatcher = createMockDispatcher()
|
||||
|
||||
expect(
|
||||
() =>
|
||||
new AiVaultHandler(dispatcher.value, {
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64')
|
||||
})
|
||||
).not.toThrow()
|
||||
|
||||
expect(() => dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, {})).toThrow(/No handler/)
|
||||
} finally {
|
||||
stderr.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
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')
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64'),
|
||||
service: createTestService('/home/ada', getRemoteHostPlatform('linux-x64'))
|
||||
})
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
@@ -267,6 +309,70 @@ describe('AiVaultHandler', () => {
|
||||
dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, {}, controller.signal)
|
||||
).rejects.toMatchObject({ name: 'AbortError' })
|
||||
})
|
||||
|
||||
it('returns a host issue when the sidecar is unavailable', async () => {
|
||||
const dispatcher = createMockDispatcher()
|
||||
new AiVaultHandler(dispatcher.value, {
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64'),
|
||||
service: {
|
||||
listSessions: () => Promise.reject(new Error('sidecar crashed')),
|
||||
resolveSessionTitles: () => Promise.resolve({ titles: [] })
|
||||
}
|
||||
})
|
||||
|
||||
await expect(dispatcher.call(SSH_AI_VAULT_LIST_SESSIONS_METHOD, {})).resolves.toMatchObject({
|
||||
sessions: [],
|
||||
issues: [
|
||||
expect.objectContaining({ kind: 'host', message: expect.stringContaining('sidecar') })
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('returns no titles instead of an RPC error when the sidecar is unavailable', async () => {
|
||||
const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true)
|
||||
try {
|
||||
const dispatcher = createMockDispatcher()
|
||||
new AiVaultHandler(dispatcher.value, {
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64'),
|
||||
service: {
|
||||
listSessions: () => Promise.resolve(emptyResult()),
|
||||
resolveSessionTitles: () => Promise.reject(new Error('sidecar crashed'))
|
||||
}
|
||||
})
|
||||
|
||||
await expect(
|
||||
dispatcher.call(SSH_AI_VAULT_RESOLVE_SESSION_TITLES_METHOD, {
|
||||
requests: [
|
||||
{ agent: 'codex', sessionId: 'ssh-session', transcriptPath: '/home/ada/s.jsonl' }
|
||||
]
|
||||
})
|
||||
).resolves.toEqual({ titles: [] })
|
||||
} finally {
|
||||
stderr.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('propagates title cancellation instead of degrading it', async () => {
|
||||
const dispatcher = createMockDispatcher()
|
||||
new AiVaultHandler(dispatcher.value, {
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64'),
|
||||
service: {
|
||||
listSessions: () => Promise.resolve(emptyResult()),
|
||||
resolveSessionTitles: () => {
|
||||
const error = new Error('The operation was aborted.')
|
||||
error.name = 'AbortError'
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await expect(
|
||||
dispatcher.call(SSH_AI_VAULT_RESOLVE_SESSION_TITLES_METHOD, { requests: [] })
|
||||
).rejects.toMatchObject({ name: 'AbortError' })
|
||||
})
|
||||
})
|
||||
|
||||
async function makeTemporaryHome(): Promise<string> {
|
||||
@@ -279,6 +385,28 @@ function emptyResult(): AiVaultListResult {
|
||||
return { sessions: [], issues: [], scannedAt: '2026-07-26T00:00:00.000Z' }
|
||||
}
|
||||
|
||||
function createTestService(
|
||||
remoteHome: string,
|
||||
hostPlatform: RemoteHostPlatform,
|
||||
scan: typeof scanRemoteAiVaultSessions = scanRemoteAiVaultSessions
|
||||
): RelayAiVaultServiceApi {
|
||||
return {
|
||||
listSessions: (params, signal) =>
|
||||
scan({
|
||||
provider: createRelayAiVaultFilesystemProvider(),
|
||||
executionHostId: 'local',
|
||||
remoteHome,
|
||||
hostPlatform,
|
||||
limit: params.limit,
|
||||
unlimited: params.unlimited,
|
||||
scopePaths: params.scopePaths,
|
||||
signal
|
||||
}),
|
||||
resolveSessionTitles: (requests, signal) =>
|
||||
readAiVaultSessionTitlesFromFiles(requests, { signal })
|
||||
}
|
||||
}
|
||||
|
||||
function createMockDispatcher(): {
|
||||
value: RelayDispatcher
|
||||
call: (method: string, params: Record<string, unknown>, signal?: AbortSignal) => Promise<unknown>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
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'
|
||||
@@ -14,34 +13,25 @@ import {
|
||||
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'
|
||||
import { readAiVaultSessionTitlesFromFiles } from '../main/ai-vault/session-title-file-reader'
|
||||
|
||||
type ScanRemoteSessions = typeof scanRemoteAiVaultSessions
|
||||
import type { RelayAiVaultServiceApi } from './ai-vault-service-client-state'
|
||||
|
||||
type AiVaultHandlerOptions = {
|
||||
remoteHome?: string
|
||||
hostPlatform?: RemoteHostPlatform
|
||||
scanRemoteSessions?: ScanRemoteSessions
|
||||
service?: RelayAiVaultServiceApi
|
||||
}
|
||||
|
||||
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
|
||||
@@ -52,48 +42,78 @@ export class AiVaultHandler {
|
||||
)
|
||||
return
|
||||
}
|
||||
// Why: same reasoning as an unsupported platform. Throwing here would take
|
||||
// relay startup — and every PTY on the host — down over a Vault wiring bug.
|
||||
const service = options.service
|
||||
if (!service) {
|
||||
relayLogLine('[relay] Agent Session History disabled: service unavailable')
|
||||
return
|
||||
}
|
||||
dispatcher.onRequest(SSH_AI_VAULT_LIST_SESSIONS_METHOD, (params, context) =>
|
||||
this.listSessions(hostPlatform, params, context.signal)
|
||||
this.listSessions(service, params, context.signal)
|
||||
)
|
||||
dispatcher.onRequest(SSH_AI_VAULT_RESOLVE_SESSION_TITLES_METHOD, (params, context) =>
|
||||
this.resolveSessionTitles(params, context.signal)
|
||||
this.resolveSessionTitles(service, params, context.signal)
|
||||
)
|
||||
}
|
||||
|
||||
private resolveSessionTitles(
|
||||
private async resolveSessionTitles(
|
||||
service: RelayAiVaultServiceApi,
|
||||
rawParams: Record<string, unknown>,
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultSessionTitlesResult> {
|
||||
return readAiVaultSessionTitlesFromFiles(normalizeTitleRequests(rawParams.requests), { signal })
|
||||
try {
|
||||
return await service.resolveSessionTitles(normalizeTitleRequests(rawParams.requests), signal)
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw error
|
||||
}
|
||||
// Why: titles are decoration. Degrade like an unresolvable title instead of
|
||||
// failing the RPC, which would surface a raw error on every list row.
|
||||
relayLogLine(
|
||||
`[relay-ai-vault-service] title resolution unavailable: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
return { titles: [] }
|
||||
}
|
||||
}
|
||||
|
||||
private async listSessions(
|
||||
hostPlatform: RemoteHostPlatform,
|
||||
service: RelayAiVaultServiceApi,
|
||||
rawParams: Record<string, unknown>,
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultListResult> {
|
||||
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,
|
||||
let result: AiVaultListResult
|
||||
try {
|
||||
result = await this.scanCoordinator.run({
|
||||
key: JSON.stringify({
|
||||
limit: params.limit,
|
||||
unlimited: params.unlimited,
|
||||
scopePaths: params.scopePaths,
|
||||
signal: scanSignal
|
||||
})
|
||||
})
|
||||
scopePathsTruncated: params.scopePathsTruncated
|
||||
}),
|
||||
force: params.force,
|
||||
signal,
|
||||
start: (scanSignal) => service.listSessions(params, scanSignal)
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw error
|
||||
}
|
||||
result = {
|
||||
sessions: [],
|
||||
scannedAt: new Date().toISOString(),
|
||||
issues: [
|
||||
{
|
||||
executionHostId: LOCAL_EXECUTION_HOST_ID,
|
||||
agent: 'codex',
|
||||
kind: 'host',
|
||||
path: this.remoteHome,
|
||||
message: `Agent Session History service unavailable: ${error instanceof Error ? error.message : String(error)}`
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
if (!params.scopePathsTruncated) {
|
||||
return result
|
||||
}
|
||||
@@ -176,29 +196,3 @@ 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import type { ChildProcess } from 'node:child_process'
|
||||
import type { AiVaultListResult } from '../shared/ai-vault-types'
|
||||
import type {
|
||||
AiVaultSessionTitleRequest,
|
||||
AiVaultSessionTitlesResult
|
||||
} from '../shared/ai-vault-session-title'
|
||||
import type { SshAiVaultRelayListParams } from '../shared/ssh-ai-vault-relay'
|
||||
import type { RemoteHostPlatform } from '../main/ssh/ssh-remote-platform'
|
||||
import {
|
||||
relayAiVaultServiceLane,
|
||||
type RelayAiVaultServiceLane,
|
||||
type RelayAiVaultServiceRequest
|
||||
} from './ai-vault-service-protocol'
|
||||
|
||||
export const RELAY_AI_VAULT_READY_TIMEOUT_MS = 5_000
|
||||
export const RELAY_AI_VAULT_SCAN_TIMEOUT_MS = 130_000
|
||||
export const RELAY_AI_VAULT_TITLE_TIMEOUT_MS = 15_000
|
||||
export const RELAY_AI_VAULT_MAX_CALLS = 16
|
||||
export const RELAY_AI_VAULT_IDLE_TIMEOUT_MS = 10 * 60_000
|
||||
|
||||
export function relayAiVaultAbortError(): Error {
|
||||
const error = new Error('The operation was aborted.')
|
||||
error.name = 'AbortError'
|
||||
return error
|
||||
}
|
||||
|
||||
export function relayAiVaultError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(String(error))
|
||||
}
|
||||
|
||||
export function armRelayAiVaultCancellationTimeout(
|
||||
call: RelayAiVaultServiceCall,
|
||||
onExpired: () => void
|
||||
): void {
|
||||
call.timer = setTimeout(onExpired, 2_000)
|
||||
call.timer.unref?.()
|
||||
}
|
||||
|
||||
export function createRelayAiVaultServiceCall(args: {
|
||||
request: RelayAiVaultServiceRequest
|
||||
signal?: AbortSignal
|
||||
resolve: RelayAiVaultServiceCall['resolve']
|
||||
reject: RelayAiVaultServiceCall['reject']
|
||||
}): RelayAiVaultServiceCall {
|
||||
return {
|
||||
request: args.request,
|
||||
lane: relayAiVaultServiceLane(args.request.operation),
|
||||
signal: args.signal,
|
||||
forceStart: args.request.operation === 'list' && args.request.params.force === true,
|
||||
resolve: args.resolve,
|
||||
reject: args.reject,
|
||||
timer: null,
|
||||
onAbort: null,
|
||||
settled: false,
|
||||
sent: false,
|
||||
startRetried: false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A cold start that faults before the request reached the sidecar self-heals on
|
||||
* the scheduled respawn. Requeue once; the caller settles when this returns false.
|
||||
*/
|
||||
export function requeueRelayAiVaultServiceStart(
|
||||
call: RelayAiVaultServiceCall,
|
||||
queue: RelayAiVaultServiceCall[]
|
||||
): boolean {
|
||||
if (call.sent || call.settled || call.startRetried) {
|
||||
return false
|
||||
}
|
||||
call.startRetried = true
|
||||
queue.unshift(call)
|
||||
return true
|
||||
}
|
||||
|
||||
export function settleRelayAiVaultServiceCall(
|
||||
call: RelayAiVaultServiceCall,
|
||||
value: Error | AiVaultListResult | AiVaultSessionTitlesResult
|
||||
): void {
|
||||
// A cancelled call is settled before its cancel watchdog is armed, so the
|
||||
// timer has to be cleared even when the reject/resolve is already done.
|
||||
if (call.timer) {
|
||||
clearTimeout(call.timer)
|
||||
call.timer = null
|
||||
}
|
||||
if (call.settled) {
|
||||
return
|
||||
}
|
||||
call.settled = true
|
||||
if (call.signal && call.onAbort) {
|
||||
call.signal.removeEventListener('abort', call.onAbort)
|
||||
}
|
||||
if (value instanceof Error) {
|
||||
call.reject(value)
|
||||
} else {
|
||||
call.resolve(value)
|
||||
}
|
||||
}
|
||||
|
||||
export type RelayAiVaultServiceCall = {
|
||||
request: RelayAiVaultServiceRequest
|
||||
lane: RelayAiVaultServiceLane
|
||||
signal?: AbortSignal
|
||||
forceStart: boolean
|
||||
resolve: (value: AiVaultListResult | AiVaultSessionTitlesResult) => void
|
||||
reject: (error: Error) => void
|
||||
timer: NodeJS.Timeout | null
|
||||
onAbort: (() => void) | null
|
||||
settled: boolean
|
||||
/** Whether the sidecar received the request; an unsent call gets no reply. */
|
||||
sent: boolean
|
||||
startRetried: boolean
|
||||
}
|
||||
|
||||
export type RelayAiVaultServiceApi = {
|
||||
listSessions(params: SshAiVaultRelayListParams, signal?: AbortSignal): Promise<AiVaultListResult>
|
||||
resolveSessionTitles(
|
||||
requests: AiVaultSessionTitleRequest[],
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultSessionTitlesResult>
|
||||
}
|
||||
|
||||
export type RelayAiVaultServiceClientOptions = {
|
||||
processFactory: () => ChildProcess
|
||||
init: {
|
||||
remoteHome: string
|
||||
hostPlatform: RemoteHostPlatform
|
||||
}
|
||||
now?: () => number
|
||||
idleTimeoutMs?: number
|
||||
}
|
||||
|
||||
export class RelayAiVaultIdleRetirement {
|
||||
private timer: NodeJS.Timeout | null = null
|
||||
|
||||
clear(): void {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = null
|
||||
}
|
||||
}
|
||||
|
||||
schedule(busy: boolean, timeoutMs: number, retire: () => void): void {
|
||||
if (busy || this.timer) {
|
||||
return
|
||||
}
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = null
|
||||
retire()
|
||||
}, timeoutMs)
|
||||
this.timer.unref?.()
|
||||
}
|
||||
}
|
||||
|
||||
export function retireRelayAiVaultServiceChild(child: ChildProcess): void {
|
||||
const timer = setTimeout(() => child.kill(), 2_000)
|
||||
timer.unref?.()
|
||||
child.once('exit', () => clearTimeout(timer))
|
||||
child.send({ type: 'shutdown' }, () => undefined)
|
||||
}
|
||||
|
||||
/** Orderly shutdown that never holds relay teardown past the kill deadline. */
|
||||
export function shutdownRelayAiVaultServiceChild(child: ChildProcess): Promise<void> {
|
||||
child.send({ type: 'shutdown' }, () => undefined)
|
||||
return new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
child.kill()
|
||||
resolve()
|
||||
}, 2_000)
|
||||
timer.unref?.()
|
||||
child.once('exit', () => {
|
||||
clearTimeout(timer)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getRemoteHostPlatform } from '../main/ssh/ssh-remote-platform'
|
||||
import {
|
||||
AiVaultServiceTestChild,
|
||||
readyAiVaultServiceChild
|
||||
} from '../main/ai-vault/session-scanner-service-test-child'
|
||||
import { RelayAiVaultServiceClient } from './ai-vault-service-client'
|
||||
import { RELAY_AI_VAULT_READY_TIMEOUT_MS } from './ai-vault-service-client-state'
|
||||
import { relayAiVaultServiceEntryPath } from './ai-vault-service-spawn'
|
||||
|
||||
function createClient(
|
||||
children: AiVaultServiceTestChild[],
|
||||
idleTimeoutMs?: number
|
||||
): RelayAiVaultServiceClient {
|
||||
return new RelayAiVaultServiceClient({
|
||||
init: {
|
||||
remoteHome: '/home/ada',
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64')
|
||||
},
|
||||
processFactory: () => {
|
||||
const child = new AiVaultServiceTestChild(20_000 + children.length)
|
||||
children.push(child)
|
||||
return child.asChildProcess()
|
||||
},
|
||||
idleTimeoutMs
|
||||
})
|
||||
}
|
||||
|
||||
function relayRequests(child: AiVaultServiceTestChild, operation: string): { id: number }[] {
|
||||
return child.sent.filter(
|
||||
(message) => (message as { operation?: string }).operation === operation
|
||||
) as { id: number }[]
|
||||
}
|
||||
|
||||
function relayRequestCount(child: AiVaultServiceTestChild, operation: string): number {
|
||||
return relayRequests(child, operation).length
|
||||
}
|
||||
|
||||
function relayRequestId(child: AiVaultServiceTestChild, operation: string): number {
|
||||
const request = relayRequests(child, operation).at(-1)
|
||||
if (!request) {
|
||||
throw new Error(`No ${operation} request was sent.`)
|
||||
}
|
||||
return request.id
|
||||
}
|
||||
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
describe('RelayAiVaultServiceClient', () => {
|
||||
it('holds list and title calls behind the ready handshake', async () => {
|
||||
const children: AiVaultServiceTestChild[] = []
|
||||
const client = createClient(children)
|
||||
const list = client.listSessions({ limit: 20 })
|
||||
const titles = client.resolveSessionTitles([])
|
||||
const child = children[0]!
|
||||
|
||||
expect(child.sent).toEqual([expect.objectContaining({ type: 'init', protocol: 1 })])
|
||||
readyAiVaultServiceChild(child)
|
||||
await Promise.resolve()
|
||||
const listRequest = child.sent.find(
|
||||
(message) => (message as { operation?: string }).operation === 'list'
|
||||
) as { id: number }
|
||||
child.emit('message', {
|
||||
type: 'result',
|
||||
id: listRequest.id,
|
||||
operation: 'list',
|
||||
value: { sessions: [], issues: [], scannedAt: '2026-08-09T00:00:00.000Z' }
|
||||
})
|
||||
await expect(list).resolves.toMatchObject({ sessions: [] })
|
||||
const titleRequest = child.sent.find(
|
||||
(message) => (message as { operation?: string }).operation === 'titles'
|
||||
) as { id: number }
|
||||
child.emit('message', {
|
||||
type: 'result',
|
||||
id: titleRequest.id,
|
||||
operation: 'titles',
|
||||
value: { titles: [] }
|
||||
})
|
||||
await expect(titles).resolves.toEqual({ titles: [] })
|
||||
const disposing = client.dispose()
|
||||
child.emit('exit', 0)
|
||||
await disposing
|
||||
})
|
||||
|
||||
it('resolves titles while a scan still occupies the cache lane', async () => {
|
||||
const children: AiVaultServiceTestChild[] = []
|
||||
const client = createClient(children)
|
||||
const list = client.listSessions({})
|
||||
const child = children[0]!
|
||||
readyAiVaultServiceChild(child)
|
||||
await Promise.resolve()
|
||||
|
||||
const titles = client.resolveSessionTitles([
|
||||
{ agent: 'claude', sessionId: 'session-1', transcriptPath: '/home/ada/session-1.jsonl' }
|
||||
])
|
||||
await Promise.resolve()
|
||||
child.emit('message', {
|
||||
type: 'result',
|
||||
id: relayRequestId(child, 'titles'),
|
||||
operation: 'titles',
|
||||
value: { titles: [] }
|
||||
})
|
||||
|
||||
await expect(titles).resolves.toEqual({ titles: [] })
|
||||
child.emit('message', {
|
||||
type: 'result',
|
||||
id: relayRequestId(child, 'list'),
|
||||
operation: 'list',
|
||||
value: { sessions: [], issues: [], scannedAt: '2026-08-09T00:00:00.000Z' }
|
||||
})
|
||||
await expect(list).resolves.toMatchObject({ sessions: [] })
|
||||
const disposing = client.dispose()
|
||||
child.emit('exit', 0)
|
||||
await disposing
|
||||
})
|
||||
|
||||
it('does not start queued cache work until cancelled work acknowledges', async () => {
|
||||
vi.useFakeTimers()
|
||||
const children: AiVaultServiceTestChild[] = []
|
||||
const client = createClient(children)
|
||||
const controller = new AbortController()
|
||||
const first = client.listSessions({}, controller.signal)
|
||||
const second = client.listSessions({ limit: 5 })
|
||||
const child = children[0]!
|
||||
readyAiVaultServiceChild(child)
|
||||
await Promise.resolve()
|
||||
const firstRequest = relayRequestId(child, 'list')
|
||||
|
||||
controller.abort()
|
||||
await expect(first).rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(relayRequestCount(child, 'list')).toBe(1)
|
||||
child.emit('message', {
|
||||
type: 'error',
|
||||
id: firstRequest,
|
||||
message: 'aborted'
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(relayRequestCount(child, 'list')).toBe(2)
|
||||
void second.catch(() => undefined)
|
||||
const disposing = client.dispose()
|
||||
child.emit('exit', 0)
|
||||
await disposing
|
||||
})
|
||||
|
||||
it('drops a call cancelled before the sidecar received it', async () => {
|
||||
vi.useFakeTimers()
|
||||
const children: AiVaultServiceTestChild[] = []
|
||||
const client = createClient(children)
|
||||
const controller = new AbortController()
|
||||
const cancelled = client.listSessions({}, controller.signal)
|
||||
const child = children[0]!
|
||||
|
||||
controller.abort()
|
||||
await expect(cancelled).rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(child.sent).not.toContainEqual(expect.objectContaining({ type: 'cancel' }))
|
||||
|
||||
readyAiVaultServiceChild(child)
|
||||
const next = client.listSessions({ limit: 5 })
|
||||
await Promise.resolve()
|
||||
child.emit('message', {
|
||||
type: 'result',
|
||||
id: relayRequestId(child, 'list'),
|
||||
operation: 'list',
|
||||
value: { sessions: [], issues: [], scannedAt: '2026-08-09T00:00:00.000Z' }
|
||||
})
|
||||
await expect(next).resolves.toMatchObject({ sessions: [] })
|
||||
vi.advanceTimersByTime(2_000)
|
||||
|
||||
expect(child.killed).toBe(false)
|
||||
const disposing = client.dispose()
|
||||
child.emit('exit', 0)
|
||||
await disposing
|
||||
})
|
||||
|
||||
it('retries a cold-start crash once without faulting the replacement sidecar', async () => {
|
||||
vi.useFakeTimers()
|
||||
const children: AiVaultServiceTestChild[] = []
|
||||
const client = createClient(children)
|
||||
const list = client.listSessions({})
|
||||
|
||||
children[0]!.emit('exit', 1)
|
||||
await Promise.resolve()
|
||||
vi.advanceTimersByTime(250)
|
||||
expect(children).toHaveLength(2)
|
||||
readyAiVaultServiceChild(children[1]!)
|
||||
await Promise.resolve()
|
||||
vi.advanceTimersByTime(RELAY_AI_VAULT_READY_TIMEOUT_MS)
|
||||
|
||||
expect(children[1]!.killed).toBe(false)
|
||||
children[1]!.emit('message', {
|
||||
type: 'result',
|
||||
id: relayRequestId(children[1]!, 'list'),
|
||||
operation: 'list',
|
||||
value: { sessions: [], issues: [], scannedAt: '2026-08-09T00:00:00.000Z' }
|
||||
})
|
||||
await expect(list).resolves.toMatchObject({ sessions: [] })
|
||||
const disposing = client.dispose()
|
||||
children[1]!.emit('exit', 0)
|
||||
await disposing
|
||||
})
|
||||
|
||||
it('keeps the sidecar alive once a cancelled call acknowledges', async () => {
|
||||
vi.useFakeTimers()
|
||||
const children: AiVaultServiceTestChild[] = []
|
||||
const client = createClient(children)
|
||||
const controller = new AbortController()
|
||||
const first = client.listSessions({}, controller.signal)
|
||||
const second = client.resolveSessionTitles([])
|
||||
const child = children[0]!
|
||||
readyAiVaultServiceChild(child)
|
||||
await Promise.resolve()
|
||||
const firstRequest = child.sent.find(
|
||||
(message) => (message as { operation?: string }).operation === 'list'
|
||||
) as { id: number }
|
||||
|
||||
controller.abort()
|
||||
await expect(first).rejects.toMatchObject({ name: 'AbortError' })
|
||||
child.emit('message', { type: 'error', id: firstRequest.id, message: 'aborted' })
|
||||
await Promise.resolve()
|
||||
vi.advanceTimersByTime(2_000)
|
||||
|
||||
expect(child.killed).toBe(false)
|
||||
const titleRequest = child.sent.find(
|
||||
(message) => (message as { operation?: string }).operation === 'titles'
|
||||
) as { id: number }
|
||||
child.emit('message', {
|
||||
type: 'result',
|
||||
id: titleRequest.id,
|
||||
operation: 'titles',
|
||||
value: { titles: [] }
|
||||
})
|
||||
await expect(second).resolves.toEqual({ titles: [] })
|
||||
const disposing = client.dispose()
|
||||
child.emit('exit', 0)
|
||||
await disposing
|
||||
})
|
||||
|
||||
it('restarts queued work after a sidecar crash with bounded backoff', async () => {
|
||||
vi.useFakeTimers()
|
||||
const children: AiVaultServiceTestChild[] = []
|
||||
const client = createClient(children)
|
||||
const first = client.listSessions({})
|
||||
const second = client.listSessions({ limit: 5 })
|
||||
readyAiVaultServiceChild(children[0]!)
|
||||
await Promise.resolve()
|
||||
|
||||
children[0]!.emit('exit', 1)
|
||||
await expect(first).rejects.toThrow('exited')
|
||||
expect(children).toHaveLength(1)
|
||||
vi.advanceTimersByTime(250)
|
||||
expect(children).toHaveLength(2)
|
||||
readyAiVaultServiceChild(children[1]!)
|
||||
await Promise.resolve()
|
||||
children[1]!.emit('message', {
|
||||
type: 'result',
|
||||
id: relayRequestId(children[1]!, 'list'),
|
||||
operation: 'list',
|
||||
value: { sessions: [], issues: [], scannedAt: '2026-08-09T00:00:00.000Z' }
|
||||
})
|
||||
await expect(second).resolves.toMatchObject({ sessions: [] })
|
||||
const disposing = client.dispose()
|
||||
children[1]!.emit('exit', 0)
|
||||
await disposing
|
||||
})
|
||||
|
||||
it('retires the sidecar after the idle bound', async () => {
|
||||
vi.useFakeTimers()
|
||||
const children: AiVaultServiceTestChild[] = []
|
||||
const client = createClient(children, 100)
|
||||
const list = client.listSessions({})
|
||||
const child = children[0]!
|
||||
readyAiVaultServiceChild(child)
|
||||
await Promise.resolve()
|
||||
const request = child.sent.find(
|
||||
(message) => (message as { operation?: string }).operation === 'list'
|
||||
) as { id: number }
|
||||
child.emit('message', {
|
||||
type: 'result',
|
||||
id: request.id,
|
||||
operation: 'list',
|
||||
value: { sessions: [], issues: [], scannedAt: '2026-08-09T00:00:00.000Z' }
|
||||
})
|
||||
await list
|
||||
|
||||
vi.advanceTimersByTime(99)
|
||||
expect(child.sent).not.toContainEqual({ type: 'shutdown' })
|
||||
vi.advanceTimersByTime(1)
|
||||
expect(child.sent).toContainEqual({ type: 'shutdown' })
|
||||
vi.advanceTimersByTime(2_000)
|
||||
expect(child.killed).toBe(true)
|
||||
await client.dispose()
|
||||
})
|
||||
|
||||
it('resolves the sidecar beside each bundled relay', () => {
|
||||
expect(relayAiVaultServiceEntryPath('/opt/orca/relay')).toBe(
|
||||
'/opt/orca/relay/relay-ai-vault-service.js'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,314 @@
|
||||
import type { ChildProcess } from 'node:child_process'
|
||||
import type { AiVaultListResult } from '../shared/ai-vault-types'
|
||||
import type {
|
||||
AiVaultSessionTitleRequest,
|
||||
AiVaultSessionTitlesResult
|
||||
} from '../shared/ai-vault-session-title'
|
||||
import type { SshAiVaultRelayListParams } from '../shared/ssh-ai-vault-relay'
|
||||
import {
|
||||
RELAY_AI_VAULT_MAX_CALLS,
|
||||
RELAY_AI_VAULT_IDLE_TIMEOUT_MS,
|
||||
RELAY_AI_VAULT_READY_TIMEOUT_MS,
|
||||
RELAY_AI_VAULT_SCAN_TIMEOUT_MS,
|
||||
RELAY_AI_VAULT_TITLE_TIMEOUT_MS,
|
||||
armRelayAiVaultCancellationTimeout,
|
||||
createRelayAiVaultServiceCall,
|
||||
relayAiVaultAbortError,
|
||||
relayAiVaultError,
|
||||
RelayAiVaultIdleRetirement,
|
||||
requeueRelayAiVaultServiceStart,
|
||||
retireRelayAiVaultServiceChild,
|
||||
settleRelayAiVaultServiceCall,
|
||||
shutdownRelayAiVaultServiceChild,
|
||||
type RelayAiVaultServiceApi,
|
||||
type RelayAiVaultServiceCall,
|
||||
type RelayAiVaultServiceClientOptions
|
||||
} from './ai-vault-service-client-state'
|
||||
import {
|
||||
RELAY_AI_VAULT_SERVICE_PROTOCOL,
|
||||
isRelayAiVaultServiceChildMessage,
|
||||
type RelayAiVaultServiceChildMessage,
|
||||
type RelayAiVaultServiceInit,
|
||||
type RelayAiVaultServiceLane,
|
||||
type RelayAiVaultServiceRequest
|
||||
} from './ai-vault-service-protocol'
|
||||
import { RelayAiVaultRestartPolicy } from './ai-vault-service-restart-policy'
|
||||
import { relayLogLine } from './relay-diagnostic-log'
|
||||
|
||||
export class RelayAiVaultServiceClient implements RelayAiVaultServiceApi {
|
||||
private child: ChildProcess | null = null
|
||||
private ready: Promise<ChildProcess> | null = null
|
||||
private readyReject: ((error: Error) => void) | null = null
|
||||
private readyTimer: NodeJS.Timeout | null = null
|
||||
private readonly active = new Map<RelayAiVaultServiceLane, RelayAiVaultServiceCall>()
|
||||
private readonly queue: RelayAiVaultServiceCall[] = []
|
||||
private nextId = 1
|
||||
private readonly restartPolicy: RelayAiVaultRestartPolicy
|
||||
private readonly idleRetirement = new RelayAiVaultIdleRetirement()
|
||||
private disposed = false
|
||||
|
||||
constructor(private readonly options: RelayAiVaultServiceClientOptions) {
|
||||
this.restartPolicy = new RelayAiVaultRestartPolicy(options.now)
|
||||
}
|
||||
|
||||
listSessions(
|
||||
params: SshAiVaultRelayListParams,
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultListResult> {
|
||||
return this.request({ type: 'request', id: this.nextId++, operation: 'list', params }, signal)
|
||||
}
|
||||
|
||||
resolveSessionTitles(
|
||||
requests: AiVaultSessionTitleRequest[],
|
||||
signal?: AbortSignal
|
||||
): Promise<AiVaultSessionTitlesResult> {
|
||||
return this.request(
|
||||
{ type: 'request', id: this.nextId++, operation: 'titles', requests },
|
||||
signal
|
||||
)
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.disposed = true
|
||||
this.idleRetirement.clear()
|
||||
this.clearReadyTimer()
|
||||
this.restartPolicy.dispose()
|
||||
const error = new Error('Relay AI Vault service was disposed.')
|
||||
for (const call of [...this.active.values(), ...this.queue.splice(0)]) {
|
||||
settleRelayAiVaultServiceCall(call, error)
|
||||
}
|
||||
this.active.clear()
|
||||
const child = this.detachChild()
|
||||
if (child) {
|
||||
await shutdownRelayAiVaultServiceChild(child)
|
||||
}
|
||||
}
|
||||
|
||||
private request<T extends AiVaultListResult | AiVaultSessionTitlesResult>(
|
||||
request: RelayAiVaultServiceRequest,
|
||||
signal?: AbortSignal
|
||||
): Promise<T> {
|
||||
if (this.disposed) {
|
||||
return Promise.reject(new Error('Relay AI Vault service was disposed.'))
|
||||
}
|
||||
if (signal?.aborted) {
|
||||
return Promise.reject(relayAiVaultAbortError())
|
||||
}
|
||||
if (this.queue.length + this.active.size >= RELAY_AI_VAULT_MAX_CALLS) {
|
||||
return Promise.reject(new Error('Relay AI Vault service queue is full.'))
|
||||
}
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const call = createRelayAiVaultServiceCall({
|
||||
request,
|
||||
signal,
|
||||
resolve: resolve as RelayAiVaultServiceCall['resolve'],
|
||||
reject
|
||||
})
|
||||
if (signal) {
|
||||
call.onAbort = () => this.cancel(call)
|
||||
signal.addEventListener('abort', call.onAbort, { once: true })
|
||||
}
|
||||
this.queue.push(call)
|
||||
this.idleRetirement.clear()
|
||||
this.pump()
|
||||
})
|
||||
}
|
||||
|
||||
private pump(): void {
|
||||
if (this.disposed || this.restartPolicy.restartScheduled) {
|
||||
return
|
||||
}
|
||||
for (const lane of ['cache', 'interactive'] as const) {
|
||||
if (this.active.has(lane)) {
|
||||
continue
|
||||
}
|
||||
const index = this.queue.findIndex((call) => call.lane === lane)
|
||||
if (index < 0) {
|
||||
continue
|
||||
}
|
||||
const call = this.queue.splice(index, 1)[0]!
|
||||
this.active.set(lane, call)
|
||||
void this.ensureChild(call.forceStart).then(
|
||||
(child) => this.sendCall(child, call),
|
||||
(error: Error) => {
|
||||
if (this.active.get(lane) !== call) {
|
||||
return
|
||||
}
|
||||
this.active.delete(lane)
|
||||
this.retryStartOrSettle(call, error)
|
||||
this.pump()
|
||||
}
|
||||
)
|
||||
}
|
||||
this.scheduleIdleIfNeeded()
|
||||
}
|
||||
|
||||
private sendCall(child: ChildProcess, call: RelayAiVaultServiceCall): void {
|
||||
if (this.active.get(call.lane) !== call || call.settled) {
|
||||
return
|
||||
}
|
||||
const timeout =
|
||||
call.request.operation === 'list'
|
||||
? RELAY_AI_VAULT_SCAN_TIMEOUT_MS
|
||||
: RELAY_AI_VAULT_TITLE_TIMEOUT_MS
|
||||
call.timer = setTimeout(
|
||||
() => this.onFault(new Error(`Relay AI Vault service timed out after ${timeout}ms.`)),
|
||||
timeout
|
||||
)
|
||||
call.timer.unref?.()
|
||||
call.sent = true
|
||||
child.send(call.request)
|
||||
}
|
||||
|
||||
private retryStartOrSettle(call: RelayAiVaultServiceCall, error: Error): void {
|
||||
if (this.disposed || !requeueRelayAiVaultServiceStart(call, this.queue)) {
|
||||
settleRelayAiVaultServiceCall(call, error)
|
||||
}
|
||||
}
|
||||
|
||||
private ensureChild(forceStart: boolean): Promise<ChildProcess> {
|
||||
if (this.child && !this.ready) {
|
||||
return Promise.resolve(this.child)
|
||||
}
|
||||
if (this.ready) {
|
||||
return this.ready
|
||||
}
|
||||
const startError = this.restartPolicy.startError(forceStart)
|
||||
if (startError) {
|
||||
return Promise.reject(startError)
|
||||
}
|
||||
let child: ChildProcess
|
||||
try {
|
||||
child = this.options.processFactory()
|
||||
} catch (error) {
|
||||
return Promise.reject(relayAiVaultError(error))
|
||||
}
|
||||
this.child = child
|
||||
this.ready = new Promise<ChildProcess>((resolve, reject) => {
|
||||
this.readyReject = reject
|
||||
// Why: held on the instance so a crash before ready cannot leave the deadline
|
||||
// armed, where it would later fault the healthy replacement sidecar.
|
||||
this.readyTimer = setTimeout(
|
||||
() => this.onFault(new Error('Relay AI Vault service did not become ready.')),
|
||||
RELAY_AI_VAULT_READY_TIMEOUT_MS
|
||||
)
|
||||
this.readyTimer.unref?.()
|
||||
child.on('message', (message) => {
|
||||
if (isRelayAiVaultServiceChildMessage(message) && message.type === 'ready') {
|
||||
this.clearReadyTimer()
|
||||
this.ready = null
|
||||
this.readyReject = null
|
||||
resolve(child)
|
||||
return
|
||||
}
|
||||
this.onMessage(message)
|
||||
})
|
||||
})
|
||||
child.on('error', (error) => this.onFault(error))
|
||||
child.on('disconnect', () => this.onFault(new Error('Relay AI Vault service disconnected.')))
|
||||
child.on('exit', (code) => this.onFault(new Error(`Relay AI Vault service exited (${code}).`)))
|
||||
child.stderr?.on('data', (chunk: Buffer) =>
|
||||
relayLogLine(`[relay-ai-vault-service] ${String(chunk).trimEnd()}`)
|
||||
)
|
||||
child.send({
|
||||
type: 'init',
|
||||
protocol: RELAY_AI_VAULT_SERVICE_PROTOCOL,
|
||||
...this.options.init
|
||||
} satisfies RelayAiVaultServiceInit)
|
||||
return this.ready
|
||||
}
|
||||
|
||||
private onMessage(raw: unknown): void {
|
||||
if (!isRelayAiVaultServiceChildMessage(raw)) {
|
||||
this.onFault(new Error('Relay AI Vault service sent a malformed message.'))
|
||||
return
|
||||
}
|
||||
const message = raw as RelayAiVaultServiceChildMessage
|
||||
if (message.type === 'ready') {
|
||||
return
|
||||
}
|
||||
const call = [...this.active.values()].find((entry) => entry.request.id === message.id)
|
||||
if (!call) {
|
||||
return
|
||||
}
|
||||
this.active.delete(call.lane)
|
||||
settleRelayAiVaultServiceCall(
|
||||
call,
|
||||
message.type === 'error' ? new Error(message.message) : message.value
|
||||
)
|
||||
this.pump()
|
||||
}
|
||||
|
||||
private cancel(call: RelayAiVaultServiceCall): void {
|
||||
const index = this.queue.indexOf(call)
|
||||
if (index >= 0) {
|
||||
this.queue.splice(index, 1)
|
||||
settleRelayAiVaultServiceCall(call, relayAiVaultAbortError())
|
||||
this.pump()
|
||||
return
|
||||
}
|
||||
if (this.active.get(call.lane) === call) {
|
||||
// Why: a call cancelled before it reached the sidecar gets no acknowledgement,
|
||||
// so waiting on one would kill a healthy sidecar and stall the lane.
|
||||
if (!call.sent) {
|
||||
this.active.delete(call.lane)
|
||||
settleRelayAiVaultServiceCall(call, relayAiVaultAbortError())
|
||||
this.pump()
|
||||
return
|
||||
}
|
||||
this.child?.send({ type: 'cancel', id: call.request.id })
|
||||
settleRelayAiVaultServiceCall(call, relayAiVaultAbortError())
|
||||
armRelayAiVaultCancellationTimeout(call, () =>
|
||||
this.onFault(new Error('Relay AI Vault service did not cancel within 2000ms.'))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private onFault(error: Error): void {
|
||||
if (!this.child) {
|
||||
return
|
||||
}
|
||||
this.idleRetirement.clear()
|
||||
this.detachChild()?.kill()
|
||||
this.clearReadyTimer()
|
||||
this.readyReject?.(error)
|
||||
this.readyReject = null
|
||||
this.ready = null
|
||||
const active = [...this.active.values()]
|
||||
this.active.clear()
|
||||
for (const call of active) {
|
||||
this.retryStartOrSettle(call, error)
|
||||
}
|
||||
this.restartPolicy.recordFault()
|
||||
if (this.queue.length > 0 && !this.disposed) {
|
||||
this.restartPolicy.scheduleRestart(() => this.pump())
|
||||
}
|
||||
}
|
||||
|
||||
private clearReadyTimer(): void {
|
||||
if (this.readyTimer) {
|
||||
clearTimeout(this.readyTimer)
|
||||
this.readyTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
private detachChild(): ChildProcess | null {
|
||||
const child = this.child
|
||||
this.child = null
|
||||
child?.removeAllListeners()
|
||||
return child
|
||||
}
|
||||
|
||||
private scheduleIdleIfNeeded(): void {
|
||||
this.idleRetirement.schedule(
|
||||
this.active.size > 0 || this.queue.length > 0 || !this.child,
|
||||
this.options.idleTimeoutMs ?? RELAY_AI_VAULT_IDLE_TIMEOUT_MS,
|
||||
() => {
|
||||
const child = this.detachChild()
|
||||
if (child) {
|
||||
retireRelayAiVaultServiceChild(child)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../shared/execution-host'
|
||||
import { scanRemoteAiVaultSessions } from '../main/ai-vault/remote-session-scanner'
|
||||
import { readAiVaultSessionTitlesFromFiles } from '../main/ai-vault/session-title-file-reader'
|
||||
import { createRelayAiVaultFilesystemProvider } from './ai-vault-service-filesystem'
|
||||
import {
|
||||
RELAY_AI_VAULT_SERVICE_PROTOCOL,
|
||||
isRelayAiVaultServiceRequest,
|
||||
relayAiVaultServiceLane,
|
||||
type RelayAiVaultServiceChildMessage,
|
||||
type RelayAiVaultServiceInit,
|
||||
type RelayAiVaultServiceParentMessage,
|
||||
type RelayAiVaultServiceRequest
|
||||
} from './ai-vault-service-protocol'
|
||||
|
||||
if (!process.send) {
|
||||
throw new Error('Relay AI Vault service requires a parent IPC channel.')
|
||||
}
|
||||
|
||||
const controllers = new Map<number, AbortController>()
|
||||
const cancelled = new Set<number>()
|
||||
const pending = new Set<number>()
|
||||
const provider = createRelayAiVaultFilesystemProvider()
|
||||
let init: RelayAiVaultServiceInit | null = null
|
||||
let cacheLane = Promise.resolve()
|
||||
let interactiveLane = Promise.resolve()
|
||||
let shuttingDown = false
|
||||
|
||||
function send(message: RelayAiVaultServiceChildMessage): void {
|
||||
process.send?.(message)
|
||||
}
|
||||
|
||||
async function execute(request: RelayAiVaultServiceRequest): Promise<void> {
|
||||
const controller = new AbortController()
|
||||
controllers.set(request.id, controller)
|
||||
if (cancelled.delete(request.id)) {
|
||||
controller.abort()
|
||||
}
|
||||
try {
|
||||
if (!init) {
|
||||
throw new Error('Relay AI Vault service is not initialized.')
|
||||
}
|
||||
if (request.operation === 'titles') {
|
||||
const value = await readAiVaultSessionTitlesFromFiles(request.requests, {
|
||||
signal: controller.signal
|
||||
})
|
||||
send({ type: 'result', id: request.id, operation: 'titles', value })
|
||||
return
|
||||
}
|
||||
const value = await scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
executionHostId: LOCAL_EXECUTION_HOST_ID,
|
||||
remoteHome: init.remoteHome,
|
||||
hostPlatform: init.hostPlatform,
|
||||
limit: request.params.limit,
|
||||
unlimited: request.params.unlimited,
|
||||
scopePaths: request.params.scopePaths,
|
||||
signal: controller.signal
|
||||
})
|
||||
send({ type: 'result', id: request.id, operation: 'list', value })
|
||||
} catch (error) {
|
||||
send({
|
||||
type: 'error',
|
||||
id: request.id,
|
||||
message: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
} finally {
|
||||
controllers.delete(request.id)
|
||||
cancelled.delete(request.id)
|
||||
pending.delete(request.id)
|
||||
}
|
||||
}
|
||||
|
||||
async function shutdown(): Promise<void> {
|
||||
if (shuttingDown) {
|
||||
return
|
||||
}
|
||||
shuttingDown = true
|
||||
for (const controller of controllers.values()) {
|
||||
controller.abort()
|
||||
}
|
||||
await Promise.allSettled([cacheLane, interactiveLane])
|
||||
process.disconnect?.()
|
||||
}
|
||||
|
||||
process.on('message', (raw: RelayAiVaultServiceParentMessage) => {
|
||||
if (raw?.type === 'init') {
|
||||
if (init || raw.protocol !== RELAY_AI_VAULT_SERVICE_PROTOCOL) {
|
||||
void shutdown()
|
||||
return
|
||||
}
|
||||
init = raw
|
||||
send({ type: 'ready', protocol: RELAY_AI_VAULT_SERVICE_PROTOCOL, pid: process.pid })
|
||||
return
|
||||
}
|
||||
if (!init || shuttingDown) {
|
||||
return
|
||||
}
|
||||
if (raw?.type === 'cancel') {
|
||||
cancelled.add(raw.id)
|
||||
controllers.get(raw.id)?.abort()
|
||||
return
|
||||
}
|
||||
if (raw?.type === 'shutdown') {
|
||||
void shutdown()
|
||||
return
|
||||
}
|
||||
if (!isRelayAiVaultServiceRequest(raw)) {
|
||||
return
|
||||
}
|
||||
if (pending.size >= 16) {
|
||||
send({ type: 'error', id: raw.id, message: 'Relay AI Vault service queue is full.' })
|
||||
return
|
||||
}
|
||||
pending.add(raw.id)
|
||||
if (relayAiVaultServiceLane(raw.operation) === 'interactive') {
|
||||
interactiveLane = interactiveLane.then(() => execute(raw))
|
||||
return
|
||||
}
|
||||
cacheLane = cacheLane.then(() => execute(raw))
|
||||
})
|
||||
|
||||
process.on('disconnect', () => void shutdown())
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { RemoteHostPlatform } from '../main/ssh/ssh-remote-platform'
|
||||
import { RelayAiVaultServiceClient } from './ai-vault-service-client'
|
||||
import { spawnRelayAiVaultService } from './ai-vault-service-spawn'
|
||||
|
||||
export function createRelayAiVaultService(
|
||||
remoteHome: string,
|
||||
hostPlatform: RemoteHostPlatform
|
||||
): RelayAiVaultServiceClient {
|
||||
return new RelayAiVaultServiceClient({
|
||||
init: { remoteHome, hostPlatform },
|
||||
processFactory: spawnRelayAiVaultService
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { lstat, readdir } from 'node:fs/promises'
|
||||
import type { RemoteSessionFilesystemProvider } from '../main/ai-vault/remote-session-scanner-types'
|
||||
import { readRelayFileContent } from './fs-handler-file-read'
|
||||
|
||||
export 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { AiVaultListResult } from '../shared/ai-vault-types'
|
||||
import type {
|
||||
AiVaultSessionTitleRequest,
|
||||
AiVaultSessionTitlesResult
|
||||
} from '../shared/ai-vault-session-title'
|
||||
import type { SshAiVaultRelayListParams } from '../shared/ssh-ai-vault-relay'
|
||||
import type { RemoteHostPlatform } from '../main/ssh/ssh-remote-platform'
|
||||
|
||||
export const RELAY_AI_VAULT_SERVICE_PROTOCOL = 1
|
||||
|
||||
export type RelayAiVaultServiceInit = {
|
||||
type: 'init'
|
||||
protocol: typeof RELAY_AI_VAULT_SERVICE_PROTOCOL
|
||||
remoteHome: string
|
||||
hostPlatform: RemoteHostPlatform
|
||||
}
|
||||
|
||||
export type RelayAiVaultServiceRequest =
|
||||
| {
|
||||
type: 'request'
|
||||
id: number
|
||||
operation: 'list'
|
||||
params: SshAiVaultRelayListParams
|
||||
}
|
||||
| {
|
||||
type: 'request'
|
||||
id: number
|
||||
operation: 'titles'
|
||||
requests: AiVaultSessionTitleRequest[]
|
||||
}
|
||||
|
||||
export type RelayAiVaultServiceLane = 'cache' | 'interactive'
|
||||
export type RelayAiVaultServiceOperation = RelayAiVaultServiceRequest['operation']
|
||||
|
||||
/** Title reads must not queue behind a full scan; they back interactive UI. */
|
||||
export function relayAiVaultServiceLane(
|
||||
operation: RelayAiVaultServiceOperation
|
||||
): RelayAiVaultServiceLane {
|
||||
return operation === 'titles' ? 'interactive' : 'cache'
|
||||
}
|
||||
|
||||
export type RelayAiVaultServiceParentMessage =
|
||||
| RelayAiVaultServiceInit
|
||||
| RelayAiVaultServiceRequest
|
||||
| { type: 'cancel'; id: number }
|
||||
| { type: 'shutdown' }
|
||||
|
||||
export type RelayAiVaultServiceChildMessage =
|
||||
| {
|
||||
type: 'ready'
|
||||
protocol: typeof RELAY_AI_VAULT_SERVICE_PROTOCOL
|
||||
pid: number
|
||||
}
|
||||
| { type: 'result'; id: number; operation: 'list'; value: AiVaultListResult }
|
||||
| {
|
||||
type: 'result'
|
||||
id: number
|
||||
operation: 'titles'
|
||||
value: AiVaultSessionTitlesResult
|
||||
}
|
||||
| { type: 'error'; id: number; message: string }
|
||||
|
||||
export function isRelayAiVaultServiceRequest(value: unknown): value is RelayAiVaultServiceRequest {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false
|
||||
}
|
||||
const message = value as Record<string, unknown>
|
||||
return (
|
||||
message.type === 'request' &&
|
||||
Number.isSafeInteger(message.id) &&
|
||||
(message.operation === 'list' || message.operation === 'titles')
|
||||
)
|
||||
}
|
||||
|
||||
export function isRelayAiVaultServiceChildMessage(
|
||||
value: unknown
|
||||
): value is RelayAiVaultServiceChildMessage {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false
|
||||
}
|
||||
const message = value as Record<string, unknown>
|
||||
if (message.type === 'ready') {
|
||||
return message.protocol === RELAY_AI_VAULT_SERVICE_PROTOCOL && Number.isSafeInteger(message.pid)
|
||||
}
|
||||
return (message.type === 'result' || message.type === 'error') && Number.isSafeInteger(message.id)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { RelayAiVaultRestartPolicy } from './ai-vault-service-restart-policy'
|
||||
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
describe('RelayAiVaultRestartPolicy', () => {
|
||||
it('opens the circuit after three faults inside the window and closes it after', () => {
|
||||
let now = 0
|
||||
const policy = new RelayAiVaultRestartPolicy(() => now)
|
||||
|
||||
expect(policy.startError(false)).toBeNull()
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
policy.recordFault()
|
||||
now += 1_000
|
||||
}
|
||||
|
||||
expect(policy.startError(false)?.message).toContain('circuit is open')
|
||||
now += 60_000
|
||||
expect(policy.startError(false)).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the circuit closed when the faults age out of the window', () => {
|
||||
let now = 0
|
||||
const policy = new RelayAiVaultRestartPolicy(() => now)
|
||||
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
policy.recordFault()
|
||||
now += 30_000
|
||||
}
|
||||
|
||||
expect(policy.startError(false)).toBeNull()
|
||||
})
|
||||
|
||||
it('lets a forced refresh reopen an open circuit', () => {
|
||||
let now = 0
|
||||
const policy = new RelayAiVaultRestartPolicy(() => now)
|
||||
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
policy.recordFault()
|
||||
}
|
||||
expect(policy.startError(false)?.message).toContain('circuit is open')
|
||||
|
||||
expect(policy.startError(true)).toBeNull()
|
||||
// The forced start clears the circuit outright, so the next background
|
||||
// start is no longer refused either.
|
||||
expect(policy.startError(false)).toBeNull()
|
||||
})
|
||||
|
||||
it('backs off further with each fault and keeps one pending restart', () => {
|
||||
vi.useFakeTimers()
|
||||
const policy = new RelayAiVaultRestartPolicy()
|
||||
const restart = vi.fn()
|
||||
|
||||
policy.recordFault()
|
||||
policy.scheduleRestart(restart)
|
||||
vi.advanceTimersByTime(250)
|
||||
expect(restart).toHaveBeenCalledTimes(1)
|
||||
|
||||
policy.recordFault()
|
||||
policy.scheduleRestart(restart)
|
||||
policy.recordFault()
|
||||
policy.scheduleRestart(restart)
|
||||
vi.advanceTimersByTime(250)
|
||||
expect(restart).toHaveBeenCalledTimes(1)
|
||||
|
||||
vi.advanceTimersByTime(5_000)
|
||||
expect(restart).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('cancels the pending restart on dispose', () => {
|
||||
vi.useFakeTimers()
|
||||
const policy = new RelayAiVaultRestartPolicy()
|
||||
const restart = vi.fn()
|
||||
|
||||
policy.recordFault()
|
||||
policy.scheduleRestart(restart)
|
||||
policy.dispose()
|
||||
vi.advanceTimersByTime(10_000)
|
||||
|
||||
expect(restart).not.toHaveBeenCalled()
|
||||
expect(policy.restartScheduled).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
const RELAY_AI_VAULT_FAULT_WINDOW_MS = 60_000
|
||||
const RELAY_AI_VAULT_RESTART_DELAYS_MS = [250, 1_000, 5_000] as const
|
||||
|
||||
export class RelayAiVaultRestartPolicy {
|
||||
private faults: number[] = []
|
||||
private circuitUntil = 0
|
||||
private timer: NodeJS.Timeout | null = null
|
||||
|
||||
constructor(private readonly now: () => number = Date.now) {}
|
||||
|
||||
get restartScheduled(): boolean {
|
||||
return this.timer !== null
|
||||
}
|
||||
|
||||
/** A forced refresh is a deliberate user action, so it reopens the circuit. */
|
||||
startError(forceStart: boolean): Error | null {
|
||||
if (forceStart) {
|
||||
this.circuitUntil = 0
|
||||
return null
|
||||
}
|
||||
return this.now() < this.circuitUntil
|
||||
? new Error('Relay AI Vault service restart circuit is open.')
|
||||
: null
|
||||
}
|
||||
|
||||
recordFault(): void {
|
||||
const now = this.now()
|
||||
this.faults = [
|
||||
...this.faults.filter((time) => now - time < RELAY_AI_VAULT_FAULT_WINDOW_MS),
|
||||
now
|
||||
]
|
||||
if (this.faults.length >= 3) {
|
||||
this.circuitUntil = now + RELAY_AI_VAULT_FAULT_WINDOW_MS
|
||||
}
|
||||
}
|
||||
|
||||
scheduleRestart(restart: () => void): void {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer)
|
||||
}
|
||||
const delay = RELAY_AI_VAULT_RESTART_DELAYS_MS[Math.min(this.faults.length - 1, 2)]
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = null
|
||||
restart()
|
||||
}, delay)
|
||||
this.timer.unref?.()
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { ChildProcess } from 'node:child_process'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const forkMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('node:child_process', () => ({ fork: forkMock }))
|
||||
|
||||
const { spawnRelayAiVaultService } = await import('./ai-vault-service-spawn')
|
||||
|
||||
function forkOptions(): { env?: NodeJS.ProcessEnv; execArgv?: string[] } {
|
||||
return forkMock.mock.calls.at(-1)?.[2] ?? {}
|
||||
}
|
||||
|
||||
describe('spawnRelayAiVaultService', () => {
|
||||
beforeEach(() => {
|
||||
forkMock.mockReset()
|
||||
forkMock.mockReturnValue({ pid: undefined, unref: vi.fn() } as unknown as ChildProcess)
|
||||
})
|
||||
|
||||
it('keeps NODE_OPTIONS out of the sidecar so the heap cap and loader stand', () => {
|
||||
vi.stubEnv('NODE_OPTIONS', '--max-old-space-size=8192 --require=/tmp/evil.js')
|
||||
spawnRelayAiVaultService()
|
||||
const options = forkOptions()
|
||||
|
||||
// Asserted first: omitting `env` entirely inherits everything, and would
|
||||
// leave the NODE_OPTIONS assertion below passing for the wrong reason.
|
||||
expect(options.env).toBeDefined()
|
||||
expect(options.env?.NODE_OPTIONS).toBeUndefined()
|
||||
expect(options.execArgv).toEqual(['--max-old-space-size=384'])
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('does not hand the sidecar the rest of the remote login environment', () => {
|
||||
vi.stubEnv('AWS_SECRET_ACCESS_KEY', 'shhh')
|
||||
spawnRelayAiVaultService()
|
||||
const options = forkOptions()
|
||||
|
||||
expect(options.env).toBeDefined()
|
||||
expect(options.env?.AWS_SECRET_ACCESS_KEY).toBeUndefined()
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import { fork, type ChildProcess } from 'node:child_process'
|
||||
import { join } from 'node:path'
|
||||
import { buildRelayAiVaultServiceEnv } from '../main/ai-vault/session-scanner-service-env'
|
||||
import { lowerAiVaultServicePriority } from '../main/ai-vault/session-scanner-service-priority'
|
||||
|
||||
export function relayAiVaultServiceEntryPath(baseDir = __dirname): string {
|
||||
return join(baseDir, 'relay-ai-vault-service.js')
|
||||
}
|
||||
|
||||
export function spawnRelayAiVaultService(): ChildProcess {
|
||||
const child = fork(relayAiVaultServiceEntryPath(), [], {
|
||||
stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
|
||||
execArgv: ['--max-old-space-size=384'],
|
||||
env: buildRelayAiVaultServiceEnv(),
|
||||
...(process.platform === 'win32' ? { windowsHide: true } : {})
|
||||
})
|
||||
lowerAiVaultServicePriority(child.pid)
|
||||
child.unref()
|
||||
return child
|
||||
}
|
||||
+21
-2
@@ -9,6 +9,7 @@
|
||||
// reconnects via `relay.js --connect`, bridging the new SSH channel's stdio to the existing relay's socket.
|
||||
|
||||
import { createServer, createConnection, type Socket, type Server } from 'node:net'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
unlinkSync,
|
||||
@@ -41,6 +42,9 @@ 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 { createRelayAiVaultService } from './ai-vault-service-factory'
|
||||
import { getRemoteHostPlatform } from '../main/ssh/ssh-remote-platform'
|
||||
import { parseUnameToRelayPlatform } from '../main/ssh/relay-protocol'
|
||||
import { endpointDirForRelaySocket, RelayAgentHookServer } from './agent-hook-server'
|
||||
import { PluginOverlayManager } from './plugin-overlay'
|
||||
import {
|
||||
@@ -723,7 +727,17 @@ async function main(): Promise<void> {
|
||||
const _workspaceSessionHandler = new WorkspaceSessionHandler(dispatcher)
|
||||
void _workspaceSessionHandler
|
||||
|
||||
const _aiVaultHandler = new AiVaultHandler(dispatcher)
|
||||
const aiVaultRelayPlatform = parseUnameToRelayPlatform(process.platform, process.arch)
|
||||
const aiVaultHostPlatform = aiVaultRelayPlatform
|
||||
? getRemoteHostPlatform(aiVaultRelayPlatform)
|
||||
: undefined
|
||||
const aiVaultService = aiVaultHostPlatform
|
||||
? createRelayAiVaultService(homedir(), aiVaultHostPlatform)
|
||||
: null
|
||||
const _aiVaultHandler = new AiVaultHandler(dispatcher, {
|
||||
hostPlatform: aiVaultHostPlatform,
|
||||
service: aiVaultService ?? undefined
|
||||
})
|
||||
void _aiVaultHandler
|
||||
|
||||
// Why: relay-hosted plugin provisioning is a later phase. Register the
|
||||
@@ -1285,7 +1299,12 @@ async function main(): Promise<void> {
|
||||
graceBranch = null
|
||||
void ptyHandler
|
||||
.dispose()
|
||||
.then(() => {
|
||||
.then(async () => {
|
||||
await aiVaultService?.dispose().catch((error) => {
|
||||
relayLogLine(
|
||||
`[relay] AI Vault sidecar shutdown failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
})
|
||||
stopPoolWatch()
|
||||
stopPoolActiveWatch()
|
||||
dispatcher.dispose()
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AiVaultListResult } from '../../../../shared/ai-vault-types'
|
||||
import {
|
||||
markInputQuietSchedulerInput,
|
||||
resetInputQuietSchedulerForTest
|
||||
} from '@/lib/input-quiet-scheduler'
|
||||
import { AiVaultSessionPublicationGate } from './ai-vault-session-publication-gate'
|
||||
|
||||
const FIRST: AiVaultListResult = {
|
||||
sessions: [],
|
||||
issues: [],
|
||||
scannedAt: '2026-08-09T00:00:00.000Z'
|
||||
}
|
||||
const SECOND: AiVaultListResult = {
|
||||
...FIRST,
|
||||
scannedAt: '2026-08-09T00:00:01.000Z'
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
resetInputQuietSchedulerForTest()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('AiVaultSessionPublicationGate', () => {
|
||||
it('publishes immediately when terminal input is already quiet', () => {
|
||||
const apply = vi.fn()
|
||||
|
||||
new AiVaultSessionPublicationGate().publish(FIRST, apply)
|
||||
|
||||
expect(apply).toHaveBeenCalledWith(FIRST)
|
||||
})
|
||||
|
||||
it('retains only the newest result while input is active', () => {
|
||||
const apply = vi.fn()
|
||||
const gate = new AiVaultSessionPublicationGate()
|
||||
markInputQuietSchedulerInput()
|
||||
|
||||
gate.publish(FIRST, apply)
|
||||
gate.publish(SECOND, apply)
|
||||
vi.advanceTimersByTime(101)
|
||||
|
||||
expect(apply).toHaveBeenCalledOnce()
|
||||
expect(apply).toHaveBeenCalledWith(SECOND)
|
||||
})
|
||||
|
||||
it('publishes by one second even when input never becomes quiet', () => {
|
||||
const apply = vi.fn()
|
||||
const gate = new AiVaultSessionPublicationGate()
|
||||
markInputQuietSchedulerInput()
|
||||
gate.publish(FIRST, apply)
|
||||
|
||||
for (let elapsed = 90; elapsed < 1_000; elapsed += 90) {
|
||||
vi.advanceTimersByTime(90)
|
||||
markInputQuietSchedulerInput()
|
||||
}
|
||||
vi.advanceTimersByTime(10)
|
||||
|
||||
expect(apply).toHaveBeenCalledWith(FIRST)
|
||||
})
|
||||
|
||||
it('cancels a pending publication on scope change or unmount', () => {
|
||||
const apply = vi.fn()
|
||||
const gate = new AiVaultSessionPublicationGate()
|
||||
markInputQuietSchedulerInput()
|
||||
gate.publish(FIRST, apply)
|
||||
|
||||
gate.cancel()
|
||||
vi.advanceTimersByTime(1_000)
|
||||
|
||||
expect(apply).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import { startTransition } from 'react'
|
||||
import type { AiVaultListResult } from '../../../../shared/ai-vault-types'
|
||||
import { hasInputBeenQuietFor, scheduleAfterInputQuiet } from '@/lib/input-quiet-scheduler'
|
||||
|
||||
const AI_VAULT_PUBLICATION_QUIET_MS = 100
|
||||
const AI_VAULT_PUBLICATION_MAX_WAIT_MS = 1_000
|
||||
|
||||
export class AiVaultSessionPublicationGate {
|
||||
private generation = 0
|
||||
private cancelPending: (() => void) | null = null
|
||||
|
||||
publish(result: AiVaultListResult, apply: (result: AiVaultListResult) => void): void {
|
||||
this.cancel()
|
||||
const generation = this.generation
|
||||
if (hasInputBeenQuietFor(AI_VAULT_PUBLICATION_QUIET_MS)) {
|
||||
startTransition(() => apply(result))
|
||||
return
|
||||
}
|
||||
this.cancelPending = scheduleAfterInputQuiet(
|
||||
() => {
|
||||
if (generation !== this.generation) {
|
||||
return
|
||||
}
|
||||
this.cancelPending = null
|
||||
startTransition(() => apply(result))
|
||||
},
|
||||
{
|
||||
delayMs: 0,
|
||||
quietMs: AI_VAULT_PUBLICATION_QUIET_MS,
|
||||
idleTimeoutMs: AI_VAULT_PUBLICATION_QUIET_MS,
|
||||
maxWaitMs: AI_VAULT_PUBLICATION_MAX_WAIT_MS
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.generation += 1
|
||||
this.cancelPending?.()
|
||||
this.cancelPending = null
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import type { ExecutionHostScope } from '../../../../shared/execution-host'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { AiVaultSessionLimit } from './ai-vault-session-limit'
|
||||
import { AiVaultSessionPublicationGate } from './ai-vault-session-publication-gate'
|
||||
import {
|
||||
aiVaultSessionResultCacheKey,
|
||||
cacheAiVaultSessionResult,
|
||||
@@ -53,6 +54,7 @@ export function useAiVaultSessionRefresh(
|
||||
const pendingBackgroundRef = useRef(true)
|
||||
const lastAppliedScanRef = useRef<{ scopeKey: string; scannedAt: string } | null>(null)
|
||||
const mountedRef = useRef(true)
|
||||
const publicationGateRef = useRef(new AiVaultSessionPublicationGate())
|
||||
const scanScopeKey = `${aiVaultSessionResultCacheKey(executionHostScope, scopePaths)}\n${sessionLimit}`
|
||||
const scopePathsRef = useRef<readonly string[]>(scopePaths)
|
||||
scopePathsRef.current = scopePaths
|
||||
@@ -89,8 +91,10 @@ export function useAiVaultSessionRefresh(
|
||||
const scanKey = `${baseKey}\n${selectedLimit}`
|
||||
lastAppliedScanRef.current = { scopeKey: scanKey, scannedAt: cachedResult.scannedAt }
|
||||
setError(null)
|
||||
setScanResult(cachedResult)
|
||||
setSessions(cachedResult.sessions)
|
||||
publicationGateRef.current.publish(cachedResult, (published) => {
|
||||
setScanResult(published)
|
||||
setSessions(published.sessions)
|
||||
})
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
@@ -155,8 +159,12 @@ export function useAiVaultSessionRefresh(
|
||||
result,
|
||||
replaceHostEntries: args.force === true
|
||||
})
|
||||
setScanResult(result)
|
||||
setSessions(result.sessions)
|
||||
publicationGateRef.current.publish(result, (published) => {
|
||||
if (mountedRef.current && scanKey === currentScanScopeKey()) {
|
||||
setScanResult(published)
|
||||
setSessions(published.sessions)
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
// A cancelled scan is not a failure: another caller's forced refresh
|
||||
// preempts the shared scan, and painting its abort would replace the
|
||||
@@ -214,8 +222,10 @@ export function useAiVaultSessionRefresh(
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
const requestToken = requestTokenRef.current
|
||||
const publicationGate = publicationGateRef.current
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
publicationGate.cancel()
|
||||
refreshIdRef.current += 1
|
||||
refreshInFlightRef.current = false
|
||||
void window.api.aiVault.cancelListSessions({
|
||||
@@ -230,6 +240,7 @@ export function useAiVaultSessionRefresh(
|
||||
|
||||
// Panel entry reuses the renderer result first, then the host scan cache.
|
||||
useEffect(() => {
|
||||
publicationGateRef.current.cancel()
|
||||
if (refreshInFlightRef.current) {
|
||||
void window.api.aiVault.cancelListSessions({
|
||||
requestToken: requestTokenRef.current
|
||||
|
||||
@@ -2,6 +2,7 @@ type InputQuietScheduleOptions = {
|
||||
delayMs: number
|
||||
quietMs: number
|
||||
idleTimeoutMs: number
|
||||
maxWaitMs?: number
|
||||
}
|
||||
|
||||
const INPUT_QUIET_EVENTS: readonly (keyof WindowEventMap)[] = [
|
||||
@@ -14,7 +15,7 @@ const INPUT_QUIET_EVENTS: readonly (keyof WindowEventMap)[] = [
|
||||
]
|
||||
|
||||
let listenersInstalled = false
|
||||
let lastInputAt = 0
|
||||
let lastInputAt = Number.NEGATIVE_INFINITY
|
||||
|
||||
function now(): number {
|
||||
return typeof performance !== 'undefined' ? performance.now() : Date.now()
|
||||
@@ -28,12 +29,22 @@ export function markInputQuietSchedulerInput(): void {
|
||||
recordInput()
|
||||
}
|
||||
|
||||
export function resetInputQuietSchedulerForTest(): void {
|
||||
lastInputAt = Number.NEGATIVE_INFINITY
|
||||
}
|
||||
|
||||
export function hasInputBeenQuietFor(quietMs: number): boolean {
|
||||
if (typeof window !== 'undefined') {
|
||||
ensureInputQuietListeners(window)
|
||||
}
|
||||
return now() - lastInputAt >= quietMs
|
||||
}
|
||||
|
||||
function ensureInputQuietListeners(targetWindow: Window): void {
|
||||
if (listenersInstalled) {
|
||||
return
|
||||
}
|
||||
listenersInstalled = true
|
||||
lastInputAt = now()
|
||||
const options: AddEventListenerOptions = { capture: true, passive: true }
|
||||
for (const eventName of INPUT_QUIET_EVENTS) {
|
||||
targetWindow.addEventListener(eventName, recordInput, options)
|
||||
@@ -63,7 +74,7 @@ function cancelIdleCallback(targetWindow: Window, idleId: number): void {
|
||||
|
||||
export function scheduleAfterInputQuiet(
|
||||
callback: () => void,
|
||||
{ delayMs, quietMs, idleTimeoutMs }: InputQuietScheduleOptions
|
||||
{ delayMs, quietMs, idleTimeoutMs, maxWaitMs }: InputQuietScheduleOptions
|
||||
): () => void {
|
||||
if (typeof window === 'undefined') {
|
||||
const fallbackTimer = setTimeout(callback, delayMs)
|
||||
@@ -77,12 +88,34 @@ export function scheduleAfterInputQuiet(
|
||||
let delayTimer: number | null = null
|
||||
let quietTimer: number | null = null
|
||||
let idleId: number | null = null
|
||||
let maxWaitTimer: number | null = null
|
||||
|
||||
const clearScheduledWork = (): void => {
|
||||
if (delayTimer !== null) {
|
||||
targetWindow.clearTimeout(delayTimer)
|
||||
delayTimer = null
|
||||
}
|
||||
if (quietTimer !== null) {
|
||||
targetWindow.clearTimeout(quietTimer)
|
||||
quietTimer = null
|
||||
}
|
||||
if (idleId !== null) {
|
||||
cancelIdleCallback(targetWindow, idleId)
|
||||
idleId = null
|
||||
}
|
||||
if (maxWaitTimer !== null) {
|
||||
targetWindow.clearTimeout(maxWaitTimer)
|
||||
maxWaitTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const run = (): void => {
|
||||
idleId = null
|
||||
if (!cancelled) {
|
||||
callback()
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
clearScheduledWork()
|
||||
cancelled = true
|
||||
callback()
|
||||
}
|
||||
|
||||
const checkQuietWindow = (): void => {
|
||||
@@ -106,17 +139,12 @@ export function scheduleAfterInputQuiet(
|
||||
delayTimer = null
|
||||
checkQuietWindow()
|
||||
}, delayMs)
|
||||
if (maxWaitMs !== undefined) {
|
||||
maxWaitTimer = targetWindow.setTimeout(run, Math.max(0, maxWaitMs))
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (delayTimer !== null) {
|
||||
targetWindow.clearTimeout(delayTimer)
|
||||
}
|
||||
if (quietTimer !== null) {
|
||||
targetWindow.clearTimeout(quietTimer)
|
||||
}
|
||||
if (idleId !== null) {
|
||||
cancelIdleCallback(targetWindow, idleId)
|
||||
}
|
||||
clearScheduledWork()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { mkdirSync, utimesSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
export type SeededVaultBatch = {
|
||||
newestTitle: string
|
||||
totalBytes: number
|
||||
}
|
||||
|
||||
function jsonLine(value: unknown): string {
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
|
||||
export function seedVaultTranscriptBatch(args: {
|
||||
homePath: string
|
||||
cwd: string
|
||||
batch: number
|
||||
sessionCount: number
|
||||
payloadBytes: number
|
||||
}): SeededVaultBatch {
|
||||
const sessionsDir = path.join(args.homePath, '.codex', 'sessions', '2026', '08', '09')
|
||||
mkdirSync(sessionsDir, { recursive: true })
|
||||
const padding = 'x'.repeat(args.payloadBytes)
|
||||
const baseTimeMs = Date.now() + args.batch * 10_000
|
||||
let totalBytes = 0
|
||||
let newestTitle = ''
|
||||
|
||||
for (let index = 0; index < args.sessionCount; index += 1) {
|
||||
const sessionId = `vault-bench-${args.batch}-${index}`
|
||||
const title = `Vault benchmark batch ${args.batch} session ${index}`
|
||||
const timestamp = new Date(baseTimeMs + index).toISOString()
|
||||
const content = `${[
|
||||
jsonLine({
|
||||
timestamp,
|
||||
type: 'session_meta',
|
||||
payload: { id: sessionId, cwd: args.cwd }
|
||||
}),
|
||||
jsonLine({
|
||||
timestamp,
|
||||
type: 'response_item',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: title }]
|
||||
}
|
||||
}),
|
||||
jsonLine({
|
||||
timestamp,
|
||||
type: 'response_item',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'output_text', text: padding }]
|
||||
}
|
||||
})
|
||||
].join('\n')}\n`
|
||||
const filePath = path.join(sessionsDir, `rollout-${sessionId}.jsonl`)
|
||||
writeFileSync(filePath, content)
|
||||
const mtime = new Date(baseTimeMs + index)
|
||||
utimesSync(filePath, mtime, mtime)
|
||||
totalBytes += Buffer.byteLength(content)
|
||||
newestTitle = title
|
||||
}
|
||||
|
||||
return { newestTitle, totalBytes }
|
||||
}
|
||||
|
||||
export function typingEchoScript(readyMarker: string): string {
|
||||
return `
|
||||
process.stdin.setEncoding('utf8')
|
||||
if (process.stdin.isTTY) process.stdin.setRawMode(true)
|
||||
process.stdin.resume()
|
||||
let input = ''
|
||||
const interrupt = String.fromCharCode(3)
|
||||
process.stdout.write('\\x1b[2J\\x1b[H${readyMarker}\\n')
|
||||
process.stdin.on('data', (chunk) => {
|
||||
if (chunk.includes(interrupt)) process.exit(0)
|
||||
for (const char of chunk) {
|
||||
if (char === '\\r' || char === '\\n') continue
|
||||
input += char
|
||||
}
|
||||
process.stdout.write('\\r\\x1b[2K' + input)
|
||||
})
|
||||
`
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { expect, type Page } from '@stablyai/playwright-test'
|
||||
|
||||
export type RendererJank = {
|
||||
longTaskCount: number
|
||||
maxLongTaskMs: number
|
||||
totalLongTaskMs: number
|
||||
maxTimerDriftMs: number
|
||||
maxFrameGapMs: number
|
||||
}
|
||||
|
||||
export async function startRendererJankProbe(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const durations: number[] = []
|
||||
let maxTimerDriftMs = 0
|
||||
let maxFrameGapMs = 0
|
||||
let lastTimer = performance.now()
|
||||
let lastFrame = performance.now()
|
||||
let stopped = false
|
||||
const observer = new PerformanceObserver((list) => {
|
||||
durations.push(...list.getEntries().map((entry) => entry.duration))
|
||||
})
|
||||
observer.observe({ type: 'longtask' })
|
||||
const timer = window.setInterval(() => {
|
||||
const now = performance.now()
|
||||
maxTimerDriftMs = Math.max(maxTimerDriftMs, now - lastTimer - 16)
|
||||
lastTimer = now
|
||||
}, 16)
|
||||
const frame = (now: number): void => {
|
||||
maxFrameGapMs = Math.max(maxFrameGapMs, now - lastFrame)
|
||||
lastFrame = now
|
||||
if (!stopped) {
|
||||
requestAnimationFrame(frame)
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(frame)
|
||||
const target = window as Window & { __vaultBenchJank?: { stop: () => RendererJank } }
|
||||
target.__vaultBenchJank = {
|
||||
stop: () => {
|
||||
stopped = true
|
||||
window.clearInterval(timer)
|
||||
durations.push(...observer.takeRecords().map((entry) => entry.duration))
|
||||
observer.disconnect()
|
||||
return {
|
||||
longTaskCount: durations.length,
|
||||
maxLongTaskMs: Math.max(0, ...durations),
|
||||
totalLongTaskMs: durations.reduce((sum, value) => sum + value, 0),
|
||||
maxTimerDriftMs,
|
||||
maxFrameGapMs
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function stopRendererJankProbe(page: Page): Promise<RendererJank> {
|
||||
return page.evaluate(() => {
|
||||
const target = window as Window & { __vaultBenchJank?: { stop: () => RendererJank } }
|
||||
if (!target.__vaultBenchJank) {
|
||||
throw new Error('Renderer jank probe was not installed')
|
||||
}
|
||||
const result = target.__vaultBenchJank.stop()
|
||||
delete target.__vaultBenchJank
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
||||
export async function triggerVaultRefresh(page: Page): Promise<void> {
|
||||
const refreshButton = page.getByRole('button', { name: 'Refresh Session History' })
|
||||
await page.evaluate(() => {
|
||||
const button = document.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Refresh Session History"]'
|
||||
)
|
||||
if (!button) {
|
||||
throw new Error('Vault refresh button unavailable')
|
||||
}
|
||||
const target = window as Window & {
|
||||
__vaultBenchRefresh?: { startedAt: number; durationMs: number | null }
|
||||
}
|
||||
const refresh = { startedAt: performance.now(), durationMs: null }
|
||||
target.__vaultBenchRefresh = refresh
|
||||
let sawBusy = false
|
||||
const observer = new MutationObserver(() => {
|
||||
sawBusy ||= button.getAttribute('aria-busy') === 'true'
|
||||
if (sawBusy && button.getAttribute('aria-busy') !== 'true') {
|
||||
refresh.durationMs = performance.now() - refresh.startedAt
|
||||
observer.disconnect()
|
||||
}
|
||||
})
|
||||
observer.observe(button, { attributes: true, attributeFilter: ['aria-busy'] })
|
||||
button.click()
|
||||
})
|
||||
await expect(refreshButton).toHaveAttribute('aria-busy', 'true', { timeout: 5_000 })
|
||||
}
|
||||
|
||||
export async function readVaultRefreshDuration(page: Page): Promise<number> {
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate(() => {
|
||||
const target = window as Window & {
|
||||
__vaultBenchRefresh?: { durationMs: number | null }
|
||||
}
|
||||
return target.__vaultBenchRefresh?.durationMs ?? null
|
||||
}),
|
||||
{ timeout: 120_000 }
|
||||
)
|
||||
.not.toBeNull()
|
||||
return page.evaluate(() => {
|
||||
const target = window as Window & {
|
||||
__vaultBenchRefresh?: { durationMs: number | null }
|
||||
}
|
||||
const durationMs = target.__vaultBenchRefresh?.durationMs
|
||||
delete target.__vaultBenchRefresh
|
||||
if (durationMs === null || durationMs === undefined) {
|
||||
throw new Error('Vault refresh completion was not observed')
|
||||
}
|
||||
return durationMs
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import type { Page, TestInfo } from '@stablyai/playwright-test'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import {
|
||||
focusActiveTerminalInput,
|
||||
sendToTerminal,
|
||||
waitForActivePanePtyId,
|
||||
waitForActiveTerminalManager,
|
||||
waitForTerminalOutput
|
||||
} from './helpers/terminal'
|
||||
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
||||
import {
|
||||
collectCodexEchoLatencyReport,
|
||||
installCodexEchoLatencyProbe,
|
||||
summarizeLatencies,
|
||||
type CodexEchoProbeReport,
|
||||
type LatencyDistribution
|
||||
} from './codex-composer-echo-latency-probe'
|
||||
import { seedVaultTranscriptBatch, typingEchoScript } from './ai-vault-typing-bench-corpus'
|
||||
import {
|
||||
readVaultRefreshDuration,
|
||||
startRendererJankProbe,
|
||||
stopRendererJankProbe,
|
||||
triggerVaultRefresh,
|
||||
type RendererJank
|
||||
} from './ai-vault-typing-bench-renderer-probe'
|
||||
|
||||
const BENCH_ENABLED = process.env.ORCA_AI_VAULT_TYPING_BENCH === '1'
|
||||
const RESULTS_DIR = path.resolve(__dirname, '..', 'tools', 'benchmarks', 'results')
|
||||
|
||||
function readPositiveInt(name: string, fallback: number): number {
|
||||
const value = Number(process.env[name])
|
||||
return Number.isInteger(value) && value > 0 ? value : fallback
|
||||
}
|
||||
|
||||
const ITERATIONS = readPositiveInt('ORCA_AI_VAULT_BENCH_ITERATIONS', 3)
|
||||
const SESSION_COUNT = readPositiveInt('ORCA_AI_VAULT_BENCH_SESSIONS', 300)
|
||||
const PAYLOAD_KIB = readPositiveInt('ORCA_AI_VAULT_BENCH_PAYLOAD_KIB', 128)
|
||||
const KEY_COUNT = readPositiveInt('ORCA_AI_VAULT_BENCH_KEYS', 100)
|
||||
const KEY_CADENCE_MS = readPositiveInt('ORCA_AI_VAULT_BENCH_CADENCE_MS', 30)
|
||||
const BENCH_LABEL = process.env.ORCA_AI_VAULT_BENCH_LABEL ?? 'dev'
|
||||
const TYPING_ALPHABET = 'abcdefghijklmnopqrstuvwxyz'
|
||||
|
||||
type ArmResult = {
|
||||
iteration: number
|
||||
scenario: 'control' | 'vault-refresh'
|
||||
order: number
|
||||
refreshDurationMs: number | null
|
||||
echo: CodexEchoProbeReport
|
||||
parse: LatencyDistribution
|
||||
render: LatencyDistribution
|
||||
missingEchoCount: number
|
||||
rendererJank: RendererJank
|
||||
}
|
||||
|
||||
async function openAiVaultSidebar(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const store = window.__store
|
||||
if (!store) {
|
||||
throw new Error('Store unavailable')
|
||||
}
|
||||
store.getState().setRightSidebarOpen(true)
|
||||
store.getState().setRightSidebarTab('vault')
|
||||
})
|
||||
const refresh = page.getByRole('button', { name: 'Refresh Session History' })
|
||||
await expect(refresh).toBeVisible()
|
||||
await expect(refresh).toBeEnabled({ timeout: 30_000 })
|
||||
}
|
||||
|
||||
async function typeAtCadence(page: Page, target: string): Promise<void> {
|
||||
for (const char of target) {
|
||||
const startedAt = performance.now()
|
||||
await page.keyboard.type(char)
|
||||
const remaining = KEY_CADENCE_MS - (performance.now() - startedAt)
|
||||
if (remaining > 0) {
|
||||
await page.waitForTimeout(remaining)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runArm(args: {
|
||||
page: Page
|
||||
ptyId: string
|
||||
scriptPath: string
|
||||
iteration: number
|
||||
scenario: ArmResult['scenario']
|
||||
order: number
|
||||
}): Promise<ArmResult> {
|
||||
const readyMarker = `VAULT_TYPING_READY_${randomUUID()}`
|
||||
writeFileSync(args.scriptPath, typingEchoScript(readyMarker))
|
||||
await sendToTerminal(args.page, args.ptyId, `node ${JSON.stringify(args.scriptPath)}\r`)
|
||||
await waitForTerminalOutput(args.page, readyMarker, 15_000)
|
||||
const target = TYPING_ALPHABET.repeat(Math.ceil(KEY_COUNT / TYPING_ALPHABET.length)).slice(
|
||||
0,
|
||||
KEY_COUNT
|
||||
)
|
||||
await installCodexEchoLatencyProbe(args.page, target)
|
||||
await startRendererJankProbe(args.page)
|
||||
if (args.scenario === 'vault-refresh') {
|
||||
await triggerVaultRefresh(args.page)
|
||||
}
|
||||
await focusActiveTerminalInput(args.page)
|
||||
await typeAtCadence(args.page, target)
|
||||
if (args.scenario === 'vault-refresh') {
|
||||
await expect(args.page.getByRole('button', { name: 'Refresh Session History' })).toBeEnabled({
|
||||
timeout: 120_000
|
||||
})
|
||||
}
|
||||
const refreshDurationMs =
|
||||
args.scenario === 'vault-refresh' ? await readVaultRefreshDuration(args.page) : null
|
||||
await args.page.waitForTimeout(100)
|
||||
const echo = await collectCodexEchoLatencyReport(args.page)
|
||||
const rendererJank = await stopRendererJankProbe(args.page)
|
||||
await sendToTerminal(args.page, args.ptyId, '\x03').catch(() => undefined)
|
||||
const parse = summarizeLatencies(echo.samples.map((sample) => sample.keyToParseMs))
|
||||
const render = summarizeLatencies(
|
||||
echo.samples.flatMap((sample) => (sample.keyToRenderMs === null ? [] : [sample.keyToRenderMs]))
|
||||
)
|
||||
return {
|
||||
iteration: args.iteration,
|
||||
scenario: args.scenario,
|
||||
order: args.order,
|
||||
refreshDurationMs,
|
||||
echo,
|
||||
parse,
|
||||
render,
|
||||
missingEchoCount: KEY_COUNT - echo.samples.length,
|
||||
rendererJank
|
||||
}
|
||||
}
|
||||
|
||||
function aggregate(arms: ArmResult[], scenario: ArmResult['scenario']): object {
|
||||
const selected = arms.filter((arm) => arm.scenario === scenario)
|
||||
return {
|
||||
parse: summarizeLatencies(
|
||||
selected.flatMap((arm) => arm.echo.samples.map((s) => s.keyToParseMs))
|
||||
),
|
||||
render: summarizeLatencies(
|
||||
selected.flatMap((arm) => arm.echo.samples.flatMap((s) => s.keyToRenderMs ?? []))
|
||||
),
|
||||
maxTimerDriftMs: Math.max(...selected.map((arm) => arm.rendererJank.maxTimerDriftMs)),
|
||||
maxFrameGapMs: Math.max(...selected.map((arm) => arm.rendererJank.maxFrameGapMs)),
|
||||
maxLongTaskMs: Math.max(...selected.map((arm) => arm.rendererJank.maxLongTaskMs)),
|
||||
refresh: summarizeLatencies(selected.flatMap((arm) => arm.refreshDurationMs ?? [])),
|
||||
missingEchoCount: selected.reduce((sum, arm) => sum + arm.missingEchoCount, 0)
|
||||
}
|
||||
}
|
||||
|
||||
function writeReport(testInfo: TestInfo, arms: ArmResult[], seededBytes: number): string {
|
||||
const report = {
|
||||
benchmark: 'terminal-ai-vault-typing-latency',
|
||||
label: BENCH_LABEL,
|
||||
timestamp: new Date().toISOString(),
|
||||
config: {
|
||||
iterations: ITERATIONS,
|
||||
sessionCount: SESSION_COUNT,
|
||||
payloadKib: PAYLOAD_KIB,
|
||||
keyCount: KEY_COUNT,
|
||||
keyCadenceMs: KEY_CADENCE_MS
|
||||
},
|
||||
seededBytes,
|
||||
aggregate: {
|
||||
control: aggregate(arms, 'control'),
|
||||
vaultRefresh: aggregate(arms, 'vault-refresh')
|
||||
},
|
||||
arms
|
||||
}
|
||||
mkdirSync(RESULTS_DIR, { recursive: true })
|
||||
const stamp = report.timestamp.replace(/[:.]/g, '-')
|
||||
const outPath = path.join(RESULTS_DIR, `ai-vault-typing-${BENCH_LABEL}-${stamp}.json`)
|
||||
writeFileSync(outPath, JSON.stringify(report, null, 2))
|
||||
testInfo.annotations.push({ type: 'ai-vault-typing-bench', description: outPath })
|
||||
console.log(`[ai-vault-typing] report ${outPath}`)
|
||||
console.log(`[ai-vault-typing] ${JSON.stringify(report.aggregate)}`)
|
||||
return outPath
|
||||
}
|
||||
|
||||
test.describe('Terminal typing during AI Vault refresh bench', () => {
|
||||
test.setTimeout(10 * 60 * 1000)
|
||||
|
||||
test('alternates control typing and forced Vault refresh typing', async ({
|
||||
electronApp,
|
||||
orcaPage,
|
||||
testRepoPath
|
||||
}, testInfo) => {
|
||||
test.skip(!BENCH_ENABLED, 'Bench-only: run via pnpm bench:ai-vault-typing')
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
await ensureTerminalVisible(orcaPage)
|
||||
await waitForActiveTerminalManager(orcaPage, 30_000)
|
||||
await openAiVaultSidebar(orcaPage)
|
||||
const ptyId = await waitForActivePanePtyId(orcaPage)
|
||||
const homePath = await electronApp.evaluate(({ app }) => app.getPath('home'))
|
||||
const scriptPath = path.join(testRepoPath, `.orca-vault-typing-${randomUUID()}.mjs`)
|
||||
const arms: ArmResult[] = []
|
||||
let seededBytes = 0
|
||||
|
||||
try {
|
||||
for (let iteration = 0; iteration < ITERATIONS; iteration += 1) {
|
||||
const batch = seedVaultTranscriptBatch({
|
||||
homePath,
|
||||
cwd: testRepoPath,
|
||||
batch: iteration,
|
||||
sessionCount: SESSION_COUNT,
|
||||
payloadBytes: PAYLOAD_KIB * 1024
|
||||
})
|
||||
seededBytes += batch.totalBytes
|
||||
const scenarios: ArmResult['scenario'][] =
|
||||
iteration % 2 === 0 ? ['control', 'vault-refresh'] : ['vault-refresh', 'control']
|
||||
for (const [order, scenario] of scenarios.entries()) {
|
||||
arms.push(await runArm({ page: orcaPage, ptyId, scriptPath, iteration, scenario, order }))
|
||||
if (scenario === 'vault-refresh') {
|
||||
await expect(
|
||||
orcaPage.getByText(batch.newestTitle, { exact: true }).first()
|
||||
).toBeVisible({
|
||||
timeout: 30_000
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
writeReport(testInfo, arms, seededBytes)
|
||||
expect(arms.every((arm) => arm.missingEchoCount === 0)).toBe(true)
|
||||
} finally {
|
||||
await sendToTerminal(orcaPage, ptyId, '\x03').catch(() => undefined)
|
||||
rmSync(scriptPath, { force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user