From 40e60b7385caa22170de54bebb16517abe8aaef1 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 2 Oct 2026 19:58:33 -0400 Subject: [PATCH] refactor(ai-vault): delete the unused session-scanner worker thread (#24607) * refactor(ai-vault): delete the unused session-scanner worker thread Production always scans through the forked session-scanner service process; the worker thread was reachable only under NODE_ENV=test or the undocumented ORCA_AI_VAULT_SERVICE_PROCESS=0 switch, and nothing fell back to it on service failure. Remove the thread (spawn, client, protocol, entry, tests), its build entry, knip and plain-node-guard listings, and the backend switch, so session-scanner-background always routes to the service. - Move the scan options type to the service protocol as AiVaultServiceScanOptions. - Tests now mock session-scanner-service-spawn, the seam production calls. - Repoint the hot-path listing reliability gate from the worker-client test to the service-client test, which covers the same bounded-queue, cancellation, and fault-restart properties for the real executor. STA-9122 * test(ai-vault): cover the service's Claude-vs-OMP subagent lister choice Runs the real service entry and subagent reader, replacing only the two per-agent listers, so a swapped lister choice fails. STA-9122 --- .../build-plugins/plain-node-entry-guard.ts | 1 - config/knip.json | 1 - config/reliability-gates.jsonc | 28 +-- config/scripts/ci-shard-timings.json | 2 - electron.vite.config.ts | 3 - .../cached-session-list-wsl-probe.test.ts | 16 +- src/main/ai-vault/cached-session-list.test.ts | 22 +- .../session-scanner-background.test.ts | 36 --- .../ai-vault/session-scanner-background.ts | 55 ++-- .../session-scanner-fs-import-guard.test.ts | 1 - ...on-scanner-service-entry-subagents.test.ts | 71 ++++++ .../session-scanner-service-protocol.ts | 6 +- .../ai-vault/session-scanner-service-spawn.ts | 4 +- .../session-scanner-worker-client.test.ts | 196 --------------- .../ai-vault/session-scanner-worker-client.ts | 238 ------------------ .../ai-vault/session-scanner-worker-entry.ts | 115 --------- .../session-scanner-worker-protocol.ts | 29 --- .../ai-vault/session-scanner-worker-spawn.ts | 57 ----- src/main/ipc/ai-vault-scan-coalescing.test.ts | 26 +- src/main/ipc/ai-vault.test.ts | 75 +++--- src/main/runtime/rpc/methods/ai-vault.test.ts | 80 +++--- ...ructured-agent-session-held-signal.test.ts | 8 +- 22 files changed, 215 insertions(+), 855 deletions(-) delete mode 100644 src/main/ai-vault/session-scanner-background.test.ts create mode 100644 src/main/ai-vault/session-scanner-service-entry-subagents.test.ts delete mode 100644 src/main/ai-vault/session-scanner-worker-client.test.ts delete mode 100644 src/main/ai-vault/session-scanner-worker-client.ts delete mode 100644 src/main/ai-vault/session-scanner-worker-entry.ts delete mode 100644 src/main/ai-vault/session-scanner-worker-protocol.ts delete mode 100644 src/main/ai-vault/session-scanner-worker-spawn.ts diff --git a/config/build-plugins/plain-node-entry-guard.ts b/config/build-plugins/plain-node-entry-guard.ts index 12e06db7be1..716bc1e4c63 100644 --- a/config/build-plugins/plain-node-entry-guard.ts +++ b/config/build-plugins/plain-node-entry-guard.ts @@ -56,7 +56,6 @@ const WORKER_THREAD_ENTRY_NAMES = [ 'stt-worker', 'warp-theme-parser-worker', 'foreign-sqlite-reader-entry', - 'session-scanner-worker-entry', 'main-thread-hang-watchdog-entry', 'port-scan-command-worker-entry', 'usage-scan-worker-entry', diff --git a/config/knip.json b/config/knip.json index 99516709af5..98891b9e687 100644 --- a/config/knip.json +++ b/config/knip.json @@ -10,7 +10,6 @@ "src/main/speech/stt-worker.ts", "src/main/warp-themes/warp-theme-parser-worker.ts", "src/main/foreign-sqlite-readers/foreign-sqlite-reader-entry.ts", - "src/main/ai-vault/session-scanner-worker-entry.ts", "src/main/ports/port-scan-command-worker-entry.ts", "src/main/ipc/parcel-watcher-process-entry.ts", "src/main/hang-watchdog/main-thread-hang-watchdog-entry.ts", diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 5b4c135c1da..a157e9dc79c 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -12107,10 +12107,10 @@ "https://github.com/stablyai/orca/pull/12778" ], "invariant": "Typing, focus, terminal switch, workspace switch, visibility resume, resize, render, per-pane liveness, and tab-title synchronization must not call global pty:listSessions or aiVault.listSessions; they must use targeted APIs or cached provider-owned state.", - "oracle": "The current executable slice asserts targeted visibility/first-input liveness, resize re-assertion after visibility resume, light tab/active-state resume, SSH/remote skip behavior, and a closed Resource Manager budget of one readiness seed plus one coalesced inventory read only for unknown spawn IDs. AI Vault title sync deterministically accepts only resolveSessionTitles, batches at most 64 exact identities, serializes worker work, routes requests to the transcript-owning local/SSH/runtime host, and proves zero broad scans for unsupported hosts. The full hot-path oracle still needs instrumentation around raw focus, split focus, workspace switch, render ticks, and high-session PTY fixtures.", + "oracle": "The current executable slice asserts targeted visibility/first-input liveness, resize re-assertion after visibility resume, light tab/active-state resume, SSH/remote skip behavior, and a closed Resource Manager budget of one readiness seed plus one coalesced inventory read only for unknown spawn IDs. AI Vault title sync deterministically accepts only resolveSessionTitles, batches at most 64 exact identities, bounds scanner-service calls at sixteen, routes requests to the transcript-owning local/SSH/runtime host, and proves zero broad scans for unsupported hosts. The full hot-path oracle still needs instrumentation around raw focus, split focus, workspace switch, render ticks, and high-session PTY fixtures.", "commands": [ "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty-startup-barrier-and-listing.test.ts src/renderer/src/components/status-bar/use-resource-session-inventory.test.tsx src/renderer/src/components/status-bar/resource-session-inventory.test.ts src/renderer/src/components/status-bar/ResourceUsageStatusSegment.session-polling.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/ai-vault-tab-title-sync.test.ts src/main/ai-vault/session-scanner-worker-client.test.ts src/main/ai-vault/session-title-file-reader.test.ts src/main/ai-vault/session-parse-cache-persistence.test.ts src/main/ipc/ai-vault.test.ts src/main/runtime/rpc/methods/ai-vault.test.ts src/relay/ai-vault-handler.test.ts" + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/ai-vault-tab-title-sync.test.ts src/main/ai-vault/session-scanner-service-client.test.ts src/main/ai-vault/session-title-file-reader.test.ts src/main/ai-vault/session-parse-cache-persistence.test.ts src/main/ipc/ai-vault.test.ts src/main/runtime/rpc/methods/ai-vault.test.ts src/relay/ai-vault-handler.test.ts" ], "testFiles": [ "src/main/ipc/pty-startup-barrier-and-listing.test.ts", @@ -12118,7 +12118,7 @@ "src/renderer/src/components/status-bar/resource-session-inventory.test.ts", "src/renderer/src/components/status-bar/ResourceUsageStatusSegment.session-polling.test.ts", "src/renderer/src/lib/ai-vault-tab-title-sync.test.ts", - "src/main/ai-vault/session-scanner-worker-client.test.ts", + "src/main/ai-vault/session-scanner-service-client.test.ts", "src/main/ai-vault/session-title-file-reader.test.ts", "src/main/ai-vault/session-parse-cache-persistence.test.ts", "src/main/ipc/ai-vault.test.ts", @@ -12168,12 +12168,12 @@ ] }, { - "file": "src/main/ai-vault/session-scanner-worker-client.test.ts", + "file": "src/main/ai-vault/session-scanner-service-client.test.ts", "assertions": [ - "full scans and exact-title reads share one serial FIFO worker", - "active cancellation stays serialized and queued work remains bounded", - "worker faults restart queued work and idle time preserves incremental parse state", - "worker disposal rejects retained work and terminates the worker" + "the service waits for ready and runs the cache and interactive lanes independently", + "active and queued calls are bounded together at sixteen", + "cancellation reaches active work and kills a service that ignores it", + "service faults restart queued work under a restart circuit that a forced refresh reopens" ] }, { @@ -12202,13 +12202,13 @@ "summary": "4 files and 358 tests passed, covering readiness seed/recovery, zero interval polling, bounded unknown-spawn reconciliation, concurrent provider starts, exit fencing, cleanup, and out-of-order refresh fencing." }, { - "date": "2026-08-09", + "date": "2026-10-02", "runner": "local", "platform": "macos", - "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/ai-vault-tab-title-sync.test.ts src/main/ai-vault/session-scanner-worker-client.test.ts src/main/ai-vault/session-title-file-reader.test.ts src/main/ai-vault/session-parse-cache-persistence.test.ts src/main/ipc/ai-vault.test.ts src/main/runtime/rpc/methods/ai-vault.test.ts src/relay/ai-vault-handler.test.ts", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/ai-vault-tab-title-sync.test.ts src/main/ai-vault/session-scanner-service-client.test.ts src/main/ai-vault/session-title-file-reader.test.ts src/main/ai-vault/session-parse-cache-persistence.test.ts src/main/ipc/ai-vault.test.ts src/main/runtime/rpc/methods/ai-vault.test.ts src/relay/ai-vault-handler.test.ts", "result": "passed", - "durationSeconds": 3.1, - "summary": "The focused run passed 112 tests across 7 files, proving exact-title-only renderer requests, provider-isolated batching, persistent serial worker lifecycle and fault recovery, exact transcript identity, host routing, mixed-version degradation, and zero broad-scan fallback." + "durationSeconds": 5.3, + "summary": "The focused run passed 138 tests across 7 files, proving exact-title-only renderer requests, provider-isolated batching, per-lane scanner-service lifecycle and fault recovery, exact transcript identity, host routing, mixed-version degradation, and zero broad-scan fallback." } ], "runtimeBudget": { @@ -12221,11 +12221,11 @@ }, "redGreenEvidence": { "status": "partial", - "evidence": "Tests assert visibility resume prefers targeted hasPty over listSessions, first input after visibility resume calls targeted hasPty once, resize re-assertion after visibility resume uses getSize/resize without listSessions, light tab switches and visible active-state resume avoid listSessions/hasPty/getSize fanout, and the closed Resource Manager performs one readiness seed while known reattach signals and steady time perform no additional reads. For the #12778 regression, title sync no longer receives a listSessions dependency at all: it sends at most 64 exact identities per batch to one serial worker or transcript-owning remote host, and old hosts degrade without broad fallback. Needs broader raw focus/workspace-switch/render/high-session PTY count coverage before promotion." + "evidence": "Tests assert visibility resume prefers targeted hasPty over listSessions, first input after visibility resume calls targeted hasPty once, resize re-assertion after visibility resume uses getSize/resize without listSessions, light tab switches and visible active-state resume avoid listSessions/hasPty/getSize fanout, and the closed Resource Manager performs one readiness seed while known reattach signals and steady time perform no additional reads. For the #12778 regression, title sync no longer receives a listSessions dependency at all: it sends at most 64 exact identities per batch to the local scanner service or transcript-owning remote host, and old hosts degrade without broad fallback. Needs broader raw focus/workspace-switch/render/high-session PTY count coverage before promotion." }, "performanceBudget": { "required": true, - "evidence": "This gate is the performance budget for global session listing in hot paths. AI Vault title sync permits zero global scans, at most 64 exact identities per request, one active worker operation, 16 queued operations, four concurrent transcript parses inside the worker, a 4,096-title index, and no worktree-path-triggered refresh. The worker emits the aiVault.scan.worker span with duration and session count for full scans." + "evidence": "This gate is the performance budget for global session listing in hot paths. AI Vault title sync permits zero global scans, at most 64 exact identities per request, one active scanner-service call per lane (cache, interactive), 16 active plus queued calls, four concurrent transcript parses inside the service, a 4,096-title index, and no worktree-path-triggered refresh. The service emits the aiVault.scan.service span with duration and session count for full scans." }, "promotionCriteria": [ "Add deterministic call-count instrumentation.", diff --git a/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json index 7a3a3710d4e..73d0226943b 100644 --- a/config/scripts/ci-shard-timings.json +++ b/config/scripts/ci-shard-timings.json @@ -626,7 +626,6 @@ "src/main/ai-vault/session-scanner-agent-root-overrides.test.ts": 1121, "src/main/ai-vault/session-scanner-antigravity-parser.test.ts": 72, "src/main/ai-vault/session-scanner-antigravity-source.test.ts": 330, - "src/main/ai-vault/session-scanner-background.test.ts": 354, "src/main/ai-vault/session-scanner-claude-cwd-drift.test.ts": 245, "src/main/ai-vault/session-scanner-claude-subagent-prune.test.ts": 170, "src/main/ai-vault/session-scanner-claude-subagents.test.ts": 137, @@ -706,7 +705,6 @@ "src/main/ai-vault/session-scanner-timeline.test.ts": 75, "src/main/ai-vault/session-scanner-unlimited-dedup.test.ts": 3562, "src/main/ai-vault/session-scanner-values.test.ts": 92, - "src/main/ai-vault/session-scanner-worker-client.test.ts": 48, "src/main/ai-vault/session-scanner.test.ts": 343, "src/main/ai-vault/session-sidecar-stat.test.ts": 25, "src/main/ai-vault/session-title-file-reader-wsl-stall.test.ts": 173, diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 729d1b19bc7..4f4cedb456c 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -239,9 +239,6 @@ export const electronViteConfig: UserConfig = { 'foreign-sqlite-reader-entry': resolve( 'src/main/foreign-sqlite-readers/foreign-sqlite-reader-entry.ts' ), - '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' ), diff --git a/src/main/ai-vault/cached-session-list-wsl-probe.test.ts b/src/main/ai-vault/cached-session-list-wsl-probe.test.ts index 0c2ef27316a..14256c9a83a 100644 --- a/src/main/ai-vault/cached-session-list-wsl-probe.test.ts +++ b/src/main/ai-vault/cached-session-list-wsl-probe.test.ts @@ -2,18 +2,18 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type * as childProcess from 'node:child_process' -const { execFileMock, scanAiVaultSessionsInWorker } = vi.hoisted(() => ({ +const { execFileMock, scanAiVaultSessionsInService } = vi.hoisted(() => ({ execFileMock: vi.fn(), - scanAiVaultSessionsInWorker: vi.fn() + scanAiVaultSessionsInService: vi.fn() })) vi.mock('child_process', async (importOriginal) => ({ ...(await importOriginal()), execFile: execFileMock })) -vi.mock('./session-scanner-worker-spawn', () => ({ - scanAiVaultSessionsInWorker, - resetAiVaultScannerWorkerForTests: vi.fn() +vi.mock('./session-scanner-service-spawn', async (importOriginal) => ({ + ...(await importOriginal()), + scanAiVaultSessionsInService })) vi.mock('./opencode-wsl-runtime-preparation', () => ({ prepareOpenCodeWslReaders: vi.fn(async () => []) @@ -44,7 +44,7 @@ describe('AI Vault listing wsl.exe probes', () => { vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') resetAiVaultSessionListCacheForTests() configureAiVaultSessionSources({ getAdditionalCodexHomePaths: () => [NATIVE_CODEX_HOME] }) - scanAiVaultSessionsInWorker.mockResolvedValue({ sessions: [], issues: [], scannedAt: 'scan' }) + scanAiVaultSessionsInService.mockResolvedValue({ sessions: [], issues: [], scannedAt: 'scan' }) }) afterEach(() => { execFileMock.mockReset() @@ -59,7 +59,7 @@ describe('AI Vault listing wsl.exe probes', () => { await listAiVaultSessions() expect(wslSpawns()).toEqual([]) - expect(scanAiVaultSessionsInWorker).toHaveBeenCalledWith( + expect(scanAiVaultSessionsInService).toHaveBeenCalledWith( expect.objectContaining({ additionalCodexSessionsDirs: [join(NATIVE_CODEX_HOME, 'sessions')], wslHomeDirs: [] @@ -80,7 +80,7 @@ describe('AI Vault listing wsl.exe probes', () => { ['--list', '--running', '--quiet'], ['-d', 'Ubuntu', '--exec', 'bash', '-c', 'echo $HOME'] ]) - expect(scanAiVaultSessionsInWorker).toHaveBeenCalledWith( + expect(scanAiVaultSessionsInService).toHaveBeenCalledWith( expect.objectContaining({ wslHomeDirs: [WSL_HOME] }), expect.anything() ) diff --git a/src/main/ai-vault/cached-session-list.test.ts b/src/main/ai-vault/cached-session-list.test.ts index c0d3668f1f3..55801c6df83 100644 --- a/src/main/ai-vault/cached-session-list.test.ts +++ b/src/main/ai-vault/cached-session-list.test.ts @@ -6,18 +6,18 @@ const { getCachedWslDistros, hasCachedWslDistros, listRunningWslHomeDirsAsync, - scanAiVaultSessionsInWorker + scanAiVaultSessionsInService } = vi.hoisted(() => ({ filterPathsToRunningWslDistrosAsync: vi.fn(async (paths: readonly string[]) => [...paths]), getCachedWslDistros: vi.fn((): string[] | null => null), hasCachedWslDistros: vi.fn(() => false), listRunningWslHomeDirsAsync: vi.fn().mockResolvedValue([]), - scanAiVaultSessionsInWorker: vi.fn() + scanAiVaultSessionsInService: vi.fn() })) -vi.mock('./session-scanner-worker-spawn', () => ({ - scanAiVaultSessionsInWorker, - resetAiVaultScannerWorkerForTests: vi.fn() +vi.mock('./session-scanner-service-spawn', async (importOriginal) => ({ + ...(await importOriginal()), + scanAiVaultSessionsInService })) vi.mock('../wsl', () => ({ getCachedWslDistros, @@ -43,7 +43,7 @@ function scanResult(scannedAt: string): AiVaultListResult { // mid-flight. function deferredScan(): { resolve: (value: AiVaultListResult) => void } { let resolveFn: (value: AiVaultListResult) => void = () => {} - scanAiVaultSessionsInWorker.mockReturnValueOnce( + scanAiVaultSessionsInService.mockReturnValueOnce( new Promise((resolve) => { resolveFn = resolve }) @@ -60,7 +60,7 @@ describe('invalidateAiVaultSessionListCache generation guard', () => { getCachedWslDistros.mockReset().mockReturnValue(null) hasCachedWslDistros.mockReset().mockReturnValue(false) listRunningWslHomeDirsAsync.mockReset().mockResolvedValue([]) - scanAiVaultSessionsInWorker.mockReset() + scanAiVaultSessionsInService.mockReset() }) afterEach(() => { resetAiVaultSessionListCacheForTests() @@ -81,22 +81,22 @@ describe('invalidateAiVaultSessionListCache generation guard', () => { // A non-force list must re-scan (cache empty) rather than serve A's stale // result — proof A's late .then() did not repopulate the cache. - scanAiVaultSessionsInWorker.mockResolvedValueOnce(scanResult('scan-B')) + scanAiVaultSessionsInService.mockResolvedValueOnce(scanResult('scan-B')) const next = await listAiVaultSessions() expect(next.scannedAt).toBe('scan-B') - expect(scanAiVaultSessionsInWorker).toHaveBeenCalledTimes(2) + expect(scanAiVaultSessionsInService).toHaveBeenCalledTimes(2) }) it('caches normally when no invalidation interrupts the scan', async () => { - scanAiVaultSessionsInWorker.mockResolvedValueOnce(scanResult('scan-A')) + scanAiVaultSessionsInService.mockResolvedValueOnce(scanResult('scan-A')) await listAiVaultSessions() // Second non-force call is a cache hit — no second scan. const cached = await listAiVaultSessions() expect(cached.scannedAt).toBe('scan-A') - expect(scanAiVaultSessionsInWorker).toHaveBeenCalledTimes(1) + expect(scanAiVaultSessionsInService).toHaveBeenCalledTimes(1) expect(listRunningWslHomeDirsAsync).toHaveBeenCalledTimes(1) }) diff --git a/src/main/ai-vault/session-scanner-background.test.ts b/src/main/ai-vault/session-scanner-background.test.ts deleted file mode 100644 index 0b0ca493db4..00000000000 --- a/src/main/ai-vault/session-scanner-background.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -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) - }) -}) diff --git a/src/main/ai-vault/session-scanner-background.ts b/src/main/ai-vault/session-scanner-background.ts index 70eca2434ef..3a13ca04c51 100644 --- a/src/main/ai-vault/session-scanner-background.ts +++ b/src/main/ai-vault/session-scanner-background.ts @@ -3,10 +3,9 @@ import type { AiVaultSessionTitleRequest, AiVaultSessionTitlesResult } from '../../shared/ai-vault-session-title' -import { - readAiVaultFirstUserPrompt, - type ReadAiVaultFirstUserPromptArgs, - type ReadAiVaultFirstUserPromptResult +import type { + ReadAiVaultFirstUserPromptArgs, + ReadAiVaultFirstUserPromptResult } from './session-first-user-prompt-read' import { clearAiVaultServiceRestartCircuit, @@ -17,58 +16,35 @@ import { 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' +import type { + AiVaultServiceScanOptions, + AiVaultServiceSubagentRequest +} from './session-scanner-service-protocol' import { isWslUncPath } from '../../shared/wsl-paths' -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' -} - // Let forced refreshes retry after a local service circuit opens. export function clearAiVaultBackgroundRestartCircuit(): void { - if (shouldUseAiVaultServiceProcess()) { - clearAiVaultServiceRestartCircuit() - } + clearAiVaultServiceRestartCircuit() } export function scanAiVaultSessionsInBackground( - options: AiVaultWorkerScanOptions, + options: AiVaultServiceScanOptions, signal?: AbortSignal ): Promise { - return shouldUseAiVaultServiceProcess() - ? scanAiVaultSessionsInService(options, signal) - : scanAiVaultSessionsInWorker(options, signal) + return scanAiVaultSessionsInService(options, signal) } export function resolveAiVaultSessionTitlesInBackground( requests: AiVaultSessionTitleRequest[], signal?: AbortSignal ): Promise { - return shouldUseAiVaultServiceProcess() - ? resolveAiVaultSessionTitlesInService(requests, signal) - : resolveAiVaultSessionTitlesInWorker(requests, signal) + return resolveAiVaultSessionTitlesInService(requests, signal) } export function listAiVaultSubagentSessionsInBackground( request: AiVaultServiceSubagentRequest ): Promise { - return shouldUseAiVaultServiceProcess() - ? listAiVaultSubagentSessionsInService(request) - : listLocalAiVaultSubagentSessions(request) + return listAiVaultSubagentSessionsInService(request) } export async function readAiVaultFirstUserPromptInBackground( @@ -79,16 +55,13 @@ export async function readAiVaultFirstUserPromptInBackground( const roots = await localAiVaultScanRoots() request = { ...request, wslOpenCodeReaders: roots.wslOpenCodeReaders ?? [] } } - return shouldUseAiVaultServiceProcess() - ? readAiVaultFirstUserPromptInService(request) - : readAiVaultFirstUserPrompt(request) + return readAiVaultFirstUserPromptInService(request) } export function invalidateAiVaultBackgroundCache(paths: string[]): Promise { - return shouldUseAiVaultServiceProcess() ? invalidateAiVaultServiceCache(paths) : Promise.resolve() + return invalidateAiVaultServiceCache(paths) } export function resetAiVaultScannerBackgroundForTests(): void { resetAiVaultScannerServiceForTests() - resetAiVaultScannerWorkerForTests() } diff --git a/src/main/ai-vault/session-scanner-fs-import-guard.test.ts b/src/main/ai-vault/session-scanner-fs-import-guard.test.ts index 3d431cb93da..74c0f441f07 100644 --- a/src/main/ai-vault/session-scanner-fs-import-guard.test.ts +++ b/src/main/ai-vault/session-scanner-fs-import-guard.test.ts @@ -22,7 +22,6 @@ const ALLOWLIST = new Set([ // `existsSync` on bundled module paths inside the app dir. 'session-scanner-service-entry-path.ts', 'session-scanner-service-spawn.ts', - 'session-scanner-worker-spawn.ts', 'session-scanner-opencode-sqlite-worker-spawn.ts', // On-demand IPC readers, gated in the STA-4049 follow-up. 'session-scanner-claude-subagents.ts', diff --git a/src/main/ai-vault/session-scanner-service-entry-subagents.test.ts b/src/main/ai-vault/session-scanner-service-entry-subagents.test.ts new file mode 100644 index 00000000000..36ffe6d7013 --- /dev/null +++ b/src/main/ai-vault/session-scanner-service-entry-subagents.test.ts @@ -0,0 +1,71 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { AI_VAULT_SERVICE_PROTOCOL_VERSION } from './session-scanner-service-protocol' + +const listClaudeSubagentSessions = vi.hoisted(() => vi.fn()) +const listOmpSubagentSessions = vi.hoisted(() => vi.fn()) + +// Only the per-agent listers are replaced; the entry and its reader run for real. +vi.mock('./session-scanner-claude-subagents', () => ({ listClaudeSubagentSessions })) +vi.mock('./session-scanner-omp-subagent-listing', () => ({ listOmpSubagentSessions })) +vi.mock('./session-parse-cache-persistence', () => ({ + flushSessionParseCachePersist: vi.fn(() => Promise.resolve()), + initSessionParseCachePersistence: vi.fn() +})) + +type SentMessage = { type: string; id?: number; operation?: string; value?: unknown } +const sent: SentMessage[] = [] + +function emit(message: unknown): void { + process.emit('message', message, undefined) +} + +async function requestSubagents( + id: number, + agent: 'claude' | 'omp', + parentFilePath: string +): Promise { + emit({ type: 'request', id, operation: 'subagents', request: { agent, parentFilePath } }) + await vi.waitFor(() => expect(sent.some((message) => message.id === id)).toBe(true)) + return sent.find((message) => message.id === id) +} + +describe('AI Vault service entry subagent listing', () => { + beforeAll(async () => { + process.send = (message: SentMessage) => { + sent.push(message) + return true + } + await import('./session-scanner-service-entry') + emit({ type: 'init', protocol: AI_VAULT_SERVICE_PROTOCOL_VERSION }) + }) + + beforeEach(() => { + sent.length = 0 + listClaudeSubagentSessions.mockReset() + listOmpSubagentSessions.mockReset() + }) + + it('lists Claude subagents with the Claude lister only', async () => { + const claude = { sessions: [], issues: [{ message: 'claude-lister' }] } + listClaudeSubagentSessions.mockResolvedValue(claude) + + const reply = await requestSubagents(1, 'claude', '/claude/proj/sess.jsonl') + + expect(reply).toMatchObject({ type: 'result', operation: 'subagents', value: claude }) + expect(listClaudeSubagentSessions).toHaveBeenCalledWith({ + parentFilePath: '/claude/proj/sess.jsonl' + }) + expect(listOmpSubagentSessions).not.toHaveBeenCalled() + }) + + it('lists OMP subagents with the OMP lister only', async () => { + const omp = { sessions: [], issues: [{ message: 'omp-lister' }] } + listOmpSubagentSessions.mockResolvedValue(omp) + + const reply = await requestSubagents(2, 'omp', '/omp/slug/sess.jsonl') + + expect(reply).toMatchObject({ type: 'result', operation: 'subagents', value: omp }) + expect(listOmpSubagentSessions).toHaveBeenCalledWith({ parentFilePath: '/omp/slug/sess.jsonl' }) + expect(listClaudeSubagentSessions).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ai-vault/session-scanner-service-protocol.ts b/src/main/ai-vault/session-scanner-service-protocol.ts index faff7a29896..c2164075e37 100644 --- a/src/main/ai-vault/session-scanner-service-protocol.ts +++ b/src/main/ai-vault/session-scanner-service-protocol.ts @@ -14,10 +14,12 @@ import type { SessionSearchHostScope } from '../ai-vault-search/session-search-s import type { SessionSearchScanRoots } from '../ai-vault-search/session-search-scan-roots' 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' +import type { AiVaultScanOptions } from './session-scanner-types' export const AI_VAULT_SERVICE_PROTOCOL_VERSION = 1 +export type AiVaultServiceScanOptions = Omit + export type AiVaultServiceLane = 'cache' | 'interactive' export type AiVaultServiceOperation = | 'scan' @@ -67,7 +69,7 @@ export type AiVaultServiceInit = { } export type AiVaultServiceRequestBody = - | { type: 'request'; operation: 'scan'; options: AiVaultWorkerScanOptions } + | { type: 'request'; operation: 'scan'; options: AiVaultServiceScanOptions } | { type: 'request' operation: 'titles' diff --git a/src/main/ai-vault/session-scanner-service-spawn.ts b/src/main/ai-vault/session-scanner-service-spawn.ts index a923e904023..eeb575b7b52 100644 --- a/src/main/ai-vault/session-scanner-service-spawn.ts +++ b/src/main/ai-vault/session-scanner-service-spawn.ts @@ -24,10 +24,10 @@ import { AiVaultScannerServiceClient } from './session-scanner-service-client' import { getAiVaultServiceEntryPath } from './session-scanner-service-entry-path' import { lowerAiVaultServicePriority } from './session-scanner-service-priority' import type { + AiVaultServiceScanOptions, AiVaultServiceSubagentRequest, AiVaultSessionSearchInit } from './session-scanner-service-protocol' -import type { AiVaultWorkerScanOptions } from './session-scanner-worker-protocol' export function spawnAiVaultServiceProcess(): ChildProcess { const entryPath = getAiVaultServiceEntryPath() @@ -61,7 +61,7 @@ function getSharedClient(): AiVaultScannerServiceClient { } export function scanAiVaultSessionsInService( - options: AiVaultWorkerScanOptions, + options: AiVaultServiceScanOptions, signal?: AbortSignal ): Promise { return withSpan('aiVault.scan.service', async (span) => { diff --git a/src/main/ai-vault/session-scanner-worker-client.test.ts b/src/main/ai-vault/session-scanner-worker-client.test.ts deleted file mode 100644 index 862f9c75e06..00000000000 --- a/src/main/ai-vault/session-scanner-worker-client.test.ts +++ /dev/null @@ -1,196 +0,0 @@ -import type { Worker } from 'node:worker_threads' -import { describe, expect, it, vi } from 'vitest' -import type { - AiVaultWorkerControl, - AiVaultWorkerRequest, - AiVaultWorkerResponse -} from './session-scanner-worker-protocol' -import { AiVaultScannerWorkerClient } from './session-scanner-worker-client' - -class FakeWorker { - readonly posted: (AiVaultWorkerRequest | AiVaultWorkerControl)[] = [] - terminated = false - unrefed = false - private readonly listeners = new Map void>>() - - on(event: string, listener: (value: unknown) => void): this { - const listeners = this.listeners.get(event) ?? new Set() - listeners.add(listener) - this.listeners.set(event, listeners) - return this - } - - removeAllListeners(): void { - this.listeners.clear() - } - - postMessage(message: AiVaultWorkerRequest | AiVaultWorkerControl): void { - this.posted.push(message) - } - - unref(): void { - this.unrefed = true - } - - async terminate(): Promise { - this.terminated = true - return 1 - } - - emit(event: string, value: unknown): void { - for (const listener of this.listeners.get(event) ?? []) { - listener(value) - } - } -} - -function setup(): { client: AiVaultScannerWorkerClient; worker: FakeWorker } { - const worker = new FakeWorker() - return { - client: new AiVaultScannerWorkerClient({ - workerFactory: () => worker as unknown as Worker - }), - worker - } -} - -function setupWorkerFactory(): { - client: AiVaultScannerWorkerClient - workers: FakeWorker[] -} { - const workers: FakeWorker[] = [] - return { - client: new AiVaultScannerWorkerClient({ - workerFactory: () => { - const worker = new FakeWorker() - workers.push(worker) - return worker as unknown as Worker - } - }), - workers - } -} - -function titleResponse( - id: number, - title: string -): Extract { - return { - id, - ok: true, - kind: 'titles', - value: { titles: [{ agent: 'codex', sessionId: 'session', title }] } - } -} - -describe('AiVaultScannerWorkerClient', () => { - it('serializes requests in FIFO order and ignores stale responses', async () => { - const { client, worker } = setup() - const first = client.resolveTitles([{ agent: 'codex', sessionId: 'session' }]) - const second = client.resolveTitles([{ agent: 'claude', sessionId: 'other' }]) - - expect(worker.posted).toHaveLength(1) - const firstId = worker.posted[0]!.id - worker.emit('message', titleResponse(999, 'stale')) - expect(worker.posted).toHaveLength(1) - - worker.emit('message', titleResponse(firstId, 'first')) - await expect(first).resolves.toEqual(titleResponse(firstId, 'first').value) - expect(worker.posted).toHaveLength(2) - - const secondId = worker.posted[1]!.id - worker.emit('message', titleResponse(secondId, 'second')) - await expect(second).resolves.toEqual(titleResponse(secondId, 'second').value) - expect(worker.unrefed).toBe(true) - client.dispose() - }) - - it('cancels active work without dispatching the next call concurrently', async () => { - const { client, worker } = setup() - const controller = new AbortController() - const first = client.resolveTitles( - [{ agent: 'codex', sessionId: 'session' }], - controller.signal - ) - const second = client.resolveTitles([{ agent: 'claude', sessionId: 'other' }]) - const firstId = worker.posted[0]!.id - - controller.abort() - await expect(first).rejects.toMatchObject({ name: 'AbortError' }) - expect(worker.posted).toEqual([ - expect.objectContaining({ id: firstId, kind: 'titles' }), - { id: firstId, kind: 'cancel' } - ]) - - worker.emit('message', titleResponse(firstId, 'ignored')) - expect(worker.posted).toHaveLength(3) - const secondId = worker.posted[2]!.id - worker.emit('message', titleResponse(secondId, 'second')) - await expect(second).resolves.toEqual(titleResponse(secondId, 'second').value) - client.dispose() - }) - - it.each([ - ['error', new Error('worker crashed')], - ['exit', 1] - ] as const)('restarts queued work after a worker %s', async (event, value) => { - const { client, workers } = setupWorkerFactory() - const first = client.resolveTitles([{ agent: 'codex', sessionId: 'first' }]) - const second = client.resolveTitles([{ agent: 'claude', sessionId: 'second' }]) - - workers[0]!.emit(event, value) - - await expect(first).rejects.toThrow() - expect(workers).toHaveLength(2) - expect(workers[0]!.terminated).toBe(true) - const secondId = workers[1]!.posted[0]!.id - workers[1]!.emit('message', titleResponse(secondId, 'second')) - await expect(second).resolves.toEqual(titleResponse(secondId, 'second').value) - client.dispose() - }) - - it('keeps the unrefed worker resident so incremental parse state survives idle time', async () => { - vi.useFakeTimers() - try { - const { client, worker } = setup() - const result = client.resolveTitles([{ agent: 'codex', sessionId: 'session' }]) - const requestId = worker.posted[0]!.id - worker.emit('message', titleResponse(requestId, 'title')) - await result - - await vi.advanceTimersByTimeAsync(10 * 60_000) - - expect(worker.terminated).toBe(false) - client.dispose() - } finally { - vi.useRealTimers() - } - }) - - it('bounds queued calls while one request is active', async () => { - const { client } = setup() - const active = client.resolveTitles([{ agent: 'codex', sessionId: 'active' }]) - const queued = Array.from({ length: 16 }, (_, index) => - client.resolveTitles([{ agent: 'codex', sessionId: `queued-${index}` }]) - ) - - await expect(client.resolveTitles([{ agent: 'codex', sessionId: 'overflow' }])).rejects.toThrow( - 'queue is full' - ) - client.dispose() - await expect(active).rejects.toThrow('disposed') - await Promise.all(queued.map((promise) => expect(promise).rejects.toThrow('disposed'))) - }) - - it('terminates and rejects active and queued calls on disposal', async () => { - const { client, worker } = setup() - const active = client.resolveTitles([{ agent: 'codex', sessionId: 'active' }]) - const queued = client.resolveTitles([{ agent: 'claude', sessionId: 'queued' }]) - - client.dispose() - - expect(worker.terminated).toBe(true) - await expect(active).rejects.toThrow('disposed') - await expect(queued).rejects.toThrow('disposed') - }) -}) diff --git a/src/main/ai-vault/session-scanner-worker-client.ts b/src/main/ai-vault/session-scanner-worker-client.ts deleted file mode 100644 index 70e75c4f982..00000000000 --- a/src/main/ai-vault/session-scanner-worker-client.ts +++ /dev/null @@ -1,238 +0,0 @@ -import type { Worker } from 'node:worker_threads' -import type { AiVaultListResult } from '../../shared/ai-vault-types' -import type { - AiVaultSessionTitleRequest, - AiVaultSessionTitlesResult -} from '../../shared/ai-vault-session-title' -import { createAiVaultScanCancelledError } from './ai-vault-scan-cancellation' -import type { - AiVaultWorkerRequest, - AiVaultWorkerResponse, - AiVaultWorkerScanOptions -} from './session-scanner-worker-protocol' - -const SCAN_TIMEOUT_MS = 130_000 -const TITLE_TIMEOUT_MS = 15_000 -const MAX_QUEUED_CALLS = 16 - -export type AiVaultWorkerFactory = () => Worker - -type RequestBody = - | Omit, 'id'> - | Omit, 'id'> - -type PendingCall = { - request: AiVaultWorkerRequest - timeoutMs: number - signal?: AbortSignal - resolve: (value: unknown) => void - reject: (error: Error) => void - timer: NodeJS.Timeout | null - onAbort: (() => void) | null - cancelled: boolean -} - -export class AiVaultScannerWorkerClient { - private worker: Worker | null = null - private active: PendingCall | null = null - private queue: PendingCall[] = [] - private nextId = 1 - private readonly workerFactory: AiVaultWorkerFactory - - constructor(options: { workerFactory: AiVaultWorkerFactory }) { - this.workerFactory = options.workerFactory - } - - scan( - options: AiVaultWorkerScanOptions, - signal?: AbortSignal - ): Promise<{ result: AiVaultListResult; durationMs: number }> { - return this.dispatch({ kind: 'scan', options }, SCAN_TIMEOUT_MS, signal) as Promise<{ - result: AiVaultListResult - durationMs: number - }> - } - - resolveTitles( - requests: AiVaultSessionTitleRequest[], - signal?: AbortSignal - ): Promise { - return this.dispatch( - { kind: 'titles', requests }, - TITLE_TIMEOUT_MS, - signal - ) as Promise - } - - dispose(): void { - this.destroyWorker() - const pending = this.queue - this.queue = [] - for (const call of pending) { - this.rejectCall(call, new Error('AI Vault scanner worker was disposed.')) - } - if (this.active) { - this.rejectCall(this.active, new Error('AI Vault scanner worker was disposed.')) - this.active = null - } - } - - private dispatch(body: RequestBody, timeoutMs: number, signal?: AbortSignal): Promise { - if (signal?.aborted) { - return Promise.reject(createAiVaultScanCancelledError()) - } - if (this.queue.length >= MAX_QUEUED_CALLS) { - return Promise.reject(new Error('AI Vault scanner worker queue is full.')) - } - return new Promise((resolve, reject) => { - const call: PendingCall = { - request: { ...body, id: this.nextId++ } as AiVaultWorkerRequest, - timeoutMs, - signal, - resolve, - reject, - timer: null, - onAbort: null, - cancelled: false - } - if (signal) { - call.onAbort = () => this.cancel(call) - signal.addEventListener('abort', call.onAbort, { once: true }) - } - this.queue.push(call) - this.pump() - }) - } - - private pump(): void { - if (this.active || this.queue.length === 0) { - return - } - const worker = this.ensureWorker() - if (!worker) { - this.failQueue(new Error('AI Vault background scanner could not start.')) - return - } - const call = this.queue.shift() - if (!call) { - return - } - this.active = call - call.timer = setTimeout(() => { - this.onWorkerFault(new Error(`AI Vault scanner worker timed out after ${call.timeoutMs}ms.`)) - }, call.timeoutMs) - call.timer.unref?.() - worker.postMessage(call.request) - } - - private ensureWorker(): Worker | null { - if (this.worker) { - return this.worker - } - try { - const worker = this.workerFactory() - worker.on('message', (response: AiVaultWorkerResponse) => this.onMessage(response)) - worker.on('error', (error: Error) => this.onWorkerFault(error)) - worker.on('exit', (code: number) => { - if (code !== 0 || this.active || this.queue.length > 0) { - this.onWorkerFault(new Error(`AI Vault scanner worker exited with code ${code}.`)) - } else { - this.destroyWorker() - } - }) - worker.unref?.() - this.worker = worker - return worker - } catch { - return null - } - } - - private onMessage(response: AiVaultWorkerResponse): void { - const call = this.active - if (!call || call.request.id !== response.id) { - return - } - this.active = null - this.clearCall(call) - if (!call.cancelled) { - if (response.ok) { - call.resolve(response.value) - } else { - call.reject(new Error(response.error)) - } - } - this.afterSettle() - } - - private cancel(call: PendingCall): void { - if (call.cancelled) { - return - } - call.cancelled = true - call.reject(createAiVaultScanCancelledError()) - if (this.active === call) { - this.worker?.postMessage({ id: call.request.id, kind: 'cancel' }) - return - } - const index = this.queue.indexOf(call) - if (index !== -1) { - this.queue.splice(index, 1) - this.clearCall(call) - } - } - - private onWorkerFault(error: Error): void { - const active = this.active - this.active = null - this.destroyWorker() - if (active) { - this.rejectCall(active, error) - } - if (this.queue.length > 0) { - this.pump() - } - } - - private rejectCall(call: PendingCall, error: Error): void { - this.clearCall(call) - if (!call.cancelled) { - call.reject(error) - } - } - - private failQueue(error: Error): void { - const pending = this.queue - this.queue = [] - for (const call of pending) { - this.rejectCall(call, error) - } - } - - private clearCall(call: PendingCall): void { - if (call.timer) { - clearTimeout(call.timer) - call.timer = null - } - if (call.signal && call.onAbort) { - call.signal.removeEventListener('abort', call.onAbort) - call.onAbort = null - } - } - - private afterSettle(): void { - if (this.queue.length > 0) { - this.pump() - } - } - - private destroyWorker(): void { - const worker = this.worker - this.worker = null - if (!worker) { - return - } - worker.removeAllListeners() - void worker.terminate().catch(() => undefined) - } -} diff --git a/src/main/ai-vault/session-scanner-worker-entry.ts b/src/main/ai-vault/session-scanner-worker-entry.ts deleted file mode 100644 index 33c06d30401..00000000000 --- a/src/main/ai-vault/session-scanner-worker-entry.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { parentPort, workerData } from 'node:worker_threads' -import type { - AiVaultSessionTitle, - AiVaultSessionTitleRequest -} from '../../shared/ai-vault-session-title' -import { scanAiVaultSessions } from './session-scanner' -import { initSessionParseCachePersistence } from './session-parse-cache-persistence' -import { readAiVaultSessionTitlesFromFiles } from './session-title-file-reader' -import { resolveHostReadableAiVaultTitleRequests } from './session-title-request-paths' -import type { - AiVaultWorkerControl, - AiVaultWorkerData, - AiVaultWorkerRequest, - AiVaultWorkerResponse -} from './session-scanner-worker-protocol' - -const TITLE_INDEX_MAX_ENTRIES = 4_096 - -if (!parentPort) { - throw new Error('AI Vault scanner worker must run with a parent port.') -} -const port = parentPort -const data = workerData as AiVaultWorkerData | undefined -if (data?.sessionParseCache) { - initSessionParseCachePersistence(data.sessionParseCache) -} -const controllers = new Map() -const titleIndex = new Map() - -function titleKey(request: Pick): string { - return `${request.agent}\0${request.sessionId}` -} - -function storeTitle(title: AiVaultSessionTitle): void { - const key = titleKey(title) - titleIndex.delete(key) - titleIndex.set(key, title) - while (titleIndex.size > TITLE_INDEX_MAX_ENTRIES) { - const oldest = titleIndex.keys().next().value - if (oldest === undefined) { - break - } - titleIndex.delete(oldest) - } -} - -async function handleRequest(request: AiVaultWorkerRequest): Promise { - const controller = new AbortController() - controllers.set(request.id, controller) - try { - if (request.kind === 'titles') { - const requests = await resolveHostReadableAiVaultTitleRequests( - request.requests, - controller.signal - ) - return { - id: request.id, - ok: true, - kind: 'titles', - value: await readAiVaultSessionTitlesFromFiles(requests, { - signal: controller.signal, - cache: { - get: (titleRequest) => titleIndex.get(titleKey(titleRequest)) ?? null, - set: storeTitle - } - }) - } - } - 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()) { - storeTitle({ - agent: session.agent, - sessionId: session.sessionId, - title: session.title.trim() - }) - } - } - return { - id: request.id, - ok: true, - kind: 'scan', - value: { result, durationMs: performance.now() - startedAt } - } - } catch (error) { - return { - id: request.id, - ok: false, - error: error instanceof Error ? error.message : String(error) - } - } finally { - controllers.delete(request.id) - } -} - -let pending = Promise.resolve() -port.on('message', (message: AiVaultWorkerRequest | AiVaultWorkerControl) => { - if (message.kind === 'cancel') { - controllers.get(message.id)?.abort() - return - } - pending = pending.then(async () => { - const response = await handleRequest(message) - try { - port.postMessage(response) - } catch { - port.postMessage({ - id: message.id, - ok: false, - error: 'AI Vault worker result could not be serialized.' - } satisfies AiVaultWorkerResponse) - } - }) -}) diff --git a/src/main/ai-vault/session-scanner-worker-protocol.ts b/src/main/ai-vault/session-scanner-worker-protocol.ts deleted file mode 100644 index a49b8ab7120..00000000000 --- a/src/main/ai-vault/session-scanner-worker-protocol.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { AiVaultListResult } from '../../shared/ai-vault-types' -import type { - AiVaultSessionTitleRequest, - AiVaultSessionTitlesResult -} from '../../shared/ai-vault-session-title' -import type { AiVaultScanOptions } from './session-scanner-types' -import type { SessionParseCachePersistenceOptions } from './session-parse-cache-persistence' - -export type AiVaultWorkerScanOptions = Omit - -export type AiVaultWorkerData = { - sessionParseCache: SessionParseCachePersistenceOptions | null -} - -export type AiVaultWorkerRequest = - | { id: number; kind: 'scan'; options: AiVaultWorkerScanOptions } - | { id: number; kind: 'titles'; requests: AiVaultSessionTitleRequest[] } - -export type AiVaultWorkerControl = { id: number; kind: 'cancel' } - -export type AiVaultWorkerResponse = - | { - id: number - ok: true - kind: 'scan' - value: { result: AiVaultListResult; durationMs: number } - } - | { id: number; ok: true; kind: 'titles'; value: AiVaultSessionTitlesResult } - | { id: number; ok: false; error: string } diff --git a/src/main/ai-vault/session-scanner-worker-spawn.ts b/src/main/ai-vault/session-scanner-worker-spawn.ts deleted file mode 100644 index 3c5abbd3551..00000000000 --- a/src/main/ai-vault/session-scanner-worker-spawn.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { existsSync } from 'node:fs' -import { join } from 'node:path' -import { Worker } from 'node:worker_threads' -import type { AiVaultListResult } from '../../shared/ai-vault-types' -import type { - AiVaultSessionTitleRequest, - AiVaultSessionTitlesResult -} from '../../shared/ai-vault-session-title' -import { withSpan } from '../observability/tracer' -import { getSessionParseCachePersistenceOptions } from './session-parse-cache-persistence' -import { AiVaultScannerWorkerClient } from './session-scanner-worker-client' -import type { AiVaultWorkerData, AiVaultWorkerScanOptions } from './session-scanner-worker-protocol' - -const WORKER_ENTRY_FILENAME = 'session-scanner-worker-entry.js' - -function defaultWorkerFactory(): Worker { - const workerPath = join(__dirname, WORKER_ENTRY_FILENAME) - if (!existsSync(workerPath)) { - throw new Error(`AI Vault scanner worker entry not found: ${workerPath}`) - } - return new Worker(workerPath, { - workerData: { - sessionParseCache: getSessionParseCachePersistenceOptions() - } satisfies AiVaultWorkerData - }) -} - -let sharedClient: AiVaultScannerWorkerClient | null = null - -function getSharedClient(): AiVaultScannerWorkerClient { - sharedClient ??= new AiVaultScannerWorkerClient({ workerFactory: defaultWorkerFactory }) - return sharedClient -} - -export async function scanAiVaultSessionsInWorker( - options: AiVaultWorkerScanOptions, - signal?: AbortSignal -): Promise { - return withSpan('aiVault.scan.worker', async (span) => { - const { result, durationMs } = await getSharedClient().scan(options, signal) - span.setAttribute('workerDurationMs', durationMs) - span.setAttribute('sessions', result.sessions.length) - return result - }) -} - -export function resolveAiVaultSessionTitlesInWorker( - requests: AiVaultSessionTitleRequest[], - signal?: AbortSignal -): Promise { - return getSharedClient().resolveTitles(requests, signal) -} - -export function resetAiVaultScannerWorkerForTests(): void { - sharedClient?.dispose() - sharedClient = null -} diff --git a/src/main/ipc/ai-vault-scan-coalescing.test.ts b/src/main/ipc/ai-vault-scan-coalescing.test.ts index 0efbea4d1ee..157161f7852 100644 --- a/src/main/ipc/ai-vault-scan-coalescing.test.ts +++ b/src/main/ipc/ai-vault-scan-coalescing.test.ts @@ -5,8 +5,8 @@ import type { IFilesystemProvider } from '../providers/types' import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' const mocks = vi.hoisted(() => ({ - scanAiVaultSessionsInWorker: vi.fn(), - resolveAiVaultSessionTitlesInWorker: vi.fn(), + scanAiVaultSessionsInService: vi.fn(), + resolveAiVaultSessionTitlesInService: vi.fn(), scanRemoteAiVaultSessions: vi.fn(), scanRuntimeAiVaultSessions: vi.fn(), getSshFilesystemProvider: vi.fn(), @@ -18,10 +18,10 @@ const mocks = vi.hoisted(() => ({ })) vi.mock('electron', () => ({ app: { on: vi.fn() }, ipcMain: { handle: mocks.ipcHandle } })) -vi.mock('../ai-vault/session-scanner-worker-spawn', () => ({ - scanAiVaultSessionsInWorker: mocks.scanAiVaultSessionsInWorker, - resolveAiVaultSessionTitlesInWorker: mocks.resolveAiVaultSessionTitlesInWorker, - resetAiVaultScannerWorkerForTests: vi.fn() +vi.mock('../ai-vault/session-scanner-service-spawn', async (importOriginal) => ({ + ...(await importOriginal()), + scanAiVaultSessionsInService: mocks.scanAiVaultSessionsInService, + resolveAiVaultSessionTitlesInService: mocks.resolveAiVaultSessionTitlesInService })) vi.mock('../ai-vault/remote-session-scanner', () => ({ scanRemoteAiVaultSessions: mocks.scanRemoteAiVaultSessions @@ -54,8 +54,8 @@ const EMPTY_RESULT: AiVaultListResult = { beforeEach(() => { vi.clearAllMocks() _internals.resetAiVaultCacheForTests() - mocks.scanAiVaultSessionsInWorker.mockResolvedValue(EMPTY_RESULT) - mocks.resolveAiVaultSessionTitlesInWorker.mockResolvedValue({ titles: [] }) + mocks.scanAiVaultSessionsInService.mockResolvedValue(EMPTY_RESULT) + mocks.resolveAiVaultSessionTitlesInService.mockResolvedValue({ titles: [] }) mocks.scanRemoteAiVaultSessions.mockResolvedValue(EMPTY_RESULT) mocks.scanRuntimeAiVaultSessions.mockResolvedValue(EMPTY_RESULT) mocks.getSshFilesystemProvider.mockReturnValue({} as IFilesystemProvider) @@ -67,7 +67,7 @@ beforeEach(() => { describe('Agent Session History scan coalescing', () => { it.each([ - ['local', mocks.scanAiVaultSessionsInWorker], + ['local', mocks.scanAiVaultSessionsInService], ['runtime:remote-server', mocks.scanRuntimeAiVaultSessions] ] as const)('coalesces %s scans while isolating caller cancellation', async (scope, scan) => { let resolveScan: ((result: AiVaultListResult) => void) | undefined @@ -113,7 +113,7 @@ describe('Agent Session History scan coalescing', () => { const second = _internals.listAiVaultSessions({ executionHostScope: 'all' }) await vi.waitFor(() => expect(resolveRuntime).toBeDefined()) - expect(mocks.scanAiVaultSessionsInWorker).toHaveBeenCalledTimes(1) + expect(mocks.scanAiVaultSessionsInService).toHaveBeenCalledTimes(1) expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1) expect(mocks.scanRuntimeAiVaultSessions).toHaveBeenCalledTimes(1) controller.abort() @@ -181,7 +181,7 @@ describe('Agent Session History scan coalescing', () => { }) it('reports a failed local scan as a host issue rather than rejecting', async () => { - mocks.scanAiVaultSessionsInWorker.mockRejectedValue(new Error('transcript root is unreadable')) + mocks.scanAiVaultSessionsInService.mockRejectedValue(new Error('transcript root is unreadable')) registerAiVaultHandlers() const list = ipcHandler('aiVault:listSessions') @@ -206,7 +206,7 @@ describe('Agent Session History scan coalescing', () => { signal.addEventListener('abort', () => resolve(EMPTY_RESULT), { once: true }) }) } - mocks.scanAiVaultSessionsInWorker.mockImplementation((_args, signal: AbortSignal) => + mocks.scanAiVaultSessionsInService.mockImplementation((_args, signal: AbortSignal) => waitForAbort(signal) ) mocks.requestActiveSshAiVaultSessionList.mockImplementation( @@ -256,7 +256,7 @@ describe('Agent Session History scan coalescing', () => { finishOwnership?.() await expect(pending).resolves.toMatchObject({ cancelled: true }) - expect(mocks.scanAiVaultSessionsInWorker).not.toHaveBeenCalled() + expect(mocks.scanAiVaultSessionsInService).not.toHaveBeenCalled() expect(event.sender.eventNames()).toEqual([]) }) diff --git a/src/main/ipc/ai-vault.test.ts b/src/main/ipc/ai-vault.test.ts index b9f5661e19c..826b7986d62 100644 --- a/src/main/ipc/ai-vault.test.ts +++ b/src/main/ipc/ai-vault.test.ts @@ -10,11 +10,10 @@ import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform' import { SSH_MUX_REQUEST_TIMEOUT_CODE } from '../ssh/ssh-channel-multiplexer' const mocks = vi.hoisted(() => ({ - scanAiVaultSessionsInWorker: vi.fn(), - resolveAiVaultSessionTitlesInWorker: vi.fn(), + scanLocalSessions: vi.fn(), + resolveAiVaultSessionTitlesInService: vi.fn(), scanRemoteAiVaultSessions: vi.fn(), - listClaudeSubagentSessions: vi.fn(), - listOmpSubagentSessions: vi.fn(), + listSubagentsInService: vi.fn(), scanRuntimeAiVaultSessions: vi.fn(), getAiVaultWslHomeDirs: vi.fn(), getSshFilesystemProvider: vi.fn(), @@ -33,24 +32,17 @@ vi.mock('electron', () => ({ ipcMain: { handle: mocks.ipcHandle } })) -vi.mock('../ai-vault/session-scanner-worker-spawn', () => ({ - scanAiVaultSessionsInWorker: mocks.scanAiVaultSessionsInWorker, - resolveAiVaultSessionTitlesInWorker: mocks.resolveAiVaultSessionTitlesInWorker, - resetAiVaultScannerWorkerForTests: vi.fn() +vi.mock('../ai-vault/session-scanner-service-spawn', async (importOriginal) => ({ + ...(await importOriginal()), + scanAiVaultSessionsInService: mocks.scanLocalSessions, + resolveAiVaultSessionTitlesInService: mocks.resolveAiVaultSessionTitlesInService, + listAiVaultSubagentSessionsInService: mocks.listSubagentsInService })) vi.mock('../ai-vault/remote-session-scanner', () => ({ scanRemoteAiVaultSessions: mocks.scanRemoteAiVaultSessions })) -vi.mock('../ai-vault/session-scanner-claude-subagents', () => ({ - listClaudeSubagentSessions: mocks.listClaudeSubagentSessions -})) - -vi.mock('../ai-vault/session-scanner-omp-subagent-listing', () => ({ - listOmpSubagentSessions: mocks.listOmpSubagentSessions -})) - vi.mock('../ai-vault/session-delete', () => ({ deleteAiVaultSessionFile: mocks.deleteAiVaultSessionFile })) @@ -102,13 +94,12 @@ const provider = {} as IFilesystemProvider beforeEach(() => { vi.clearAllMocks() _internals.resetAiVaultCacheForTests() - mocks.scanAiVaultSessionsInWorker.mockResolvedValue(result([session('local', 'local-session')])) - mocks.resolveAiVaultSessionTitlesInWorker.mockResolvedValue({ titles: [] }) + mocks.scanLocalSessions.mockResolvedValue(result([session('local', 'local-session')])) + mocks.resolveAiVaultSessionTitlesInService.mockResolvedValue({ titles: [] }) mocks.scanRemoteAiVaultSessions.mockResolvedValue( result([session('ssh:dev-box', 'remote-session')]) ) - mocks.listClaudeSubagentSessions.mockResolvedValue({ sessions: [], issues: [] }) - mocks.listOmpSubagentSessions.mockResolvedValue({ sessions: [], issues: [] }) + mocks.listSubagentsInService.mockResolvedValue({ sessions: [], issues: [] }) mocks.scanRuntimeAiVaultSessions.mockResolvedValue( result([session('runtime:remote-server', 'runtime-session')]) ) @@ -123,7 +114,7 @@ describe('listAiVaultSessions host routing', () => { it('routes local scope to the local scanner', async () => { await _internals.listAiVaultSessions({ executionHostScope: 'local', scopePaths: ['/repo'] }) - expect(mocks.scanAiVaultSessionsInWorker).toHaveBeenCalledWith( + expect(mocks.scanLocalSessions).toHaveBeenCalledWith( expect.objectContaining({ scopePaths: ['/repo'], executionHostId: 'local' @@ -139,7 +130,7 @@ describe('listAiVaultSessions host routing', () => { scopePaths: ['/home/ada/repo'] }) - expect(mocks.scanAiVaultSessionsInWorker).not.toHaveBeenCalled() + expect(mocks.scanLocalSessions).not.toHaveBeenCalled() expect(mocks.getActiveSshAiVaultHostInfo).toHaveBeenCalledWith('dev-box') expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledWith( expect.objectContaining({ @@ -298,7 +289,7 @@ describe('listAiVaultSessions host routing', () => { it('merges local plus connected SSH targets for all hosts', async () => { const result = await _internals.listAiVaultSessions({ executionHostScope: 'all' }) - expect(mocks.scanAiVaultSessionsInWorker).toHaveBeenCalledTimes(1) + expect(mocks.scanLocalSessions).toHaveBeenCalledTimes(1) expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1) expect(mocks.requestActiveSshAiVaultSessionList).toHaveBeenCalledWith( 'dev-box', @@ -345,7 +336,7 @@ describe('listAiVaultSessions host routing', () => { const result = await _internals.listAiVaultSessions({ executionHostScope: 'all' }) - expect(mocks.scanAiVaultSessionsInWorker).toHaveBeenCalledTimes(1) + expect(mocks.scanLocalSessions).toHaveBeenCalledTimes(1) expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1) expect(mocks.scanRuntimeAiVaultSessions).not.toHaveBeenCalled() expect(result.sessions.map((entry) => entry.executionHostId)).toEqual(['ssh:dev-box', 'local']) @@ -361,7 +352,7 @@ describe('listAiVaultSessions host routing', () => { it('keeps SSH results when the local scan itself throws', async () => { // Why: `all` awaits every leg together, so an unguarded local throw (parse // cache load, WSL home resolution) would discard every host's sessions. - mocks.scanAiVaultSessionsInWorker.mockRejectedValue(new Error('session parse cache is corrupt')) + mocks.scanLocalSessions.mockRejectedValue(new Error('session parse cache is corrupt')) registerAiVaultHandlers({ getActiveRuntimeAiVaultHostInfos: () => [], scanRuntimeAiVaultSessions: mocks.scanRuntimeAiVaultSessions @@ -391,7 +382,7 @@ describe('listAiVaultSessions host routing', () => { const result = await _internals.listAiVaultSessions({ executionHostScope: 'all' }) - expect(mocks.scanAiVaultSessionsInWorker).toHaveBeenCalledTimes(1) + expect(mocks.scanLocalSessions).toHaveBeenCalledTimes(1) expect(result.sessions.map((entry) => entry.executionHostId)).toEqual(['local']) expect(result.issues).toEqual([ expect.objectContaining({ @@ -445,7 +436,7 @@ describe('listAiVaultSessions host routing', () => { await _internals.listAiVaultSessions({ executionHostScope: 'local' }) await _internals.listAiVaultSessions({ executionHostScope: 'ssh:dev-box' }) - expect(mocks.scanAiVaultSessionsInWorker).toHaveBeenCalledTimes(1) + expect(mocks.scanLocalSessions).toHaveBeenCalledTimes(1) expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(1) }) @@ -512,15 +503,15 @@ describe('resolveAiVaultSessionTitles host routing', () => { titles: [{ agent: 'codex' as const, sessionId: 'session-1', title: 'Exact title' }] } - it('routes local identities to the worker without a broad scan', async () => { - mocks.resolveAiVaultSessionTitlesInWorker.mockResolvedValue(titles) + it('routes local identities to the scanner service without a broad scan', async () => { + mocks.resolveAiVaultSessionTitlesInService.mockResolvedValue(titles) await expect( _internals.resolveAiVaultSessionTitles({ executionHostScope: 'local', requests }) ).resolves.toEqual(titles) - expect(mocks.resolveAiVaultSessionTitlesInWorker).toHaveBeenCalledWith(requests, undefined) - expect(mocks.scanAiVaultSessionsInWorker).not.toHaveBeenCalled() + expect(mocks.resolveAiVaultSessionTitlesInService).toHaveBeenCalledWith(requests, undefined) + expect(mocks.scanLocalSessions).not.toHaveBeenCalled() }) it('routes SSH identities to the transcript-owning relay', async () => { @@ -569,7 +560,7 @@ describe('resolveAiVaultSessionTitles host routing', () => { }) ).resolves.toEqual({ titles: [] }) - expect(mocks.scanAiVaultSessionsInWorker).not.toHaveBeenCalled() + expect(mocks.scanLocalSessions).not.toHaveBeenCalled() expect(mocks.scanRemoteAiVaultSessions).not.toHaveBeenCalled() }) }) @@ -667,7 +658,7 @@ describe('listAiVaultSubagentSessions gating', () => { executionHostId: 'local' }) - expect(mocks.listClaudeSubagentSessions).toHaveBeenCalledWith({ parentFilePath }) + expect(mocks.listSubagentsInService).toHaveBeenCalledWith({ agent: 'claude', parentFilePath }) }) it('returns empty for a remote Claude session without reading the filesystem', async () => { @@ -678,7 +669,7 @@ describe('listAiVaultSubagentSessions gating', () => { }) expect(result).toEqual({ sessions: [], issues: [] }) - expect(mocks.listClaudeSubagentSessions).not.toHaveBeenCalled() + expect(mocks.listSubagentsInService).not.toHaveBeenCalled() }) it('rejects a path outside the Claude projects root', async () => { @@ -689,7 +680,7 @@ describe('listAiVaultSubagentSessions gating', () => { }) expect(result).toEqual({ sessions: [], issues: [] }) - expect(mocks.listClaudeSubagentSessions).not.toHaveBeenCalled() + expect(mocks.listSubagentsInService).not.toHaveBeenCalled() }) it('rejects a dot-segment traversal out of the Claude projects root', async () => { @@ -703,7 +694,7 @@ describe('listAiVaultSubagentSessions gating', () => { }) expect(result).toEqual({ sessions: [], issues: [] }) - expect(mocks.listClaudeSubagentSessions).not.toHaveBeenCalled() + expect(mocks.listSubagentsInService).not.toHaveBeenCalled() }) it('resolves empty for malformed IPC payloads instead of throwing', async () => { @@ -716,7 +707,7 @@ describe('listAiVaultSubagentSessions gating', () => { expect(missing).toEqual({ sessions: [], issues: [] }) expect(badPath).toEqual({ sessions: [], issues: [] }) - expect(mocks.listClaudeSubagentSessions).not.toHaveBeenCalled() + expect(mocks.listSubagentsInService).not.toHaveBeenCalled() }) it('returns empty for an agent with no sibling subagent layout', async () => { @@ -727,8 +718,7 @@ describe('listAiVaultSubagentSessions gating', () => { }) expect(result).toEqual({ sessions: [], issues: [] }) - expect(mocks.listClaudeSubagentSessions).not.toHaveBeenCalled() - expect(mocks.listOmpSubagentSessions).not.toHaveBeenCalled() + expect(mocks.listSubagentsInService).not.toHaveBeenCalled() }) it('lists subagents for a local OMP session inside the sessions root', async () => { @@ -744,8 +734,7 @@ describe('listAiVaultSubagentSessions gating', () => { executionHostId: 'local' }) - expect(mocks.listOmpSubagentSessions).toHaveBeenCalledWith({ parentFilePath }) - expect(mocks.listClaudeSubagentSessions).not.toHaveBeenCalled() + expect(mocks.listSubagentsInService).toHaveBeenCalledWith({ agent: 'omp', parentFilePath }) }) it('returns empty for a remote OMP session without reading the filesystem', async () => { @@ -756,7 +745,7 @@ describe('listAiVaultSubagentSessions gating', () => { }) expect(result).toEqual({ sessions: [], issues: [] }) - expect(mocks.listOmpSubagentSessions).not.toHaveBeenCalled() + expect(mocks.listSubagentsInService).not.toHaveBeenCalled() }) it('rejects an OMP path that only sits inside another agent root', async () => { @@ -776,7 +765,7 @@ describe('listAiVaultSubagentSessions gating', () => { expect(crossAgent).toEqual({ sessions: [], issues: [] }) expect(traversal).toEqual({ sessions: [], issues: [] }) - expect(mocks.listOmpSubagentSessions).not.toHaveBeenCalled() + expect(mocks.listSubagentsInService).not.toHaveBeenCalled() }) }) diff --git a/src/main/runtime/rpc/methods/ai-vault.test.ts b/src/main/runtime/rpc/methods/ai-vault.test.ts index a240c0c3919..6853b5f572f 100644 --- a/src/main/runtime/rpc/methods/ai-vault.test.ts +++ b/src/main/runtime/rpc/methods/ai-vault.test.ts @@ -3,21 +3,24 @@ import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' import { OrcaRuntimeService } from '../../orca-runtime' import type { AiVaultListResult, AiVaultSession } from '../../../../shared/ai-vault-types' -import type { AiVaultScanOptions } from '../../../ai-vault/session-scanner-types' +import type { AiVaultServiceScanOptions } from '../../../ai-vault/session-scanner-service-protocol' import { AI_VAULT_SESSION_TITLES_RUNTIME_CAPABILITY, RUNTIME_CAPABILITIES } from '../../../../shared/protocol-version' -const { scanAiVaultSessionsInWorker, resolveAiVaultSessionTitlesInWorker } = vi.hoisted(() => ({ - scanAiVaultSessionsInWorker: vi.fn(), - resolveAiVaultSessionTitlesInWorker: vi.fn() +const { scanAiVaultSessionsInService, resolveAiVaultSessionTitlesInService } = vi.hoisted(() => ({ + scanAiVaultSessionsInService: + vi.fn< + (options: AiVaultServiceScanOptions, signal?: AbortSignal) => Promise + >(), + resolveAiVaultSessionTitlesInService: vi.fn() })) -vi.mock('../../../ai-vault/session-scanner-worker-spawn', () => ({ - scanAiVaultSessionsInWorker, - resolveAiVaultSessionTitlesInWorker, - resetAiVaultScannerWorkerForTests: vi.fn() +vi.mock('../../../ai-vault/session-scanner-service-spawn', async (importOriginal) => ({ + ...(await importOriginal()), + scanAiVaultSessionsInService, + resolveAiVaultSessionTitlesInService })) import { @@ -71,13 +74,14 @@ function makeSession(): AiVaultSession { function makeDispatcher(): RpcDispatcher { // Why: the handler only needs getRuntimeId (envelope) + listAiVaultSessions, // which delegates to the shared cache module the IPC handler also uses. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handlers under test read only these three runtime members. const runtime = { getRuntimeId: () => 'test-runtime', ensureStructuredAgentSessionHost: vi.fn(async () => undefined), listAiVaultSessions: (args?: Parameters[0]) => listAiVaultSessions(args), resolveAiVaultSessionTitles: (requests: unknown[], signal?: AbortSignal) => - resolveAiVaultSessionTitlesInWorker(requests, signal) + resolveAiVaultSessionTitlesInService(requests, signal) } as unknown as OrcaRuntimeService return new RpcDispatcher({ runtime, methods: AI_VAULT_METHODS }) } @@ -93,11 +97,11 @@ function makeFailingDispatcher(error: Error): RpcDispatcher { describe('aiVault.resolveSessionTitles handler', () => { beforeEach(() => { - resolveAiVaultSessionTitlesInWorker.mockReset() + resolveAiVaultSessionTitlesInService.mockReset() }) it('advertises and routes the bounded exact-title capability', async () => { - resolveAiVaultSessionTitlesInWorker.mockResolvedValue({ + resolveAiVaultSessionTitlesInService.mockResolvedValue({ titles: [{ agent: 'codex', sessionId: 'session-1', title: 'Exact title' }] }) const dispatcher = makeDispatcher() @@ -111,12 +115,12 @@ describe('aiVault.resolveSessionTitles handler', () => { ok: true, result: { titles: [{ sessionId: 'session-1', title: 'Exact title' }] } }) - expect(resolveAiVaultSessionTitlesInWorker).toHaveBeenCalledWith(requests, undefined) + expect(resolveAiVaultSessionTitlesInService).toHaveBeenCalledWith(requests, undefined) expect(RUNTIME_CAPABILITIES).toContain(AI_VAULT_SESSION_TITLES_RUNTIME_CAPABILITY) }) it('forwards transport cancellation to the background scanner', async () => { - resolveAiVaultSessionTitlesInWorker.mockResolvedValue({ titles: [] }) + resolveAiVaultSessionTitlesInService.mockResolvedValue({ titles: [] }) const dispatcher = makeDispatcher() const controller = new AbortController() const requests = [{ agent: 'codex', sessionId: 'session-1' }] @@ -125,7 +129,7 @@ describe('aiVault.resolveSessionTitles handler', () => { signal: controller.signal }) - expect(resolveAiVaultSessionTitlesInWorker).toHaveBeenCalledWith(requests, controller.signal) + expect(resolveAiVaultSessionTitlesInService).toHaveBeenCalledWith(requests, controller.signal) }) it('rejects more than 64 title identities before reaching the host', async () => { @@ -138,7 +142,7 @@ describe('aiVault.resolveSessionTitles handler', () => { await expect( dispatcher.dispatch(makeRequest('aiVault.resolveSessionTitles', { requests })) ).resolves.toMatchObject({ ok: false }) - expect(resolveAiVaultSessionTitlesInWorker).not.toHaveBeenCalled() + expect(resolveAiVaultSessionTitlesInService).not.toHaveBeenCalled() }) }) @@ -234,8 +238,8 @@ describe('aiVault methods without a structured host', () => { beforeEach(() => { resetAiVaultSessionListCacheForTests() - scanAiVaultSessionsInWorker.mockReset() - scanAiVaultSessionsInWorker.mockResolvedValue(makeResult()) + scanAiVaultSessionsInService.mockReset() + scanAiVaultSessionsInService.mockResolvedValue(makeResult()) recordStructuredAgentSessionHostInstallRefusal(refusal) }) @@ -285,10 +289,10 @@ describe('aiVault methods without a structured host', () => { describe('aiVault.listSessions handler + shared cache', () => { beforeEach(() => { resetAiVaultSessionListCacheForTests() - scanAiVaultSessionsInWorker.mockReset() - scanAiVaultSessionsInWorker.mockResolvedValue(makeResult()) - resolveAiVaultSessionTitlesInWorker.mockReset() - resolveAiVaultSessionTitlesInWorker.mockResolvedValue({ titles: [] }) + scanAiVaultSessionsInService.mockReset() + scanAiVaultSessionsInService.mockResolvedValue(makeResult()) + resolveAiVaultSessionTitlesInService.mockReset() + resolveAiVaultSessionTitlesInService.mockResolvedValue({ titles: [] }) }) afterEach(() => { @@ -336,7 +340,7 @@ describe('aiVault.listSessions handler + shared cache', () => { const scopePaths = Array.from({ length: 65 }, (_, index) => `/p/${index}`) const response = await dispatcher.dispatch(makeRequest('aiVault.listSessions', { scopePaths })) expect(response).toMatchObject({ ok: true }) - expect(scanAiVaultSessionsInWorker.mock.calls[0]?.[0]).toMatchObject({ + expect(scanAiVaultSessionsInService.mock.calls[0]?.[0]).toMatchObject({ scopePaths: scopePaths.slice(0, 64) }) }) @@ -347,7 +351,7 @@ describe('aiVault.listSessions handler + shared cache', () => { await listAiVaultSessions({ limit: 500 }) // Second call via the RPC method with the same cache key. await dispatcher.dispatch(makeRequest('aiVault.listSessions', { limit: 500 })) - expect(scanAiVaultSessionsInWorker).toHaveBeenCalledTimes(1) + expect(scanAiVaultSessionsInService).toHaveBeenCalledTimes(1) }) it('keeps completed scans cached for one minute', async () => { @@ -356,11 +360,11 @@ describe('aiVault.listSessions handler + shared cache', () => { await listAiVaultSessions({ limit: 500 }) await vi.advanceTimersByTimeAsync(59_999) await listAiVaultSessions({ limit: 500 }) - expect(scanAiVaultSessionsInWorker).toHaveBeenCalledTimes(1) + expect(scanAiVaultSessionsInService).toHaveBeenCalledTimes(1) await vi.advanceTimersByTimeAsync(1) await listAiVaultSessions({ limit: 500 }) - expect(scanAiVaultSessionsInWorker).toHaveBeenCalledTimes(2) + expect(scanAiVaultSessionsInService).toHaveBeenCalledTimes(2) } finally { vi.useRealTimers() } @@ -371,14 +375,14 @@ describe('aiVault.listSessions handler + shared cache', () => { await listAiVaultSessions({ limit: 250 }) await listAiVaultSessions({ limit: 500 }) - expect(scanAiVaultSessionsInWorker).toHaveBeenCalledTimes(1) + expect(scanAiVaultSessionsInService).toHaveBeenCalledTimes(1) }) it('shares a cache entry across equivalent scope path ordering', async () => { await listAiVaultSessions({ limit: 500, scopePaths: ['/repo/a', '/repo/b'] }) await listAiVaultSessions({ limit: 500, scopePaths: ['/repo/b', '/repo/a'] }) - expect(scanAiVaultSessionsInWorker).toHaveBeenCalledTimes(1) + expect(scanAiVaultSessionsInService).toHaveBeenCalledTimes(1) }) it('forwards Unlimited without a numeric limit', async () => { @@ -388,7 +392,7 @@ describe('aiVault.listSessions handler + shared cache', () => { ) expect(response).toMatchObject({ ok: true }) - expect(scanAiVaultSessionsInWorker).toHaveBeenCalledWith( + expect(scanAiVaultSessionsInService).toHaveBeenCalledWith( expect.objectContaining({ limit: undefined, unlimited: true }), expect.any(AbortSignal) ) @@ -398,7 +402,7 @@ describe('aiVault.listSessions handler + shared cache', () => { // Why: the resolving scan's cleanup must not clear tracking a concurrent // different-key scan replaced, or re-requests start a duplicate rescan. const deferreds: ((result: AiVaultListResult) => void)[] = [] - scanAiVaultSessionsInWorker.mockImplementation( + scanAiVaultSessionsInService.mockImplementation( () => new Promise((resolve) => deferreds.push(resolve)) ) // The scanner is invoked a microtask after the call (WSL-home await), so @@ -416,13 +420,13 @@ describe('aiVault.listSessions handler + shared cache', () => { // reverted guard reads 3, so this assertion — not a Promise.all hang — pins // the fix. await new Promise((resolve) => setTimeout(resolve)) - expect(scanAiVaultSessionsInWorker).toHaveBeenCalledTimes(2) + expect(scanAiVaultSessionsInService).toHaveBeenCalledTimes(2) deferreds[1]?.(makeResult()) await Promise.all([scanB, scanBAgain]) }) it('restamps the shared cached result as the addressed runtime host', async () => { - scanAiVaultSessionsInWorker.mockResolvedValue({ + scanAiVaultSessionsInService.mockResolvedValue({ sessions: [makeSession()], issues: [{ executionHostId: 'local', agent: 'claude', path: '/tmp', message: 'boom' }], scannedAt: SCANNED_AT @@ -442,8 +446,8 @@ describe('aiVault.listSessions handler + shared cache', () => { // Why: the host id must never change what is scanned — one host-local scan // (and one cache entry) serves every caller; only the stamps differ. - expect(scanAiVaultSessionsInWorker).toHaveBeenCalledTimes(1) - expect(scanAiVaultSessionsInWorker.mock.calls[0]?.[0]).toMatchObject({ + expect(scanAiVaultSessionsInService).toHaveBeenCalledTimes(1) + expect(scanAiVaultSessionsInService.mock.calls[0]?.[0]).toMatchObject({ executionHostId: 'local' }) @@ -461,11 +465,11 @@ describe('aiVault.listSessions handler + shared cache', () => { }) const dispatcher = makeDispatcher() await dispatcher.dispatch(makeRequest('aiVault.listSessions', {})) - const options = scanAiVaultSessionsInWorker.mock.calls[0]?.[0] as AiVaultScanOptions + const options = scanAiVaultSessionsInService.mock.calls[0]?.[0] // Why: the codex-home is sourced from the runtime, not the window-only // registerCoreHandlers path, so it survives in serve mode. - expect(options.additionalCodexSessionsDirs).toContain('/runtime/codex/home/sessions') - expect(options.wslHomeDirs).toEqual([]) + expect(options?.additionalCodexSessionsDirs).toContain('/runtime/codex/home/sessions') + expect(options?.wslHomeDirs).toEqual([]) }) it('forwards codex-home through the real OrcaRuntimeService construction path', async () => { @@ -476,7 +480,7 @@ describe('aiVault.listSessions handler + shared cache', () => { getAdditionalAiVaultCodexHomePaths: () => ['/ctor/codex/home'] }) await runtime.listAiVaultSessions({}) - const options = scanAiVaultSessionsInWorker.mock.calls[0]?.[0] as AiVaultScanOptions - expect(options.additionalCodexSessionsDirs).toContain('/ctor/codex/home/sessions') + const options = scanAiVaultSessionsInService.mock.calls[0]?.[0] + expect(options?.additionalCodexSessionsDirs).toContain('/ctor/codex/home/sessions') }) }) diff --git a/src/main/runtime/structured-agent-session-held-signal.test.ts b/src/main/runtime/structured-agent-session-held-signal.test.ts index a467e45521f..2bf1a423fe1 100644 --- a/src/main/runtime/structured-agent-session-held-signal.test.ts +++ b/src/main/runtime/structured-agent-session-held-signal.test.ts @@ -22,10 +22,10 @@ import { } from './structured-agent-session-runtime' import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' -vi.mock('../ai-vault/session-scanner-worker-spawn', () => ({ - scanAiVaultSessionsInWorker: vi.fn(), - resolveAiVaultSessionTitlesInWorker: vi.fn(), - resetAiVaultScannerWorkerForTests: vi.fn() +vi.mock('../ai-vault/session-scanner-service-spawn', async (importOriginal) => ({ + ...(await importOriginal()), + scanAiVaultSessionsInService: vi.fn(), + resolveAiVaultSessionTitlesInService: vi.fn() })) const { AI_VAULT_METHODS } = await import('./rpc/methods/ai-vault')