# Files changed · PR \#20347 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/20347/diffs)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

[fix\(server\): Claude background subagents get approvals after the root turn ends](https://git.cafe/pingdotgg/t3code/pull/20347.md)

Version: 17

Head commit: 73b573182edea3ec0916e3e3c39f91e5548b4ccc

Base commit: 60b35a1ee02d717009ff4a3de34de285db9e8a60

Comparison base: 60b35a1ee02d717009ff4a3de34de285db9e8a60

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts

Status: modified; +314; −0

```diff
@@ -7194,6 +7194,320 @@
     ),
   );
 
+  const makeIdlePermissionHarness = Effect.fnUntraced(function* (
+    runtimeMode: "approval-required" | "full-access" = "approval-required",
+    options?: Parameters<typeof makeWakeHarnessWithOptions>[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, taskId = "auditor") =>
+      claudeSdkFrame({
+        ...makeSubagentTaskStartedFrame({ taskId, 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",
+      providerTurnId = harness.terminalEvents().at(-1)!.providerTurnId,
+    ) {
+      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}`),
+          ),
+        ),
+      );
+      assert.equal(runtimeRequest.providerTurnId, 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 a completed 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());
+        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();
+        yield* harness.approve("auditor", "accept", launchTurn);
+        assert.equal((yield* harness.request("another-agent"))?.behavior, "deny");
+        assert.equal(
+          new Set(
+            harness.events.flatMap((event) =>
+              event.type === "runtime_request.updated" ? [event.runtimeRequest.id] : [],
+            ),
+          ).size,
+          2,
+        );
+      }).pipe(
+        Effect.scoped,
+        Effect.provide(
+          Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
+        ),
+      ),
+  );
+
+  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("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();
+      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<void>();
+      const finishClose = yield* Deferred.make<void>();
+      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();
+      yield* harness.approve("auditor", "accept");
+      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),
+      ),
+    ),
+  );
+
   it.effect("answers an approval a held wake turn raises without waiting for the echo", () =>
     Effect.scoped(
       Effect.gen(function* () {
```

## apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts

Status: modified; +107; −11

```diff
@@ -4144,6 +4144,9 @@
         const subagentToolCalls = new Map<string, ActiveClaudeToolCall>();
         const pendingSubagentLaunchesByToolUseId = new Map<string, PendingClaudeSubagentLaunch>();
         const settledTurnByNativeThread = new Map<string, ActiveClaudeTurnContext>();
+        // 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<RunId, ActiveClaudeTurnContext>();
         const subagentStopOutcomes = new Map<
           string,
           Deferred.Deferred<ProviderAdapter.ProviderAdapterV2SubagentOutcome>
@@ -6409,6 +6412,7 @@
           readonly prompt?: string;
           readonly presentation?: ReturnType<typeof claudeApprovalPresentation>;
           readonly questions?: ReadonlyArray<OrchestrationV2UserInputQuestion>;
+          readonly parentNodeId?: OrchestrationV2ExecutionNode["id"] | undefined;
         }) {
           const createdAt = yield* DateTime.now;
           const requestId = yield* idAllocator.allocate.runtimeRequest({
@@ -6437,10 +6441,12 @@
             id: nodeId,
             threadId: input.context.input.threadId,
             runId: input.context.input.runId,
+            parentNodeId:
+              input.parentNodeId ??
+              idAllocator.derive.nodeFromProviderItem({
+                driver: CLAUDE_PROVIDER,
-            parentNodeId: idAllocator.derive.nodeFromProviderItem({
-              driver: CLAUDE_PROVIDER,
+                nativeItemId: input.nativeItemId,
+              }),
-              nativeItemId: input.nativeItemId,
-            }),
             rootNodeId: input.context.input.rootNodeId,
             kind: input.questions === undefined ? "approval_request" : "user_input_request",
             status: "waiting",
@@ -6503,6 +6509,47 @@
           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<ReturnType<typeof buildApprovalRequestArtifacts>>,
+        ) {
+          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,
@@ -6889,8 +6936,22 @@
           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 &&
+              (runId === input.context.input.runId || !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);
           }
@@ -8808,8 +8869,32 @@
           toolName: Parameters<CanUseTool>[0],
           toolInput: Parameters<CanUseTool>[1],
           callbackOptions: Parameters<CanUseTool>[2],
+          owningQueryClosed: Deferred.Deferred<void, never> | undefined,
         ) {
+          let context = yield* Ref.get(activeTurn);
+          const settled = context === null;
+          if (settled && !callbackOptions.signal.aborted) {
+            const live = yield* Ref.get(queryContext);
+            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]
+                    : [];
+                },
-          const context = yield* Ref.get(activeTurn);
+              );
+              const latest = settledTurnByNativeThread.get(live.nativeThreadId);
+              context = owners.find((owner) => owner === latest) ?? owners[0] ?? null;
+            }
+          }
           if (context === null) {
             return {
               behavior: "deny",
@@ -8821,6 +8906,10 @@
           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,
@@ -8830,7 +8919,7 @@
               nativeToolInput,
               null,
             );
-          } else if (!heldForEcho) {
+          } else if (!heldForEcho && !settled) {
             yield* ensureToolCallStarted({
               context,
               nativeItemId: nativeRequestId,
@@ -8870,6 +8959,7 @@
               nativeRequestId,
               requestKind: "user_input",
               questions,
+              parentNodeId,
             });
             const answers = yield* Deferred.make<ProviderUserInputAnswers, never>();
             yield* Ref.update(pendingRuntimeRequests, (current) => {
@@ -8913,6 +9003,9 @@
                 }),
               ),
             );
