From f19f4b8e74f6ee58c77150c3524b1e61d1b1e0a9 Mon Sep 17 00:00:00 2001 From: Serhii Vecherenko Date: Sat, 8 Aug 2026 23:14:52 -0700 Subject: [PATCH 1/2] fix(supervisor): honor abort signals across agent probes and cleanup - Forward AbortSignal through detection/probe and spawn paths (codex, claude, cursor, gemini, opencode, copilot, grok, kimi, pi, qoder, qwen, acp, factory) so aborted detections stop promptly - Terminate child process trees with owned process groups on abort/timeout, including a WSL bridge command timeout that kills hung processes - Fix OpenCode SDK probe pooling and idle-shutdown cleanup, and dedupe concurrent detection probes - Scope agent status refresh to active WSL project distros from the project watcher and fix watcher unsubscribe ordering - Cover with new provider probe tests, process-tree, bridge, and registry tests --- .../state/chatRuntimePersister.test.ts | 32 +++ src/renderer/state/chatRuntimePersister.ts | 2 +- src/shared/processTree.test.ts | 14 + src/shared/processTree.ts | 29 +- src/supervisor/agents/acp-generic/index.ts | 1 + .../agents/acp/probe.stress.test.ts | 15 + src/supervisor/agents/acp/probe.ts | 19 +- .../agents/antigravity/detection.ts | 8 +- src/supervisor/agents/antigravity/models.ts | 2 +- .../agents/base.detect-version.test.ts | 34 +++ src/supervisor/agents/base/index.ts | 33 ++- .../agents/base/processRuntime.env.test.ts | 58 +++- src/supervisor/agents/base/processRuntime.ts | 84 +++++- .../base/readAgentCommandOutput.test.ts | 16 +- src/supervisor/agents/base/types.ts | 3 + src/supervisor/agents/claude/detection.ts | 1 + src/supervisor/agents/claude/probe.test.ts | 58 ++++ src/supervisor/agents/claude/probe.ts | 9 +- .../agents/claude/sdkProbeProcess.ts | 16 +- src/supervisor/agents/codex/detection.ts | 7 +- src/supervisor/agents/codex/probe.ts | 36 ++- .../agents/commandcode/detection.ts | 1 + src/supervisor/agents/copilot/detection.ts | 262 +++++++++++------- src/supervisor/agents/cursor/detection.ts | 25 +- .../agents/cursor/sdkDetection.test.ts | 26 ++ src/supervisor/agents/cursor/sdkDetection.ts | 15 +- .../agents/cursor/windowsExecutable.ts | 2 + src/supervisor/agents/factory/detection.ts | 4 +- src/supervisor/agents/gemini/detection.ts | 10 +- src/supervisor/agents/grok/detection.test.ts | 4 +- src/supervisor/agents/grok/detection.ts | 12 +- src/supervisor/agents/kimi/detection.ts | 4 +- .../opencode/detection.concurrent.test.ts | 69 +++++ src/supervisor/agents/opencode/detection.ts | 41 ++- .../agents/opencode/sdkClient.test.ts | 72 +++++ src/supervisor/agents/opencode/sdkClient.ts | 82 +++++- .../agents/opencode/sdkProbe.test.ts | 73 +++++ src/supervisor/agents/opencode/sdkProbe.ts | 32 ++- src/supervisor/agents/pi/detection.ts | 12 +- src/supervisor/agents/qoder/detection.ts | 12 +- src/supervisor/agents/qwen/detection.ts | 4 +- src/supervisor/oneShotSpawn.ts | 1 + src/supervisor/projectWatcher.test.ts | 90 +++++- src/supervisor/projectWatcher.ts | 44 ++- .../runtime/agentRegistryService.test.ts | 101 ++++++- .../runtime/agentRegistryService.ts | 7 +- .../runtime/agentStatusCache.test.ts | 18 +- .../runtime/agentStatusService.test.ts | 49 +++- src/supervisor/runtime/agentStatusService.ts | 4 +- src/supervisor/supervisorRuntime.ts | 1 + src/supervisor/wsl/bridge/bridge.mjs | 63 ++++- src/supervisor/wsl/bridge/bridge.test.ts | 77 +++++ 52 files changed, 1461 insertions(+), 233 deletions(-) create mode 100644 src/supervisor/agents/opencode/detection.concurrent.test.ts create mode 100644 src/supervisor/agents/opencode/sdkProbe.test.ts diff --git a/src/renderer/state/chatRuntimePersister.test.ts b/src/renderer/state/chatRuntimePersister.test.ts index 656ed9e37..20f42dd4d 100644 --- a/src/renderer/state/chatRuntimePersister.test.ts +++ b/src/renderer/state/chatRuntimePersister.test.ts @@ -5,6 +5,8 @@ import { compactRuntimeItemsForHydration, hydrateThreadRuntimeItems, loadOlderThreadRuntimeItems, + releaseThreadRuntimeItems, + retainThreadRuntimeItems, seedOlderThreadRuntimeItemsCursor, } from "./chatRuntimePersister"; @@ -285,4 +287,34 @@ describe("paged runtime hydration", () => { targetTimelineEntryCount: 40, }); }); + + it("rehydrates a transcript after the inactive cache evicts it", async () => { + const threadIds = Array.from({ length: 11 }, (_, index) => `cached-thread-${index}`); + bridge.dbGetThreadRuntimeItemsPage.mockImplementation(async ({ threadId }) => ({ + items: [makeItem({ id: `${threadId}-item`, type: "assistant_message" })], + nextCursor: null, + })); + + for (const threadId of threadIds) { + await hydrateThreadRuntimeItems(threadId); + retainThreadRuntimeItems(threadId); + releaseThreadRuntimeItems(threadId); + } + + expect(useAppStore.getState().runtimeItemIdsByThread[threadIds[0]!]).toBeUndefined(); + for (const threadId of threadIds.slice(1)) { + expect(useAppStore.getState().runtimeItemIdsByThread[threadId]).toBeDefined(); + } + + bridge.dbGetThreadRuntimeItemsPage.mockClear(); + await hydrateThreadRuntimeItems(threadIds[0]!); + expect(bridge.dbGetThreadRuntimeItemsPage).toHaveBeenCalledWith({ + threadId: threadIds[0], + limit: 500, + targetTimelineEntryCount: 40, + }); + expect(useAppStore.getState().runtimeItemIdsByThread[threadIds[0]!]).toEqual([ + `${threadIds[0]}-item`, + ]); + }); }); diff --git a/src/renderer/state/chatRuntimePersister.ts b/src/renderer/state/chatRuntimePersister.ts index cddf74c17..68baf44ce 100644 --- a/src/renderer/state/chatRuntimePersister.ts +++ b/src/renderer/state/chatRuntimePersister.ts @@ -12,7 +12,7 @@ import { const RUNTIME_PAGE_SCAN_SIZE = 500; const RUNTIME_TIMELINE_PAGE_SIZE = 40; -const MAX_CACHED_THREAD_TRANSCRIPTS = 40; +const MAX_CACHED_THREAD_TRANSCRIPTS = 10; const hydratedThreadRuntimeIds = new Set(); const pendingThreadRuntimeHydrations = new Map>(); const olderRuntimePageCursorByThread = new Map(); diff --git a/src/shared/processTree.test.ts b/src/shared/processTree.test.ts index ed19efe9a..d04102aed 100644 --- a/src/shared/processTree.test.ts +++ b/src/shared/processTree.test.ts @@ -74,4 +74,18 @@ describe("processTree", () => { expect(taskkillSpawnSyncMock).not.toHaveBeenCalled(); expect(processKillSpy).toHaveBeenCalledWith(31337); }); + + it("kills an owned POSIX process group", () => { + const processKillSpy = vi.spyOn(process, "kill").mockImplementation(() => true); + + Object.defineProperty(process, "platform", { + configurable: true, + value: "linux", + }); + + terminateChildProcessTree({ pid: 31337 }, { ownedProcessGroup: true }); + + expect(taskkillSpawnSyncMock).not.toHaveBeenCalled(); + expect(processKillSpy).toHaveBeenCalledExactlyOnceWith(-31337, "SIGKILL"); + }); }); diff --git a/src/shared/processTree.ts b/src/shared/processTree.ts index c577dcdff..1f0d26b25 100644 --- a/src/shared/processTree.ts +++ b/src/shared/processTree.ts @@ -9,7 +9,12 @@ function isRunnablePid(pid: number): boolean { } } -export function terminateProcessTree(pid: number): void { +export interface TerminateProcessTreeOptions { + /** The child was launched detached and owns its POSIX process group. */ + ownedProcessGroup?: boolean; +} + +export function terminateProcessTree(pid: number, options?: TerminateProcessTreeOptions): void { if (!Number.isInteger(pid) || pid <= 0) { return; } @@ -30,17 +35,33 @@ export function terminateProcessTree(pid: number): void { } } + if (options?.ownedProcessGroup) { + try { + process.kill(-pid, "SIGKILL"); + return; + } catch { + // The group may already be gone; fall back to the immediate process. + } + } + try { - process.kill(pid); + if (options?.ownedProcessGroup) { + process.kill(pid, "SIGKILL"); + } else { + process.kill(pid); + } } catch { // Best effort; the process may already be gone. } } -export function terminateChildProcessTree(child: Pick): void { +export function terminateChildProcessTree( + child: Pick, + options?: TerminateProcessTreeOptions, +): void { if (typeof child.pid !== "number") { return; } - terminateProcessTree(child.pid); + terminateProcessTree(child.pid, options); } diff --git a/src/supervisor/agents/acp-generic/index.ts b/src/supervisor/agents/acp-generic/index.ts index 1c1838a7c..53737d424 100644 --- a/src/supervisor/agents/acp-generic/index.ts +++ b/src/supervisor/agents/acp-generic/index.ts @@ -236,6 +236,7 @@ async function probeGenericCapabilities( ...(command.env ? { env: command.env } : {}), label, ...(timeoutMs !== undefined ? { timeoutMs } : {}), + ...(ctx?.signal ? { signal: ctx.signal } : {}), }); } diff --git a/src/supervisor/agents/acp/probe.stress.test.ts b/src/supervisor/agents/acp/probe.stress.test.ts index bbac505e7..3f4a12321 100644 --- a/src/supervisor/agents/acp/probe.stress.test.ts +++ b/src/supervisor/agents/acp/probe.stress.test.ts @@ -133,4 +133,19 @@ describe("probeAcpCapabilities live-process paths", () => { expect(elapsed).toBeLessThan(1_000); }); + + it("aborts and reaps the probe process when its caller is cancelled", async () => { + const abort = new AbortController(); + const started = Date.now(); + const pending = probeAcpCapabilities(process.execPath, [FIXTURE], process.cwd(), { + timeoutMs: 5_000, + label: "cancelled", + signal: abort.signal, + }); + setTimeout(() => abort.abort(), 100); + + await pending; + + expect(Date.now() - started).toBeLessThan(1_000); + }); }); diff --git a/src/supervisor/agents/acp/probe.ts b/src/supervisor/agents/acp/probe.ts index b0b529c2b..904f5e108 100644 --- a/src/supervisor/agents/acp/probe.ts +++ b/src/supervisor/agents/acp/probe.ts @@ -399,6 +399,7 @@ export async function probeAcpCapabilities( timeoutMs?: number; label?: string; env?: Record; + signal?: AbortSignal; /** * Auth method IDs to call `authenticate` with (in order) after `initialize` * but before `newSession`. Stops at the first one advertised by the agent. @@ -414,8 +415,12 @@ export async function probeAcpCapabilities( const deadline = Date.now() + timeoutMs; const tag = options?.label ? `[acp-probe:${options.label}]` : "[acp-probe]"; let child: ReturnType | undefined; + let abortProbe: (() => void) | undefined; + const ownedProcessGroup = process.platform !== "win32"; const probeResult: AcpProbeResult = {}; + if (options?.signal?.aborted) return undefined; + try { const configOptionsWaiters: Array<(configOptions: unknown[] | undefined) => void> = []; let latestSlashCommands: AgentSlashCommand[] | undefined; @@ -430,6 +435,7 @@ export async function probeAcpCapabilities( env: options?.env ? { ...process.env, ...options.env } : process.env, shell: false, windowsHide: true, + detached: ownedProcessGroup, }); let childExited = false; @@ -441,6 +447,16 @@ export async function probeAcpCapabilities( child!.once("error", markClosed); child!.once("exit", markClosed); }); + abortProbe = () => { + try { + child?.stdin?.destroy(); + } catch { + // Ignore cleanup races. + } + if (child) terminateChildProcessTree(child, { ownedProcessGroup }); + }; + options?.signal?.addEventListener("abort", abortProbe, { once: true }); + if (options?.signal?.aborted) abortProbe(); const remainingBudgetMs = () => Math.max(0, deadline - Date.now()); const waitForProbeWindow = async (maxMs: number): Promise => { const waitMs = Math.min(maxMs, remainingBudgetMs()); @@ -717,6 +733,7 @@ export async function probeAcpCapabilities( } return undefined; } finally { + if (abortProbe) options?.signal?.removeEventListener("abort", abortProbe); if (child && !child.killed) { // Destroy stdin before killing to prevent the ACP SDK from writing // to a dead pipe (which causes noisy "ACP write error" logs). @@ -725,7 +742,7 @@ export async function probeAcpCapabilities( } catch { /* ignore */ } - terminateChildProcessTree(child); + terminateChildProcessTree(child, { ownedProcessGroup }); } } } diff --git a/src/supervisor/agents/antigravity/detection.ts b/src/supervisor/agents/antigravity/detection.ts index 4d06943b0..8a3c50c49 100644 --- a/src/supervisor/agents/antigravity/detection.ts +++ b/src/supervisor/agents/antigravity/detection.ts @@ -51,9 +51,11 @@ const configDirAuthProbe: AuthProbe = async (ctx) => { // reports "unknown" (→ "Login required") on a distro that is actually signed // in. A direct `test -d` is cache-independent and reliable (mirrors grok). if (ctx.location.kind === "wsl") { - const [result] = await batchWslCommandsAsync(ctx.location.distro, [ - `test -d ~/${ANTIGRAVITY_CONFIG_SUBPATH} && echo yes || echo no`, - ]); + const [result] = await batchWslCommandsAsync( + ctx.location.distro, + [`test -d ~/${ANTIGRAVITY_CONFIG_SUBPATH} && echo yes || echo no`], + ctx.signal, + ); return result?.ok && result.stdout.trim() === "yes" ? "authenticated" : "unknown"; } return antigravityConfigDirExists(ctx.location) ? "authenticated" : "unknown"; diff --git a/src/supervisor/agents/antigravity/models.ts b/src/supervisor/agents/antigravity/models.ts index 0f3a1c909..46985e540 100644 --- a/src/supervisor/agents/antigravity/models.ts +++ b/src/supervisor/agents/antigravity/models.ts @@ -443,7 +443,7 @@ export async function probeAntigravityRuntime( try { return { ok: true, - output: await spawnAgentPty(spec, "", 10_000), + output: await spawnAgentPty(spec, "", 10_000, ctx.signal), }; } catch { return { ok: false, output: "" }; diff --git a/src/supervisor/agents/base.detect-version.test.ts b/src/supervisor/agents/base.detect-version.test.ts index 39a1fc1ef..e6e6ba60b 100644 --- a/src/supervisor/agents/base.detect-version.test.ts +++ b/src/supervisor/agents/base.detect-version.test.ts @@ -1,15 +1,27 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; import type { AgentCapability } from "@/shared/contracts"; const execFileAsyncMock = vi.hoisted(() => vi.fn<(...args: unknown[]) => Promise<{ stdout: string; stderr?: string }>>(), ); +const spawnMock = vi.hoisted(() => + vi.fn< + ( + command: string, + args: string[], + options: Record, + ) => import("node:child_process").ChildProcess + >(), +); vi.mock("node:child_process", async () => { const actual = await vi.importActual("node:child_process"); const { promisify } = require("node:util") as typeof import("node:util"); return { ...actual, + spawn: spawnMock, execFile: Object.assign(vi.fn(), { [promisify.custom]: execFileAsyncMock, }), @@ -53,6 +65,28 @@ describe("detectAgentInstall version probe", () => { Object.defineProperty(process, "platform", { value: "linux", configurable: true }); clearExecutablePathCache(); execFileAsyncMock.mockReset(); + spawnMock.mockReset(); + spawnMock.mockImplementation((command, args, options) => { + const stdout = new PassThrough(); + const stderr = new PassThrough(); + const child = Object.assign(new EventEmitter(), { + stdout, + stderr, + pid: 12_345, + killed: false, + }) as unknown as import("node:child_process").ChildProcess; + queueMicrotask(() => { + void execFileAsyncMock(command, args, options).then( + (result) => { + stdout.end(result.stdout); + stderr.end(result.stderr ?? ""); + child.emit("close", 0); + }, + (error: unknown) => child.emit("error", error), + ); + }); + return child; + }); }); afterEach(() => { diff --git a/src/supervisor/agents/base/index.ts b/src/supervisor/agents/base/index.ts index 61a367a9a..5ffff7339 100644 --- a/src/supervisor/agents/base/index.ts +++ b/src/supervisor/agents/base/index.ts @@ -425,6 +425,7 @@ export function envVarAuthProbe(names: string[]): AuthProbe { const results = await batchWslCommandsAsync( ctx.location.distro, names.map((n) => `printf %s "$${n}"`), + ctx.signal, ); const any = results.some((r) => r.ok && r.stdout.trim().length > 0); return any ? "authenticated" : "unknown"; @@ -465,6 +466,7 @@ export function cliSubcommandAuthProbe(args: string[]): AuthProbe { const result = await readCommandOutputAsync(spec.command, spec.args, { ...(spec.cwd ? { cwd: spec.cwd } : {}), ...(spec.env ? { env: spec.env } : {}), + ...(ctx.signal ? { signal: ctx.signal } : {}), }); return result.ok ? "authenticated" : "unknown"; }; @@ -519,7 +521,7 @@ async function resolveDetectedBinary( `home="\${${env}:-}"; if [ -z "$home" ]; then home="$HOME"/${quotePosixShellArg(defaultSubpath)}; fi; candidate="$home/bin/"${quotePosixShellArg(binary)}; if [ -x "$candidate" ]; then printf '%s\\n' "$candidate"; fi`, ); } - const results = await batchWslCommandsAsync(ctx.wslDistro, commands); + const results = await batchWslCommandsAsync(ctx.wslDistro, commands, ctx.signal); // A `/mnt/...` result is a Windows binary surfaced via PATH interop, not a // real Linux install — reject it so detection matches launch-time // resolution (see isWslInteropBinaryPath). @@ -546,7 +548,9 @@ export async function readDetectedVersion( executablePath: string | undefined, versionArgs: string[], probeEnv?: Record, + signal?: AbortSignal, ): Promise { + signal?.throwIfAborted(); if (!executablePath) return undefined; if (location.kind === "wsl") { const result = await readWslLoginShellCommandOutputAsync( @@ -554,8 +558,11 @@ export async function readDetectedVersion( PROBE_WSL_LINUX_PATH, executablePath, versionArgs, - probeEnv ? { env: probeEnv } : undefined, + probeEnv || signal + ? { ...(probeEnv ? { env: probeEnv } : {}), ...(signal ? { signal } : {}) } + : undefined, ); + signal?.throwIfAborted(); return result.ok ? extractSemverFromVersionOutput(result.stdout) : undefined; } // Run the resolved binary directly rather than re-resolving the bare name @@ -568,8 +575,12 @@ export async function readDetectedVersion( const result = await readCommandOutputAsync( spec.command, spec.args, - spec.cwd || spec.env - ? { ...(spec.cwd ? { cwd: spec.cwd } : {}), ...(spec.env ? { env: spec.env } : {}) } + spec.cwd || spec.env || signal + ? { + ...(spec.cwd ? { cwd: spec.cwd } : {}), + ...(spec.env ? { env: spec.env } : {}), + ...(signal ? { signal } : {}), + } : undefined, ); return result.ok ? extractSemverFromVersionOutput(result.stdout) : undefined; @@ -593,14 +604,16 @@ export async function readAgentCommandOutput( wslLinuxCwd?: string; posixCwd?: string; env?: Record; + signal?: AbortSignal; }, ): Promise<{ ok: boolean; stdout: string; stderr: string }> { if (location.kind === "wsl") { const wslOptions = - options?.timeoutMs !== undefined || options?.env + options?.timeoutMs !== undefined || options?.env || options?.signal ? { ...(options?.timeoutMs !== undefined ? { timeout: options.timeoutMs } : {}), ...(options?.env ? { env: options.env } : {}), + ...(options?.signal ? { signal: options.signal } : {}), } : undefined; return readWslLoginShellCommandOutputAsync( @@ -617,6 +630,7 @@ export async function readAgentCommandOutput( ...(effectiveCwd ? { cwd: effectiveCwd } : {}), ...(spec.env ? { env: spec.env } : {}), ...(options?.timeoutMs ? { timeout: options.timeoutMs } : {}), + ...(options?.signal ? { signal: options.signal } : {}), }; return readCommandOutputAsync( spec.command, @@ -705,8 +719,10 @@ export async function detectAgentInstall( ctx: AgentEnvContext | undefined, spec: DetectionSpec, ): Promise { + ctx?.signal?.throwIfAborted(); const location = detectProbeLocation(ctx); const executablePath = await resolveDetectedBinary(ctx, spec); + ctx?.signal?.throwIfAborted(); const versionArgs = spec.versionArgs ?? ["--version"]; const version = spec.versionProbe @@ -715,8 +731,9 @@ export async function detectAgentInstall( executablePath, ...(ctx?.agentSettings ? { agentSettings: ctx.agentSettings } : {}), ...(spec.probeEnv ? { probeEnv: spec.probeEnv } : {}), + ...(ctx?.signal ? { signal: ctx.signal } : {}), }) - : await readDetectedVersion(location, executablePath, versionArgs, spec.probeEnv); + : await readDetectedVersion(location, executablePath, versionArgs, spec.probeEnv, ctx?.signal); let capabilities = spec.capabilities; let statusProbeResult: StatusProbeResult | undefined; @@ -732,11 +749,13 @@ export async function detectAgentInstall( version, ...(ctx?.agentSettings ? { agentSettings: ctx.agentSettings } : {}), probeEnv: spec.probeEnv, + ...(ctx?.signal ? { signal: ctx.signal } : {}), }; const [capabilityPartial, nextStatusProbeResult] = await Promise.all([ spec.capabilitiesProbe ? spec.capabilitiesProbe(probeCtx) : Promise.resolve(undefined), spec.statusProbe ? spec.statusProbe(probeCtx) : Promise.resolve(undefined), ]); + ctx?.signal?.throwIfAborted(); if (capabilityPartial) { const { authMethods: probeAuthMethods, @@ -771,6 +790,7 @@ export async function detectAgentInstall( executablePath, version, ...(ctx?.agentSettings ? { agentSettings: ctx.agentSettings } : {}), + ...(ctx?.signal ? { signal: ctx.signal } : {}), }; let authState: AuthState; @@ -787,6 +807,7 @@ export async function detectAgentInstall( authState = statusProbeResult?.authState ?? "unknown"; if (authState !== "authenticated") { for (const probe of spec.authProbes ?? []) { + ctx?.signal?.throwIfAborted(); const result = await probe(probeCtx); if (result === "authenticated") { authState = "authenticated"; diff --git a/src/supervisor/agents/base/processRuntime.env.test.ts b/src/supervisor/agents/base/processRuntime.env.test.ts index 6adc8a402..fd93e016f 100644 --- a/src/supervisor/agents/base/processRuntime.env.test.ts +++ b/src/supervisor/agents/base/processRuntime.env.test.ts @@ -1,5 +1,15 @@ import { describe, expect, it } from "vitest"; -import { parsePrimedEnvDump } from "./processRuntime"; +import { terminateProcessTree } from "@/shared/processTree"; +import { parsePrimedEnvDump, readCommandOutputAsync } from "./processRuntime"; + +function isProcessRunning(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} describe("parsePrimedEnvDump", () => { it("parses simple NAME=value lines", () => { @@ -27,3 +37,49 @@ describe("parsePrimedEnvDump", () => { expect(parsePrimedEnvDump(["", ""])).toEqual({}); }); }); + +describe("readCommandOutputAsync", () => { + it("kills a successful POSIX command's remaining process group", async () => { + if (process.platform === "win32") return; + + const script = [ + 'const { spawn } = require("node:child_process");', + 'const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" });', + "child.unref();", + "process.stdout.write(String(child.pid));", + ].join("\n"); + const result = await readCommandOutputAsync(process.execPath, ["-e", script]); + const descendantPid = Number(result.stdout); + + try { + expect(result.ok).toBe(true); + expect(Number.isInteger(descendantPid)).toBe(true); + await expect.poll(() => isProcessRunning(descendantPid), { timeout: 3_000 }).toBe(false); + } finally { + terminateProcessTree(descendantPid); + } + }, 10_000); + + it("kills a timed-out Windows command's descendant process", async () => { + if (process.platform !== "win32") return; + + const script = [ + 'const { spawn } = require("node:child_process");', + 'const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore", windowsHide: true });', + "process.stdout.write(String(child.pid));", + "setInterval(() => {}, 1000);", + ].join("\n"); + const result = await readCommandOutputAsync(process.execPath, ["-e", script], { + timeout: 500, + }); + const descendantPid = Number(result.stdout); + + try { + expect(result.ok).toBe(false); + expect(Number.isInteger(descendantPid)).toBe(true); + await expect.poll(() => isProcessRunning(descendantPid), { timeout: 3_000 }).toBe(false); + } finally { + terminateProcessTree(descendantPid); + } + }, 10_000); +}); diff --git a/src/supervisor/agents/base/processRuntime.ts b/src/supervisor/agents/base/processRuntime.ts index 4523844b0..ff08fc40d 100644 --- a/src/supervisor/agents/base/processRuntime.ts +++ b/src/supervisor/agents/base/processRuntime.ts @@ -1,8 +1,9 @@ -import { execFile, spawnSync } from "node:child_process"; +import { execFile, spawn, spawnSync } from "node:child_process"; import { existsSync, readFileSync, statSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { promisify } from "node:util"; +import { terminateChildProcessTree } from "@/shared/processTree"; import { getPosixLoginShellArgs, getWindowsSystemCommand, @@ -16,6 +17,7 @@ const execFileAsync = promisify(execFile); /** Default exec timeout for agent CLI probes and commands without an explicit `timeout`. */ export const DEFAULT_COMMAND_OUTPUT_TIMEOUT_MS = 30_000; +const DEFAULT_COMMAND_OUTPUT_MAX_BUFFER = 1024 * 1024; let cachedWindowsSearchPath: string | undefined | null = null; @@ -824,30 +826,74 @@ export async function resolveExecutablePathAsync(command: string): Promise; timeout?: number }, + options?: { + cwd?: string; + env?: Record; + timeout?: number; + signal?: AbortSignal; + }, ): Promise<{ ok: boolean; stdout: string; stderr: string }> { - try { - const { stdout, stderr } = await execFileAsync(command, args, { + if (options?.signal?.aborted) { + return { ok: false, stdout: "", stderr: "" }; + } + + return new Promise((resolve) => { + let stdout = ""; + let stderr = ""; + let settled = false; + const ownedProcessGroup = process.platform !== "win32"; + const child = spawn(command, args, { windowsHide: true, - timeout: options?.timeout ?? DEFAULT_COMMAND_OUTPUT_TIMEOUT_MS, + stdio: ["ignore", "pipe", "pipe"], + shell: false, + detached: ownedProcessGroup, ...(options?.cwd ? { cwd: options.cwd } : {}), ...(options?.env ? { env: { ...process.env, ...options.env } } : {}), }); - return { ok: true, stdout: (stdout ?? "").trim(), stderr: (stderr ?? "").trim() }; - } catch (error: unknown) { - const err = error as { stdout?: string; stderr?: string } | undefined; - return { - ok: false, - stdout: (err?.stdout ?? "").trim(), - stderr: (err?.stderr ?? "").trim(), + + const finish = (ok: boolean) => { + if (settled) return; + settled = true; + clearTimeout(timer); + options?.signal?.removeEventListener("abort", stop); + terminateChildProcessTree(child, { ownedProcessGroup }); + resolve({ ok, stdout: stdout.trim(), stderr: stderr.trim() }); }; - } + const stop = () => { + finish(false); + }; + const timer = setTimeout(stop, options?.timeout ?? DEFAULT_COMMAND_OUTPUT_TIMEOUT_MS); + if (typeof timer.unref === "function") timer.unref(); + + child.stdout?.setEncoding("utf8"); + child.stderr?.setEncoding("utf8"); + child.stdout?.on("data", (chunk: string) => { + if (stdout.length + chunk.length > DEFAULT_COMMAND_OUTPUT_MAX_BUFFER) { + stop(); + return; + } + stdout += chunk; + }); + child.stderr?.on("data", (chunk: string) => { + if (stderr.length + chunk.length > DEFAULT_COMMAND_OUTPUT_MAX_BUFFER) { + stop(); + return; + } + stderr += chunk; + }); + child.once("error", () => finish(false)); + child.once("close", (code) => finish(code === 0)); + options?.signal?.addEventListener("abort", stop, { once: true }); + if (options?.signal?.aborted) stop(); + }); } export async function batchWslCommandsAsync( distro: string, commands: string[], + signal?: AbortSignal, ): Promise<{ ok: boolean; stdout: string }[]> { + signal?.throwIfAborted(); if (!wslProcessBridgeClient) return bridgeBatchFallback(commands.length); try { const result = await wslProcessBridgeClient.processBatch(makeWslBridgeLocation(distro), { @@ -859,11 +905,13 @@ export async function batchWslCommandsAsync( loginEnv: true, })), }); + signal?.throwIfAborted(); return result.results.map((entry) => ({ ok: entry.ok, stdout: entry.stdout.trim(), })); } catch { + signal?.throwIfAborted(); return bridgeBatchFallback(commands.length); } } @@ -925,8 +973,14 @@ export async function readWslLoginShellCommandOutputAsync( linuxCwd: string, command: string, args: string[], - options?: { timeout?: number; maxBuffer?: number; env?: Record }, + options?: { + timeout?: number; + maxBuffer?: number; + env?: Record; + signal?: AbortSignal; + }, ): Promise<{ ok: boolean; stdout: string; stderr: string }> { + options?.signal?.throwIfAborted(); if (!wslProcessBridgeClient) return { ok: false, stdout: "", stderr: "" }; try { const result = await wslProcessBridgeClient.processExec( @@ -940,8 +994,10 @@ export async function readWslLoginShellCommandOutputAsync( ...(options?.env ? { env: options.env } : {}), }, ); + options?.signal?.throwIfAborted(); return bridgeProcessOutput(result); } catch { + options?.signal?.throwIfAborted(); return { ok: false, stdout: "", stderr: "" }; } } diff --git a/src/supervisor/agents/base/readAgentCommandOutput.test.ts b/src/supervisor/agents/base/readAgentCommandOutput.test.ts index 71d8d7336..e6884db1a 100644 --- a/src/supervisor/agents/base/readAgentCommandOutput.test.ts +++ b/src/supervisor/agents/base/readAgentCommandOutput.test.ts @@ -6,7 +6,12 @@ const readCommandOutputAsyncMock = vi.hoisted(() => ( command: string, args: string[], - options?: { cwd?: string; env?: Record; timeout?: number }, + options?: { + cwd?: string; + env?: Record; + timeout?: number; + signal?: AbortSignal; + }, ) => Promise<{ ok: boolean; stdout: string; stderr: string }> >(), ); @@ -47,4 +52,13 @@ describe("readAgentCommandOutput", () => { expect(readCommandOutputAsyncMock).toHaveBeenCalledOnce(); expect(readCommandOutputAsyncMock.mock.calls[0]?.[2]?.timeout).toBeUndefined(); }); + + it("forwards cancellation to native readCommandOutputAsync", async () => { + const abort = new AbortController(); + await readAgentCommandOutput(WINDOWS_LOCATION, "cursor-agent", ["--version"], { + signal: abort.signal, + }); + + expect(readCommandOutputAsyncMock.mock.calls[0]?.[2]?.signal).toBe(abort.signal); + }); }); diff --git a/src/supervisor/agents/base/types.ts b/src/supervisor/agents/base/types.ts index 397c064ed..6e455f963 100644 --- a/src/supervisor/agents/base/types.ts +++ b/src/supervisor/agents/base/types.ts @@ -40,6 +40,8 @@ export interface CommandSpec { export interface AgentEnvContext { envKind: "windows" | "wsl" | "posix"; wslDistro?: string; + /** Cancels short-lived install, version, status, and capability probes. */ + signal?: AbortSignal; /** * Provider-global settings for this adapter. Detection receives the same * snapshot launch receives, allowing a provider with multiple structured @@ -258,6 +260,7 @@ export interface DetectProbeCtx { location: ProjectLocation; executablePath: string | undefined; version?: string | undefined; + signal?: AbortSignal; agentSettings?: Record; /** {@link DetectionSpec.probeEnv}, so `capabilitiesProbe`/`statusProbe` can forward it. */ probeEnv?: Record | undefined; diff --git a/src/supervisor/agents/claude/detection.ts b/src/supervisor/agents/claude/detection.ts index 98fb90214..bc8950462 100644 --- a/src/supervisor/agents/claude/detection.ts +++ b/src/supervisor/agents/claude/detection.ts @@ -174,6 +174,7 @@ export async function probeClaudeStatus( { posixCwd: getAgentProbeCwd(ctx.location), ...(options?.env ? { env: options.env } : {}), + ...(ctx.signal ? { signal: ctx.signal } : {}), }, ); const parsed = parseClaudeAuthStatusJson(result.stdout || result.stderr); diff --git a/src/supervisor/agents/claude/probe.test.ts b/src/supervisor/agents/claude/probe.test.ts index 736c0d19e..60dbf993e 100644 --- a/src/supervisor/agents/claude/probe.test.ts +++ b/src/supervisor/agents/claude/probe.test.ts @@ -19,6 +19,10 @@ const mockChildProcess = vi.hoisted(() => ({ vi.fn<(command: string, args: string[], options: Record) => SpawnedProcess>(), })); +const mockProcessTree = vi.hoisted(() => ({ + terminateChildProcessTree: vi.fn<(child: unknown) => void>(), +})); + vi.mock("@anthropic-ai/claude-agent-sdk", () => ({ query: mockSdk.query, })); @@ -31,6 +35,8 @@ vi.mock("node:child_process", async () => { }; }); +vi.mock("@/shared/processTree", () => mockProcessTree); + import { claudeCapabilitiesFromCliVersion, probeClaudeCapabilities, @@ -101,6 +107,7 @@ beforeEach(() => { mockSdk.query.mockReset(); mockChildProcess.spawn.mockReset(); mockChildProcess.spawn.mockImplementation(() => makeSpawnedProcess()); + mockProcessTree.terminateChildProcessTree.mockReset(); }); afterEach(() => { @@ -285,6 +292,57 @@ describe("Claude SDK probe process handling", () => { expect(() => child.stdin.emit("error", ebadfError())).toThrow("write EBADF"); }); + it("tree-kills the SDK probe child when its abort signal fires", () => { + const abort = new AbortController(); + const child = makeSpawnedProcess(); + mockChildProcess.spawn.mockReturnValueOnce(child); + + spawnClaudeProbeProcess({ + command: "claude", + args: ["--sdk-mcp-server"], + cwd: "/tmp", + env: {}, + signal: abort.signal, + }); + abort.abort(); + + expect(mockProcessTree.terminateChildProcessTree).toHaveBeenCalledExactlyOnceWith(child, { + ownedProcessGroup: process.platform !== "win32", + }); + expect(mockChildProcess.spawn.mock.calls[0]?.[2]).not.toHaveProperty("signal"); + expect(mockChildProcess.spawn.mock.calls[0]?.[2]).toEqual( + expect.objectContaining({ detached: process.platform !== "win32" }), + ); + }); + + it("tree-kills the SDK probe process group when the direct child closes", () => { + const abort = new AbortController(); + let closeListener: (() => void) | undefined; + const child = { + ...makeSpawnedProcess(), + once(event: string, listener: () => void) { + if (event === "close") closeListener = listener; + return this; + }, + } as unknown as SpawnedProcess; + mockChildProcess.spawn.mockReturnValueOnce(child); + + spawnClaudeProbeProcess({ + command: "claude", + args: ["--sdk-mcp-server"], + cwd: "/tmp", + env: {}, + signal: abort.signal, + }); + closeListener?.(); + + expect(mockProcessTree.terminateChildProcessTree).toHaveBeenCalledExactlyOnceWith(child, { + ownedProcessGroup: process.platform !== "win32", + }); + abort.abort(); + expect(mockProcessTree.terminateChildProcessTree).toHaveBeenCalledTimes(1); + }); + it("wraps native Windows SDK .cmd shims instead of spawning them directly", () => { Object.defineProperty(process, "platform", { value: "win32", configurable: true }); const dir = mkdtempSync(join(tmpdir(), "poracode-claude-probe-shim-")); diff --git a/src/supervisor/agents/claude/probe.ts b/src/supervisor/agents/claude/probe.ts index 7fa1da955..eed496eed 100644 --- a/src/supervisor/agents/claude/probe.ts +++ b/src/supervisor/agents/claude/probe.ts @@ -73,10 +73,14 @@ async function probeClaudeSdkPartialNative( executablePath: string, timeoutMs: number, envOverrides?: Record, + signal?: AbortSignal, ): Promise | undefined> { + if (signal?.aborted) return undefined; try { const { query } = await import("@anthropic-ai/claude-agent-sdk"); const abort = new AbortController(); + const abortFromParent = () => abort.abort(); + signal?.addEventListener("abort", abortFromParent, { once: true }); const timer = setTimeout(() => abort.abort(), timeoutMs); const queue = new AsyncPromptQueue(); try { @@ -120,6 +124,8 @@ async function probeClaudeSdkPartialNative( }; } finally { clearTimeout(timer); + signal?.removeEventListener("abort", abortFromParent); + abort.abort(); } } catch (error) { console.log( @@ -155,6 +161,7 @@ async function probeClaudeSdkPartialWsl( { timeout: timeoutMs + 3000, ...(envOverrides ? { env: envOverrides } : {}), + ...(ctx.signal ? { signal: ctx.signal } : {}), }, ); @@ -209,7 +216,7 @@ export async function probeClaudeCapabilities( const sdkPartial = ctx.location.kind === "wsl" ? await probeClaudeSdkPartialWsl(ctx, timeoutMs, options?.env) - : await probeClaudeSdkPartialNative(ctx.executablePath, timeoutMs, options?.env); + : await probeClaudeSdkPartialNative(ctx.executablePath, timeoutMs, options?.env, ctx.signal); const versionPartial = claudeCapabilitiesFromCliVersion(ctx.version); diff --git a/src/supervisor/agents/claude/sdkProbeProcess.ts b/src/supervisor/agents/claude/sdkProbeProcess.ts index 809421934..1233436d1 100644 --- a/src/supervisor/agents/claude/sdkProbeProcess.ts +++ b/src/supervisor/agents/claude/sdkProbeProcess.ts @@ -1,5 +1,6 @@ import { spawn } from "node:child_process"; import type { SpawnOptions, SpawnedProcess } from "@anthropic-ai/claude-agent-sdk"; +import { terminateChildProcessTree } from "@/shared/processTree"; import { buildAgentCommand, definedEnv } from "../base"; function isEpipeError(error: Error): boolean { @@ -33,19 +34,28 @@ export function spawnClaudeProbeProcess(options: SpawnOptions): SpawnedProcess { env = spec.env; cwd = spec.cwd; } + const ownedProcessGroup = process.platform !== "win32"; const child = spawn(command, args, { ...(env ? { env } : {}), - signal: options.signal, stdio: ["pipe", "pipe", "pipe"], windowsHide: true, + detached: ownedProcessGroup, ...(cwd ? { cwd } : {}), - }) as unknown as SpawnedProcess; + }); child.stdin.on("error", (error: Error) => { if (isEpipeError(error)) return; throw error; }); - return child; + const abort = () => terminateChildProcessTree(child, { ownedProcessGroup }); + options.signal.addEventListener("abort", abort, { once: true }); + child.once("close", () => { + options.signal.removeEventListener("abort", abort); + terminateChildProcessTree(child, { ownedProcessGroup }); + }); + if (options.signal.aborted) abort(); + + return child as unknown as SpawnedProcess; } diff --git a/src/supervisor/agents/codex/detection.ts b/src/supervisor/agents/codex/detection.ts index 947bd6285..c87932024 100644 --- a/src/supervisor/agents/codex/detection.ts +++ b/src/supervisor/agents/codex/detection.ts @@ -301,6 +301,7 @@ async function probeCodexStatus(ctx: Parameters( const tag = options?.label ? `[codex-probe:${options.label}]` : "[codex-probe]"; let appServer: ChildProcess | undefined; let client: ProbeClient | undefined; + let timeout: NodeJS.Timeout | undefined; + let abortProbe: (() => void) | undefined; + const ownedProcessGroup = process.platform !== "win32"; + + if (options?.signal?.aborted) return undefined; try { const wslNodePath = @@ -460,6 +466,7 @@ async function runWithCodexAppServer( stdio: ["pipe", "pipe", "pipe"], shell: false, windowsHide: true, + detached: ownedProcessGroup, }); const transport = new CodexStdioTransport(appServer); @@ -476,6 +483,20 @@ async function runWithCodexAppServer( return undefined; } + const stop = () => { + if (appServer) terminateChildProcessTree(appServer, { ownedProcessGroup }); + }; + const signal = options?.signal; + const abortPromise = signal + ? new Promise((_, reject) => { + abortProbe = () => { + stop(); + reject(new Error("Codex probe aborted")); + }; + signal.addEventListener("abort", abortProbe, { once: true }); + if (signal.aborted) abortProbe(); + }) + : undefined; return await Promise.race([ (async () => { client = new ProbeClient(transport); @@ -486,17 +507,24 @@ async function runWithCodexAppServer( client.notify("initialized"); return await fn({ client, initResult }); })(), - new Promise((_, reject) => - setTimeout(() => reject(new Error("Codex probe timed out")), timeoutMs), - ), + new Promise((_, reject) => { + timeout = setTimeout(() => { + stop(); + reject(new Error("Codex probe timed out")); + }, timeoutMs); + if (typeof timeout.unref === "function") timeout.unref(); + }), + ...(abortPromise ? [abortPromise] : []), ]); } catch (err) { console.log("%s failed: %s", tag, err instanceof Error ? err.message : err); return undefined; } finally { + if (timeout) clearTimeout(timeout); + if (abortProbe) options?.signal?.removeEventListener("abort", abortProbe); client?.dispose(); if (appServer && !appServer.killed) { - terminateChildProcessTree(appServer); + terminateChildProcessTree(appServer, { ownedProcessGroup }); } } } diff --git a/src/supervisor/agents/commandcode/detection.ts b/src/supervisor/agents/commandcode/detection.ts index 43da321cd..cdf19e534 100644 --- a/src/supervisor/agents/commandcode/detection.ts +++ b/src/supervisor/agents/commandcode/detection.ts @@ -434,6 +434,7 @@ export const commandCodeDetectionSpec: DetectionSpec = { posixCwd: getAgentProbeCwd(ctx.location), // Suppress the CLI's background self-updater (sourced from spec.probeEnv). ...(ctx.probeEnv ? { env: ctx.probeEnv } : {}), + ...(ctx.signal ? { signal: ctx.signal } : {}), }, ).catch((error) => { console.warn("[commandcode] model list probe failed:", error); diff --git a/src/supervisor/agents/copilot/detection.ts b/src/supervisor/agents/copilot/detection.ts index 8f3a86415..60bbef252 100644 --- a/src/supervisor/agents/copilot/detection.ts +++ b/src/supervisor/agents/copilot/detection.ts @@ -45,6 +45,8 @@ export const copilotDefaultCapabilities: AgentCapability = { settingDefs: [], }; +const COPILOT_MODEL_EFFORT_PROBE_TIMEOUT_MS = 15_000; + export function buildCopilotCommand( location: ProjectLocation, args: string[], @@ -60,14 +62,18 @@ export function buildCopilotCommand( */ const ghAuthProbe: AuthProbe = async (ctx) => { if (ctx.location.kind === "wsl") { - const [result] = await batchWslCommandsAsync(ctx.location.distro, [ - "command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1 && echo yes", - ]); + const [result] = await batchWslCommandsAsync( + ctx.location.distro, + ["command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1 && echo yes"], + ctx.signal, + ); return result?.ok && result.stdout.trim() === "yes" ? "authenticated" : "unknown"; } const ghPath = await resolveExecutablePathAsync("gh"); if (!ghPath) return "unknown"; - const result = await readCommandOutputAsync(ghPath, ["auth", "status"]); + const result = await readCommandOutputAsync(ghPath, ["auth", "status"], { + ...(ctx.signal ? { signal: ctx.signal } : {}), + }); return result.ok ? "authenticated" : "unknown"; }; @@ -75,15 +81,19 @@ async function probeCopilotModelEfforts( location: ProjectLocation, executablePath: string | undefined, models: { id: string }[], + signal?: AbortSignal, ): Promise<{ defaultEffort?: string; modelEfforts?: Record }> { + if (signal?.aborted) return {}; const spec = buildCopilotCommand(location, ["--acp", "--stdio"], executablePath); const sessionCwd = getAgentProbeCwd(location); const spawnCwd = resolveProbeSpawnCwd(location, spec.cwd); + const ownedProcessGroup = process.platform !== "win32"; const child = spawn(spec.command, spec.args, { ...(spawnCwd ? { cwd: spawnCwd } : {}), stdio: ["pipe", "pipe", "pipe"], shell: false, windowsHide: true, + detached: ownedProcessGroup, }); child.on("error", (err) => { console.log("[copilot-probe] spawn error:", err.message); @@ -105,124 +115,162 @@ async function probeCopilotModelEfforts( stream, ); + let timeout: NodeJS.Timeout | undefined; + let abortProbe: (() => void) | undefined; + const stop = () => { + try { + child.stdin?.destroy(); + } catch { + // Ignore cleanup races. + } + terminateChildProcessTree(child, { ownedProcessGroup }); + }; + try { - await connection.initialize({ - protocolVersion: PROTOCOL_VERSION, - clientInfo: { name: "poracode-probe", version: "0.1.0" }, - clientCapabilities: {}, - }); - const session = await connection.newSession({ cwd: sessionCwd, mcpServers: [] }); + const runProbe = async () => { + await connection.initialize({ + protocolVersion: PROTOCOL_VERSION, + clientInfo: { name: "poracode-probe", version: "0.1.0" }, + clientCapabilities: {}, + }); + const session = await connection.newSession({ cwd: sessionCwd, mcpServers: [] }); - const baseUpdate = session.configOptions - ? { sessionUpdate: "config_option_update", configOptions: session.configOptions } - : undefined; + const baseUpdate = session.configOptions + ? { sessionUpdate: "config_option_update", configOptions: session.configOptions } + : undefined; - function extractThoughtLevelConfig(update: unknown): - | { - currentValue?: string; - options: string[]; - } - | undefined { - if (!update || typeof update !== "object" || !("configOptions" in update)) { - return undefined; - } - const configOptions = (update as { configOptions?: unknown }).configOptions; - if (!Array.isArray(configOptions)) { - return undefined; - } - const thoughtLevel = configOptions.find((candidate) => { - if (typeof candidate !== "object" || candidate === null) { - return false; - } - const option = candidate as { - category?: string; - currentValue?: string; - options?: unknown; - }; - return option.category === "thought_level"; - }) as + function extractThoughtLevelConfig(update: unknown): | { currentValue?: string; - options?: Array<{ value?: string }> | Array<{ options?: Array<{ value?: string }> }>; + options: string[]; } - | undefined; - if (!thoughtLevel) { - return undefined; - } - const flattened = (Array.isArray(thoughtLevel.options) ? thoughtLevel.options : []).flatMap( - (entry) => { - if (typeof entry !== "object" || entry === null) { - return []; - } - if ("value" in entry) { - return [entry as { value?: string }]; - } - if ("options" in entry && Array.isArray((entry as { options?: unknown }).options)) { - return (entry as { options: Array<{ value?: string }> }).options; + | undefined { + if (!update || typeof update !== "object" || !("configOptions" in update)) { + return undefined; + } + const configOptions = (update as { configOptions?: unknown }).configOptions; + if (!Array.isArray(configOptions)) { + return undefined; + } + const thoughtLevel = configOptions.find((candidate) => { + if (typeof candidate !== "object" || candidate === null) { + return false; } - return []; - }, - ); - const options = flattened - .map((entry) => entry.value) - .filter((value): value is string => typeof value === "string" && value.length > 0); - return { - options, - ...(thoughtLevel.currentValue ? { currentValue: thoughtLevel.currentValue } : {}), - }; - } + const option = candidate as { + category?: string; + currentValue?: string; + options?: unknown; + }; + return option.category === "thought_level"; + }) as + | { + currentValue?: string; + options?: Array<{ value?: string }> | Array<{ options?: Array<{ value?: string }> }>; + } + | undefined; + if (!thoughtLevel) { + return undefined; + } + const flattened = (Array.isArray(thoughtLevel.options) ? thoughtLevel.options : []).flatMap( + (entry) => { + if (typeof entry !== "object" || entry === null) { + return []; + } + if ("value" in entry) { + return [entry as { value?: string }]; + } + if ("options" in entry && Array.isArray((entry as { options?: unknown }).options)) { + return (entry as { options: Array<{ value?: string }> }).options; + } + return []; + }, + ); + const options = flattened + .map((entry) => entry.value) + .filter((value): value is string => typeof value === "string" && value.length > 0); + return { + options, + ...(thoughtLevel.currentValue ? { currentValue: thoughtLevel.currentValue } : {}), + }; + } - const initialThoughtLevel = baseUpdate ? extractThoughtLevelConfig(baseUpdate) : undefined; - const modelEfforts: Record = {}; - const defaultEffort = initialThoughtLevel?.currentValue; + const initialThoughtLevel = baseUpdate ? extractThoughtLevelConfig(baseUpdate) : undefined; + const modelEfforts: Record = {}; + const defaultEffort = initialThoughtLevel?.currentValue; - // Unstable pre-1.0 model state (see unstableModelCompat.ts) — the SDK no - // longer types the `models` field on the session response. - const sessionModels = readUnstableSessionModels(session); - if (sessionModels?.currentModelId && initialThoughtLevel?.options.length) { - modelEfforts[sessionModels.currentModelId] = initialThoughtLevel.options; - } + // Unstable pre-1.0 model state (see unstableModelCompat.ts) — the SDK no + // longer types the `models` field on the session response. + const sessionModels = readUnstableSessionModels(session); + if (sessionModels?.currentModelId && initialThoughtLevel?.options.length) { + modelEfforts[sessionModels.currentModelId] = initialThoughtLevel.options; + } - for (const model of models) { - try { - updates.length = 0; - await setUnstableSessionModel(connection, { - sessionId: session.sessionId, - modelId: model.id, - }); - await new Promise((resolve) => setTimeout(resolve, 300)); - const update = updates - .filter( - (entry) => - typeof entry === "object" && - entry !== null && - "sessionUpdate" in entry && - (entry as { sessionUpdate?: string }).sessionUpdate === "config_option_update", - ) - .at(-1); - const thoughtLevel = extractThoughtLevelConfig(update); - if (!thoughtLevel || thoughtLevel.options.length === 0) { - continue; + for (const model of models) { + try { + updates.length = 0; + await setUnstableSessionModel(connection, { + sessionId: session.sessionId, + modelId: model.id, + }); + await new Promise((resolve) => setTimeout(resolve, 300)); + const update = updates + .filter( + (entry) => + typeof entry === "object" && + entry !== null && + "sessionUpdate" in entry && + (entry as { sessionUpdate?: string }).sessionUpdate === "config_option_update", + ) + .at(-1); + const thoughtLevel = extractThoughtLevelConfig(update); + if (!thoughtLevel || thoughtLevel.options.length === 0) { + continue; + } + modelEfforts[model.id] = thoughtLevel.options; + } catch (err) { + console.log( + `[copilot-probe] model effort probe failed at ${model.id}:`, + err instanceof Error ? err.message : err, + ); + break; } - modelEfforts[model.id] = thoughtLevel.options; - } catch (err) { - console.log( - `[copilot-probe] model effort probe failed at ${model.id}:`, - err instanceof Error ? err.message : err, - ); - break; } - } - return { - ...(defaultEffort ? { defaultEffort } : {}), - ...(Object.keys(modelEfforts).length > 0 ? { modelEfforts } : {}), + return { + ...(defaultEffort ? { defaultEffort } : {}), + ...(Object.keys(modelEfforts).length > 0 ? { modelEfforts } : {}), + }; }; + + const timeoutPromise = new Promise((_, reject) => { + timeout = setTimeout(() => { + stop(); + reject(new Error("Copilot model-effort probe timed out")); + }, COPILOT_MODEL_EFFORT_PROBE_TIMEOUT_MS); + if (typeof timeout.unref === "function") timeout.unref(); + }); + const abortPromise = signal + ? new Promise((_, reject) => { + abortProbe = () => { + stop(); + reject(new Error("Copilot model-effort probe aborted")); + }; + signal.addEventListener("abort", abortProbe, { once: true }); + if (signal.aborted) abortProbe(); + }) + : undefined; + return await Promise.race([ + runProbe(), + timeoutPromise, + ...(abortPromise ? [abortPromise] : []), + ]); } catch { return {}; } finally { + if (timeout) clearTimeout(timeout); + if (abortProbe) signal?.removeEventListener("abort", abortProbe); try { - terminateChildProcessTree(child); + stop(); } catch { // Ignore cleanup races. } @@ -249,6 +297,7 @@ function withCopilotModelRates( async function probeCapabilities( location: ProjectLocation, executablePath?: string, + signal?: AbortSignal, ): Promise { const spec = buildCopilotCommand(location, ["--acp", "--stdio"], executablePath); const sessionCwd = getAgentProbeCwd(location); @@ -257,11 +306,12 @@ async function probeCapabilities( ...(processCwd ? { processCwd } : {}), timeoutMs: 15_000, label: location.kind === "wsl" ? `copilot:wsl:${location.distro}` : `copilot:${location.kind}`, + ...(signal ? { signal } : {}), }); const modelEffortProbe = probe?.models?.length && executablePath !== undefined - ? await probeCopilotModelEfforts(location, executablePath, probe.models) + ? await probeCopilotModelEfforts(location, executablePath, probe.models, signal) : {}; // Merge probe approval policies with defaults (probe labels take precedence, @@ -310,6 +360,6 @@ export const copilotDetectionSpec: DetectionSpec = { authProbes: [envVarAuthProbe(["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"]), ghAuthProbe], async capabilitiesProbe(ctx) { if (!ctx.executablePath) return undefined; - return probeCapabilities(ctx.location, ctx.executablePath); + return probeCapabilities(ctx.location, ctx.executablePath, ctx.signal); }, }; diff --git a/src/supervisor/agents/cursor/detection.ts b/src/supervisor/agents/cursor/detection.ts index a8d922be9..6f53b7fbe 100644 --- a/src/supervisor/agents/cursor/detection.ts +++ b/src/supervisor/agents/cursor/detection.ts @@ -95,11 +95,13 @@ export function buildCursorProbeSpec( async function readCursorProbeOutputAsync( executablePath: string, args: string[], + signal?: AbortSignal, ): Promise<{ ok: boolean; stdout: string; stderr: string }> { const spec = buildCursorProbeSpec(executablePath, args); return readCommandOutputAsync(spec.command, spec.args, { ...(spec.cwd ? { cwd: spec.cwd } : {}), ...(spec.env ? { env: spec.env } : {}), + ...(signal ? { signal } : {}), }); } @@ -124,6 +126,7 @@ async function probeCursorLogoutSupport( timeoutMs: 5_000, wslLinuxCwd: "/tmp", posixCwd: probeCwd, + ...(ctx.signal ? { signal: ctx.signal } : {}), }, ); return parseCursorLogoutHelpOutput(`${result.stdout}\n${result.stderr}`); @@ -405,6 +408,7 @@ async function probeCursorAcpCapabilities( const result = await probeAcpCapabilities(spec.command, spec.args, probeCwd, { ...(processCwd ? { processCwd } : {}), timeoutMs: 15_000, + ...(ctx.signal ? { signal: ctx.signal } : {}), label: ctx.location.kind === "wsl" ? `cursor-acp:wsl:${ctx.location.distro}` @@ -632,9 +636,11 @@ async function probeCursorStatus(ctx: Parameters { diff --git a/src/supervisor/agents/cursor/sdkDetection.test.ts b/src/supervisor/agents/cursor/sdkDetection.test.ts index 7622c0f1d..34535d429 100644 --- a/src/supervisor/agents/cursor/sdkDetection.test.ts +++ b/src/supervisor/agents/cursor/sdkDetection.test.ts @@ -223,6 +223,32 @@ describe("probeCursorSdkRuntime", () => { diagnosticMessage: "catalog network unavailable", }); }); + + it("terminates a pending worker probe when detection is cancelled", async () => { + const abort = new AbortController(); + let rejectProbe: ((error: Error) => void) | undefined; + const handle = { + probe: vi.fn<() => Promise>( + () => + new Promise((_resolve, reject) => { + rejectProbe = reject; + }), + ), + terminate: vi.fn<() => void>(() => rejectProbe?.(new Error("worker terminated"))), + dispose: vi.fn<() => Promise>().mockResolvedValue(undefined), + }; + const pending = probeCursorSdkRuntime( + { envKind: "wsl", wslDistro: "Ubuntu", signal: abort.signal }, + { spawnWorker: async () => handle }, + ); + await vi.waitFor(() => expect(handle.probe).toHaveBeenCalledOnce()); + + abort.abort(); + await pending; + + expect(handle.terminate).toHaveBeenCalledOnce(); + expect(handle.dispose).toHaveBeenCalledOnce(); + }); }); const cliStatus: AgentStatus = { diff --git a/src/supervisor/agents/cursor/sdkDetection.ts b/src/supervisor/agents/cursor/sdkDetection.ts index a9fdcd0b1..4217e0d6f 100644 --- a/src/supervisor/agents/cursor/sdkDetection.ts +++ b/src/supervisor/agents/cursor/sdkDetection.ts @@ -46,7 +46,10 @@ export interface CursorSdkRuntimeProbe { export interface CursorSdkDetectionDependencies { spawnWorker?( options: CursorSdkWorkerSpawnOptions, - ): Promise>; + ): Promise< + Pick & + Partial> + >; } /** @@ -233,11 +236,18 @@ export async function probeCursorSdkRuntime( const projectLocation = detectProbeLocation(ctx); const configuredApiKey = typeof ctx?.agentSettings?.sdkApiKey === "string" ? ctx.agentSettings.sdkApiKey.trim() : ""; - let worker: Pick | undefined; + let worker: + | (Pick & + Partial>) + | undefined; + const abortProbe = () => worker?.terminate?.(); + ctx?.signal?.addEventListener("abort", abortProbe, { once: true }); try { + ctx?.signal?.throwIfAborted(); worker = await (dependencies.spawnWorker ?? spawnCursorSdkWorker)({ projectLocation, }); + ctx?.signal?.throwIfAborted(); const result = await worker.probe(configuredApiKey || undefined); return { installed: true, @@ -260,6 +270,7 @@ export async function probeCursorSdkRuntime( diagnosticMessage: cursorSdkProbeErrorMessage(error), }; } finally { + ctx?.signal?.removeEventListener("abort", abortProbe); await worker?.dispose().catch(() => undefined); } } diff --git a/src/supervisor/agents/cursor/windowsExecutable.ts b/src/supervisor/agents/cursor/windowsExecutable.ts index 8352cf0f7..519034893 100644 --- a/src/supervisor/agents/cursor/windowsExecutable.ts +++ b/src/supervisor/agents/cursor/windowsExecutable.ts @@ -138,6 +138,7 @@ export async function readCursorAgentCommandOutput( wslLinuxCwd?: string; posixCwd?: string; env?: Record; + signal?: AbortSignal; }, ): Promise<{ ok: boolean; stdout: string; stderr: string }> { if (location.kind !== "windows") { @@ -149,6 +150,7 @@ export async function readCursorAgentCommandOutput( ...(effectiveCwd ? { cwd: effectiveCwd } : {}), ...(spec.env || options?.env ? { env: { ...spec.env, ...options?.env } } : {}), ...(options?.timeoutMs ? { timeout: options.timeoutMs } : {}), + ...(options?.signal ? { signal: options.signal } : {}), }; return readCommandOutputAsync( spec.command, diff --git a/src/supervisor/agents/factory/detection.ts b/src/supervisor/agents/factory/detection.ts index 97792c498..96d2a2f49 100644 --- a/src/supervisor/agents/factory/detection.ts +++ b/src/supervisor/agents/factory/detection.ts @@ -86,6 +86,7 @@ export function buildFactoryProbeCapabilities(probe: AcpProbeResult): Capabiliti async function probeCapabilities( location: ProjectLocation, executablePath: string, + signal?: AbortSignal, ): Promise { const command = buildFactoryCommand(location, executablePath); const processCwd = resolveProbeSpawnCwd(location, command.cwd); @@ -97,6 +98,7 @@ async function probeCapabilities( ...(processCwd ? { processCwd } : {}), ...(command.env ? { env: command.env } : {}), timeoutMs: 30_000, + ...(signal ? { signal } : {}), label: location.kind === "wsl" ? `factory:wsl:${location.distro}` : `factory:${location.kind}`, }, @@ -117,6 +119,6 @@ export const factoryDetectionSpec: DetectionSpec = { authProbes: [envVarAuthProbe(["FACTORY_API_KEY"])], async capabilitiesProbe(ctx) { if (!ctx.executablePath) return undefined; - return probeCapabilities(ctx.location, ctx.executablePath); + return probeCapabilities(ctx.location, ctx.executablePath, ctx.signal); }, }; diff --git a/src/supervisor/agents/gemini/detection.ts b/src/supervisor/agents/gemini/detection.ts index 933360f2c..4a62a021b 100644 --- a/src/supervisor/agents/gemini/detection.ts +++ b/src/supervisor/agents/gemini/detection.ts @@ -60,9 +60,11 @@ const configDirAuthProbe: AuthProbe = async (ctx) => { if (ctx.location.kind !== "wsl") { return existsSync(join(homedir(), ".gemini")) ? "authenticated" : "unknown"; } - const [result] = await batchWslCommandsAsync(ctx.location.distro, [ - "test -d ~/.gemini && echo yes", - ]); + const [result] = await batchWslCommandsAsync( + ctx.location.distro, + ["test -d ~/.gemini && echo yes"], + ctx.signal, + ); return result?.ok && result.stdout.trim() === "yes" ? "authenticated" : "unknown"; }; @@ -92,6 +94,7 @@ async function probeGeminiMetadata(ctx: Parameters/dev/null || printf ""', ], + ctx.signal, ); const apiKeySet = !!(apiKeyResult?.ok && apiKeyResult.stdout.trim().length > 0); const configDirPresent = !!(configDirResult?.ok && configDirResult.stdout.trim() === "yes"); @@ -152,6 +155,7 @@ export const geminiDetectionSpec: DetectionSpec = { const probeCwd = ctx.location.kind === "wsl" ? "/tmp" : getAgentProbeCwd(ctx.location); const probeResult = await probeAcpCapabilities(probeCmd.command, probeCmd.args, probeCwd, { timeoutMs: 15_000, + ...(ctx.signal ? { signal: ctx.signal } : {}), label: ctx.location.kind === "wsl" ? `gemini:wsl:${ctx.location.distro}` diff --git a/src/supervisor/agents/grok/detection.test.ts b/src/supervisor/agents/grok/detection.test.ts index 4ad9549d1..f6cb4c3b3 100644 --- a/src/supervisor/agents/grok/detection.test.ts +++ b/src/supervisor/agents/grok/detection.test.ts @@ -21,7 +21,9 @@ vi.mock("node:fs", async (importOriginal) => { return { ...actual, existsSync: (path: import("node:fs").PathLike) => - String(path).endsWith("/.grok/auth.json") ? authFileMock.exists : actual.existsSync(path), + String(path).replaceAll("\\", "/").endsWith("/.grok/auth.json") + ? authFileMock.exists + : actual.existsSync(path), }; }); diff --git a/src/supervisor/agents/grok/detection.ts b/src/supervisor/agents/grok/detection.ts index 20df5d207..048044170 100644 --- a/src/supervisor/agents/grok/detection.ts +++ b/src/supervisor/agents/grok/detection.ts @@ -63,6 +63,7 @@ export function buildGrokCommand(location: ProjectLocation, args: string[], wslE async function probeCapabilities( location: ProjectLocation, executablePath?: string, + signal?: AbortSignal, ): Promise { const spec = buildGrokCommand(location, ["--no-auto-update", "agent", "stdio"], executablePath); const sessionCwd = getAgentProbeCwd(location); @@ -75,6 +76,7 @@ async function probeCapabilities( // exits before ACP initialize with `env: node: No such file or directory`. ...(spec.env ? { env: spec.env } : {}), timeoutMs: 20_000, // grok may take a moment on first init + ...(signal ? { signal } : {}), label: location.kind === "wsl" ? `grok:wsl:${location.distro}` : `grok:${location.kind}`, // Grok returns identity (email, auth_mode, subscription_tier) in the // `authenticate` response's `_meta`. `cached_token` is the non-interactive @@ -230,9 +232,11 @@ async function grokAuthFileProbe( if (ctx.location.kind !== "wsl") { return check(homedir()); } - const [r] = await batchWslCommandsAsync(ctx.location.distro, [ - "test -f ~/.grok/auth.json && echo yes || echo no", - ]); + const [r] = await batchWslCommandsAsync( + ctx.location.distro, + ["test -f ~/.grok/auth.json && echo yes || echo no"], + ctx.signal, + ); if (!r?.ok) return "unknown"; return r.stdout.trim() === "yes" ? "authenticated" : "missing"; } @@ -258,6 +262,6 @@ export const grokDetectionSpec: DetectionSpec = { authProbes: [envVarAuthProbe(["GROK_API_KEY", "XAI_API_KEY"]), grokAuthFileProbe], async capabilitiesProbe(ctx) { if (!ctx.executablePath) return undefined; - return probeCapabilities(ctx.location, ctx.executablePath); + return probeCapabilities(ctx.location, ctx.executablePath, ctx.signal); }, }; diff --git a/src/supervisor/agents/kimi/detection.ts b/src/supervisor/agents/kimi/detection.ts index 07b8fd1d8..6786d295a 100644 --- a/src/supervisor/agents/kimi/detection.ts +++ b/src/supervisor/agents/kimi/detection.ts @@ -160,6 +160,7 @@ export function buildKimiProbeCapabilities( async function probeCapabilities( location: ProjectLocation, executablePath?: string, + signal?: AbortSignal, ): Promise { const spec = buildKimiCommand(location, ["acp"], executablePath); const sessionCwd = getAgentProbeCwd(location); @@ -178,6 +179,7 @@ async function probeCapabilities( const probe = await probeAcpCapabilities(spec.command, spec.args, sessionCwd, { ...(processCwd ? { processCwd } : {}), timeoutMs: 20_000, + ...(signal ? { signal } : {}), label: location.kind === "wsl" ? `kimi:wsl:${location.distro}` : `kimi:${location.kind}`, }); return buildKimiProbeCapabilities(probe, await credentialStatePromise); @@ -316,6 +318,6 @@ export const kimiDetectionSpec: DetectionSpec = { }, async capabilitiesProbe(ctx) { if (!ctx.executablePath) return undefined; - return probeCapabilities(ctx.location, ctx.executablePath); + return probeCapabilities(ctx.location, ctx.executablePath, ctx.signal); }, }; diff --git a/src/supervisor/agents/opencode/detection.concurrent.test.ts b/src/supervisor/agents/opencode/detection.concurrent.test.ts new file mode 100644 index 000000000..06d59f6a0 --- /dev/null +++ b/src/supervisor/agents/opencode/detection.concurrent.test.ts @@ -0,0 +1,69 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ProjectLocation } from "@/shared/contracts"; +import type { DetectProbeCtx } from "../base"; +import type { OpenCodeSdkInventory } from "./sdkProbe"; + +const probeOpenCodeInventoryViaSdk = vi.hoisted(() => + vi.fn< + (location: ProjectLocation, signal?: AbortSignal) => Promise + >(), +); + +vi.mock("./sdkProbe", () => ({ probeOpenCodeInventoryViaSdk })); + +import { opencodeDetectionSpec } from "./detection"; + +const inventory = { providers: [], connected: [], agents: [] }; + +function probeContext(location: ProjectLocation, signal: AbortSignal): DetectProbeCtx { + return { + location, + executablePath: "opencode", + version: "1.14.19", + signal, + }; +} + +beforeEach(() => { + probeOpenCodeInventoryViaSdk.mockReset(); +}); + +describe("OpenCode detection probe sharing", () => { + it("shares status and capability work only when callers share a cancellation signal", async () => { + probeOpenCodeInventoryViaSdk.mockResolvedValue(inventory); + const signal = new AbortController().signal; + const ctx = probeContext({ kind: "posix", path: "/same-signal" }, signal); + + await Promise.all([ + opencodeDetectionSpec.statusProbe?.(ctx), + opencodeDetectionSpec.capabilitiesProbe?.(ctx), + ]); + + expect(probeOpenCodeInventoryViaSdk).toHaveBeenCalledOnce(); + }); + + it("does not share pending work between independently cancellable detections", async () => { + let resolveFirst: ((value: typeof inventory) => void) | undefined; + probeOpenCodeInventoryViaSdk + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }), + ) + .mockResolvedValueOnce(inventory); + const location: ProjectLocation = { kind: "posix", path: "/different-signals" }; + const first = opencodeDetectionSpec.statusProbe?.( + probeContext(location, new AbortController().signal), + ); + await vi.waitFor(() => expect(probeOpenCodeInventoryViaSdk).toHaveBeenCalledOnce()); + + const second = opencodeDetectionSpec.statusProbe?.( + probeContext(location, new AbortController().signal), + ); + await vi.waitFor(() => expect(probeOpenCodeInventoryViaSdk).toHaveBeenCalledTimes(2)); + resolveFirst?.(inventory); + + await Promise.all([first, second]); + }); +}); diff --git a/src/supervisor/agents/opencode/detection.ts b/src/supervisor/agents/opencode/detection.ts index 89b600a57..191ba3713 100644 --- a/src/supervisor/agents/opencode/detection.ts +++ b/src/supervisor/agents/opencode/detection.ts @@ -203,12 +203,14 @@ export function parseOpenCodeVerboseModels(stdout: string): OpenCodeProbedModel[ async function probeOpenCodeModels( location: ProjectLocation, executablePath: string, + signal?: AbortSignal, ): Promise { const result = await readAgentCommandOutput(location, executablePath, ["models", "--verbose"], { // Verbose mode prints a JSON object per model — slower than the bare // `models` listing but still bounded by OpenCode's local cache. timeoutMs: 15_000, posixCwd: getAgentProbeCwd(location), + ...(signal ? { signal } : {}), }); if (!result.ok || !result.stdout) return undefined; const parsed = parseOpenCodeVerboseModels(result.stdout); @@ -371,7 +373,10 @@ async function probeOpenCodeStatusViaCli( ctx.location, ctx.executablePath, ["providers", "list"], - { posixCwd: getAgentProbeCwd(ctx.location) }, + { + posixCwd: getAgentProbeCwd(ctx.location), + ...(ctx.signal ? { signal: ctx.signal } : {}), + }, ); const text = `${result.stdout}\n${result.stderr}`.trim(); const parsedProviders = parseOpenCodeProvidersList(text); @@ -420,7 +425,12 @@ interface OpenCodeDetectionProbeResult { type OpenCodeDetectionProbeContext = Parameters>[0]; -const pendingOpenCodeDetectionProbes = new Map>(); +interface PendingOpenCodeDetectionProbe { + signal: AbortSignal | undefined; + promise: Promise; +} + +const pendingOpenCodeDetectionProbes = new Map(); function openCodeDetectionProbeKey(ctx: OpenCodeDetectionProbeContext): string { return JSON.stringify([ctx.location, ctx.executablePath, ctx.version, ctx.probeEnv]); @@ -436,14 +446,17 @@ async function runOpenCodeDetectionProbe( return status ? { status } : {}; } - const sdkInventory = await probeOpenCodeInventoryViaSdk(ctx.location).catch((cause) => { - console.warn( - `[opencode] SDK capabilities probe failed, falling back to CLI parser: ${ - cause instanceof Error ? cause.message : String(cause) - }`, - ); - return undefined; - }); + const sdkInventory = await probeOpenCodeInventoryViaSdk(ctx.location, ctx.signal).catch( + (cause) => { + console.warn( + `[opencode] SDK capabilities probe failed, falling back to CLI parser: ${ + cause instanceof Error ? cause.message : String(cause) + }`, + ); + return undefined; + }, + ); + ctx.signal?.throwIfAborted(); if (sdkInventory) { return { capabilities: buildCapabilityPartialFromSdkInventory(sdkInventory), @@ -454,7 +467,7 @@ async function runOpenCodeDetectionProbe( // OpenCode's CLI and server share a SQLite database. Keep the two fallback // commands serial so startup never races `models --verbose` against // `providers list` and surfaces a spurious "database is locked" failure. - const probedModels = await probeOpenCodeModels(ctx.location, ctx.executablePath); + const probedModels = await probeOpenCodeModels(ctx.location, ctx.executablePath, ctx.signal); const status = await probeOpenCodeStatusViaCli(ctx); return { ...(probedModels ? { capabilities: buildCapabilityPartialFromProbedModels(probedModels) } : {}), @@ -467,12 +480,12 @@ function probeOpenCodeDetection( ): Promise { const key = openCodeDetectionProbeKey(ctx); const existing = pendingOpenCodeDetectionProbes.get(key); - if (existing) return existing; + if (existing && existing.signal === ctx.signal) return existing.promise; const pending = runOpenCodeDetectionProbe(ctx); - pendingOpenCodeDetectionProbes.set(key, pending); + pendingOpenCodeDetectionProbes.set(key, { signal: ctx.signal, promise: pending }); const clearPending = () => { - if (pendingOpenCodeDetectionProbes.get(key) === pending) { + if (pendingOpenCodeDetectionProbes.get(key)?.promise === pending) { pendingOpenCodeDetectionProbes.delete(key); } }; diff --git a/src/supervisor/agents/opencode/sdkClient.test.ts b/src/supervisor/agents/opencode/sdkClient.test.ts index b87d9d054..07ef5a926 100644 --- a/src/supervisor/agents/opencode/sdkClient.test.ts +++ b/src/supervisor/agents/opencode/sdkClient.test.ts @@ -283,6 +283,78 @@ describe("acquireOpenCodeServer", () => { expect(handle.dispose).not.toHaveBeenCalled(); }); + it("stops the shared sidecar after its last lease stays idle", async () => { + vi.useFakeTimers(); + try { + const handle = makeHandle("http://127.0.0.1:4350"); + mocks.spawnOpenCodeServer.mockReturnValue(handle); + + const { acquireOpenCodeServer } = await import("./sdkClient"); + const first = await acquireOpenCodeServer({ + projectLocation: { kind: "posix", path: "/repo-a" }, + }); + const second = await acquireOpenCodeServer({ + projectLocation: { kind: "posix", path: "/repo-b" }, + }); + + await first.dispose(); + await vi.advanceTimersByTimeAsync(30_000); + expect(handle.dispose).not.toHaveBeenCalled(); + + await second.dispose(); + await vi.advanceTimersByTimeAsync(29_999); + expect(handle.dispose).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(handle.dispose).toHaveBeenCalledOnce(); + + await acquireOpenCodeServer({ + projectLocation: { kind: "posix", path: "/repo-c" }, + }); + expect(mocks.spawnOpenCodeServer).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); + + it("closes the shared sidecar immediately when the last probe lease is released", async () => { + const handle = makeHandle("http://127.0.0.1:4375"); + mocks.spawnOpenCodeServer.mockReturnValue(handle); + + const { acquireOpenCodeServer } = await import("./sdkClient"); + const activeSession = await acquireOpenCodeServer({ + projectLocation: { kind: "posix", path: "/repo-active" }, + }); + const probe = await acquireOpenCodeServer({ + projectLocation: { kind: "posix", path: "/repo-probe" }, + }); + + await probe.dispose({ closeServerIfIdle: true }); + expect(handle.dispose).not.toHaveBeenCalled(); + + await activeSession.dispose({ closeServerIfIdle: true }); + expect(handle.dispose).toHaveBeenCalledOnce(); + }); + + it("keeps the sidecar alive for an acquisition that is still starting", async () => { + const handle = makeHandle("http://127.0.0.1:4380"); + mocks.spawnOpenCodeServer.mockReturnValue(handle); + + const { acquireOpenCodeServer } = await import("./sdkClient"); + const probe = await acquireOpenCodeServer({ + projectLocation: { kind: "posix", path: "/repo-probe" }, + }); + const sessionPromise = acquireOpenCodeServer({ + projectLocation: { kind: "posix", path: "/repo-session" }, + }); + + await probe.dispose({ closeServerIfIdle: true }); + expect(handle.dispose).not.toHaveBeenCalled(); + + const session = await sessionPromise; + await session.dispose({ closeServerIfIdle: true }); + expect(handle.dispose).toHaveBeenCalledOnce(); + }); + it("pools WSL directories by distro", async () => { const ubuntuHandle = makeHandle("http://127.0.0.1:4400"); const debianHandle = makeHandle("http://127.0.0.1:4401"); diff --git a/src/supervisor/agents/opencode/sdkClient.ts b/src/supervisor/agents/opencode/sdkClient.ts index ff37c9d21..e847ec2d4 100644 --- a/src/supervisor/agents/opencode/sdkClient.ts +++ b/src/supervisor/agents/opencode/sdkClient.ts @@ -47,7 +47,7 @@ export interface AcquiredOpenCodeServer { handle: OpenCodeServerHandle; onServerExit?(callback: () => void): () => void; updateMcpServers(servers: readonly ResolvedMcpServer[]): Promise; - dispose(): Promise; + dispose(options?: { closeServerIfIdle?: boolean }): Promise; } interface ServerSnapshot { @@ -67,6 +67,8 @@ interface PoolEntry { ready: Promise; /** Dynamic MCP state is isolated by OpenCode directory instance. */ directoryMcp: Map; + leases: number; + idleTimer: ReturnType | undefined; } async function installSharedServerPlugin(projectLocation: ProjectLocation): Promise { @@ -92,11 +94,38 @@ async function installSharedServerPlugin(projectLocation: ProjectLocation): Prom } } -// One app-lifetime server per execution runtime: one native process for the +// One shared server per active execution runtime: one native process for the // host platform, plus one process per WSL distro. OpenCode routes each SDK // request to a lazily-created directory instance via x-opencode-directory, // matching the official Desktop app's shared-server compatibility path. const pool = new Map(); +const IDLE_SHUTDOWN_MS = 30_000; + +function clearIdleShutdown(entry: PoolEntry): void { + if (entry.idleTimer === undefined) return; + clearTimeout(entry.idleTimer); + entry.idleTimer = undefined; +} + +async function closeServerIfIdle(key: string, entry: PoolEntry): Promise { + if (entry.leases > 0 || pool.get(key) !== entry) return; + clearIdleShutdown(entry); + pool.delete(key); + const snapshot = await entry.ready; + await snapshot.handle.dispose(); +} + +function scheduleIdleShutdown(key: string, entry: PoolEntry): void { + if (entry.leases > 0 || pool.get(key) !== entry) return; + clearIdleShutdown(entry); + entry.idleTimer = setTimeout(() => { + entry.idleTimer = undefined; + void closeServerIfIdle(key, entry).catch((error) => + console.warn("[opencode] failed to dispose idle server:", error), + ); + }, IDLE_SHUTDOWN_MS); + entry.idleTimer.unref(); +} // Total budget for confirming the server is reachable once it has announced // its URL, and the per-attempt fetch timeout inside that budget. @@ -195,9 +224,8 @@ async function spawnAndWire(projectLocation: ProjectLocation): Promise { - if (pool.get(key) === entry) pool.delete(key); + if (pool.get(key) === createdEntry) pool.delete(key); }); // If the server crashes after wiring, evict so subsequent acquires get a @@ -298,7 +332,8 @@ async function acquireOpenCodeServerInner( void ready.then( (snapshot) => { snapshot.handle.child.once("exit", () => { - if (pool.get(key) === entry) pool.delete(key); + clearIdleShutdown(createdEntry); + if (pool.get(key) === createdEntry) pool.delete(key); }); }, () => undefined, @@ -306,12 +341,29 @@ async function acquireOpenCodeServerInner( } const acquiringEntry = entry; + clearIdleShutdown(acquiringEntry); + acquiringEntry.leases += 1; + let released = false; + const releaseLease = (scheduleIdle = true): boolean => { + if (released) return false; + released = true; + acquiringEntry.leases -= 1; + if (scheduleIdle) scheduleIdleShutdown(key, acquiringEntry); + return true; + }; - const snapshot = await acquiringEntry.ready; + let snapshot: ServerSnapshot; + try { + snapshot = await acquiringEntry.ready; + } catch (error) { + releaseLease(false); + throw error; + } const directory = resolveOpenCodeSessionDirectory(input.projectLocation); - const client = await createLegacySdkClient(snapshot.baseUrl, snapshot.authorization, directory); + let client: LegacyOpenCodeClient; try { + client = await createLegacySdkClient(snapshot.baseUrl, snapshot.authorization, directory); // Omitted MCP config means this caller (notably one-shot generation) does // not own provider settings and must leave the directory instance alone. // An explicit empty array is the settings-level request to clear the set. @@ -321,8 +373,11 @@ async function acquireOpenCodeServerInner( } } catch (error) { if (!retryMcpConnectionLoss || !isOpenCodeConnectionLoss(error)) { + releaseLease(); throw error; } + releaseLease(false); + clearIdleShutdown(acquiringEntry); if (pool.get(key) === acquiringEntry) pool.delete(key); await snapshot.handle.dispose().catch((disposeErr) => { console.warn("[opencode] failed to dispose handle during retry:", disposeErr); @@ -346,7 +401,11 @@ async function acquireOpenCodeServerInner( updateMcpServers: async (servers) => { await syncDirectoryMcpServers(acquiringEntry, directory, buildOpenCodeMcp(servers), client); }, - dispose: () => Promise.resolve(), + dispose: async (options) => { + const closeImmediately = options?.closeServerIfIdle === true; + if (!releaseLease(!closeImmediately) || !closeImmediately) return; + await closeServerIfIdle(key, acquiringEntry); + }, }; } @@ -357,6 +416,7 @@ async function acquireOpenCodeServerInner( * `opencode.exe` processes the user started outside the app. */ export function shutdownSpawnedOpenCodeServers(): void { + for (const entry of pool.values()) clearIdleShutdown(entry); pool.clear(); disposeSpawnedOpenCodeServerHandles(); } diff --git a/src/supervisor/agents/opencode/sdkProbe.test.ts b/src/supervisor/agents/opencode/sdkProbe.test.ts new file mode 100644 index 000000000..04ecbcebf --- /dev/null +++ b/src/supervisor/agents/opencode/sdkProbe.test.ts @@ -0,0 +1,73 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ProjectLocation } from "@/shared/contracts"; +import type { AcquiredOpenCodeServer } from "./sdkClient"; + +const acquireOpenCodeServer = vi.hoisted(() => + vi.fn<(input: { projectLocation: ProjectLocation }) => Promise>(), +); + +vi.mock("./sdkClient", () => ({ acquireOpenCodeServer })); + +import { probeOpenCodeInventoryViaSdk } from "./sdkProbe"; + +const location: ProjectLocation = { kind: "posix", path: "/repo" }; + +function pendingAcquisition(): { + acquired: AcquiredOpenCodeServer; + dispose: ReturnType>; + providerList: ReturnType Promise>>; +} { + const providerList = vi.fn<() => Promise>(() => new Promise(() => undefined)); + const dispose = vi.fn().mockResolvedValue(undefined); + const acquired = { + client: { + provider: { list: providerList }, + app: { agents: vi.fn<() => Promise>(() => new Promise(() => undefined)) }, + }, + dispose, + } as unknown as AcquiredOpenCodeServer; + return { acquired, dispose, providerList }; +} + +describe("probeOpenCodeInventoryViaSdk cancellation", () => { + beforeEach(() => { + acquireOpenCodeServer.mockReset(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("settles and releases the probe lease when the caller aborts", async () => { + const { acquired, dispose, providerList } = pendingAcquisition(); + acquireOpenCodeServer.mockResolvedValue(acquired); + const abort = new AbortController(); + + const probe = probeOpenCodeInventoryViaSdk(location, abort.signal); + await vi.waitFor(() => expect(providerList).toHaveBeenCalledOnce()); + abort.abort(new Error("detection cancelled")); + + await expect(probe).rejects.toThrow("detection cancelled"); + expect(dispose).toHaveBeenCalledExactlyOnceWith({ closeServerIfIdle: true }); + }); + + it("settles and releases the probe lease when inventory times out", async () => { + vi.useFakeTimers(); + const { acquired, dispose } = pendingAcquisition(); + acquireOpenCodeServer.mockResolvedValue(acquired); + + const probe = probeOpenCodeInventoryViaSdk(location); + await Promise.resolve(); + await Promise.resolve(); + const rejection = probe.then( + () => undefined, + (error: unknown) => error, + ); + await vi.advanceTimersByTimeAsync(15_000); + + await expect(rejection).resolves.toEqual( + expect.objectContaining({ message: "OpenCode inventory probe timed out" }), + ); + expect(dispose).toHaveBeenCalledExactlyOnceWith({ closeServerIfIdle: true }); + }); +}); diff --git a/src/supervisor/agents/opencode/sdkProbe.ts b/src/supervisor/agents/opencode/sdkProbe.ts index 6dd53a91a..2de4a997d 100644 --- a/src/supervisor/agents/opencode/sdkProbe.ts +++ b/src/supervisor/agents/opencode/sdkProbe.ts @@ -24,6 +24,8 @@ import type { ProjectLocation } from "@/shared/contracts"; import { acquireOpenCodeServer } from "./sdkClient"; +const OPENCODE_INVENTORY_PROBE_TIMEOUT_MS = 15_000; + /** Per-model entry returned by the SDK provider list, normalised. */ export interface OpenCodeSdkModel { id: string; @@ -155,13 +157,25 @@ function normalizeAgentsResponse(raw: unknown): OpenCodeSdkAgent[] { */ export async function probeOpenCodeInventoryViaSdk( location: ProjectLocation, + signal?: AbortSignal, ): Promise { + signal?.throwIfAborted(); const acquired = await acquireOpenCodeServer({ projectLocation: location }); try { + signal?.throwIfAborted(); const client = acquired.client; - const [providerListResult, agentsResult] = await Promise.all([ + let abortProbe: (() => void) | undefined; + let timeout: NodeJS.Timeout | undefined; + const abortPromise = signal + ? new Promise((_, reject) => { + abortProbe = () => reject(signal.reason ?? new Error("OpenCode inventory probe aborted")); + signal.addEventListener("abort", abortProbe, { once: true }); + if (signal.aborted) abortProbe(); + }) + : undefined; + const inventoryPromise = Promise.all([ client.provider.list().catch((err: unknown) => { throw new Error( `provider.list failed: ${err instanceof Error ? err.message : String(err)}`, @@ -179,6 +193,20 @@ export async function probeOpenCodeInventoryViaSdk( return { data: [] }; }), ]); + const [providerListResult, agentsResult] = await Promise.race([ + inventoryPromise, + new Promise((_, reject) => { + timeout = setTimeout( + () => reject(new Error("OpenCode inventory probe timed out")), + OPENCODE_INVENTORY_PROBE_TIMEOUT_MS, + ); + if (typeof timeout.unref === "function") timeout.unref(); + }), + ...(abortPromise ? [abortPromise] : []), + ]).finally(() => { + if (timeout) clearTimeout(timeout); + if (abortProbe) signal?.removeEventListener("abort", abortProbe); + }); const providerPayload = (providerListResult as { data?: unknown }).data; const agentsPayload = (agentsResult as { data?: unknown }).data; @@ -186,6 +214,6 @@ export async function probeOpenCodeInventoryViaSdk( const agents = normalizeAgentsResponse(agentsPayload); return { providers, connected, agents }; } finally { - await acquired.dispose(); + await acquired.dispose({ closeServerIfIdle: true }); } } diff --git a/src/supervisor/agents/pi/detection.ts b/src/supervisor/agents/pi/detection.ts index c7c0d2207..1593cd4c9 100644 --- a/src/supervisor/agents/pi/detection.ts +++ b/src/supervisor/agents/pi/detection.ts @@ -42,9 +42,11 @@ function nativeAuthProviders(): string[] { const piAuthFileProbe: AuthProbe = async (ctx) => { if (ctx.location.kind === "wsl") { - const [result] = await batchWslCommandsAsync(ctx.location.distro, [ - 'test -s "${PI_CODING_AGENT_DIR:-$HOME/.pi/agent}/auth.json"', - ]); + const [result] = await batchWslCommandsAsync( + ctx.location.distro, + ['test -s "${PI_CODING_AGENT_DIR:-$HOME/.pi/agent}/auth.json"'], + ctx.signal, + ); return result?.ok ? "authenticated" : "missing"; } return existsSync(nativePiAuthPath()) && nativeAuthProviders().length > 0 @@ -101,11 +103,13 @@ export function parsePiModelList(stdout: string): PiCliModel[] { async function probePiCapabilities( location: ProjectLocation, executablePath: string, + signal?: AbortSignal, ): Promise { // Native and WSL alike probe the installed CLI's model table — no bundled Pi // SDK. The GUI structured session likewise drives the installed `pi --mode rpc`. const output = await readAgentCommandOutput(location, executablePath, ["--list-models"], { timeoutMs: 15_000, + ...(signal ? { signal } : {}), }); const models = output.ok ? parsePiModelList(output.stdout) : []; const modelEfforts = Object.fromEntries( @@ -152,7 +156,7 @@ export const piDetectionSpec: DetectionSpec = { authProbes: [envVarAuthProbe([...PI_AUTH_ENV_KEYS]), piAuthFileProbe], async capabilitiesProbe(ctx) { if (!ctx.executablePath) return undefined; - return probePiCapabilities(ctx.location, ctx.executablePath); + return probePiCapabilities(ctx.location, ctx.executablePath, ctx.signal); }, }; diff --git a/src/supervisor/agents/qoder/detection.ts b/src/supervisor/agents/qoder/detection.ts index 7c1580a17..e551fb0d9 100644 --- a/src/supervisor/agents/qoder/detection.ts +++ b/src/supervisor/agents/qoder/detection.ts @@ -62,9 +62,11 @@ const credentialFileAuthProbe: AuthProbe = async (ctx) => { if (ctx.location.kind !== "wsl") { return existsSync(join(homedir(), ".qoder", ".auth", "user")) ? "authenticated" : "unknown"; } - const [result] = await batchWslCommandsAsync(ctx.location.distro, [ - "test -f ~/.qoder/.auth/user && echo yes", - ]); + const [result] = await batchWslCommandsAsync( + ctx.location.distro, + ["test -f ~/.qoder/.auth/user && echo yes"], + ctx.signal, + ); return result?.ok && result.stdout.trim() === "yes" ? "authenticated" : "unknown"; }; @@ -92,6 +94,7 @@ export function buildQoderProbeCapabilities( async function probeCapabilities( location: ProjectLocation, executablePath: string, + signal?: AbortSignal, ): Promise { const command = buildQoderCommand(location, ["--acp"], executablePath); const processCwd = resolveProbeSpawnCwd(location, command.cwd); @@ -103,6 +106,7 @@ async function probeCapabilities( ...(processCwd ? { processCwd } : {}), ...(command.env ? { env: command.env } : {}), timeoutMs: 20_000, + ...(signal ? { signal } : {}), label: location.kind === "wsl" ? `qoder:wsl:${location.distro}` : `qoder:${location.kind}`, }, ); @@ -122,6 +126,6 @@ export const qoderDetectionSpec: DetectionSpec = { authProbes: [envVarAuthProbe([...QODER_AUTH_ENV_KEYS]), credentialFileAuthProbe], async capabilitiesProbe(ctx) { if (!ctx.executablePath) return undefined; - return probeCapabilities(ctx.location, ctx.executablePath); + return probeCapabilities(ctx.location, ctx.executablePath, ctx.signal); }, }; diff --git a/src/supervisor/agents/qwen/detection.ts b/src/supervisor/agents/qwen/detection.ts index 3c449d24d..51d117c48 100644 --- a/src/supervisor/agents/qwen/detection.ts +++ b/src/supervisor/agents/qwen/detection.ts @@ -195,6 +195,7 @@ export function buildQwenProbeCapabilities( async function probeCapabilities( location: ProjectLocation, executablePath: string, + signal?: AbortSignal, ): Promise { const command = buildQwenCommand(location, ["--acp"], executablePath); const processCwd = resolveProbeSpawnCwd(location, command.cwd); @@ -206,6 +207,7 @@ async function probeCapabilities( ...(processCwd ? { processCwd } : {}), ...(command.env ? { env: command.env } : {}), timeoutMs: 20_000, + ...(signal ? { signal } : {}), label: location.kind === "wsl" ? `qwen:wsl:${location.distro}` : `qwen:${location.kind}`, }, ); @@ -227,6 +229,6 @@ export const qwenDetectionSpec: DetectionSpec = { authProbes: [envVarAuthProbe([...QWEN_AUTH_ENV_KEYS])], async capabilitiesProbe(ctx) { if (!ctx.executablePath) return undefined; - return probeCapabilities(ctx.location, ctx.executablePath); + return probeCapabilities(ctx.location, ctx.executablePath, ctx.signal); }, }; diff --git a/src/supervisor/oneShotSpawn.ts b/src/supervisor/oneShotSpawn.ts index 49d910948..671e8c1b7 100644 --- a/src/supervisor/oneShotSpawn.ts +++ b/src/supervisor/oneShotSpawn.ts @@ -191,6 +191,7 @@ export function spawnAgentPty( }); signal?.addEventListener("abort", onAbort, { once: true }); + if (signal?.aborted) onAbort(); if (input) pty.write(input); }); } diff --git a/src/supervisor/projectWatcher.test.ts b/src/supervisor/projectWatcher.test.ts index 8e6dce37d..c8c2e811e 100644 --- a/src/supervisor/projectWatcher.test.ts +++ b/src/supervisor/projectWatcher.test.ts @@ -2,12 +2,12 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { isIgnoredWorkTreeFile, ProjectWatcher } from "./projectWatcher"; import type { WslBridgeClient, WslLocation } from "./wsl/bridge/client"; -function makeLocation(linuxPath: string): WslLocation { +function makeLocation(linuxPath: string, distro = "Ubuntu"): WslLocation { return { kind: "wsl", - distro: "Ubuntu", + distro, linuxPath, - uncPath: `\\\\wsl.localhost\\Ubuntu${linuxPath.replaceAll("/", "\\")}`, + uncPath: `\\\\wsl.localhost\\${distro}${linuxPath.replaceAll("/", "\\")}`, }; } @@ -215,6 +215,52 @@ describe("ProjectWatcher WSL worktrees", () => { await watcher.dispose(); }); + it("keeps replacement worktree watchers while the previous project teardown finishes", async () => { + vi.useFakeTimers(); + const { unsubscribe, watch, waitForSubscription } = createWatchHarness(); + let finishOldProjectUnsubscribe!: () => void; + const oldProjectUnsubscribe = new Promise((resolve) => { + finishOldProjectUnsubscribe = resolve; + }); + unsubscribe.mockReturnValueOnce(oldProjectUnsubscribe).mockResolvedValue(undefined); + const client = { + readFile: vi.fn(async () => { + throw new Error("missing"); + }), + stat: vi.fn(async () => ({ stats: [] })), + watch, + } as unknown as WslBridgeClient; + const onTreeChanged = vi.fn<(projectId: string) => void>(); + const watcher = new ProjectWatcher({ + onGitChanged: vi.fn<(projectId: string) => void>(), + onTreeChanged, + }); + watcher.setWslClient(client); + const worktreePath = "/home/demo/.poracode/worktrees/repo/feature"; + + watcher.watch("project-1", makeLocation("/home/demo/old")); + watcher.watchWorktrees("project-1", [worktreePath]); + await waitForSubscription(2); + + watcher.watch("project-1", makeLocation("/home/demo/new", "Debian")); + watcher.watchWorktrees("project-1", [worktreePath]); + await waitForSubscription(4); + finishOldProjectUnsubscribe(); + await oldProjectUnsubscribe; + await Promise.resolve(); + + expect(watcher.getWslDistros()).toEqual(["Debian"]); + watch.mock.calls[3]![2]({ + subscriptionId: "replacement-worktree", + scope: "worktree", + paths: ["src/App.tsx"], + }); + await vi.advanceTimersByTimeAsync(300); + expect(onTreeChanged).toHaveBeenCalledWith("project-1"); + + await watcher.dispose(); + }); + it("ignores linked-worktree directory churn from git status", async () => { vi.useFakeTimers(); const { watch, waitForSubscription } = createWatchHarness(); @@ -295,19 +341,57 @@ describe("ProjectWatcher.hasWslProjects", () => { onTreeChanged: vi.fn<(projectId: string) => void>(), }); expect(watcher.hasWslProjects()).toBe(false); + expect(watcher.getWslDistros()).toEqual([]); // Native projects don't count. The path doesn't exist — both fs.watch // calls fail into their try/catch, but the entry still registers. watcher.watch("native", { kind: "windows", path: "C:\\poracode-test-does-not-exist" }); expect(watcher.hasWslProjects()).toBe(false); + expect(watcher.getWslDistros()).toEqual([]); // No wslClient is wired, so the WSL subscription itself is a no-op while // the watcher entry registers synchronously. watcher.watch("wsl", makeLocation("/home/u/repo")); expect(watcher.hasWslProjects()).toBe(true); + expect(watcher.getWslDistros()).toEqual(["Ubuntu"]); await watcher.unwatch("wsl"); expect(watcher.hasWslProjects()).toBe(false); + expect(watcher.getWslDistros()).toEqual([]); + await watcher.dispose(); + }); + + it("keeps a replacement WSL watcher while the previous unsubscribe finishes", async () => { + const { unsubscribe, watch, waitForSubscription } = createWatchHarness(); + let finishOldUnsubscribe!: () => void; + const oldUnsubscribe = new Promise((resolve) => { + finishOldUnsubscribe = resolve; + }); + unsubscribe.mockReturnValueOnce(oldUnsubscribe).mockResolvedValue(undefined); + const client = { + readFile: vi.fn(async () => { + throw new Error("missing"); + }), + stat: vi.fn(async () => ({ stats: [] })), + watch, + } as unknown as WslBridgeClient; + const watcher = new ProjectWatcher({ + onGitChanged: vi.fn<(projectId: string) => void>(), + onTreeChanged: vi.fn<(projectId: string) => void>(), + }); + watcher.setWslClient(client); + + watcher.watch("project-1", makeLocation("/home/demo/old")); + await waitForSubscription(1); + watcher.watch("project-1", makeLocation("/home/demo/new", "Debian")); + await waitForSubscription(2); + + expect(watcher.getWslDistros()).toEqual(["Debian"]); + finishOldUnsubscribe(); + await oldUnsubscribe; + await Promise.resolve(); + expect(watcher.getWslDistros()).toEqual(["Debian"]); + await watcher.dispose(); }); }); diff --git a/src/supervisor/projectWatcher.ts b/src/supervisor/projectWatcher.ts index ef625a83b..ee51bfbbb 100644 --- a/src/supervisor/projectWatcher.ts +++ b/src/supervisor/projectWatcher.ts @@ -149,10 +149,18 @@ export class ProjectWatcher { /** True when at least one watched project lives inside a WSL distro. */ hasWslProjects(): boolean { - for (const entry of this.watchers.values()) { - if (entry.location.kind === "wsl") return true; - } - return false; + return this.getWslDistros().length > 0; + } + + /** Distinct WSL distros backing watched (non-disabled) projects. */ + getWslDistros(): string[] { + return [ + ...new Set( + [...this.watchers.values()].flatMap((entry) => + entry.location.kind === "wsl" ? [entry.location.distro] : [], + ), + ), + ]; } /** @@ -322,8 +330,18 @@ export class ProjectWatcher { /** Stop watching a project and its worktrees. */ async unwatch(projectId: string): Promise { + const worktreeEntries = [...this.worktreeWatchers].filter( + ([, worktreeEntry]) => worktreeEntry.projectId === projectId, + ); + for (const [path] of worktreeEntries) { + this.worktreeWatchers.delete(path); + } + const entry = this.watchers.get(projectId); if (entry) { + // Remove the captured entry before the first await. A replacement watch + // for the same projectId must not be deleted when this teardown resumes. + this.watchers.delete(projectId); if (entry.gitDebounceTimer) clearTimeout(entry.gitDebounceTimer); if (entry.treeDebounceTimer) clearTimeout(entry.treeDebounceTimer); entry.gitWatcher?.close(); @@ -333,13 +351,10 @@ export class ProjectWatcher { console.warn(`[watcher] WSL unsubscribe failed for project ${projectId}:`, error); }); } - this.watchers.delete(projectId); } - for (const [path, wtEntry] of this.worktreeWatchers) { - if (wtEntry.projectId === projectId) { - await this.closeWorktreeWatcher(path); - } + for (const [path, worktreeEntry] of worktreeEntries) { + await this.closeWorktreeWatcherEntry(path, worktreeEntry); } } @@ -400,6 +415,16 @@ export class ProjectWatcher { private async closeWorktreeWatcher(path: string): Promise { const entry = this.worktreeWatchers.get(path); if (!entry) return; + // Match project watcher ownership: async teardown must not delete a + // replacement registered for the same path. + this.worktreeWatchers.delete(path); + await this.closeWorktreeWatcherEntry(path, entry); + } + + private async closeWorktreeWatcherEntry( + path: string, + entry: WorktreeWatcherEntry, + ): Promise { if (entry.gitDebounceTimer) clearTimeout(entry.gitDebounceTimer); if (entry.treeDebounceTimer) clearTimeout(entry.treeDebounceTimer); entry.watcher?.close(); @@ -408,7 +433,6 @@ export class ProjectWatcher { console.warn(`[watcher] WSL worktree unsubscribe failed for ${path}:`, error); }); } - this.worktreeWatchers.delete(path); } private async startWslSubscription( diff --git a/src/supervisor/runtime/agentRegistryService.test.ts b/src/supervisor/runtime/agentRegistryService.test.ts index 27b6bce7b..1a753e9f3 100644 --- a/src/supervisor/runtime/agentRegistryService.test.ts +++ b/src/supervisor/runtime/agentRegistryService.test.ts @@ -4,6 +4,7 @@ import type { GetLatestAgentVersionResult, NpmPackageVersionQuery, } from "@/shared/contracts"; +import { defaultSharedSettings } from "@/shared/settings"; import type { AgentAdapter } from "../agents/base"; import type { AgentStatusService } from "./agentStatusService"; import type { SupervisorSharedSettingsCache } from "./supervisorSharedSettings"; @@ -17,6 +18,12 @@ const detectProbeLocationMock = vi.hoisted(() => const runUpdateCommandWithFallbackMock = vi.hoisted(() => vi.fn(), ); +const acpRegistryMocks = vi.hoisted(() => ({ + cacheLocalAcpRegistryIcons: + vi.fn(), + installAcpRegistryAgent: vi.fn(), + readAcpRegistrySettings: vi.fn(), +})); const getLatestVersionForAdapterMock = vi.hoisted(() => vi.fn<(adapter: AgentAdapter) => Promise>(), @@ -44,6 +51,16 @@ vi.mock("../agents/updateAgent", async (importOriginal) => { }; }); +vi.mock("../agents/acpRegistry", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + cacheLocalAcpRegistryIcons: acpRegistryMocks.cacheLocalAcpRegistryIcons, + installAcpRegistryAgent: acpRegistryMocks.installAcpRegistryAgent, + readAcpRegistrySettings: acpRegistryMocks.readAcpRegistrySettings, + }; +}); + import { AgentRegistryService } from "./agentRegistryService"; const capabilities: AgentStatus["capabilities"] = { @@ -105,6 +122,7 @@ describe("AgentRegistryService.updateAgentBinary", () => { invalidate: vi.fn(), } as unknown as SupervisorSharedSettingsCache, getAgentStatusService: () => agentStatusService, + getActiveWslProjectDistros: () => [], }); runUpdateCommandWithFallbackMock.mockResolvedValue({ ok: false, @@ -200,6 +218,7 @@ describe("AgentRegistryService.updateAgentBinary", () => { invalidate: vi.fn(), } as unknown as SupervisorSharedSettingsCache, getAgentStatusService: () => agentStatusService, + getActiveWslProjectDistros: () => ["Ubuntu"], }); runUpdateCommandWithFallbackMock.mockResolvedValue({ ok: true, @@ -215,6 +234,7 @@ describe("AgentRegistryService.updateAgentBinary", () => { agentKinds: ["claude", "claude:personal", "claude:work"], }, }); + expect(listWslDistros).not.toHaveBeenCalled(); }); it("tracks the installed version for built-in updaters that require verification", async () => { @@ -251,10 +271,13 @@ describe("AgentRegistryService.updateAgentBinary", () => { wsl: [], fromCache: false, }); + const listWslDistros = vi + .fn() + .mockResolvedValue(["Ubuntu"]); const agentStatusService = { refreshAgentStatuses, getAgentStatuses: vi.fn(), - listWslDistros: vi.fn().mockResolvedValue([]), + listWslDistros, } as unknown as AgentStatusService; const service = new AgentRegistryService({ adapters: new Map([["qwen", adapter]]), @@ -265,6 +288,7 @@ describe("AgentRegistryService.updateAgentBinary", () => { invalidate: vi.fn(), } as unknown as SupervisorSharedSettingsCache, getAgentStatusService: () => agentStatusService, + getActiveWslProjectDistros: () => [], }); detectProbeLocationMock.mockReturnValueOnce({ kind: "windows", @@ -293,6 +317,11 @@ describe("AgentRegistryService.updateAgentBinary", () => { ["--version"], ); expect(refreshAgentStatuses).toHaveBeenCalledTimes(2); + expect(refreshAgentStatuses).toHaveBeenNthCalledWith(2, { + wslDistros: [], + scope: { agentKinds: ["qwen"] }, + }); + expect(listWslDistros).not.toHaveBeenCalled(); }); }); @@ -317,6 +346,7 @@ describe("AgentRegistryService.getLatestAgentVersion", () => { invalidate: vi.fn(), } as unknown as SupervisorSharedSettingsCache, getAgentStatusService: () => ({}) as unknown as AgentStatusService, + getActiveWslProjectDistros: () => [], }); } @@ -354,3 +384,72 @@ describe("AgentRegistryService.getLatestAgentVersion", () => { expect(getLatestSupportedNpmPackageVersionMock).not.toHaveBeenCalled(); }); }); + +describe("AgentRegistryService project-scoped ACP refreshes", () => { + const settings = { + ...defaultSharedSettings, + agentInstances: { + demo: { + id: "demo", + driver: "acp-generic", + displayName: "Demo", + enabled: true, + config: { binary: "demo", cwd: "project", authMode: "none" }, + }, + }, + acpRegistryInstalledAgents: {}, + } satisfies ReturnType; + + function createService(activeWslDistros: string[]) { + const refreshAgentStatuses = vi + .fn() + .mockResolvedValue({ windows: [], wsl: [], fromCache: false }); + const listWslDistros = vi + .fn() + .mockResolvedValue(["Ubuntu"]); + const agentStatusService = { + refreshAgentStatuses, + listWslDistros, + } as unknown as AgentStatusService; + const service = new AgentRegistryService({ + adapters: new Map(), + settingsPath: "/data/settings.json", + baseDir: "/data", + acpIconsDir: "/data/icons", + sharedSettingsCache: { + invalidate: vi.fn(), + } as unknown as SupervisorSharedSettingsCache, + getAgentStatusService: () => agentStatusService, + getActiveWslProjectDistros: () => activeWslDistros, + }); + return { listWslDistros, refreshAgentStatuses, service }; + } + + it("does not enumerate WSL during launch icon propagation without a WSL project", async () => { + acpRegistryMocks.cacheLocalAcpRegistryIcons.mockReset().mockResolvedValue(true); + acpRegistryMocks.readAcpRegistrySettings.mockReset().mockReturnValue(settings); + const { listWslDistros, refreshAgentStatuses, service } = createService([]); + + await service.cacheLocalAcpIconsOnLaunch(); + + expect(refreshAgentStatuses).toHaveBeenCalledExactlyOnceWith({ + wslDistros: [], + scope: { agentKinds: ["acp-generic:demo"] }, + }); + expect(listWslDistros).not.toHaveBeenCalled(); + }); + + it("does not enumerate WSL after an ACP install without a WSL project", async () => { + acpRegistryMocks.installAcpRegistryAgent.mockReset().mockResolvedValue([]); + acpRegistryMocks.readAcpRegistrySettings.mockReset().mockReturnValue(settings); + const { listWslDistros, refreshAgentStatuses, service } = createService([]); + + await service.installAcpRegistryAgent({ agentId: "demo" }); + + expect(refreshAgentStatuses).toHaveBeenCalledExactlyOnceWith({ + wslDistros: [], + scope: { agentKinds: ["acp-generic:demo"] }, + }); + expect(listWslDistros).not.toHaveBeenCalled(); + }); +}); diff --git a/src/supervisor/runtime/agentRegistryService.ts b/src/supervisor/runtime/agentRegistryService.ts index 8294e67bc..3b6480b65 100644 --- a/src/supervisor/runtime/agentRegistryService.ts +++ b/src/supervisor/runtime/agentRegistryService.ts @@ -61,6 +61,7 @@ export interface AgentRegistryServiceDeps { acpIconsDir: string; sharedSettingsCache: SupervisorSharedSettingsCache; getAgentStatusService: () => AgentStatusService; + getActiveWslProjectDistros: () => string[]; } /** @@ -121,9 +122,8 @@ export class AgentRegistryService { .map(([id]) => acpGenericKind(id)); if (acpKinds.length === 0) return; try { - const wslDistros = await this.agentStatusService.listWslDistros(); await this.agentStatusService.refreshAgentStatuses({ - wslDistros, + wslDistros: this.deps.getActiveWslProjectDistros(), scope: { agentKinds: acpKinds }, }); } catch (error) { @@ -151,9 +151,8 @@ export class AgentRegistryService { private async refreshAffectedAgentStatuses(agentKinds: AgentKind[]): Promise { try { - const wslDistros = await this.agentStatusService.listWslDistros(); await this.agentStatusService.refreshAgentStatuses({ - wslDistros, + wslDistros: this.deps.getActiveWslProjectDistros(), scope: { agentKinds }, }); } catch (error) { diff --git a/src/supervisor/runtime/agentStatusCache.test.ts b/src/supervisor/runtime/agentStatusCache.test.ts index f79b6b9bb..cb7ab037f 100644 --- a/src/supervisor/runtime/agentStatusCache.test.ts +++ b/src/supervisor/runtime/agentStatusCache.test.ts @@ -415,8 +415,22 @@ describe("detectWslAgentStatuses", () => { ); expect(detectInstall).toHaveBeenCalledTimes(2); - expect(detectInstall).toHaveBeenNthCalledWith(1, { envKind: "wsl", wslDistro: "Ubuntu" }); - expect(detectInstall).toHaveBeenNthCalledWith(2, { envKind: "wsl", wslDistro: "Debian" }); + expect(detectInstall).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + envKind: "wsl", + wslDistro: "Ubuntu", + signal: expect.any(AbortSignal), + }), + ); + expect(detectInstall).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + envKind: "wsl", + wslDistro: "Debian", + signal: expect.any(AbortSignal), + }), + ); expect(statuses).toEqual([ expect.objectContaining({ envKind: "wsl", envDistro: "Ubuntu", installed: true }), expect.objectContaining({ envKind: "wsl", envDistro: "Debian", installed: false }), diff --git a/src/supervisor/runtime/agentStatusService.test.ts b/src/supervisor/runtime/agentStatusService.test.ts index f4bb443b7..591a23ff5 100644 --- a/src/supervisor/runtime/agentStatusService.test.ts +++ b/src/supervisor/runtime/agentStatusService.test.ts @@ -17,7 +17,11 @@ vi.mock("../agents/base", async (importActual) => { }); import { invalidateExecutablePathCache } from "../agents/base"; -import { AgentStatusService, parseWslRegistryDistributionNames } from "./agentStatusService"; +import { + AgentStatusService, + detectWslAgentStatuses, + parseWslRegistryDistributionNames, +} from "./agentStatusService"; const tempDirs: string[] = []; @@ -298,6 +302,36 @@ HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Lxss\\{333} ); }); + it("aborts the underlying WSL probe when its launch deadline expires", async () => { + vi.useFakeTimers(); + const error = vi.spyOn(console, "error").mockImplementation(() => undefined); + let signal: AbortSignal | undefined; + const detectInstall = vi.fn((ctx) => { + signal = ctx?.signal; + return new Promise(() => undefined); + }); + const adapter = makeAdapter("codex", "Codex", detectInstall); + + try { + const pending = detectWslAgentStatuses([adapter], ["Ubuntu"]); + await vi.advanceTimersByTimeAsync(60_000); + const statuses = await pending; + + expect(signal?.aborted).toBe(true); + expect(statuses).toEqual([ + expect.objectContaining({ + kind: "codex", + installed: false, + envKind: "wsl", + envDistro: "Ubuntu", + }), + ]); + } finally { + error.mockRestore(); + vi.useRealTimers(); + } + }); + it("passes provider settings to native, WSL, and scoped detection", async () => { const detectInstall = vi.fn().mockResolvedValue(makeStatus()); const adapter = makeAdapter("cursor", "Cursor", detectInstall); @@ -322,11 +356,14 @@ HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Lxss\\{333} envKind: process.platform === "win32" ? "windows" : "posix", agentSettings: initialSettings, }); - expect(detectInstall).toHaveBeenCalledWith({ - envKind: "wsl", - wslDistro: "Ubuntu", - agentSettings: initialSettings, - }); + expect(detectInstall).toHaveBeenCalledWith( + expect.objectContaining({ + envKind: "wsl", + wslDistro: "Ubuntu", + agentSettings: initialSettings, + signal: expect.any(AbortSignal), + }), + ); const updatedSettings = { structuredRuntime: "acp", diff --git a/src/supervisor/runtime/agentStatusService.ts b/src/supervisor/runtime/agentStatusService.ts index d70f0a3cd..cce2bb934 100644 --- a/src/supervisor/runtime/agentStatusService.ts +++ b/src/supervisor/runtime/agentStatusService.ts @@ -186,10 +186,12 @@ export async function detectWslAgentStatuses( } else { try { let timeout: NodeJS.Timeout | undefined; + const abort = new AbortController(); const detected = await Promise.race([ - adapter.detectInstall(ctx), + adapter.detectInstall({ ...ctx, signal: abort.signal }), new Promise((_, reject) => { timeout = setTimeout(() => { + abort.abort(); reject( new Error( `detectInstall(${adapter.kind}, wsl:${distro}) timed out after ${WSL_AGENT_DETECTION_TIMEOUT_MS}ms`, diff --git a/src/supervisor/supervisorRuntime.ts b/src/supervisor/supervisorRuntime.ts index 1d11e659c..01a2650a2 100644 --- a/src/supervisor/supervisorRuntime.ts +++ b/src/supervisor/supervisorRuntime.ts @@ -184,6 +184,7 @@ export class SupervisorRuntime { acpIconsDir: this.acpIconsDir, sharedSettingsCache: this.sharedSettingsCache, getAgentStatusService: () => this.agentStatusService, + getActiveWslProjectDistros: () => this._projectWatcher?.getWslDistros() ?? [], }); this.agentRegistryService.refreshAgentRegistryAdapters(); mkdirSync(paths.cacheDir, { recursive: true }); diff --git a/src/supervisor/wsl/bridge/bridge.mjs b/src/supervisor/wsl/bridge/bridge.mjs index f00acc207..ebce5f0a0 100644 --- a/src/supervisor/wsl/bridge/bridge.mjs +++ b/src/supervisor/wsl/bridge/bridge.mjs @@ -51,7 +51,7 @@ import { isAbsolute, normalize, resolve as resolvePath } from "node:path/posix"; import { createRequire } from "node:module"; // Bumped on every behavioural change. Windows side reads this via regex. -const BRIDGE_VERSION = "2.10.0"; +const BRIDGE_VERSION = "2.13.0"; /** * Lazily loads `@parcel/watcher` (staged next to this script as @@ -733,19 +733,65 @@ function runGitExec(command) { return runProcessExec("git", command); } +function collectDescendantPids(pid, seen = new Set()) { + if (!Number.isInteger(pid) || pid <= 0 || seen.has(pid)) return []; + seen.add(pid); + let childPids; + try { + childPids = readFileSync(`/proc/${pid}/task/${pid}/children`, "utf8") + .trim() + .split(/\s+/) + .filter(Boolean) + .map(Number) + .filter((childPid) => Number.isInteger(childPid) && childPid > 0); + } catch { + return []; + } + return childPids.flatMap((childPid) => [...collectDescendantPids(childPid, seen), childPid]); +} + +function terminateProcessTree(child) { + if (!Number.isInteger(child.pid) || child.pid <= 0) return; + for (const pid of collectDescendantPids(child.pid)) { + try { + process.kill(pid, "SIGKILL"); + } catch { + // The process may already have exited. + } + } + try { + process.kill(-child.pid, "SIGKILL"); + } catch { + try { + child.kill("SIGKILL"); + } catch { + // The process may already have exited. + } + } +} + function runProcessExec(binary, command) { return new Promise((resolve) => { - execFile( - binary, - command.args, + let timedOut = false; + let timer; + const setsidBinary = existsSync("/usr/bin/setsid") + ? "/usr/bin/setsid" + : existsSync("/bin/setsid") + ? "/bin/setsid" + : undefined; + const child = execFile( + setsidBinary ?? binary, + setsidBinary ? [binary, ...command.args] : command.args, { cwd: command.cwd, + detached: setsidBinary === undefined, env: buildGitEnv(command), encoding: "utf8", maxBuffer: 50 * 1024 * 1024, - timeout: command.timeoutMs, }, (err, stdout, stderr) => { + clearTimeout(timer); + terminateProcessTree(child); if (!err) { resolve({ ok: true, stdout: stdout ?? "", stderr: stderr ?? "", exitCode: 0 }); return; @@ -758,10 +804,15 @@ function runProcessExec(binary, command) { exitCode: code, ...(typeof err.signal === "string" ? { signal: err.signal } : {}), error: String(err.message ?? err), - ...(err.killed ? { timedOut: true } : {}), + ...(timedOut ? { timedOut: true } : {}), }); }, ); + timer = setTimeout(() => { + timedOut = true; + terminateProcessTree(child); + }, command.timeoutMs); + timer.unref(); }); } diff --git a/src/supervisor/wsl/bridge/bridge.test.ts b/src/supervisor/wsl/bridge/bridge.test.ts index 2cbfbc344..9a334894f 100644 --- a/src/supervisor/wsl/bridge/bridge.test.ts +++ b/src/supervisor/wsl/bridge/bridge.test.ts @@ -71,6 +71,15 @@ function closeServer(server: Server): Promise { return new Promise((resolve) => server.close(() => resolve())); } +function isProcessRunning(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + function listenLocalServer(server: Server, preferredHost: string): Promise { return new Promise((resolve, reject) => { function listen(host: string) { @@ -425,6 +434,74 @@ describeOnPosix("bridge.mjs fs endpoints", () => { }); }); + it("kills a timed-out process and its descendants", async () => { + const script = [ + 'const { spawn } = require("node:child_process");', + 'const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { detached: true, stdio: "ignore" });', + "process.stdout.write(String(child.pid));", + "setInterval(() => {}, 1000);", + ].join("\n"); + const { status, body } = await post(`${bridge.baseUrl}/v1/process/exec`, { + command: process.execPath, + cwd: projectRoot, + args: ["-e", script], + timeoutMs: 1_000, + }); + const envelope = body as { + ok: boolean; + data: { ok: boolean; stdout: string; timedOut?: boolean }; + }; + const descendantPid = Number(envelope.data.stdout); + + try { + expect(status).toBe(200); + expect(envelope.ok).toBe(true); + expect(envelope.data).toMatchObject({ ok: false, timedOut: true }); + expect(Number.isInteger(descendantPid)).toBe(true); + await expect.poll(() => isProcessRunning(descendantPid), { timeout: 3_000 }).toBe(false); + } finally { + try { + process.kill(descendantPid, "SIGKILL"); + } catch { + // Already reaped by the bridge timeout. + } + } + }); + + it("kills a successful process command's remaining process group", async () => { + const script = [ + 'const { spawn } = require("node:child_process");', + 'const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" });', + "child.unref();", + "process.stdout.write(String(child.pid));", + ].join("\n"); + const { status, body } = await post(`${bridge.baseUrl}/v1/process/exec`, { + command: process.execPath, + cwd: projectRoot, + args: ["-e", script], + timeoutMs: 5_000, + }); + const envelope = body as { + ok: boolean; + data: { ok: boolean; stdout: string; exitCode: number }; + }; + const descendantPid = Number(envelope.data.stdout); + + try { + expect(status).toBe(200); + expect(envelope.ok).toBe(true); + expect(envelope.data).toMatchObject({ ok: true, exitCode: 0 }); + expect(Number.isInteger(descendantPid)).toBe(true); + await expect.poll(() => isProcessRunning(descendantPid), { timeout: 3_000 }).toBe(false); + } finally { + try { + process.kill(descendantPid, "SIGKILL"); + } catch { + // Already reaped by the bridge completion cleanup. + } + } + }); + it("strips inherited Git control variables from process env", async () => { await bridge.dispose(); bridge = await startBridge({ GIT_DIR: "/tmp/host-git-dir" }); From 784231c30934b126665a4dfc97be605f5f140e34 Mon Sep 17 00:00:00 2001 From: Serhii Vecherenko Date: Sat, 8 Aug 2026 23:23:14 -0700 Subject: [PATCH 2/2] test(supervisor): mock direct spawn in POSIX auth probe --- .../agents/base.posix-login-shell.test.ts | 37 ++++++++++++++++--- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/src/supervisor/agents/base.posix-login-shell.test.ts b/src/supervisor/agents/base.posix-login-shell.test.ts index 0c3ebb99c..0e22395d7 100644 --- a/src/supervisor/agents/base.posix-login-shell.test.ts +++ b/src/supervisor/agents/base.posix-login-shell.test.ts @@ -1,16 +1,28 @@ import { homedir } from "node:os"; +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { ProjectLocation } from "@/shared/contracts"; const execFileAsyncMock = vi.hoisted(() => vi.fn<(...args: unknown[]) => Promise<{ stdout: string; stderr?: string }>>(), ); +const spawnMock = vi.hoisted(() => + vi.fn< + ( + command: string, + args: string[], + options: Record, + ) => import("node:child_process").ChildProcess + >(), +); vi.mock("node:child_process", async () => { const actual = await vi.importActual("node:child_process"); const { promisify } = require("node:util") as typeof import("node:util"); return { ...actual, + spawn: spawnMock, execFile: Object.assign(vi.fn(), { [promisify.custom]: execFileAsyncMock, }), @@ -40,6 +52,7 @@ describe.skipIf(process.platform === "win32")("POSIX login shell wrappers", () = beforeEach(() => { vi.clearAllMocks(); + spawnMock.mockReset(); clearExecutablePathCache(); process.env.SHELL = "/bin/zsh"; }); @@ -213,9 +226,21 @@ describe.skipIf(process.platform === "win32")("POSIX login shell wrappers", () = }); it("runs CLI auth probes via direct spawn", async () => { - execFileAsyncMock.mockResolvedValueOnce({ - stdout: "Authenticated\n", - stderr: "", + spawnMock.mockImplementationOnce(() => { + const stdout = new PassThrough(); + const stderr = new PassThrough(); + const child = Object.assign(new EventEmitter(), { + stdout, + stderr, + pid: undefined, + killed: false, + }) as unknown as import("node:child_process").ChildProcess; + queueMicrotask(() => { + stdout.end("Authenticated\n"); + stderr.end(); + child.emit("close", 0); + }); + return child; }); const probe = cliSubcommandAuthProbe(["auth", "status"]); @@ -227,12 +252,14 @@ describe.skipIf(process.platform === "win32")("POSIX login shell wrappers", () = }), ).resolves.toBe("authenticated"); - expect(execFileAsyncMock).toHaveBeenCalledWith( + expect(spawnMock).toHaveBeenCalledWith( "/Users/demo/.nvm/versions/node/v24/bin/claude", ["auth", "status"], expect.objectContaining({ cwd: "/Users/demo/project", - timeout: 30_000, + detached: true, + shell: false, + stdio: ["ignore", "pipe", "pipe"], windowsHide: true, }), );