From 0f55d6b9b268a4acd9017e9a592a05e172a0cec1 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Wed, 14 Oct 2026 01:31:54 +0000 Subject: [PATCH 1/7] fix(server): steering no longer ends running subagents A steer could end the thread's native subagents in two ways: - Claude: steers go in with priority "now", which cancels the root turn's running tool calls. A subagent launched in the foreground is one of them. The adapter now moves the root's running subagents to the background first, so the steer is answered and the subagent keeps going. - Codex (and OpenCode): a steer whose model selection differed from the running turn's in any way was turned into interrupt-and-restart, which interrupts every descendant subagent turn. A selection the provider applies on its next turn now joins the turn as a steer and waits for the next turn, as it already did for Claude. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 167 ++++++++++++++++++ .../Adapters/ClaudeAdapterV2.testkit.ts | 5 + .../Adapters/ClaudeAdapterV2.ts | 58 ++++++ ...laudeAutomaticDelivery.integration.test.ts | 1 + .../src/orchestration-v2/Orchestrator.ts | 14 +- .../SteeringCompletion.integration.test.ts | 151 ++++++++-------- 6 files changed, 314 insertions(+), 82 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 8b8eabe8fc9d..c89b2ffb95c3 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -1028,6 +1028,7 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => { offer: () => Effect.void, setModel: () => Effect.void, setPermissionMode: () => Effect.void, + backgroundTasks: () => Effect.succeed(false), stopTask: () => Effect.void, interrupt: Effect.void, close: Effect.void, @@ -1188,6 +1189,7 @@ const captureSdkExecutablePaths = Effect.fn("captureSdkExecutablePaths")(functio offer: () => Effect.void, setModel: () => Effect.void, setPermissionMode: () => Effect.void, + backgroundTasks: () => Effect.succeed(false), stopTask: () => Effect.void, interrupt: Effect.void, close: Effect.void, @@ -1308,6 +1310,7 @@ describe("ClaudeAdapterV2 resume compaction", () => { offer: () => Effect.void, setModel: () => Effect.void, setPermissionMode: () => Effect.void, + backgroundTasks: () => Effect.succeed(false), stopTask: () => Effect.void, interrupt: Effect.void, close: Effect.void, @@ -1534,6 +1537,7 @@ describe("ClaudeAdapterV2 attachments", () => { }), setModel: () => Effect.void, setPermissionMode: () => Effect.void, + backgroundTasks: () => Effect.succeed(false), stopTask: () => Effect.void, interrupt: Effect.void, close: Effect.void, @@ -1679,6 +1683,7 @@ describe("ClaudeAdapterV2 attachments", () => { offer: () => Effect.void, setModel: () => Effect.void, setPermissionMode: () => Effect.void, + backgroundTasks: () => Effect.succeed(false), stopTask: () => Effect.void, interrupt: Effect.void, close: Effect.void, @@ -1774,6 +1779,7 @@ describe("ClaudeAdapterV2 native fork", () => { offer: () => Effect.void, setModel: () => Effect.void, setPermissionMode: () => Effect.void, + backgroundTasks: () => Effect.succeed(false), stopTask: () => Effect.void, interrupt: Effect.void, close: Effect.void, @@ -2066,6 +2072,7 @@ describe("ClaudeAdapterV2 native session identity", () => { offer: () => Effect.void, setModel: () => Effect.void, setPermissionMode: () => Effect.void, + backgroundTasks: () => Effect.succeed(false), stopTask: () => Effect.void, interrupt: Effect.void, close: Effect.void, @@ -2321,6 +2328,9 @@ describe("ClaudeAdapterV2 background wake turns", () => { readonly stopTask?: ( taskId: string, ) => Effect.Effect; + readonly backgroundTasks?: ( + toolUseId: string, + ) => Effect.Effect; readonly environment?: NodeJS.ProcessEnv; readonly subagentLaunchToolUseId?: string; readonly nativeSessionId?: string; @@ -2398,6 +2408,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { }), setModel: () => Effect.void, setPermissionMode: () => Effect.void, + backgroundTasks: options?.backgroundTasks ?? (() => Effect.succeed(false)), stopTask: options?.stopTask ?? (() => Effect.void), interrupt: Effect.void, close: Queue.shutdown(processMessages), @@ -2431,6 +2442,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { Effect.sync(() => { permissionModeChanges.push(mode); }), + backgroundTasks: options?.backgroundTasks ?? (() => Effect.succeed(false)), stopTask: options?.stopTask ?? (() => Effect.void), interrupt: options?.interrupt ?? Effect.void, close: options?.close?.(sdkMessages) ?? Effect.void, @@ -4869,6 +4881,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { offer: () => Effect.void, setModel: () => Effect.void, setPermissionMode: () => Effect.void, + backgroundTasks: () => Effect.succeed(false), stopTask: () => Effect.void, interrupt: Effect.void, // The first CLI process keeps streaming until the test ends @@ -5129,6 +5142,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { offer: () => Effect.void, setModel: () => Effect.void, setPermissionMode: () => Effect.void, + backgroundTasks: () => Effect.succeed(false), stopTask: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(queue), @@ -10220,6 +10234,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { offer: () => Effect.void, setModel: () => Effect.void, setPermissionMode: () => Effect.void, + backgroundTasks: () => Effect.succeed(false), stopTask: () => Effect.void, interrupt: Effect.void, // End this process stream so openQuery can replace it. @@ -10410,6 +10425,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { offer: () => Effect.void, setModel: () => Effect.void, setPermissionMode: () => Effect.void, + backgroundTasks: () => Effect.succeed(false), stopTask: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(sdkMessages), @@ -10652,6 +10668,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { offer: () => Effect.void, setModel: () => Effect.void, setPermissionMode: () => Effect.void, + backgroundTasks: () => Effect.succeed(false), stopTask: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(sdkMessages), @@ -10853,6 +10870,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { offer: () => Effect.void, setModel: () => Effect.void, setPermissionMode: () => Effect.void, + backgroundTasks: () => Effect.succeed(false), stopTask: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(sdkMessages), @@ -11036,6 +11054,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { offer: () => Effect.void, setModel: () => Effect.void, setPermissionMode: () => Effect.void, + backgroundTasks: () => Effect.succeed(false), stopTask: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(sdkMessages), @@ -11175,6 +11194,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { offer: () => Effect.void, setModel: () => Effect.void, setPermissionMode: () => Effect.void, + backgroundTasks: () => Effect.succeed(false), stopTask: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(sdkMessages), @@ -13776,6 +13796,153 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect.each([{ backgroundingFails: false }, { backgroundingFails: true }])( + "moves the root's foreground subagents to the background before a steer (fails=$backgroundingFails)", + ({ backgroundingFails }) => + Effect.scoped( + Effect.gen(function* () { + const FOREGROUND_TOOL_USE_ID = "toolu-steer-foreground"; + const NESTED_TOOL_USE_ID = "toolu-steer-nested"; + const FINISHED_TOOL_USE_ID = "toolu-steer-finished"; + const calls: Array = []; + const harness = yield* makeWakeHarnessWithOptions({ + backgroundTasks: (toolUseId) => + Effect.sync(() => calls.push(toolUseId)).pipe( + Effect.andThen( + backgroundingFails + ? Effect.fail( + new ClaudeAdapterV2.ClaudeAgentSdkQueryRunnerError({ + method: "backgroundTasks", + cause: new Error("Background tasks are disabled in this session."), + }), + ) + : Effect.succeed(true), + ), + ), + }); + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const attemptId = RunAttemptId.make("attempt-steer-foreground-subagent"); + const input = makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId, + text: "Audit the repo with a subagent.", + attachments: [], + }); + yield* harness.runtime.startTurn(input); + const foregroundTaskStarted = (task: { + readonly taskId: string; + readonly toolUseId: string; + readonly uuid: string; + }) => + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: task.taskId, + tool_use_id: task.toolUseId, + description: task.taskId, + subagent_type: "general-purpose", + is_backgrounded: false, + task_type: "local_agent", + prompt: "Audit.", + uuid: task.uuid, + session_id: WAKE_NATIVE_SESSION, + }); + for (const frame of [ + foregroundTaskStarted({ + taskId: "task-steer-finished", + toolUseId: FINISHED_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000a01", + }), + makeSubagentNotificationFrame({ + taskId: "task-steer-finished", + toolUseId: FINISHED_TOOL_USE_ID, + summary: "Finished before the steer.", + uuid: "00000000-0000-4000-8000-000000000a02", + }), + foregroundTaskStarted({ + taskId: "task-steer-foreground", + toolUseId: FOREGROUND_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000a03", + }), + claudeSdkFrame({ + type: "assistant", + message: { + model: "claude-sonnet-4-6", + id: "msg_steer_nested_launch", + type: "message", + role: "assistant", + content: [ + { + type: "tool_use", + id: NESTED_TOOL_USE_ID, + name: "Agent", + input: { description: "Nested check", prompt: "Check." }, + }, + ], + }, + parent_tool_use_id: FOREGROUND_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000a04", + session_id: WAKE_NATIVE_SESSION, + }), + foregroundTaskStarted({ + taskId: "task-steer-nested", + toolUseId: NESTED_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000a05", + }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + const latestSubagent = (taskId: string) => + harness.events + .flatMap((event) => + event.type === "subagent.updated" && + event.subagent.nativeTaskRef?.nativeId === taskId + ? [event.subagent] + : [], + ) + .at(-1); + yield* awaitUntil( + () => + latestSubagent("task-steer-nested")?.status === "running" && + latestSubagent("task-steer-foreground")?.status === "running" && + latestSubagent("task-steer-finished")?.status === "completed", + "subagents registered", + ); + + const offered = harness.offeredMessages.length; + yield* harness.runtime.steerTurn({ + threadId: harness.threadId, + runId: input.runId, + providerThread: harness.providerThread, + providerTurnId: idAllocator.derive.providerTurn({ + driver: ClaudeAdapterV2.CLAUDE_PROVIDER, + nativeTurnId: `turn:${attemptId}`, + }), + message: { + createdBy: "user", + creationSource: "web", + messageId: MessageId.make("message-steer-foreground-subagent"), + text: "Also check the docs.", + attachments: [], + }, + }); + + // Only the root's running subagent moves: the nested one follows its + // owner and the finished one has nothing left to move. A refused move + // still lets the steer through. + assert.deepEqual(calls, [FOREGROUND_TOOL_USE_ID]); + assert.equal(harness.offeredMessages.length, offered + 1); + assert.equal(harness.offeredMessages.at(-1)?.priority, "now"); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + describe("native goals", () => { const syntheticFrame = (uuid: string, text: string) => claudeSdkFrame({ diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts index de9254aeaf71..bb16afd1d622 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts @@ -243,6 +243,7 @@ interface ClaudeSubagentFoundFrame { type ClaudeOutboundFrame = | { readonly type: "query.stop_task"; readonly taskId: string } + | { readonly type: "query.background_tasks"; readonly toolUseId: string } | ClaudeQueryOpenFrame | ClaudePromptOfferFrame | ClaudeQuerySetModelFrame @@ -830,6 +831,10 @@ function makeReplayQueryRunner( replayEffect(() => { assertNextOutboundFrame({ type: "query.stop_task", taskId }); }), + backgroundTasks: (toolUseId) => + replayEffect(() => { + assertNextOutboundFrame({ type: "query.background_tasks", toolUseId }); + }).pipe(Effect.as(true)), interrupt: replayEffect(() => { assertNextOutboundFrame({ type: "query.interrupt" }); }), diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 7aff6b96eb5d..7417fd8ccdd1 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -382,6 +382,10 @@ export interface ClaudeAgentSdkQuerySession { mode: PermissionMode, ) => Effect.Effect; readonly stopTask: (taskId: string) => Effect.Effect; + /** Moves the foreground task one tool call started to the background; false when none matched. */ + readonly backgroundTasks: ( + toolUseId: string, + ) => Effect.Effect; readonly interrupt: Effect.Effect; readonly close: Effect.Effect; } @@ -537,6 +541,14 @@ export type ClaudeAgentSdkProtocolLogEvent = readonly taskId: string; }; } + | { + readonly direction: "outgoing"; + readonly stage: "decoded"; + readonly payload: { + readonly type: "query.background_tasks"; + readonly toolUseId: string; + }; + } | { readonly direction: "outgoing"; readonly stage: "decoded"; @@ -842,6 +854,19 @@ export const layerQueryRunner: Layer.Layer< }), ), ), + backgroundTasks: (toolUseId) => + Effect.tryPromise({ + try: () => queryRuntime.backgroundTasks(toolUseId), + catch: (cause) => queryRunnerError(cause, "backgroundTasks"), + }).pipe( + Effect.tap(() => + logProtocolEvent({ + direction: "outgoing", + stage: "decoded", + payload: { type: "query.background_tasks", toolUseId }, + }), + ), + ), interrupt: Effect.tryPromise({ try: () => queryRuntime.interrupt(), catch: (cause) => queryRunnerError(cause, "interrupt"), @@ -8814,6 +8839,38 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ), ); + // A steer is sent with priority "now", which cancels the root turn's + // running tool calls. A subagent the root launched in the foreground is + // one of them, so it moves to the background first and keeps running; + // its result arrives as a task notification like any background agent. + // Subagents a subagent launched stay put: they follow their owner. + const backgroundForegroundSubagents = Effect.fnUntraced(function* ( + live: ClaudeLiveQueryContext, + ) { + for (const subagent of (yield* Ref.get(sessionSubagentsByTaskId)).values()) { + const toolUseId = subagent.runToolUseId; + if ( + toolUseId === null || + subagent.task.status !== "running" || + subagent.task.parentNodeId !== subagent.rootNodeId || + live.subagentsFromEarlierProcesses.has(subagent) + ) { + continue; + } + // Backgrounding is refused when the session disables background + // tasks. The steer still goes through, as it did before. + yield* live.query.backgroundTasks(toolUseId).pipe( + Effect.catch((cause) => + Effect.logWarning("orchestration-v2.claude-steer-background-subagent-failed", { + taskId: subagent.task.nativeTaskRef?.nativeId, + toolUseId, + cause, + }), + ), + ); + } + }); + const steerTurn = Effect.fn("ClaudeAdapterV2.steerTurn")( function* (turnInput: ProviderAdapter.ProviderAdapterV2SteerInput) { const existing = yield* Ref.get(queryContext); @@ -8846,6 +8903,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( next.add(turnInput.providerTurnId); return next; }); + yield* backgroundForegroundSubagents(existing); yield* existing.query.offer(userMessage); }, (effect, turnInput) => diff --git a/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts b/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts index 279c9bced95e..05f712e3b1f6 100644 --- a/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts +++ b/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts @@ -160,6 +160,7 @@ it.effect.each(["child completion", "scheduled message", "user steering"] as con }), setModel: () => Effect.void, setPermissionMode: () => Effect.void, + backgroundTasks: () => Effect.succeed(false), stopTask: () => Effect.void, interrupt: Effect.die("automatic delivery must never interrupt"), close: Effect.void, diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index e1497473f1de..6c3ea5e9b66a 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -4024,10 +4024,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }); }); - // A selection the provider applies on its next turn restarts the run when - // the provider can restart it. Otherwise the steer joins the running turn - // and the selection waits for the next one, rather than failing the steer. - const turnCapabilities = session.providerSession.capabilities.turns; + // A selection the provider applies on its next turn joins the running + // turn as a steer and waits for the next turn. Restarting the turn for it + // would end the turn's native subagents, which a steer must never do. const selectionMustApplyNow = selectionChanged && (providerInstanceChanged || selectionTransition?.type !== "apply_on_next_turn"); @@ -4037,12 +4036,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio threadId: input.command.threadId, providerInstanceId: targetRun.providerInstanceId, capabilities: session.providerSession.capabilities, - forceRestart: - input.forceRestart || - selectionMustApplyNow || - (selectionChanged && - turnCapabilities.supportsInterrupt && - turnCapabilities.supportsSteeringByInterruptRestart), + forceRestart: input.forceRestart || selectionMustApplyNow, }), ); diff --git a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts index 730b72feea6b..f99328d1fc66 100644 --- a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts +++ b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts @@ -405,8 +405,9 @@ it.effect.each( ), ); -// Claude and Pi steer live but cannot interrupt-and-restart, so a changed -// selection waits for the next turn as the thread's saved selection. +// A changed selection the provider applies on its next turn waits there as the +// thread's saved selection. That holds for a provider that can restart the +// turn (Codex) too: restarting it would end the turn's native subagents. const runSelection = { instanceId, model: "test-model", @@ -417,7 +418,10 @@ const composerSelection = { options: [...runSelection.options, { id: "fastMode", value: false }], }; -const nextTurnSelectionHarness = Effect.fn("nextTurnSelectionHarness")(function* (name: string) { +const nextTurnSelectionHarness = Effect.fn("nextTurnSelectionHarness")(function* ( + name: string, + canRestart = false, +) { const cwd = yield* checkpointWorkspace(name); const events = yield* Queue.unbounded(); const started: ProviderAdapter.ProviderAdapterV2TurnInput[] = []; @@ -427,7 +431,7 @@ const nextTurnSelectionHarness = Effect.fn("nextTurnSelectionHarness")(function* turns: { ...CodexProviderCapabilitiesV2.turns, supportsActiveSteering: true, - supportsSteeringByInterruptRestart: false, + supportsSteeringByInterruptRestart: canRestart, }, }; const adapter: ProviderAdapter.ProviderAdapterV2["Service"] = { @@ -560,79 +564,82 @@ const nextTurnSelectionHarness = Effect.fn("nextTurnSelectionHarness")(function* return { events, started, steered, layer, startFirstTurn }; }); -it.effect("steers a changed turn-scoped selection into a provider that cannot restart", () => - Effect.scoped( - Effect.gen(function* () { - const { started, steered, layer, startFirstTurn } = yield* nextTurnSelectionHarness( - "steering-selection-change", - ); - yield* Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; - const threadId = yield* startFirstTurn; - const steer = (id: string, selection: ModelSelection) => - orchestrator - .dispatch({ - type: "message.dispatch", - commandId: CommandId.make(id), - threadId, - messageId: MessageId.make(`message:${id}`), - text: id, - attachments: [], - modelSelection: selection, - dispatchMode: { type: "steer_active", targetRunId: started[0]!.runId }, - createdBy: "user", - creationSource: "web", - }) - .pipe(Effect.andThen(worker.drain())); +it.effect.each([{ canRestart: false }, { canRestart: true }])( + "steers a changed turn-scoped selection into the running turn (canRestart=$canRestart)", + ({ canRestart }) => + Effect.scoped( + Effect.gen(function* () { + const { started, steered, layer, startFirstTurn } = yield* nextTurnSelectionHarness( + `steering-selection-change-${canRestart}`, + canRestart, + ); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const threadId = yield* startFirstTurn; + const steer = (id: string, selection: ModelSelection) => + orchestrator + .dispatch({ + type: "message.dispatch", + commandId: CommandId.make(id), + threadId, + messageId: MessageId.make(`message:${id}`), + text: id, + attachments: [], + modelSelection: selection, + dispatchMode: { type: "steer_active", targetRunId: started[0]!.runId }, + createdBy: "user", + creationSource: "web", + }) + .pipe(Effect.andThen(worker.drain())); - yield* steer("steer-changed", composerSelection); - const changed = yield* orchestrator.getThreadProjection(threadId); - assert.deepEqual(steered, ["steer-changed"]); - assert.equal(started.length, 1); - assert.lengthOf(changed.attempts, 1); - assert.equal(changed.runs[0]?.status, "running"); - assert.deepEqual(changed.runs[0]?.modelSelection, runSelection); - assert.deepEqual(changed.thread.modelSelection, composerSelection); + yield* steer("steer-changed", composerSelection); + const changed = yield* orchestrator.getThreadProjection(threadId); + assert.deepEqual(steered, ["steer-changed"]); + assert.equal(started.length, 1); + assert.lengthOf(changed.attempts, 1); + assert.equal(changed.runs[0]?.status, "running"); + assert.deepEqual(changed.runs[0]?.modelSelection, runSelection); + assert.deepEqual(changed.thread.modelSelection, composerSelection); - // Choosing the running run's selection again replaces the saved choice. - yield* steer("steer-reverted", runSelection); - const reverted = yield* orchestrator.getThreadProjection(threadId); - assert.deepEqual(steered, ["steer-changed", "steer-reverted"]); - assert.lengthOf(reverted.attempts, 1); - assert.deepEqual(reverted.thread.modelSelection, runSelection); + // Choosing the running run's selection again replaces the saved choice. + yield* steer("steer-reverted", runSelection); + const reverted = yield* orchestrator.getThreadProjection(threadId); + assert.deepEqual(steered, ["steer-changed", "steer-reverted"]); + assert.lengthOf(reverted.attempts, 1); + assert.deepEqual(reverted.thread.modelSelection, runSelection); - // The saved choice moves to another instance while the run keeps going. - // Steering with the run's selection brings the thread's instance back too. - const otherSelection = { - instanceId: ProviderInstanceId.make("codex-work"), - model: "other", - }; - const sink = yield* EventSink.EventSinkV2; - yield* sink.write({ - events: [ - { - id: EventId.make("switched-away"), - type: "thread.provider-switched", - threadId, - providerInstanceId: otherSelection.instanceId, - occurredAt: yield* DateTime.now, - payload: { - ...reverted.thread, + // The saved choice moves to another instance while the run keeps going. + // Steering with the run's selection brings the thread's instance back too. + const otherSelection = { + instanceId: ProviderInstanceId.make("codex-work"), + model: "other", + }; + const sink = yield* EventSink.EventSinkV2; + yield* sink.write({ + events: [ + { + id: EventId.make("switched-away"), + type: "thread.provider-switched", + threadId, providerInstanceId: otherSelection.instanceId, - modelSelection: otherSelection, + occurredAt: yield* DateTime.now, + payload: { + ...reverted.thread, + providerInstanceId: otherSelection.instanceId, + modelSelection: otherSelection, + }, }, - }, - ], - }); - yield* steer("steer-back", runSelection); - const back = yield* orchestrator.getThreadProjection(threadId); - assert.deepEqual(steered, ["steer-changed", "steer-reverted", "steer-back"]); - assert.equal(back.thread.providerInstanceId, instanceId); - assert.deepEqual(back.thread.modelSelection, runSelection); - }).pipe(Effect.provide(layer)); - }), - ), + ], + }); + yield* steer("steer-back", runSelection); + const back = yield* orchestrator.getThreadProjection(threadId); + assert.deepEqual(steered, ["steer-changed", "steer-reverted", "steer-back"]); + assert.equal(back.thread.providerInstanceId, instanceId); + assert.deepEqual(back.thread.modelSelection, runSelection); + }).pipe(Effect.provide(layer)); + }), + ), ); it.effect("starts a steer that missed the turn on the saved next-turn selection", () => From e32ab94d59bdf12396209038316921de252976b4 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Wed, 14 Oct 2026 01:48:25 +0000 Subject: [PATCH 2/7] fix(server): an unanswered background request cannot hold a steer The SDK puts no deadline on control requests, so each backgroundTasks call now times out after 5 seconds and the calls run together. The steer goes out either way. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 29 +++++++---- .../Adapters/ClaudeAdapterV2.ts | 48 +++++++++++-------- 2 files changed, 48 insertions(+), 29 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index c89b2ffb95c3..1048327f1148 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -13796,9 +13796,13 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - it.effect.each([{ backgroundingFails: false }, { backgroundingFails: true }])( - "moves the root's foreground subagents to the background before a steer (fails=$backgroundingFails)", - ({ backgroundingFails }) => + it.effect.each([ + { backgrounding: "succeeds" }, + { backgrounding: "fails" }, + { backgrounding: "never answers" }, + ] as const)( + "moves the root's foreground subagents to the background before a steer ($backgrounding)", + ({ backgrounding }) => Effect.scoped( Effect.gen(function* () { const FOREGROUND_TOOL_USE_ID = "toolu-steer-foreground"; @@ -13809,14 +13813,16 @@ describe("ClaudeAdapterV2 background wake turns", () => { backgroundTasks: (toolUseId) => Effect.sync(() => calls.push(toolUseId)).pipe( Effect.andThen( - backgroundingFails + backgrounding === "fails" ? Effect.fail( new ClaudeAdapterV2.ClaudeAgentSdkQueryRunnerError({ method: "backgroundTasks", cause: new Error("Background tasks are disabled in this session."), }), ) - : Effect.succeed(true), + : backgrounding === "never answers" + ? Effect.never + : Effect.succeed(true), ), ), }); @@ -13912,7 +13918,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); const offered = harness.offeredMessages.length; - yield* harness.runtime.steerTurn({ + const steer = harness.runtime.steerTurn({ threadId: harness.threadId, runId: input.runId, providerThread: harness.providerThread, @@ -13928,10 +13934,17 @@ describe("ClaudeAdapterV2 background wake turns", () => { attachments: [], }, }); + const steering = yield* Effect.forkScoped(steer); + if (backgrounding === "never answers") { + yield* awaitUntil(() => calls.length === 1, "background request"); + assert.equal(harness.offeredMessages.length, offered); + yield* TestClock.adjust("5 seconds"); + } + yield* Fiber.join(steering); // Only the root's running subagent moves: the nested one follows its - // owner and the finished one has nothing left to move. A refused move - // still lets the steer through. + // owner and the finished one has nothing left to move. A refused or + // unanswered move still lets the steer through. assert.deepEqual(calls, [FOREGROUND_TOOL_USE_ID]); assert.equal(harness.offeredMessages.length, offered + 1); assert.equal(harness.offeredMessages.at(-1)?.priority, "now"); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 7417fd8ccdd1..1fcca7dc74bd 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -8847,28 +8847,34 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const backgroundForegroundSubagents = Effect.fnUntraced(function* ( live: ClaudeLiveQueryContext, ) { - for (const subagent of (yield* Ref.get(sessionSubagentsByTaskId)).values()) { - const toolUseId = subagent.runToolUseId; - if ( - toolUseId === null || - subagent.task.status !== "running" || - subagent.task.parentNodeId !== subagent.rootNodeId || - live.subagentsFromEarlierProcesses.has(subagent) - ) { - continue; - } - // Backgrounding is refused when the session disables background - // tasks. The steer still goes through, as it did before. - yield* live.query.backgroundTasks(toolUseId).pipe( - Effect.catch((cause) => - Effect.logWarning("orchestration-v2.claude-steer-background-subagent-failed", { - taskId: subagent.task.nativeTaskRef?.nativeId, - toolUseId, - cause, - }), + const foreground = Array.from( + (yield* Ref.get(sessionSubagentsByTaskId)).values(), + ).flatMap((subagent) => + subagent.runToolUseId !== null && + subagent.task.status === "running" && + subagent.task.parentNodeId === subagent.rootNodeId && + !live.subagentsFromEarlierProcesses.has(subagent) + ? [{ subagent, toolUseId: subagent.runToolUseId }] + : [], + ); + // Backgrounding is refused when the session disables background + // tasks, and the SDK puts no deadline on the request. Either way the + // steer still goes through, as it did before. + yield* Effect.forEach( + foreground, + ({ subagent, toolUseId }) => + live.query.backgroundTasks(toolUseId).pipe( + Effect.timeout("5 seconds"), + Effect.catch((cause) => + Effect.logWarning("orchestration-v2.claude-steer-background-subagent-failed", { + taskId: subagent.task.nativeTaskRef?.nativeId, + toolUseId, + cause, + }), + ), ), - ); - } + { concurrency: "unbounded", discard: true }, + ); }); const steerTurn = Effect.fn("ClaudeAdapterV2.steerTurn")( From 51ba271d8d9c249301da8c2a909579aa4f34515f Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Wed, 14 Oct 2026 02:00:26 +0000 Subject: [PATCH 3/7] fix(server): a subagent moved to the background before a steer wakes the root when it ends Only subagents Claude started in the background were recorded as such, so one T3 moved there could end during a later user turn without its wake naming it. A successful move now records it the same way. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 44 +++++++++++++++++++ .../Adapters/ClaudeAdapterV2.ts | 10 +++++ 2 files changed, 54 insertions(+) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 1048327f1148..1e7a8efbc5ac 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -13948,6 +13948,50 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.deepEqual(calls, [FOREGROUND_TOOL_USE_ID]); assert.equal(harness.offeredMessages.length, offered + 1); assert.equal(harness.offeredMessages.at(-1)?.priority, "now"); + if (backgrounding !== "succeeds") return; + + // The moved subagent outlives the steered turn. When it ends during + // the user's next turn, Claude's wake after that turn names it. + for (const frame of [ + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000a06", + result: "", + terminalReason: "aborted_tools", + }), + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000a07", result: "Docs too." }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* awaitUntil(() => harness.terminalEvents().length === 1, "steered turn terminal"); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-steer-next-turn"), + text: "Anything else?", + attachments: [], + providerTurnOrdinal: 2, + }), + ); + for (const frame of [ + makeSubagentNotificationFrame({ + taskId: "task-steer-foreground", + toolUseId: FOREGROUND_TOOL_USE_ID, + summary: "AUDITED", + uuid: "00000000-0000-4000-8000-000000000a08", + }), + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000a09", result: "No." }), + ]) { + yield* Queue.offer(harness.sdkMessages, frame); + } + yield* awaitUntil(() => harness.terminalEvents().length === 2, "next turn terminal"); + yield* Queue.offer(harness.sdkMessages, wakeTurnInit); + yield* awaitUntil(() => harness.continuationRequests.length === 1, "subagent wake"); + assert.equal( + harness.continuationRequests[0]?.notification?.summary, + 'Subagent "task-steer-foreground" finished', + ); }).pipe( Effect.provide( Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 1fcca7dc74bd..81791d864788 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -8865,6 +8865,16 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ({ subagent, toolUseId }) => live.query.backgroundTasks(toolUseId).pipe( Effect.timeout("5 seconds"), + // Now a background subagent: its end wakes the root like one + // Claude started in the background. + Effect.flatMap((moved) => { + const taskId = subagent.task.nativeTaskRef?.nativeId; + return moved && typeof taskId === "string" + ? Ref.update(backgroundedSubagentTaskIds, (current) => + new Set(current).add(taskId), + ) + : Effect.void; + }), Effect.catch((cause) => Effect.logWarning("orchestration-v2.claude-steer-background-subagent-failed", { taskId: subagent.task.nativeTaskRef?.nativeId, From 440e2e8bacc2fd3850ed393e00aa17f732522fd8 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Wed, 14 Oct 2026 04:38:00 +0000 Subject: [PATCH 4/7] fix(server): steer backgrounding skips nested subagents and turns that ended - A nested subagent whose launch the adapter never saw falls back to the root as its parent node; its spawn depth now keeps it out of the move. - The turn can settle while the moves are answered. The steer then fails instead of opening a turn nobody asked for, so the orchestrator starts the follow-up turn as it does for any steer that misses its turn. - A subagent that ended before its move was answered is not marked as backgrounded, so a later foreground run of it cannot report a wake. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 56 +++++++++++---- .../Adapters/ClaudeAdapterV2.ts | 69 +++++++++++++------ 2 files changed, 90 insertions(+), 35 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 1e7a8efbc5ac..755463a37876 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -13800,6 +13800,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { { backgrounding: "succeeds" }, { backgrounding: "fails" }, { backgrounding: "never answers" }, + { backgrounding: "answers after the turn ended" }, ] as const)( "moves the root's foreground subagents to the background before a steer ($backgrounding)", ({ backgrounding }) => @@ -13809,20 +13810,24 @@ describe("ClaudeAdapterV2 background wake turns", () => { const NESTED_TOOL_USE_ID = "toolu-steer-nested"; const FINISHED_TOOL_USE_ID = "toolu-steer-finished"; const calls: Array = []; + // The root turn settles while the move is still unanswered. + let endTurnFirst: Effect.Effect = Effect.void; const harness = yield* makeWakeHarnessWithOptions({ backgroundTasks: (toolUseId) => Effect.sync(() => calls.push(toolUseId)).pipe( Effect.andThen( - backgrounding === "fails" - ? Effect.fail( - new ClaudeAdapterV2.ClaudeAgentSdkQueryRunnerError({ - method: "backgroundTasks", - cause: new Error("Background tasks are disabled in this session."), - }), - ) - : backgrounding === "never answers" - ? Effect.never - : Effect.succeed(true), + backgrounding === "answers after the turn ended" + ? Effect.suspend(() => endTurnFirst).pipe(Effect.as(true)) + : backgrounding === "fails" + ? Effect.fail( + new ClaudeAdapterV2.ClaudeAgentSdkQueryRunnerError({ + method: "backgroundTasks", + cause: new Error("Background tasks are disabled in this session."), + }), + ) + : backgrounding === "never answers" + ? Effect.never + : Effect.succeed(true), ), ), }); @@ -13837,7 +13842,14 @@ describe("ClaudeAdapterV2 background wake turns", () => { attachments: [], }); yield* harness.runtime.startTurn(input); + endTurnFirst = Queue.offer( + harness.sdkMessages, + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000a10", result: "Done." }), + ).pipe( + Effect.andThen(awaitUntil(() => harness.terminalEvents().length === 1, "turn end")), + ); const foregroundTaskStarted = (task: { + readonly spawnDepth?: number; readonly taskId: string; readonly toolUseId: string; readonly uuid: string; @@ -13852,6 +13864,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { is_backgrounded: false, task_type: "local_agent", prompt: "Audit.", + ...(task.spawnDepth === undefined ? {} : { spawn_depth: task.spawnDepth }), uuid: task.uuid, session_id: WAKE_NATIVE_SESSION, }); @@ -13897,6 +13910,13 @@ describe("ClaudeAdapterV2 background wake turns", () => { toolUseId: NESTED_TOOL_USE_ID, uuid: "00000000-0000-4000-8000-000000000a05", }), + // Nested too, though its launch never reached the adapter. + foregroundTaskStarted({ + taskId: "task-steer-deep", + toolUseId: "toolu-steer-deep", + spawnDepth: 2, + uuid: "00000000-0000-4000-8000-000000000a11", + }), ]) { yield* Queue.offer(harness.sdkMessages, frame); } @@ -13912,6 +13932,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* awaitUntil( () => latestSubagent("task-steer-nested")?.status === "running" && + latestSubagent("task-steer-deep")?.status === "running" && latestSubagent("task-steer-foreground")?.status === "running" && latestSubagent("task-steer-finished")?.status === "completed", "subagents registered", @@ -13940,12 +13961,19 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.equal(harness.offeredMessages.length, offered); yield* TestClock.adjust("5 seconds"); } - yield* Fiber.join(steering); + const steered = yield* Fiber.await(steering); - // Only the root's running subagent moves: the nested one follows its - // owner and the finished one has nothing left to move. A refused or - // unanswered move still lets the steer through. + // Only the root's running subagent moves: the nested ones follow + // their owner and the finished one has nothing left to move. assert.deepEqual(calls, [FOREGROUND_TOOL_USE_ID]); + if (backgrounding === "answers after the turn ended") { + // The steer is refused, so the orchestrator starts its follow-up turn. + assert.isTrue(Exit.isFailure(steered)); + assert.equal(harness.offeredMessages.length, offered); + return; + } + // A refused or unanswered move still lets the steer through. + assert.isTrue(Exit.isSuccess(steered)); assert.equal(harness.offeredMessages.length, offered + 1); assert.equal(harness.offeredMessages.at(-1)?.priority, "now"); if (backgrounding !== "succeeds") return; diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 81791d864788..8d530c8860c0 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -8847,37 +8847,52 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const backgroundForegroundSubagents = Effect.fnUntraced(function* ( live: ClaudeLiveQueryContext, ) { - const foreground = Array.from( - (yield* Ref.get(sessionSubagentsByTaskId)).values(), - ).flatMap((subagent) => - subagent.runToolUseId !== null && - subagent.task.status === "running" && - subagent.task.parentNodeId === subagent.rootNodeId && - !live.subagentsFromEarlierProcesses.has(subagent) - ? [{ subagent, toolUseId: subagent.runToolUseId }] - : [], - ); + const foreground: Array<{ + readonly taskId: string; + readonly toolUseId: string; + }> = []; + for (const [taskId, subagent] of yield* Ref.get(sessionSubagentsByTaskId)) { + const toolUseId = subagent.runToolUseId; + if ( + toolUseId === null || + subagent.task.status !== "running" || + subagent.task.parentNodeId !== subagent.rootNodeId || + live.subagentsFromEarlierProcesses.has(subagent) || + // A nested launch whose owner was not known yet falls back to + // the root as its parent node; its spawn depth still marks it. + (yield* isNestedSubagentTask(taskId)) + ) { + continue; + } + foreground.push({ taskId, toolUseId }); + } // Backgrounding is refused when the session disables background // tasks, and the SDK puts no deadline on the request. Either way the // steer still goes through, as it did before. yield* Effect.forEach( foreground, - ({ subagent, toolUseId }) => + ({ taskId, toolUseId }) => live.query.backgroundTasks(toolUseId).pipe( Effect.timeout("5 seconds"), // Now a background subagent: its end wakes the root like one - // Claude started in the background. - Effect.flatMap((moved) => { - const taskId = subagent.task.nativeTaskRef?.nativeId; - return moved && typeof taskId === "string" - ? Ref.update(backgroundedSubagentTaskIds, (current) => - new Set(current).add(taskId), + // Claude started in the background. One that already ended + // while the move was answered has nothing left to report. + Effect.flatMap((moved) => + moved + ? Ref.get(sessionSubagentsByTaskId).pipe( + Effect.flatMap((registry) => + registry.get(taskId)?.task.status === "running" + ? Ref.update(backgroundedSubagentTaskIds, (current) => + new Set(current).add(taskId), + ) + : Effect.void, + ), ) - : Effect.void; - }), + : Effect.void, + ), Effect.catch((cause) => Effect.logWarning("orchestration-v2.claude-steer-background-subagent-failed", { - taskId: subagent.task.nativeTaskRef?.nativeId, + taskId, toolUseId, cause, }), @@ -8914,12 +8929,24 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( fileSystem, skillNames: yield* userInvocableSkillNames(currentTurn.input.runtimePolicy.cwd), }); + yield* backgroundForegroundSubagents(existing); + // The turn can end while the moves are answered. A steer offered + // then would start a new turn the orchestrator never asked for, so + // the steer fails and the orchestrator starts the follow-up turn. + if ( + (yield* Ref.get(activeTurn))?.providerTurnId !== turnInput.providerTurnId || + (yield* Ref.get(queryContext)) !== existing + ) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CLAUDE_PROVIDER, + detail: `Claude provider turn ${turnInput.providerTurnId} ended before the steer was sent.`, + }); + } yield* Ref.update(steeredTurns, (current) => { const next = new Set(current); next.add(turnInput.providerTurnId); return next; }); - yield* backgroundForegroundSubagents(existing); yield* existing.query.offer(userMessage); }, (effect, turnInput) => From e169f915e97975a9c9e1f99738b43468de454f06 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Wed, 14 Oct 2026 04:44:29 +0000 Subject: [PATCH 5/7] fix(server): a late background answer does not mark a later run of the subagent If the subagent ended and ran again under a new tool call before the move was answered, the new run was marked as backgrounded. The mark now also requires the run the move was asked for. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 62 +++++++++++++++---- .../Adapters/ClaudeAdapterV2.ts | 4 +- 2 files changed, 52 insertions(+), 14 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 755463a37876..ca6fb4600ab0 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -13801,6 +13801,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { { backgrounding: "fails" }, { backgrounding: "never answers" }, { backgrounding: "answers after the turn ended" }, + { backgrounding: "answers after the subagent ran again" }, ] as const)( "moves the root's foreground subagents to the background before a steer ($backgrounding)", ({ backgrounding }) => @@ -13810,14 +13811,15 @@ describe("ClaudeAdapterV2 background wake turns", () => { const NESTED_TOOL_USE_ID = "toolu-steer-nested"; const FINISHED_TOOL_USE_ID = "toolu-steer-finished"; const calls: Array = []; - // The root turn settles while the move is still unanswered. - let endTurnFirst: Effect.Effect = Effect.void; + // What happens while the move is still unanswered. + let beforeAnswer: Effect.Effect = Effect.void; const harness = yield* makeWakeHarnessWithOptions({ backgroundTasks: (toolUseId) => Effect.sync(() => calls.push(toolUseId)).pipe( Effect.andThen( - backgrounding === "answers after the turn ended" - ? Effect.suspend(() => endTurnFirst).pipe(Effect.as(true)) + backgrounding === "answers after the turn ended" || + backgrounding === "answers after the subagent ran again" + ? Effect.suspend(() => beforeAnswer).pipe(Effect.as(true)) : backgrounding === "fails" ? Effect.fail( new ClaudeAdapterV2.ClaudeAgentSdkQueryRunnerError({ @@ -13842,12 +13844,14 @@ describe("ClaudeAdapterV2 background wake turns", () => { attachments: [], }); yield* harness.runtime.startTurn(input); - endTurnFirst = Queue.offer( - harness.sdkMessages, - makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000a10", result: "Done." }), - ).pipe( - Effect.andThen(awaitUntil(() => harness.terminalEvents().length === 1, "turn end")), - ); + if (backgrounding === "answers after the turn ended") { + beforeAnswer = Queue.offer( + harness.sdkMessages, + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000a10", result: "Done." }), + ).pipe( + Effect.andThen(awaitUntil(() => harness.terminalEvents().length === 1, "turn end")), + ); + } const foregroundTaskStarted = (task: { readonly spawnDepth?: number; readonly taskId: string; @@ -13955,6 +13959,30 @@ describe("ClaudeAdapterV2 background wake turns", () => { attachments: [], }, }); + const RERUN_TOOL_USE_ID = "toolu-steer-rerun"; + if (backgrounding === "answers after the subagent ran again") { + // It ends and a SendMessage runs it again, in the foreground. + beforeAnswer = harness + .offerAndWait( + makeSubagentNotificationFrame({ + taskId: "task-steer-foreground", + toolUseId: FOREGROUND_TOOL_USE_ID, + summary: "First run done.", + uuid: "00000000-0000-4000-8000-000000000a12", + }), + ) + .pipe( + Effect.andThen( + harness.offerAndWait( + foregroundTaskStarted({ + taskId: "task-steer-foreground", + toolUseId: RERUN_TOOL_USE_ID, + uuid: "00000000-0000-4000-8000-000000000a13", + }), + ), + ), + ); + } const steering = yield* Effect.forkScoped(steer); if (backgrounding === "never answers") { yield* awaitUntil(() => calls.length === 1, "background request"); @@ -13976,7 +14004,15 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.isTrue(Exit.isSuccess(steered)); assert.equal(harness.offeredMessages.length, offered + 1); assert.equal(harness.offeredMessages.at(-1)?.priority, "now"); - if (backgrounding !== "succeeds") return; + if ( + backgrounding !== "succeeds" && + backgrounding !== "answers after the subagent ran again" + ) { + return; + } + // The move answered for the first run; the second runs in the + // foreground, so its end is the parent's tool result, not a wake. + const reran = backgrounding === "answers after the subagent ran again"; // The moved subagent outlives the steered turn. When it ends during // the user's next turn, Claude's wake after that turn names it. @@ -14005,7 +14041,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { for (const frame of [ makeSubagentNotificationFrame({ taskId: "task-steer-foreground", - toolUseId: FOREGROUND_TOOL_USE_ID, + toolUseId: reran ? RERUN_TOOL_USE_ID : FOREGROUND_TOOL_USE_ID, summary: "AUDITED", uuid: "00000000-0000-4000-8000-000000000a08", }), @@ -14018,7 +14054,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* awaitUntil(() => harness.continuationRequests.length === 1, "subagent wake"); assert.equal( harness.continuationRequests[0]?.notification?.summary, - 'Subagent "task-steer-foreground" finished', + reran ? undefined : 'Subagent "task-steer-foreground" finished', ); }).pipe( Effect.provide( diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 8d530c8860c0..bb903ffe2061 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -8881,7 +8881,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( moved ? Ref.get(sessionSubagentsByTaskId).pipe( Effect.flatMap((registry) => - registry.get(taskId)?.task.status === "running" + registry.get(taskId)?.task.status === "running" && + // Not a later run of it that started meanwhile. + registry.get(taskId)?.runToolUseId === toolUseId ? Ref.update(backgroundedSubagentTaskIds, (current) => new Set(current).add(taskId), ) From e8ab9f1303c53dd6e4e0d35d4336b50cfd30e9c4 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Wed, 14 Oct 2026 04:56:22 +0000 Subject: [PATCH 6/7] fix(server): a background move answered after the steer still marks the subagent The 5 second limit used to drop the request itself, so a move answered later never marked the subagent and its end did not wake the root. Each move now runs in the session scope; only the steer's wait is bounded. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 14 +++- .../Adapters/ClaudeAdapterV2.ts | 64 +++++++++---------- 2 files changed, 43 insertions(+), 35 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index ca6fb4600ab0..5604d6bd81ff 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -13800,6 +13800,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { { backgrounding: "succeeds" }, { backgrounding: "fails" }, { backgrounding: "never answers" }, + { backgrounding: "answers after the steer went out" }, { backgrounding: "answers after the turn ended" }, { backgrounding: "answers after the subagent ran again" }, ] as const)( @@ -13829,7 +13830,9 @@ describe("ClaudeAdapterV2 background wake turns", () => { ) : backgrounding === "never answers" ? Effect.never - : Effect.succeed(true), + : backgrounding === "answers after the steer went out" + ? Effect.sleep("6 seconds").pipe(Effect.as(true)) + : Effect.succeed(true), ), ), }); @@ -13984,7 +13987,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); } const steering = yield* Effect.forkScoped(steer); - if (backgrounding === "never answers") { + if ( + backgrounding === "never answers" || + backgrounding === "answers after the steer went out" + ) { yield* awaitUntil(() => calls.length === 1, "background request"); assert.equal(harness.offeredMessages.length, offered); yield* TestClock.adjust("5 seconds"); @@ -14004,7 +14010,9 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.isTrue(Exit.isSuccess(steered)); assert.equal(harness.offeredMessages.length, offered + 1); assert.equal(harness.offeredMessages.at(-1)?.priority, "now"); - if ( + if (backgrounding === "answers after the steer went out") { + yield* TestClock.adjust("1 second"); + } else if ( backgrounding !== "succeeds" && backgrounding !== "answers after the subagent ran again" ) { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index bb903ffe2061..7bb1f08b1cd7 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -91,6 +91,7 @@ import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Equal from "effect/Equal"; import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -8868,40 +8869,39 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } // Backgrounding is refused when the session disables background // tasks, and the SDK puts no deadline on the request. Either way the - // steer still goes through, as it did before. - yield* Effect.forEach( - foreground, - ({ taskId, toolUseId }) => - live.query.backgroundTasks(toolUseId).pipe( - Effect.timeout("5 seconds"), - // Now a background subagent: its end wakes the root like one - // Claude started in the background. One that already ended - // while the move was answered has nothing left to report. - Effect.flatMap((moved) => - moved - ? Ref.get(sessionSubagentsByTaskId).pipe( - Effect.flatMap((registry) => - registry.get(taskId)?.task.status === "running" && - // Not a later run of it that started meanwhile. - registry.get(taskId)?.runToolUseId === toolUseId - ? Ref.update(backgroundedSubagentTaskIds, (current) => - new Set(current).add(taskId), - ) - : Effect.void, - ), - ) - : Effect.void, - ), - Effect.catch((cause) => - Effect.logWarning("orchestration-v2.claude-steer-background-subagent-failed", { - taskId, - toolUseId, - cause, - }), - ), + // steer still goes through, as it did before. A move answered after + // the steer stopped waiting for it still marks the subagent. + const moves = yield* Effect.forEach(foreground, ({ taskId, toolUseId }) => + live.query.backgroundTasks(toolUseId).pipe( + // Now a background subagent: its end wakes the root like one + // Claude started in the background. One that already ended + // while the move was answered has nothing left to report. + Effect.flatMap((moved) => + moved + ? Ref.get(sessionSubagentsByTaskId).pipe( + Effect.flatMap((registry) => + registry.get(taskId)?.task.status === "running" && + // Not a later run of it that started meanwhile. + registry.get(taskId)?.runToolUseId === toolUseId + ? Ref.update(backgroundedSubagentTaskIds, (current) => + new Set(current).add(taskId), + ) + : Effect.void, + ), + ) + : Effect.void, + ), + Effect.catch((cause) => + Effect.logWarning("orchestration-v2.claude-steer-background-subagent-failed", { + taskId, + toolUseId, + cause, + }), ), - { concurrency: "unbounded", discard: true }, + Effect.forkIn(sessionScope), + ), ); + yield* Fiber.joinAll(moves).pipe(Effect.timeoutOption("5 seconds")); }); const steerTurn = Effect.fn("ClaudeAdapterV2.steerTurn")( From 52d25f83778b182aed7be38229c3e93c7ac8c6fa Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Wed, 14 Oct 2026 05:30:20 +0000 Subject: [PATCH 7/7] fix(server): repeated steers reuse a background move Claude has not answered Each steer forked a new backgroundTasks request for every running subagent, so a request Claude never answered piled up once per steer. A later steer now waits on the move still in flight for that run. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 16 ++++++++++++ .../Adapters/ClaudeAdapterV2.ts | 26 +++++++++++++++---- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 5604d6bd81ff..382c74560f6b 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -14010,6 +14010,22 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.isTrue(Exit.isSuccess(steered)); assert.equal(harness.offeredMessages.length, offered + 1); assert.equal(harness.offeredMessages.at(-1)?.priority, "now"); + if (backgrounding === "never answers") { + // Later steers wait on the move still in flight instead of + // piling up another request for the same run. + for (let repeat = 1; repeat <= 2; repeat++) { + const again = yield* Effect.forkScoped(steer); + // The steer's 5 second wait starts once it reaches it; move the + // clock until the steer has gone out. + for (let tick = 0; tick < 100 && again.pollUnsafe() === undefined; tick++) { + yield* TestClock.adjust("1 second"); + yield* Effect.yieldNow; + } + assert.isTrue(Exit.isSuccess(yield* Fiber.await(again))); + assert.equal(harness.offeredMessages.length, offered + 1 + repeat); + } + assert.deepEqual(calls, [FOREGROUND_TOOL_USE_ID]); + } if (backgrounding === "answers after the steer went out") { yield* TestClock.adjust("1 second"); } else if ( diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 7bb1f08b1cd7..fe37d0edef75 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -3498,6 +3498,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( // the turn that launched the subagent (wake drain, later turns) resolve // their subagent through this index into sessionSubagentsByTaskId. const sessionSubagentTaskIdsByToolUseId = yield* Ref.make(new Map()); + // Requests that move a running subagent to the background before a + // steer, by the tool call that started its run. Claude may never + // answer one, so a later steer waits on it rather than asking again. + const backgroundMovesByToolUseId = new Map>(); // A subagent's tool calls can outlive the turn that started them, so // they live here rather than on a turn context: a turn that ends while // one runs neither fails it nor hides it from the turn that follows. @@ -8870,9 +8874,19 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( // Backgrounding is refused when the session disables background // tasks, and the SDK puts no deadline on the request. Either way the // steer still goes through, as it did before. A move answered after - // the steer stopped waiting for it still marks the subagent. - const moves = yield* Effect.forEach(foreground, ({ taskId, toolUseId }) => - live.query.backgroundTasks(toolUseId).pipe( + // the steer stopped waiting for it still marks the subagent, and a + // later steer waits on that same move instead of asking again. + for (const [toolUseId, move] of backgroundMovesByToolUseId) { + if (move.pollUnsafe() !== undefined) backgroundMovesByToolUseId.delete(toolUseId); + } + const moves: Array> = []; + for (const { taskId, toolUseId } of foreground) { + const inFlight = backgroundMovesByToolUseId.get(toolUseId); + if (inFlight !== undefined) { + moves.push(inFlight); + continue; + } + const move = yield* live.query.backgroundTasks(toolUseId).pipe( // Now a background subagent: its end wakes the root like one // Claude started in the background. One that already ended // while the move was answered has nothing left to report. @@ -8899,8 +8913,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }), ), Effect.forkIn(sessionScope), - ), - ); + ); + backgroundMovesByToolUseId.set(toolUseId, move); + moves.push(move); + } yield* Fiber.joinAll(moves).pipe(Effect.timeoutOption("5 seconds")); });