+            if (settled && !(yield* Deferred.isDone(answers))) {
+              yield* emitCancelledRuntimeRequest(artifacts);
+            }
             return callbackOptions.signal.aborted
               ? ({
                   behavior: "deny",
@@ -8980,6 +9073,7 @@
             nativeRequestId,
             requestKind,
             prompt,
+            parentNodeId,
             presentation: claudeApprovalPresentation({
               permissionMode: context.queryPolicy.permissionMode,
               prompt,
@@ -9030,6 +9124,9 @@
               }),
             ),
           );
+          if (settled && !(yield* Deferred.isDone(decision))) {
+            yield* emitCancelledRuntimeRequest(artifacts);
+          }
 
           return permissionResultFromDecision({
             toolName,
@@ -9042,9 +9139,6 @@
           });
         });
 
-        const canUseTool: CanUseTool = (toolName, toolInput, callbackOptions) =>
-          runPromise(canUseToolEffect(toolName, toolInput, callbackOptions));
-
         const onUserDialog: NonNullable<ClaudeQueryOptions["onUserDialog"]> = (
           request,
           callbackOptions,
@@ -9109,6 +9203,7 @@
                   requestId: callbackOptions.requestId,
                   toolUseID: request.toolUseID ?? callbackOptions.requestId,
                 },
+                undefined,
               );
               if (result.behavior !== "allow") return { behavior: "cancelled" as const };
               const answers =
@@ -9277,6 +9372,7 @@
             turnInput.nativeThreadHasTurns ?? turnInput.providerTurnOrdinal > 1;
           const shouldResume =
             resumeSessionAt !== undefined || openedWithResume || hasPersistedProviderTurn;
+          const closed = yield* Deferred.make<void, never>();
           const queryOptions = makeClaudeQueryOptions({
             modelSelection: turnInput.modelSelection,
             nativeThreadId,
@@ -9305,7 +9401,8 @@
               : {
                   allowDangerouslySkipPermissions: queryPolicy.allowDangerouslySkipPermissions,
                 }),
+            canUseTool: (toolName, toolInput, callbackOptions) =>
-            canUseTool,
+              runPromise(canUseToolEffect(toolName, toolInput, callbackOptions, closed)),
             onUserDialog,
             supportedDialogKinds: ["resume_return"],
             ...(appendSystemPromptFileText === undefined ? {} : { appendSystemPromptFileText }),
@@ -9342,7 +9439,6 @@
           yield* resetBackgroundTaskStateForNativeThreadProcess(nativeThreadId, {
             status: "active",
           });
-          const closed = yield* Deferred.make<void, never>();
           const context: ClaudeLiveQueryContext = {
             queryPolicy,
             nativeThreadId,
```
