From cf0ee21e2cc065e3c0c94b19267707f60f2a3ec5 Mon Sep 17 00:00:00 2001 From: ScottN-PV Date: Fri, 16 Oct 2026 13:49:13 -0400 Subject: [PATCH 01/10] fix(server): keep Claude background subagent approvals after root completion --- .../Adapters/ClaudeAdapterV2.test.ts | 205 ++++++++++++++++++ .../Adapters/ClaudeAdapterV2.ts | 53 ++++- 2 files changed, 247 insertions(+), 11 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index b4991241e1665..a0132599eb451 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -5759,6 +5759,211 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect.each([ + "accept", + "decline", + "full-access", + "without-agent-id", + "no-subagent", + "earlier-run", + "wrong-agent", + "aborted", + "stopping", + "replaced-process", + "resumed", + ] as const)("handles a settled subagent permission request: %s", (scenario) => + Effect.scoped( + Effect.gen(function* () { + const closeStarted = yield* Deferred.make(); + const finishClose = yield* Deferred.make(); + const harness = yield* makeWakeHarnessWithOptions({ + freshQueueOnReopen: true, + close: (messages) => + scenario === "stopping" + ? Deferred.succeed(closeStarted, undefined).pipe( + Effect.andThen(Deferred.await(finishClose)), + Effect.andThen(Queue.shutdown(messages)), + ) + : Queue.shutdown(messages), + }); + const now = yield* DateTime.now; + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: scenario === "full-access" ? "full-access" : "approval-required", + interactionMode: "default", + cwd: "/workspace", + }); + const start = (ordinal: number) => + harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make(`attempt-idle-permission-${ordinal}`), + providerTurnOrdinal: ordinal, + text: "Start the subagent, then return.", + attachments: [], + runtimePolicy, + }), + ); + const taskStarted = (toolUseId: string) => + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: "idle-permission-agent", + tool_use_id: toolUseId, + description: "Background permission probe", + subagent_type: "general-purpose", + task_type: "local_agent", + is_backgrounded: true, + prompt: "Read a file after the parent returns.", + uuid: `idle-permission-${toolUseId}`, + session_id: WAKE_NATIVE_SESSION, + }); + const resultFrame = (ordinal: number) => + makeResultFrame({ uuid: `idle-permission-result-${ordinal}`, result: "Started." }); + yield* start(1); + const callback = harness.getOpenedOptions()!.canUseTool!; + if (scenario !== "no-subagent") { + yield* harness.offerAndWait(taskStarted("idle-launch")); + } + if (scenario === "resumed") { + yield* harness.offerAndWait( + claudeSdkFrame({ + type: "system", + subtype: "task_notification", + task_id: "idle-permission-agent", + tool_use_id: "idle-launch", + status: "completed", + output_file: "/tmp/idle-permission-agent.output", + summary: "First answer.", + uuid: "idle-permission-completed", + session_id: WAKE_NATIVE_SESSION, + }), + ); + } + yield* harness.offerAndWait(resultFrame(1)); + yield* Queue.take(harness.terminalReceipts); + const stop = + scenario === "stopping" || scenario === "replaced-process" + ? yield* harness.runtime + .interruptTurn({ + providerThread: harness.providerThread, + providerTurnId: harness.terminalEvents()[0]!.providerTurnId, + requestRuntimeRestart: true, + }) + .pipe(Effect.forkScoped) + : null; + if (scenario === "stopping") yield* Deferred.await(closeStarted); + else if (stop !== null) yield* Fiber.join(stop); + if ( + scenario === "earlier-run" || + scenario === "resumed" || + scenario === "replaced-process" + ) { + yield* start(2); + if (scenario === "replaced-process") { + yield* Queue.offer(harness.processQueues[1]!, taskStarted("replacement-launch")); + yield* Queue.take(harness.subagentReceipts); + yield* Queue.offer(harness.processQueues[1]!, resultFrame(2)); + } else { + if (scenario === "resumed") { + yield* harness.offerAndWait(taskStarted("idle-send-message")); + } + yield* harness.offerAndWait(resultFrame(2)); + } + yield* Queue.take(harness.terminalReceipts); + } + const controller = new AbortController(); + if (scenario === "aborted") controller.abort(); + let callbackReturned = false; + const permission = yield* Effect.promise(async () => { + const decision = await callback( + "Read", + { file_path: "/etc/hosts" }, + { + signal: controller.signal, + toolUseID: "idle-read", + requestId: "idle-permission-request", + ...(scenario === "without-agent-id" + ? {} + : { + agentID: scenario === "wrong-agent" ? "another-agent" : "idle-permission-agent", + }), + }, + ); + callbackReturned = true; + return decision; + }).pipe(Effect.forkScoped); + const needsApproval = + scenario === "accept" || + scenario === "decline" || + scenario === "resumed" || + scenario === "without-agent-id"; + if (needsApproval) { + yield* awaitUntil( + () => + callbackReturned || + harness.events.some((event) => event.type === "runtime_request.updated"), + "settled subagent approval", + ); + const request = harness.events.findLast( + (event) => event.type === "runtime_request.updated", + ); + assert.equal(request?.type, "runtime_request.updated"); + if (request?.type !== "runtime_request.updated") return; + assert.equal( + request.runtimeRequest.providerTurnId, + harness.terminalEvents().at(-1)!.providerTurnId, + ); + const node = harness.events.findLast( + (event) => + event.type === "node.updated" && event.node.id === request.runtimeRequest.nodeId, + ); + assert.equal( + node?.type === "node.updated" ? node.node.parentNodeId : null, + node?.type === "node.updated" ? node.node.rootNodeId : undefined, + ); + yield* harness.runtime.respondToRuntimeRequest({ + requestId: request.runtimeRequest.id, + decision: scenario === "decline" ? "decline" : "accept", + }); + } + const decision = yield* Fiber.join(permission); + const allowed = + scenario === "accept" || + scenario === "resumed" || + scenario === "full-access" || + scenario === "without-agent-id"; + assert.equal(decision?.behavior, allowed ? "allow" : "deny"); + if (!needsApproval) { + assert.isFalse(harness.events.some((event) => event.type === "runtime_request.updated")); + if (!allowed) { + assert.equal( + decision?.behavior === "deny" ? decision.message : null, + "Claude V2 adapter has no active turn for this tool request.", + ); + } + } + assert.isFalse( + harness.events.some( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "dynamic_tool" && + event.turnItem.nativeItemRef?.nativeId === "idle-read", + ), + ); + if (scenario === "stopping" && stop !== null) { + yield* Deferred.succeed(finishClose, undefined); + yield* Fiber.join(stop); + } + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + it.effect("answers an approval a held wake turn raises without waiting for the echo", () => 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 897ac19221dfe..957c1f3490155 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -5410,6 +5410,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( readonly requestKind: OrchestrationV2RuntimeRequest["kind"]; readonly prompt?: string; readonly questions?: ReadonlyArray; + readonly parentNodeId?: OrchestrationV2ExecutionNode["id"] | undefined; }) { const createdAt = yield* DateTime.now; const requestId = yield* idAllocator.allocate.runtimeRequest({ @@ -5438,10 +5439,12 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( id: nodeId, threadId: input.context.input.threadId, runId: input.context.input.runId, - parentNodeId: idAllocator.derive.nodeFromProviderItem({ - driver: CLAUDE_PROVIDER, - nativeItemId: input.nativeItemId, - }), + parentNodeId: + input.parentNodeId ?? + idAllocator.derive.nodeFromProviderItem({ + driver: CLAUDE_PROVIDER, + nativeItemId: input.nativeItemId, + }), rootNodeId: input.context.input.rootNodeId, kind: input.questions === undefined ? "approval_request" : "user_input_request", status: "waiting", @@ -7681,8 +7684,32 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( toolName: Parameters[0], toolInput: Parameters[1], callbackOptions: Parameters[2], + owningQueryClosed?: Deferred.Deferred, ) { - const context = yield* Ref.get(activeTurn); + let context = yield* Ref.get(activeTurn); + const settled = context === null; + if (settled && !callbackOptions.signal.aborted) { + const live = yield* Ref.get(queryContext); + const previous = + live === null ? undefined : settledTurnByNativeThread.get(live.nativeThreadId); + if ( + live !== null && + !live.stopping && + live.closed === owningQueryClosed && + previous !== undefined + ) { + // Only this run still ingests its subagent's approval after the root result. + const subagents = yield* Ref.get(sessionSubagentsByTaskId); + const running = [...subagents].some( + ([taskId, subagent]) => + (callbackOptions.agentID === undefined || callbackOptions.agentID === taskId) && + subagent.task.status === "running" && + subagent.task.runId === previous.input.runId && + !live.subagentsFromEarlierProcesses.has(subagent), + ); + if (running) context = previous; + } + } if (context === null) { return { behavior: "deny", @@ -7694,6 +7721,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const nativeRequestId = callbackOptions.toolUseID; const nativeToolInput = claudeNativeToolInputFromRecord(toolInput); const heldForEcho = context.heldRootFrames.length > 0; + const parentNodeId = + settled && findToolCall(context, nativeRequestId) === undefined + ? context.input.rootNodeId + : undefined; if (toolName === "Agent") { rememberClaudeSubagentLaunch( subagentModelCatalog, @@ -7703,7 +7734,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( nativeToolInput, null, ); - } else if (!heldForEcho) { + } else if (!heldForEcho && (!settled || subagentToolCalls.has(nativeRequestId))) { yield* ensureToolCallStarted({ context, nativeItemId: nativeRequestId, @@ -7743,6 +7774,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( nativeRequestId, requestKind: "user_input", questions, + parentNodeId, }); const answers = yield* Deferred.make(); yield* Ref.update(pendingRuntimeRequests, (current) => { @@ -7841,6 +7873,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( nativeRequestId, requestKind, prompt, + parentNodeId, }); const decision = yield* Deferred.make(); yield* Ref.update(pendingRuntimeRequests, (current) => { @@ -7898,9 +7931,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }); }); - const canUseTool: CanUseTool = (toolName, toolInput, callbackOptions) => - runPromise(canUseToolEffect(toolName, toolInput, callbackOptions)); - const onUserDialog: NonNullable = ( request, callbackOptions, @@ -8099,6 +8129,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( turnInput.nativeThreadHasTurns ?? turnInput.providerTurnOrdinal > 1; const shouldResume = resumeSessionAt !== undefined || openedWithResume || hasPersistedProviderTurn; + const closed = yield* Deferred.make(); const queryOptions = makeClaudeQueryOptions({ modelSelection: turnInput.modelSelection, nativeThreadId, @@ -8121,7 +8152,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( : { allowDangerouslySkipPermissions: queryPolicy.allowDangerouslySkipPermissions, }), - canUseTool, + canUseTool: (toolName, toolInput, callbackOptions) => + runPromise(canUseToolEffect(toolName, toolInput, callbackOptions, closed)), onUserDialog, supportedDialogKinds: ["resume_return"], }); @@ -8157,7 +8189,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( yield* resetBackgroundTaskStateForNativeThreadProcess(nativeThreadId, { status: "active", }); - const closed = yield* Deferred.make(); const context: ClaudeLiveQueryContext = { nativeThreadId, query: querySession, From dc2de972c4f4e0789eb02e0b2b2f1bcfe08a09c0 Mon Sep 17 00:00:00 2001 From: ScottN-PV Date: Fri, 16 Oct 2026 14:34:09 -0400 Subject: [PATCH 02/10] test(server): assert the settled approval node exists before comparing its parent Co-Authored-By: Claude Opus 5.5 --- .../src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index a0132599eb451..8a0547b8bd6d7 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -5919,10 +5919,9 @@ describe("ClaudeAdapterV2 background wake turns", () => { (event) => event.type === "node.updated" && event.node.id === request.runtimeRequest.nodeId, ); - assert.equal( - node?.type === "node.updated" ? node.node.parentNodeId : null, - node?.type === "node.updated" ? node.node.rootNodeId : undefined, - ); + assert.equal(node?.type, "node.updated"); + if (node?.type !== "node.updated") return; + assert.strictEqual(node.node.parentNodeId, node.node.rootNodeId); yield* harness.runtime.respondToRuntimeRequest({ requestId: request.runtimeRequest.id, decision: scenario === "decline" ? "decline" : "accept", From c2b189b200e2f34af3401bcfc0c8ee2c63eb2c1b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 08:25:07 +0000 Subject: [PATCH 03/10] test(server): simplify settled Claude subagent permission coverage --- .../Adapters/ClaudeAdapterV2.test.ts | 419 ++++++++++-------- 1 file changed, 224 insertions(+), 195 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 5b0c6c7b9894d..298f35073ae85 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -2476,6 +2476,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* Queue.unbounded< Extract >(); + const runtimeRequestReceipts = + yield* Queue.unbounded< + Extract + >(); const systemNoticeReceipts = yield* Queue.unbounded< Extract @@ -2584,6 +2588,9 @@ describe("ClaudeAdapterV2 background wake turns", () => { if (event.type === "subagent.updated") { yield* Queue.offer(subagentReceipts, event); } + if (event.type === "runtime_request.updated") { + yield* Queue.offer(runtimeRequestReceipts, event); + } if (event.type === "turn.terminal") { yield* Queue.offer(terminalReceipts, event); } @@ -2626,6 +2633,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { terminalReceipts, subagentReceipts, systemNoticeReceipts, + runtimeRequestReceipts, getOpenedOptions: () => openedOptions, terminalEvents, hasPendingBackgroundWork, @@ -6527,207 +6535,228 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - it.effect.each([ - "accept", - "decline", - "full-access", - "without-agent-id", - "no-subagent", - "earlier-run", - "wrong-agent", - "aborted", - "stopping", - "replaced-process", - "resumed", - ] as const)("handles a settled subagent permission request: %s", (scenario) => - Effect.scoped( - Effect.gen(function* () { - const closeStarted = yield* Deferred.make(); - const finishClose = yield* Deferred.make(); - const harness = yield* makeWakeHarnessWithOptions({ - freshQueueOnReopen: true, - close: (messages) => - scenario === "stopping" - ? Deferred.succeed(closeStarted, undefined).pipe( - Effect.andThen(Deferred.await(finishClose)), - Effect.andThen(Queue.shutdown(messages)), - ) - : Queue.shutdown(messages), - }); - const now = yield* DateTime.now; - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: scenario === "full-access" ? "full-access" : "approval-required", - interactionMode: "default", - cwd: "/workspace", - }); - const start = (ordinal: number) => - harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make(`attempt-idle-permission-${ordinal}`), - providerTurnOrdinal: ordinal, - text: "Start the subagent, then return.", - attachments: [], - runtimePolicy, - }), - ); - const taskStarted = (toolUseId: string) => - claudeSdkFrame({ - type: "system", - subtype: "task_started", - task_id: "idle-permission-agent", - tool_use_id: toolUseId, - description: "Background permission probe", - subagent_type: "general-purpose", - task_type: "local_agent", - is_backgrounded: true, - prompt: "Read a file after the parent returns.", - uuid: `idle-permission-${toolUseId}`, - session_id: WAKE_NATIVE_SESSION, - }); - const resultFrame = (ordinal: number) => - makeResultFrame({ uuid: `idle-permission-result-${ordinal}`, result: "Started." }); - yield* start(1); - const callback = harness.getOpenedOptions()!.canUseTool!; - if (scenario !== "no-subagent") { - yield* harness.offerAndWait(taskStarted("idle-launch")); - } - if (scenario === "resumed") { - yield* harness.offerAndWait( - claudeSdkFrame({ - type: "system", - subtype: "task_notification", - task_id: "idle-permission-agent", - tool_use_id: "idle-launch", - status: "completed", - output_file: "/tmp/idle-permission-agent.output", - summary: "First answer.", - uuid: "idle-permission-completed", - session_id: WAKE_NATIVE_SESSION, - }), - ); - } - yield* harness.offerAndWait(resultFrame(1)); - yield* Queue.take(harness.terminalReceipts); - const stop = - scenario === "stopping" || scenario === "replaced-process" - ? yield* harness.runtime - .interruptTurn({ - providerThread: harness.providerThread, - providerTurnId: harness.terminalEvents()[0]!.providerTurnId, - requestRuntimeRestart: true, - }) - .pipe(Effect.forkScoped) - : null; - if (scenario === "stopping") yield* Deferred.await(closeStarted); - else if (stop !== null) yield* Fiber.join(stop); - if ( - scenario === "earlier-run" || - scenario === "resumed" || - scenario === "replaced-process" - ) { - yield* start(2); - if (scenario === "replaced-process") { - yield* Queue.offer(harness.processQueues[1]!, taskStarted("replacement-launch")); - yield* Queue.take(harness.subagentReceipts); - yield* Queue.offer(harness.processQueues[1]!, resultFrame(2)); - } else { - if (scenario === "resumed") { - yield* harness.offerAndWait(taskStarted("idle-send-message")); - } - yield* harness.offerAndWait(resultFrame(2)); - } - yield* Queue.take(harness.terminalReceipts); - } - const controller = new AbortController(); - if (scenario === "aborted") controller.abort(); - let callbackReturned = false; - const permission = yield* Effect.promise(async () => { - const decision = await callback( - "Read", - { file_path: "/etc/hosts" }, - { - signal: controller.signal, - toolUseID: "idle-read", - requestId: "idle-permission-request", - ...(scenario === "without-agent-id" - ? {} - : { - agentID: scenario === "wrong-agent" ? "another-agent" : "idle-permission-agent", - }), - }, - ); - callbackReturned = true; - return decision; - }).pipe(Effect.forkScoped); - const needsApproval = - scenario === "accept" || - scenario === "decline" || - scenario === "resumed" || - scenario === "without-agent-id"; - if (needsApproval) { - yield* awaitUntil( - () => - callbackReturned || - harness.events.some((event) => event.type === "runtime_request.updated"), - "settled subagent approval", - ); - const request = harness.events.findLast( - (event) => event.type === "runtime_request.updated", - ); - assert.equal(request?.type, "runtime_request.updated"); - if (request?.type !== "runtime_request.updated") return; - assert.equal( - request.runtimeRequest.providerTurnId, - harness.terminalEvents().at(-1)!.providerTurnId, - ); - const node = harness.events.findLast( - (event) => - event.type === "node.updated" && event.node.id === request.runtimeRequest.nodeId, - ); - assert.equal(node?.type, "node.updated"); - if (node?.type !== "node.updated") return; - assert.strictEqual(node.node.parentNodeId, node.node.rootNodeId); - yield* harness.runtime.respondToRuntimeRequest({ - requestId: request.runtimeRequest.id, - decision: scenario === "decline" ? "decline" : "accept", - }); - } - const decision = yield* Fiber.join(permission); - const allowed = - scenario === "accept" || - scenario === "resumed" || - scenario === "full-access" || - scenario === "without-agent-id"; - assert.equal(decision?.behavior, allowed ? "allow" : "deny"); - if (!needsApproval) { - assert.isFalse(harness.events.some((event) => event.type === "runtime_request.updated")); - if (!allowed) { - assert.equal( - decision?.behavior === "deny" ? decision.message : null, - "Claude V2 adapter has no active turn for this tool request.", - ); - } - } - assert.isFalse( - harness.events.some( - (event) => - event.type === "turn_item.updated" && - event.turnItem.type === "dynamic_tool" && - event.turnItem.nativeItemRef?.nativeId === "idle-read", + const makeIdlePermissionHarness = Effect.fnUntraced(function* ( + runtimeMode: "approval-required" | "full-access" = "approval-required", + options?: Parameters[0], + ) { + const harness = yield* makeWakeHarnessWithOptions(options); + let ordinal = 0; + let sequence = 0; + const uuid = () => `idle-permission-${++sequence}`; + const start = () => + harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: DateTime.makeUnsafe(0), + attemptId: RunAttemptId.make(`idle-permission-${++ordinal}`), + providerTurnOrdinal: ordinal, + text: "Run the auditor.", + attachments: [], + runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode, + interactionMode: "default", + cwd: "/workspace", + }), + }), + ); + const offer = (message: SDKMessage) => Queue.offer(harness.processQueues.at(-1)!, message); + const taskStarted = (toolUseId = "launch", background = true) => + claudeSdkFrame({ + ...makeSubagentTaskStartedFrame({ taskId: "auditor", toolUseId, uuid: uuid() }), + is_backgrounded: background, + }); + const settle = Effect.fnUntraced(function* () { + yield* offer(makeResultFrame({ uuid: uuid(), result: "Returned to the user." })); + return yield* Queue.take(harness.terminalReceipts); + }); + const callback = () => harness.getOpenedOptions()!.canUseTool!; + const request = ( + agentID: string | undefined, + signal = new AbortController().signal, + canUseTool = callback(), + ) => + Effect.promise(() => + canUseTool( + "Read", + { file_path: "/etc/hosts" }, + { + signal, + toolUseID: uuid(), + requestId: uuid(), + ...(agentID === undefined ? {} : { agentID }), + }, + ), + ); + const approve = Effect.fnUntraced(function* ( + agentID: string | undefined, + decision: "accept" | "decline", + ) { + const permission = yield* request(agentID).pipe(Effect.forkScoped); + const { runtimeRequest } = yield* Effect.raceFirst( + Queue.take(harness.runtimeRequestReceipts), + Fiber.join(permission).pipe( + Effect.flatMap((result) => + Effect.die(`Permission returned before approval: ${result?.behavior}`), ), - ); - if (scenario === "stopping" && stop !== null) { - yield* Deferred.succeed(finishClose, undefined); - yield* Fiber.join(stop); - } + ), + ); + assert.equal(runtimeRequest.providerTurnId, harness.terminalEvents().at(-1)!.providerTurnId); + const node = harness.events.findLast( + (event) => event.type === "node.updated" && event.node.id === runtimeRequest.nodeId, + ); + assert.equal(node?.type, "node.updated"); + if (node?.type !== "node.updated") return; + assert.strictEqual(node.node.parentNodeId, node.node.rootNodeId); + assert.isFalse( + harness.events.some( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "dynamic_tool" && + event.turnItem.nativeItemRef?.nativeId === runtimeRequest.nativeRequestRef?.nativeId, + ), + ); + yield* harness.runtime.respondToRuntimeRequest({ requestId: runtimeRequest.id, decision }); + assert.equal( + (yield* Fiber.join(permission))?.behavior, + decision === "accept" ? "allow" : "deny", + ); + }); + return { ...harness, start, offer, taskStarted, settle, callback, request, approve }; + }); + + it.effect("asks for settled subagent approval with or without an agent ID", () => + Effect.gen(function* () { + const harness = yield* makeIdlePermissionHarness(); + yield* harness.start(); + yield* harness.offer(harness.taskStarted()); + yield* harness.settle(); + yield* harness.approve("auditor", "accept"); + yield* harness.approve(undefined, "decline"); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + + it.effect( + "limits full-access settled permissions to an eligible subagent of the latest run", + () => + Effect.gen(function* () { + const harness = yield* makeIdlePermissionHarness("full-access"); + yield* harness.start(); + yield* harness.settle(); + assert.equal((yield* harness.request(undefined))?.behavior, "deny"); + yield* harness.start(); + yield* harness.offer(harness.taskStarted()); + yield* harness.settle(); + assert.equal((yield* harness.request("auditor"))?.behavior, "allow"); + assert.equal((yield* harness.request("another-agent"))?.behavior, "deny"); + assert.equal((yield* harness.request("auditor", AbortSignal.abort()))?.behavior, "deny"); + yield* harness.start(); + yield* harness.settle(); + // Older runs still stream work, but only the latest settled run ingests approvals. + assert.equal((yield* harness.request("auditor"))?.behavior, "deny"); + assert.equal((yield* harness.request(undefined))?.behavior, "deny"); + assert.isFalse(harness.events.some((event) => event.type === "runtime_request.updated")); }).pipe( + Effect.scoped, Effect.provide( Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), ), ), + ); + + it.effect("denies settled subagent callbacks while stopping and after process replacement", () => + Effect.gen(function* () { + const closeStarted = yield* Deferred.make(); + const finishClose = yield* Deferred.make(); + const harness = yield* makeIdlePermissionHarness("full-access", { + freshQueueOnReopen: true, + close: (messages) => + Deferred.succeed(closeStarted, undefined).pipe( + Effect.andThen(Deferred.await(finishClose)), + Effect.andThen(Queue.shutdown(messages)), + ), + }); + yield* harness.start(); + yield* harness.offer(harness.taskStarted()); + const terminal = yield* harness.settle(); + const oldCallback = harness.callback(); + const stop = yield* harness.runtime + .interruptTurn({ + providerThread: harness.providerThread, + providerTurnId: terminal.providerTurnId, + requestRuntimeRestart: true, + }) + .pipe(Effect.forkScoped); + yield* Deferred.await(closeStarted); + assert.equal((yield* harness.request("auditor"))?.behavior, "deny"); + yield* Deferred.succeed(finishClose, undefined); + yield* Fiber.join(stop); + yield* harness.start(); + yield* harness.offer(harness.taskStarted("replacement-launch")); + yield* harness.settle(); + assert.equal((yield* harness.request("auditor"))?.behavior, "allow"); + assert.equal((yield* harness.request("auditor", undefined, oldCallback))?.behavior, "deny"); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + + it.effect("approves a completed foreground subagent resumed in the latest settled run", () => + Effect.gen(function* () { + const harness = yield* makeIdlePermissionHarness(); + yield* harness.start(); + yield* harness.offer(harness.taskStarted("launch", false)); + yield* harness.offer( + makeSubagentNotificationFrame({ + taskId: "auditor", + toolUseId: "launch", + summary: "First answer.", + uuid: "initial-answer", + }), + ); + yield* harness.settle(); + yield* harness.start(); + yield* harness.offer( + claudeSdkFrame({ + type: "assistant", + message: { + id: "resume-auditor", + type: "message", + role: "assistant", + model: "claude-sonnet-4-6", + content: [ + { + type: "tool_use", + id: "resume", + name: "SendMessage", + input: { + to: "auditor", + message: "Continue the audit.", + }, + }, + ], + }, + parent_tool_use_id: null, + uuid: "resume-message", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* harness.offer(harness.taskStarted("resume")); + yield* harness.settle(); + yield* harness.approve("auditor", "accept"); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), ), ); From 83aa2ebdc1f55439a0090f2f94e8bc7ab25dd2da Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 08:37:48 +0000 Subject: [PATCH 04/10] refactor(claude): skip settled tool creation directly --- apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 1030dd6907ac8..7b1eb1907763a 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -8152,7 +8152,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( nativeToolInput, null, ); - } else if (!heldForEcho && (!settled || subagentToolCalls.has(nativeRequestId))) { + } else if (!heldForEcho && !settled) { yield* ensureToolCallStarted({ context, nativeItemId: nativeRequestId, From 3b91f7090f174e0f7cc82b5300c3aa8a96be0c2d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 20 Oct 2026 06:09:57 +0000 Subject: [PATCH 05/10] test(server): drop the duplicate runtime request queue from the merge Co-Authored-By: Claude Opus 5.5 --- .../src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 62353213b50ce..5f3402695bee7 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -2588,10 +2588,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* Queue.unbounded< Extract >(); - const runtimeRequestReceipts = - yield* Queue.unbounded< - Extract - >(); const systemNoticeReceipts = yield* Queue.unbounded< Extract @@ -2719,9 +2715,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { if (event.type === "subagent.updated") { yield* Queue.offer(subagentReceipts, event); } - if (event.type === "runtime_request.updated") { - yield* Queue.offer(runtimeRequestReceipts, event); - } if (event.type === "turn.terminal") { yield* Queue.offer(terminalReceipts, event); } From d5a51b5747dcc97bb9d1577cbaaa98aae580baa8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 14:07:35 +0000 Subject: [PATCH 06/10] test(server): settled Claude subagent approvals follow full-access asks Since full access forwards only requests that need a person's decision, the settled-subagent tests approve the eligible request instead of expecting an immediate allow. Co-Authored-By: Claude Opus 5.5 --- .../orchestration-v2/Adapters/ClaudeAdapterV2.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index bdbb453fb27ed..edb5461039560 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -7311,7 +7311,8 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* harness.start(); yield* harness.offer(harness.taskStarted()); yield* harness.settle(); - assert.equal((yield* harness.request("auditor"))?.behavior, "allow"); + // Under bypass, the CLI forwards only requests that need a person's decision. + yield* harness.approve("auditor", "accept"); assert.equal((yield* harness.request("another-agent"))?.behavior, "deny"); assert.equal((yield* harness.request("auditor", AbortSignal.abort()))?.behavior, "deny"); yield* harness.start(); @@ -7319,7 +7320,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { // Older runs still stream work, but only the latest settled run ingests approvals. assert.equal((yield* harness.request("auditor"))?.behavior, "deny"); assert.equal((yield* harness.request(undefined))?.behavior, "deny"); - assert.isFalse(harness.events.some((event) => event.type === "runtime_request.updated")); + assert.equal( + harness.events.filter((event) => event.type === "runtime_request.updated").length, + 1, + ); }).pipe( Effect.scoped, Effect.provide( @@ -7358,7 +7362,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* harness.start(); yield* harness.offer(harness.taskStarted("replacement-launch")); yield* harness.settle(); - assert.equal((yield* harness.request("auditor"))?.behavior, "allow"); + yield* harness.approve("auditor", "accept"); assert.equal((yield* harness.request("auditor", undefined, oldCallback))?.behavior, "deny"); }).pipe( Effect.scoped, From 384f226cadbc45e034b201ee7bcb87aa400c0fbc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 14:26:55 +0000 Subject: [PATCH 07/10] fix(server): an aborted settled Claude subagent request no longer stays pending After the root result there is no live run for the orchestrator to interrupt, so when Claude aborts a settled-path permission or question callback the adapter now emits the request, node and turn item as cancelled itself. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 27 ++++++++++ .../Adapters/ClaudeAdapterV2.ts | 50 ++++++++++++++++++- 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index edb5461039560..e666f70ac7604 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -7332,6 +7332,33 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect("cancels a settled subagent approval when Claude aborts its callback", () => + Effect.gen(function* () { + const harness = yield* makeIdlePermissionHarness(); + yield* harness.start(); + yield* harness.offer(harness.taskStarted()); + yield* harness.settle(); + const abort = new AbortController(); + const permission = yield* harness.request("auditor", abort.signal).pipe(Effect.forkScoped); + const { runtimeRequest } = yield* Queue.take(harness.runtimeRequestReceipts); + abort.abort(); + assert.equal((yield* Fiber.join(permission))?.behavior, "deny"); + const cancelled = (yield* Queue.take(harness.runtimeRequestReceipts)).runtimeRequest; + assert.equal(cancelled.id, runtimeRequest.id); + assert.equal(cancelled.status, "cancelled"); + assert.equal(cancelled.responseCapability.type, "not_resumable"); + const node = harness.events.findLast( + (event) => event.type === "node.updated" && event.node.id === runtimeRequest.nodeId, + ); + assert.equal(node?.type === "node.updated" ? node.node.status : undefined, "cancelled"); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + it.effect("denies settled subagent callbacks while stopping and after process replacement", () => Effect.gen(function* () { const closeStarted = yield* Deferred.make(); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index c1f6db1571da5..79139bbd6fc0e 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -6506,6 +6506,47 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( return { node, request, turnItem }; }); + // After the root result no run is left for the orchestrator to interrupt, so an + // aborted settled-path request must be closed here or it stays pending forever. + const emitCancelledRuntimeRequest = Effect.fnUntraced(function* ( + artifacts: Effect.Success>, + ) { + const now = yield* DateTime.now; + yield* Effect.all( + [ + emitProviderEvent({ + type: "node.updated", + driver: CLAUDE_PROVIDER, + node: { ...artifacts.node, status: "cancelled", completedAt: now }, + }), + emitProviderEvent({ + type: "turn_item.updated", + driver: CLAUDE_PROVIDER, + turnItem: { + ...artifacts.turnItem, + status: "cancelled", + completedAt: now, + updatedAt: now, + }, + }), + emitProviderEvent({ + type: "runtime_request.updated", + driver: CLAUDE_PROVIDER, + runtimeRequest: { + ...artifacts.request, + status: "cancelled", + responseCapability: { + type: "not_resumable", + reason: "Claude cancelled this request before it was resolved.", + }, + resolvedAt: now, + }, + }), + ], + { concurrency: 1 }, + ); + }); + const ensureReasoningBlock = Effect.fnUntraced(function* ( context: ActiveClaudeTurnContext, itemId: string, @@ -8811,7 +8852,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( toolName: Parameters[0], toolInput: Parameters[1], callbackOptions: Parameters[2], - owningQueryClosed?: Deferred.Deferred, + owningQueryClosed: Deferred.Deferred | undefined, ) { let context = yield* Ref.get(activeTurn); const settled = context === null; @@ -8945,6 +8986,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }), ), ); + if (settled && !(yield* Deferred.isDone(answers))) { + yield* emitCancelledRuntimeRequest(artifacts); + } return callbackOptions.signal.aborted ? ({ behavior: "deny", @@ -9063,6 +9107,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }), ), ); + if (settled && !(yield* Deferred.isDone(decision))) { + yield* emitCancelledRuntimeRequest(artifacts); + } return permissionResultFromDecision({ toolName, @@ -9139,6 +9186,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( requestId: callbackOptions.requestId, toolUseID: request.toolUseID ?? callbackOptions.requestId, }, + undefined, ); if (result.behavior !== "allow") return { behavior: "cancelled" as const }; const answers = From 1cdb437b8908905b2c054eafaa59051c6c9a08ae Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 14:55:50 +0000 Subject: [PATCH 08/10] fix(server): a subagent of an earlier completed run keeps asking after a later run settles A running subagent stays with the run that launched it, and that run keeps ingesting its events. The settled fallback only remembered the latest completed run, so once a wake continuation or a later turn settled, the earlier run's still-running subagent had every permission request denied. Keep the settled context per completed run and raise the approval in the subagent's own run. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 52 +++++++++++++++---- .../Adapters/ClaudeAdapterV2.ts | 51 ++++++++++++------ 2 files changed, 76 insertions(+), 27 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index e666f70ac7604..0b1a6f93cfeb0 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -7220,9 +7220,9 @@ describe("ClaudeAdapterV2 background wake turns", () => { }), ); const offer = (message: SDKMessage) => Queue.offer(harness.processQueues.at(-1)!, message); - const taskStarted = (toolUseId = "launch", background = true) => + const taskStarted = (toolUseId = "launch", background = true, taskId = "auditor") => claudeSdkFrame({ - ...makeSubagentTaskStartedFrame({ taskId: "auditor", toolUseId, uuid: uuid() }), + ...makeSubagentTaskStartedFrame({ taskId, toolUseId, uuid: uuid() }), is_backgrounded: background, }); const settle = Effect.fnUntraced(function* () { @@ -7250,6 +7250,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { const approve = Effect.fnUntraced(function* ( agentID: string | undefined, decision: "accept" | "decline", + providerTurnId = harness.terminalEvents().at(-1)!.providerTurnId, ) { const permission = yield* request(agentID).pipe(Effect.forkScoped); const { runtimeRequest } = yield* Effect.raceFirst( @@ -7260,7 +7261,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ), ); - assert.equal(runtimeRequest.providerTurnId, harness.terminalEvents().at(-1)!.providerTurnId); + assert.equal(runtimeRequest.providerTurnId, providerTurnId); const node = harness.events.findLast( (event) => event.type === "node.updated" && event.node.id === runtimeRequest.nodeId, ); @@ -7301,7 +7302,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); it.effect( - "limits full-access settled permissions to an eligible subagent of the latest run", + "limits full-access settled permissions to an eligible subagent of a completed run", () => Effect.gen(function* () { const harness = yield* makeIdlePermissionHarness("full-access"); @@ -7310,19 +7311,22 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.equal((yield* harness.request(undefined))?.behavior, "deny"); yield* harness.start(); yield* harness.offer(harness.taskStarted()); - yield* harness.settle(); + const launchTurn = (yield* harness.settle()).providerTurnId; // Under bypass, the CLI forwards only requests that need a person's decision. yield* harness.approve("auditor", "accept"); assert.equal((yield* harness.request("another-agent"))?.behavior, "deny"); assert.equal((yield* harness.request("auditor", AbortSignal.abort()))?.behavior, "deny"); yield* harness.start(); yield* harness.settle(); - // Older runs still stream work, but only the latest settled run ingests approvals. - assert.equal((yield* harness.request("auditor"))?.behavior, "deny"); - assert.equal((yield* harness.request(undefined))?.behavior, "deny"); + yield* harness.approve("auditor", "accept", launchTurn); + assert.equal((yield* harness.request("another-agent"))?.behavior, "deny"); assert.equal( - harness.events.filter((event) => event.type === "runtime_request.updated").length, - 1, + new Set( + harness.events.flatMap((event) => + event.type === "runtime_request.updated" ? [event.runtimeRequest.id] : [], + ), + ).size, + 2, ); }).pipe( Effect.scoped, @@ -7332,6 +7336,34 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect("asks for a subagent of an earlier run after a wake continuation settles", () => + Effect.gen(function* () { + const harness = yield* makeIdlePermissionHarness(); + yield* harness.start(); + yield* harness.offer(harness.taskStarted("launch-auditor", true, "auditor")); + yield* harness.offer(harness.taskStarted("launch-reviewer", true, "reviewer")); + const launchTurn = (yield* harness.settle()).providerTurnId; + yield* harness.offer( + makeSubagentNotificationFrame({ + taskId: "auditor", + toolUseId: "launch-auditor", + summary: "Audit done.", + uuid: "auditor-done", + }), + ); + yield* harness.offer(wakeTurnInit); + yield* harness.awaitContinuation(1); + yield* harness.start(); + assert.notEqual((yield* harness.settle()).providerTurnId, launchTurn); + yield* harness.approve("reviewer", "accept", launchTurn); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + it.effect("cancels a settled subagent approval when Claude aborts its callback", () => Effect.gen(function* () { const harness = yield* makeIdlePermissionHarness(); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 79139bbd6fc0e..09ccd52c0ee26 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -4144,6 +4144,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const subagentToolCalls = new Map(); const pendingSubagentLaunchesByToolUseId = new Map(); const settledTurnByNativeThread = new Map(); + // A running subagent stays with the run that launched it, and that run keeps ingesting + // its events after a later run settles, so its approvals need that run's context. + const settledTurnsByRunId = new Map(); const subagentStopOutcomes = new Map< string, Deferred.Deferred @@ -6933,8 +6936,22 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( yield* Ref.update(activeTurn, (current) => current?.providerTurnId === input.context.providerTurnId ? null : current, ); + const runningSubagentRunIds = new Set( + [...(yield* Ref.get(sessionSubagentsByTaskId)).values()].flatMap((subagent) => + subagent.task.status === "running" ? [subagent.task.runId] : [], + ), + ); + for (const [runId, settled] of settledTurnsByRunId) { + if ( + settled.nativeThreadId === input.context.nativeThreadId && + (input.status !== "completed" || !runningSubagentRunIds.has(runId)) + ) { + settledTurnsByRunId.delete(runId); + } + } if (input.status === "completed") { settledTurnByNativeThread.set(input.context.nativeThreadId, input.context); + settledTurnsByRunId.set(input.context.input.runId, input.context); } else { settledTurnByNativeThread.delete(input.context.nativeThreadId); } @@ -8858,24 +8875,24 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const settled = context === null; if (settled && !callbackOptions.signal.aborted) { const live = yield* Ref.get(queryContext); - const previous = - live === null ? undefined : settledTurnByNativeThread.get(live.nativeThreadId); - if ( - live !== null && - !live.stopping && - live.closed === owningQueryClosed && - previous !== undefined - ) { - // Only this run still ingests its subagent's approval after the root result. - const subagents = yield* Ref.get(sessionSubagentsByTaskId); - const running = [...subagents].some( - ([taskId, subagent]) => - (callbackOptions.agentID === undefined || callbackOptions.agentID === taskId) && - subagent.task.status === "running" && - subagent.task.runId === previous.input.runId && - !live.subagentsFromEarlierProcesses.has(subagent), + if (live !== null && !live.stopping && live.closed === owningQueryClosed) { + const owners = [...(yield* Ref.get(sessionSubagentsByTaskId))].flatMap( + ([taskId, subagent]) => { + const owner = + subagent.task.runId === null + ? undefined + : settledTurnsByRunId.get(subagent.task.runId); + return (callbackOptions.agentID === undefined || + callbackOptions.agentID === taskId) && + subagent.task.status === "running" && + !live.subagentsFromEarlierProcesses.has(subagent) && + owner?.nativeThreadId === live.nativeThreadId + ? [owner] + : []; + }, ); - if (running) context = previous; + const latest = settledTurnByNativeThread.get(live.nativeThreadId); + context = owners.find((owner) => owner === latest) ?? owners[0] ?? null; } } if (context === null) { From 73b573182edea3ec0916e3e3c39f91e5548b4ccc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 22 Oct 2026 15:04:30 +0000 Subject: [PATCH 09/10] fix(server): a failed turn no longer drops an earlier run's subagent approvals A turn that settled with any status but completed cleared every settled-run entry for its native thread, including earlier runs whose background subagents were still running, so their permission requests were denied. Only the settling run's own entry, or entries without a running subagent, are cleared now. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 26 +++++++++++++++++++ .../Adapters/ClaudeAdapterV2.ts | 2 +- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 0b1a6f93cfeb0..adb96bf8852c8 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -7364,6 +7364,32 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect("asks for a subagent of an earlier run after a later run fails", () => + Effect.gen(function* () { + const harness = yield* makeIdlePermissionHarness(); + yield* harness.start(); + yield* harness.offer(harness.taskStarted()); + const launchTurn = (yield* harness.settle()).providerTurnId; + yield* harness.start(); + yield* harness.offer( + makeResultFrame({ + uuid: "failed-turn", + result: "API Error: 500 Internal server error.", + isError: true, + apiErrorStatus: 500, + terminalReason: "api_error", + }), + ); + assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "failed"); + yield* harness.approve("auditor", "accept", launchTurn); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + it.effect("cancels a settled subagent approval when Claude aborts its callback", () => Effect.gen(function* () { const harness = yield* makeIdlePermissionHarness(); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 09ccd52c0ee26..e13d429d2ea7a 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -6944,7 +6944,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( for (const [runId, settled] of settledTurnsByRunId) { if ( settled.nativeThreadId === input.context.nativeThreadId && - (input.status !== "completed" || !runningSubagentRunIds.has(runId)) + (runId === input.context.input.runId || !runningSubagentRunIds.has(runId)) ) { settledTurnsByRunId.delete(runId); } From cc6331c9b3c18d4ec5161872ff2fb3b88b1c4795 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 07:05:06 +0000 Subject: [PATCH 10/10] fix(server): a stopped Claude subagent closes its pending settled approval After the root turn settles no run is left for the orchestrator to interrupt, so a late approval raised by a background subagent stayed pending when that subagent was stopped. The settled callback now also ends when its owning subagent leaves the running state, which denies the tool and cancels the card through the existing withdrawal path. AI-assisted; verified with the tests and tools listed above. --- .../Adapters/ClaudeAdapterV2.test.ts | 38 ++++++++++++++ .../Adapters/ClaudeAdapterV2.ts | 49 +++++++++++++++---- 2 files changed, 77 insertions(+), 10 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index ff92b3e12482a..7b5e61e763a72 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -12044,6 +12044,44 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect("closes a settled subagent approval when its subagent is stopped", () => + Effect.gen(function* () { + const harness = yield* makeIdlePermissionHarness(); + yield* harness.start(); + yield* harness.offer(harness.taskStarted()); + yield* harness.settle(); + const permission = yield* harness.request("auditor").pipe(Effect.forkScoped); + const { runtimeRequest } = yield* Queue.take(harness.runtimeRequestReceipts); + yield* harness.offer( + claudeSdkFrame({ + type: "system", + subtype: "task_notification", + task_id: "auditor", + tool_use_id: "launch", + status: "stopped", + output_file: "/tmp/auditor.output", + summary: "Stopped.", + uuid: "auditor-stopped", + session_id: WAKE_NATIVE_SESSION, + }), + ); + assert.equal((yield* Fiber.join(permission))?.behavior, "deny"); + const cancelled = (yield* Queue.take(harness.runtimeRequestReceipts)).runtimeRequest; + assert.equal(cancelled.id, runtimeRequest.id); + assert.equal(cancelled.status, "cancelled"); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + it.effect("denies settled subagent callbacks while stopping and after process replacement", () => Effect.gen(function* () { const closeStarted = yield* Deferred.make(); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index e5faa69c5058c..59eb4733a7370 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -5254,6 +5254,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( // A running subagent stays with the run that launched it, and that run keeps ingesting // its events after a later run settles, so its approvals need that run's context. const settledTurnsByRunId = new Map(); + // No run is left to interrupt after settle, so a settled request closes when its subagent ends. + const settledRequestStopsByTaskId = new Map>>(); const subagentStopOutcomes = new Map< string, Deferred.Deferred @@ -6967,6 +6969,11 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( return [true, new Map(current).set(input.taskId, subagent)] as const; }); if (!accepted) return; + if (input.status !== "running") { + for (const stopped of settledRequestStopsByTaskId.get(input.taskId) ?? []) { + yield* Deferred.succeed(stopped, undefined); + } + } if (input.nativeOutcome !== undefined) appliedSubagentOutcomes.add(input.nativeOutcome); if (existingSubagent !== undefined && input.status !== "running") { const openCalls = [...subagentToolCalls.values()].filter( @@ -10363,6 +10370,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ) { let context = yield* Ref.get(activeTurn); const settled = context === null; + let ownerTaskId: string | undefined; if (settled && !callbackOptions.signal.aborted) { const live = yield* Ref.get(queryContext); if (live !== null && !live.stopping && live.closed === options?.owningQueryClosed) { @@ -10377,12 +10385,14 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( subagent.task.status === "running" && !live.subagentsFromEarlierProcesses.has(subagent) && owner?.nativeThreadId === live.nativeThreadId - ? [owner] + ? [{ taskId, owner }] : []; }, ); const latest = settledTurnByNativeThread.get(live.nativeThreadId); - context = owners.find((owner) => owner === latest) ?? owners[0] ?? null; + const selected = owners.find(({ owner }) => owner === latest) ?? owners[0]; + context = selected?.owner ?? null; + ownerTaskId = selected?.taskId; } } if (context === null) { @@ -10393,6 +10403,24 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } satisfies PermissionResult; } + const ownerStopped = yield* Deferred.make(); + const untilOwnerStops = (wait: Effect.Effect, onStop: A) => + ownerTaskId === undefined + ? wait + : Effect.acquireUseRelease( + Effect.sync(() => { + const stops = settledRequestStopsByTaskId.get(ownerTaskId) ?? new Set(); + settledRequestStopsByTaskId.set(ownerTaskId, stops.add(ownerStopped)); + return stops; + }), + () => + Effect.raceFirst(wait, Deferred.await(ownerStopped).pipe(Effect.as(onStop))), + (stops) => + Effect.sync(() => { + stops.delete(ownerStopped); + if (stops.size === 0) settledRequestStopsByTaskId.delete(ownerTaskId); + }), + ); const nativeRequestId = callbackOptions.toolUseID; const nativeToolInput = claudeNativeToolInputFromRecord(toolInput); const heldForEcho = context.heldRootFrames.length > 0; @@ -10480,9 +10508,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ], { concurrency: 1 }, ); - const resolvedAnswers = yield* awaitClaudeUserInputAnswers( - answers, - callbackOptions.signal, + const resolvedAnswers = yield* untilOwnerStops( + awaitClaudeUserInputAnswers(answers, callbackOptions.signal), + {}, ).pipe( Effect.ensuring( Ref.update(pendingRuntimeRequests, (current) => { @@ -10492,8 +10520,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }), ), ); - if (!(yield* Deferred.isDone(answers))) yield* cancelWithdrawnRequest(artifacts); - return callbackOptions.signal.aborted + const withdrawn = !(yield* Deferred.isDone(answers)); + if (withdrawn) yield* cancelWithdrawnRequest(artifacts); + return withdrawn ? ({ behavior: "deny", message: "User cancelled tool execution.", @@ -10622,9 +10651,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( { concurrency: 1 }, ); - const resolvedDecision = yield* awaitClaudeApprovalDecision( - decision, - callbackOptions.signal, + const resolvedDecision = yield* untilOwnerStops( + awaitClaudeApprovalDecision(decision, callbackOptions.signal), + { decision: "cancel" }, ).pipe( Effect.ensuring( Ref.update(pendingRuntimeRequests, (current) => {