From 22249a07e0d9c3cbf1794bbc9dbf1a34f33b07af Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 16:45:51 +0000 Subject: [PATCH 1/2] test(server): ACP interrupt after the session scope closes must settle Reproduces #35274: interruptTurn forks its transition into a closed session scope, the fiber never starts, the transition permit leaks, and the next interrupt waits forever. AI-assisted; verified with the tests and tools listed above. --- .../Adapters/AcpAdapterV2.test.ts | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index fb51465629018..61db828176bb8 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -6771,6 +6771,92 @@ describe("AcpAdapterV2", () => { }).pipe(Effect.provide(layerTest), Effect.scoped), ); + it.live("settles interrupts that arrive after the session scope has closed", () => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const path = yield* Path.Path; + const selfInvocation = yield* resolveSelfInvocation(); + const mockAgentPath = yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ); + const protocolEvents = yield* Queue.bounded(256); + const instanceId = ProviderInstanceId.make("acp-test"); + const adapter = yield* makeAcpAdapterV2({ + instanceId, + flavor: { + driver: ACP_TEST_DRIVER, + capabilities: AcpProviderCapabilitiesV2, + interruptPromptOnCancel: false, + restartRuntimeAfterInterrupt: true, + terminateRuntimeProcessGroupOnInterrupt: true, + makeRuntime: makeMockRuntime({ + childProcessSpawner, + mockAgentPath, + ownDetachedProcessGroup: true, + environment: { T3_ACP_HANG_PROMPT_FOREVER: "1" }, + protocolEvents, + }), + }, + selfInvocation, + }); + const threadId = ThreadId.make("thread-acp-interrupt-after-scope-close"); + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + }); + const modelSelection = { instanceId, model: "default" } as const; + const sessionScope = yield* Scope.make(); + const runtime = yield* adapter + .openSession({ + threadId, + providerSessionId: ProviderSessionId.make( + "provider-session-acp-interrupt-after-scope-close", + ), + modelSelection, + runtimePolicy, + }) + .pipe(Effect.provideService(Scope.Scope, sessionScope)); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const now = yield* DateTime.now; + yield* runtime + .startTurn(makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now })) + .pipe(Effect.forkScoped); + yield* Stream.fromQueue(protocolEvents).pipe( + Stream.filter( + (event) => + event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt", + ), + Stream.runHead, + ); + const providerTurnId = idAllocator.derive.providerTurn({ + driver: ACP_TEST_DRIVER, + nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), + }); + // A worktree hand-off releases the session, then ingestion cleanup interrupts again. + yield* Scope.close(sessionScope, Exit.void); + const detachInterrupt = yield* Effect.exit( + runtime.interruptTurn({ providerThread, providerTurnId }), + ); + assert.isFalse( + Exit.isFailure(detachInterrupt) && Cause.hasInterruptsOnly(detachInterrupt.cause), + "an interrupt nobody cancelled must not exit as interrupted", + ); + const cleanupInterrupt = yield* Effect.exit( + runtime.interruptTurn({ providerThread, providerTurnId, requestRuntimeRestart: true }), + ); + assert.isFalse( + Exit.isFailure(cleanupInterrupt) && Cause.hasInterruptsOnly(cleanupInterrupt.cause), + "a later interrupt must settle instead of waiting on a leaked transition permit", + ); + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + it.effect( "carries a live subagent lineage across an interrupt so the next turn can complete it", () => From 73080b88f6c4740eec4b528a3d86a6ab6bb9fb41 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 16:53:23 +0000 Subject: [PATCH 2/2] fix(server): Grok hand-off turns settle after the worktree changes AcpAdapterV2.interruptTurn forked its transition into the session scope. After a worktree hand-off detaches the session, that scope is closed, so the fork never started, the transition permit leaked, and the ingestion cleanup's interrupt waited on it forever, leaving the run running. Fork detached when the session scope is already closed. Fixes pingdotgg/t3code#35274 AI-assisted; verified with the tests and tools listed above. --- packages/provider-acp/src/server/adapter.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index 8278dcc35fdfd..57962890f6f89 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -9189,7 +9189,13 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( ); }).pipe( Effect.ensuring(runtimeTransitionPermit.release(1)), - Effect.forkIn(sessionScope, { startImmediately: true, uninterruptible: true }), + // forkIn on a closed scope never starts the fiber, which would leak the permit. + sessionScope.state._tag === "Closed" + ? Effect.forkDetach({ startImmediately: true, uninterruptible: true }) + : Effect.forkIn(sessionScope, { + startImmediately: true, + uninterruptible: true, + }), ); return yield* restore(Fiber.join(transitionFiber)).pipe( Effect.onInterrupt(() =>