diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index d0b63350f1d4e9d3036458b511195af1e1cf7387..1dcfb20723b3e2eb57c95af102f688e2a74fcc6a 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -28,6 +28,8 @@ import { type PermissionUpdate, type Query as ClaudeQuery, type Settings as ClaudeSdkSettings, + type SpawnOptions as ClaudeSpawnOptions, + type SpawnedProcess as ClaudeSpawnedProcess, type SDKAssistantMessage, type SDKAPIRetryMessage, type SDKMessage, @@ -39,6 +41,8 @@ import type { AskUserQuestionInput, WebSearchOutput, } from "@anthropic-ai/claude-agent-sdk/sdk-tools"; +// @effect-diagnostics-next-line nodeBuiltinImport:off -- the SDK takes a Node ChildProcess for the CLI. +import * as NodeChildProcess from "node:child_process"; import { parseCliArgs } from "@t3tools/shared/cliArgs"; import { AgentScope } from "@t3tools/shared/AgentScope"; import * as HostProcess from "@t3tools/shared/HostProcess"; @@ -108,6 +112,7 @@ import { discoverClaudeSkills } from "../../provider/Drivers/ClaudeSkills.ts"; import { compileClaudeModelSelection } from "../../claudeModelOptions.ts"; import * as ServerConfig from "../../config.ts"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; +import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup"; import { claudeSignedOutMessage, makeClaudeEnvironment, @@ -225,6 +230,37 @@ const CLAUDE_SCRIPT_EXTENSIONS = [".js", ".mjs", ".tsx", ".ts", ".jsx"]; // mcp_set_servers once each server connected or hit its own 30 s timeout. const CLAUDE_MCP_REGISTRATION_TIMEOUT = "90 seconds"; +/** + * Starts the Claude CLI as the leader of its own process group. When the CLI + * exits it stops the MCP servers it started, but not their children: a + * plugin's `bun run start` wrapper dies and its `bun server.ts` keeps running, + * writing to a pipe nobody reads (#3089). Those stay in the CLI's group, so + * the rest of the group gets SIGKILL once the CLI exits for any reason. + * Commands the agent runs through its Bash tool start their own groups, so + * background jobs it launched are not part of this. + * + * The SDK reads stderr only from its own spawn, where it redacts credentials + * before adding the tail to exit errors. Here stderr is dropped instead, so + * exit errors carry only the code or signal. + */ +function spawnClaudeProcessGroup(options: ClaudeSpawnOptions): ClaudeSpawnedProcess { + const child = NodeChildProcess.spawn(options.command, options.args, { + cwd: options.cwd, + env: options.env, + signal: options.signal, + stdio: ["pipe", "pipe", "ignore"], + detached: true, + }); + child.once("exit", () => { + try { + signalProcessGroup(child.pid ?? 0, "SIGKILL"); + } catch { + // The group is already empty. + } + }); + return child; +} + export const ClaudeProviderCapabilitiesV2 = { sessions: { supportsMultipleProviderThreadsPerSession: false, @@ -661,6 +697,7 @@ export const layerQueryRunner: Layer.Layer< const crypto = yield* Crypto.Crypto; const { native: nativeEventLogger } = yield* ProviderEventLoggers.ProviderEventLoggers; const agentScope = yield* AgentScope; + const platform = yield* HostProcess.Platform; return ClaudeAgentSdkQueryRunner.of({ allocateSessionId: crypto @@ -689,9 +726,8 @@ export const layerQueryRunner: Layer.Layer< // of the environment every command the agent runs inherits. const { mcpServers, ...queryOptions } = input.options; // The SDK spawns a native binary as `path ...executableArgs ...sdkArgs`, - // which lets the agent scope wrapper go in front of the CLI while the - // SDK keeps its own stderr capture and exit reporting. The SDK runs a - // script entry through node instead, so those launch unwrapped. + // which lets the agent scope wrapper go in front of the CLI. The SDK + // runs a script entry through node instead, so those launch unwrapped. const binaryPath = queryOptions.pathToClaudeCodeExecutable; const launch = binaryPath === undefined || @@ -712,11 +748,23 @@ export const layerQueryRunner: Layer.Layer< pathToClaudeCodeExecutable: launch.command, executableArgs: [...launch.args, ...(queryOptions.executableArgs ?? [])], }; + // A spawner the caller passed keeps its process. Windows has no process + // groups, so there the SDK spawns the CLI itself. + const spawnClaudeCodeProcess = + options.spawnClaudeCodeProcess ?? + (platform === "win32" ? undefined : spawnClaudeProcessGroup); // The CLI is running once query() returns, so it is closed if anything // before the session is handed out fails or is interrupted. const queryRuntime = yield* Effect.uninterruptibleMask((restore) => Effect.try({ - try: () => query({ prompt, options }), + try: () => + query({ + prompt, + options: + spawnClaudeCodeProcess === undefined + ? options + : { ...options, spawnClaudeCodeProcess }, + }), catch: (cause) => queryRunnerError(cause, "query"), }).pipe( Effect.tap((queryRuntime) => diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts index b782ad676423c68a55a1cd781af37759307d2e3f..2533e356e8f9560d2f14cdbebc013a1c00968012 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts @@ -1,6 +1,11 @@ +// @effect-diagnostics nodeBuiltinImport:off -- the fake CLI and its leftover child are real processes. import * as ClaudeSdk from "@anthropic-ai/claude-agent-sdk"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, it } from "@effect/vitest"; +import { assert, describe, it } from "@effect/vitest"; import { ProviderSessionId, ThreadId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -8,6 +13,7 @@ import * as Layer from "effect/Layer"; import { vi } from "vite-plus/test"; import { ProviderEventLoggers } from "@t3tools/provider-core/server/ProviderEventLoggers"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { CLAUDE_AGENT_SDK_QUERY_PROTOCOL, ClaudeAgentSdkQueryRunner, @@ -78,3 +84,76 @@ it.effect("logs successful task stops for replay and omits failed stops", () => ); }).pipe(Effect.provide(NodeServices.layer)), ); + +const realQueryRunner = layerQueryRunner.pipe( + Layer.provide( + Layer.succeed(ProviderEventLoggers, { + native: undefined, + canonical: undefined, + }), + ), + Layer.provide(NodeServices.layer), +); + +/** Writes an executable stand-in for the Claude CLI into a fresh directory. */ +function fakeClaudeCli(script: string): string { + const dir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-claude-cli-")); + const cli = NodePath.join(dir, "claude"); + NodeFS.writeFileSync(cli, `#!/bin/sh\n${script}\n`, { mode: 0o755 }); + return cli; +} + +const openFakeCli = (cli: string) => + Effect.gen(function* () { + const runner = yield* ClaudeAgentSdkQueryRunner; + return yield* runner.open({ + threadId: ThreadId.make("thread-fake-cli"), + providerSessionId: ProviderSessionId.make("session-fake-cli"), + options: { + sessionId: "native-session-fake-cli", + model: "claude-sonnet-4-6", + tools: [], + permissionMode: "default", + pathToClaudeCodeExecutable: cli, + }, + }); + }); + +describe.skipIf(HostProcess.Platform.defaultValue() === "win32")("Claude CLI process", () => { + // The CLI stops the MCP servers it started, but not their children: a + // plugin's `bun server.ts` outlives its `bun run` wrapper (#3089). The fake + // CLI leaves a child running the same way. The child holds a FIFO open, so + // its death reads as end-of-file here, with no polling. + it.effect("stops what the CLI left running when the session closes", () => + Effect.gen(function* () { + const cli = fakeClaudeCli( + [ + 'dir=$(dirname "$0")', + 'sleep 600 > "$dir/alive" &', + 'echo $! > "$dir/leftover.pid"', + "cat > /dev/null", + ].join("\n"), + ); + const dir = NodePath.dirname(cli); + NodeChildProcess.execFileSync("mkfifo", [NodePath.join(dir, "alive")]); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + // Only does anything when the test fails: do not leak its child. + try { + process.kill(Number(NodeFS.readFileSync(NodePath.join(dir, "leftover.pid"), "utf8"))); + } catch {} + NodeFS.rmSync(dir, { recursive: true, force: true }); + }), + ); + const alive = NodeFS.createReadStream(NodePath.join(dir, "alive")); + const leftoverStarted = new Promise((resolve) => alive.once("open", () => resolve())); + const leftoverStopped = new Promise((resolve) => alive.once("close", () => resolve())); + alive.resume(); + + const session = yield* openFakeCli(cli); + yield* Effect.promise(() => leftoverStarted); + yield* session.close; + yield* Effect.promise(() => leftoverStopped); + }).pipe(Effect.provide(realQueryRunner)), + ); +});