diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 8a06dddeeec10..71029a2700a28 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -2429,7 +2429,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { const makeWakeHarnessWithOptions = (options?: { readonly close?: (sdkMessages: Queue.Queue) => Effect.Effect; - readonly interrupt?: Effect.Effect; + readonly interrupt?: Effect.Effect; readonly stopTask?: ( taskId: string, ) => Effect.Effect; @@ -7665,6 +7665,70 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect.each(["is never answered", "fails"] as const)( + "closes the CLI and ends the turn interrupted when Stop's interrupt %s", + (outcome) => + Effect.scoped( + Effect.gen(function* () { + let closes = 0; + const interruptSent = yield* Deferred.make(); + const harness = yield* makeWakeHarnessWithOptions({ + interrupt: Deferred.succeed(interruptSent, undefined).pipe( + Effect.andThen( + outcome === "fails" + ? Effect.fail( + new ClaudeAdapterV2.ClaudeAgentSdkQueryRunnerError({ + method: "interrupt", + cause: new Error("Claude Code control request failed (interrupt)"), + }), + ) + : Effect.never, + ), + ), + close: (sdkMessages) => + Effect.sync(() => { + closes++; + }).pipe(Effect.andThen(Queue.shutdown(sdkMessages))), + }); + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const attemptId = RunAttemptId.make("attempt-claude-stop-without-interrupt-answer"); + const providerTurnId = idAllocator.derive.providerTurn({ + driver: ClaudeAdapterV2.CLAUDE_PROVIDER, + nativeTurnId: `turn:${attemptId}`, + }); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId, + text: "Work until stopped.", + attachments: [], + }), + ); + + const stop = yield* harness.runtime + .interruptTurn({ providerThread: harness.providerThread, providerTurnId }) + .pipe(Effect.forkScoped); + yield* Deferred.await(interruptSent); + if (outcome === "is never answered") { + assert.equal(closes, 0, "Stop gives the CLI time to answer before closing it"); + yield* TestClock.adjust("10 seconds"); + } + yield* awaitUntil(() => stop.pollUnsafe() !== undefined, "Stop to finish"); + yield* Fiber.join(stop); + + assert.equal(closes, 1); + assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "interrupted"); + assert.lengthOf(harness.terminalEvents(), 1); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + it.effect("fails a positive task-notification error result", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index be276af5f0edf..aeb9cf3014655 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -8912,7 +8912,21 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( next.add(turnInput.providerTurnId); return next; }); - yield* existing.query.interrupt; + // The SDK puts no deadline on the interrupt, and a CLI that is alive + // but frozen (memory-throttled, swapping, stopped) never answers it. + // Closing the query is what stops the CLI, so Stop closes it whether + // the interrupt is answered, times out or fails. + yield* existing.query.interrupt.pipe( + Effect.timeout("10 seconds"), + Effect.catch((cause) => + Effect.logWarning("orchestration-v2.claude-query-interrupt-failed", { + providerSessionId: input.providerSessionId, + providerThreadId: turnInput.providerThread.id, + providerTurnId: turnInput.providerTurnId, + cause, + }), + ), + ); yield* existing.query.close.pipe(Effect.ignore); const closed = yield* Deferred.await(existing.closed).pipe( Effect.timeoutOption("10 seconds"),