diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index dff370bd61101..2e8630309ff04 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -52,6 +52,7 @@ import * as Stream from "effect/Stream"; import * as Scope from "effect/Scope"; import * as TestClock from "effect/testing/TestClock"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import * as ChildProcess from "effect/process/ChildProcess"; import * as EffectAcpErrors from "effect-acp/errors"; import type * as EffectAcpProtocol from "effect-acp/protocol"; import type * as EffectAcpSchema from "effect-acp/compat"; @@ -515,6 +516,71 @@ function makeMockRuntime(input: { }); } +it.live("does not detach an owned Windows ACP process and retains PID tree cleanup", () => { + let finalizerPid = 0; + let spawnedPid = 0; + return Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const path = yield* Path.Path; + const terminated: number[] = []; + const runtime = yield* AcpSessionRuntime.make({ + spawn: { + command: process.execPath, + args: [ + yield* path.fromFileUrl(new URL("../../../scripts/acp-mock-agent.ts", import.meta.url)), + ], + }, + cwd: process.cwd(), + clientInfo: { name: "test", version: "1" }, + ownDetachedProcessGroup: true, + processGroupPlatform: "win32", + windowsProcessTreeTerminator: (pid) => + Effect.sync(() => { + terminated.push(pid); + }), + }).pipe( + Effect.provideService(HostProcess.Platform, "win32"), + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + if (!ChildProcess.isStandardCommand(command)) + return Effect.die("Expected standard command"); + if (command.command === "taskkill") { + finalizerPid = Number(command.args[1]); + // Exercise the Windows finalizer while terminating the mock child on any host. + return spawner.spawn( + ChildProcess.make(process.execPath, ["-e", `process.kill(${spawnedPid})`]), + ); + } + assert.equal(command.command, process.execPath); + assert.isFalse(command.options.detached); + return spawner.spawn(command).pipe( + Effect.tap((child) => + Effect.sync(() => { + spawnedPid = Number(child.pid); + }), + ), + ); + }), + ), + ); + if (runtime.terminateProcessGroup === undefined) + return yield* Effect.die("Missing tree cleanup"); + yield* runtime.terminateProcessGroup; + assert.deepEqual(terminated, [spawnedPid]); + assert.isAbove(spawnedPid, 0); + }).pipe( + Effect.provide(layerTest), + Effect.scoped, + Effect.tap(() => + Effect.sync(() => { + assert.isAbove(spawnedPid, 0); + assert.equal(finalizerPid, spawnedPid); + }), + ), + ); +}); + const isProviderAdapterProtocolError = Schema.is(ProviderAdapter.ProviderAdapterProtocolError); const makePromptInactivityHarness = Effect.fnUntraced(function* (input: { diff --git a/packages/provider-acp/src/server/AcpSessionRuntime.ts b/packages/provider-acp/src/server/AcpSessionRuntime.ts index ecbe61e342746..9e42b5845af4d 100644 --- a/packages/provider-acp/src/server/AcpSessionRuntime.ts +++ b/packages/provider-acp/src/server/AcpSessionRuntime.ts @@ -1244,6 +1244,7 @@ export const make = Effect.fn("AcpSessionRuntime.make")(function* ( > { const random = yield* SecureRandom.SecureRandom; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const hostPlatform = yield* HostProcess.Platform; const runtimeScope = yield* Scope.fork(yield* Scope.Scope); const eventQueue = yield* Queue.unbounded(); const modeStateRef = yield* Ref.make(undefined); @@ -1455,7 +1456,7 @@ export const make = Effect.fn("AcpSessionRuntime.make")(function* ( extendEnv: options.spawn.extendEnv ?? true, ...(options.ownDetachedProcessGroup === undefined ? {} - : { detached: options.ownDetachedProcessGroup }), + : { detached: options.ownDetachedProcessGroup && hostPlatform !== "win32" }), shell: containedSpawnCommand.shell, }), )