From e8aee363afb944ec943e5cffdf0332809612b603 Mon Sep 17 00:00:00 2001 From: Mandeep <97558331+khairm@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:26:16 +0000 Subject: [PATCH 1/3] fix(server): a subagent's own background commands keep the thread working When a Claude subagent ends its turn while its own background Bash still runs, Claude reports the subagent completed. The shell stays in the pending background roster with parentTaskId, but the hold check ignored that field, so the thread left Working while the subagent's work ran. Count any task with a parentTaskId as holding completion. Fixes #27700 Co-Authored-By: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 18 ++++++++++++++++++ docs/user/thread-sidebar.md | 8 ++++---- ...rchestrationV2PendingBackgroundWork.test.ts | 5 +++++ .../orchestrationV2PendingBackgroundWork.ts | 12 ++++++++---- 4 files changed, 35 insertions(+), 8 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 1add2822a01e9..c141bbe7cb7ae 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -77,6 +77,7 @@ import * as TestClock from "effect/testing/TestClock"; import { Tool } from "effect/ai"; import { formatClaudeResumeCompactionQuestion } from "@t3tools/shared/claudeCompaction"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import { backgroundWorkHoldsCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { SpawnExecutableResolution } from "@t3tools/shared/shell"; @@ -10170,6 +10171,23 @@ describe("ClaudeAdapterV2 background wake turns", () => { event.providerThread.pendingBackgroundTasks?.[0]?.parentTaskId === "agent-1", ), ); + // The helper ends its turn while its shell runs; the shell still holds the thread. + yield* harness.offerAndWait( + makeSubagentNotificationFrame({ + taskId: "agent-1", + toolUseId: "toolu_agent_1", + summary: "Agent is waiting on its own background work.", + uuid: "00000000-0000-4000-8000-000000000204", + }), + ); + const helperShell = + providerThreadRosterEvents(harness.events).at(-1)?.providerThread + .pendingBackgroundTasks ?? []; + assert.deepStrictEqual( + helperShell.map((task) => task.taskId), + ["subagent-shell"], + ); + assert.isTrue(backgroundWorkHoldsCompletion(helperShell)); yield* Queue.offer(harness.sdkMessages, emptyRoster); yield* harness.until(() => diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 09ce63e2a3e65..448bf41e559d0 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -134,10 +134,10 @@ open. Threads that are working or waiting on work they will resume from move into a collapsed **Working** section below the active list. -That includes subagents, watched pull requests, and background commands or monitors the agent -says it is waiting on. A dev server or other process an agent leaves running does not keep a thread -there. A thread returns to the top of the active list when it finishes, fails, or needs an approval or -answer. The Working section lists the thread you last sent work to first. Pinned threads stay in +That includes subagents and any commands they leave running, watched pull requests, and background +commands or monitors the agent says it is waiting on. A dev server or other process the thread's own +agent leaves running does not keep a thread there. A thread returns to the top of the active list +when it finishes, fails, or needs an approval or answer. The Working section lists the thread you last sent work to first. Pinned threads stay in the pinned section. This is on by default. Turn it off with **Settings → General → Working section** on web and desktop, diff --git a/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts b/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts index 1b5331c0d1bed..400efcac5ace0 100644 --- a/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts +++ b/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts @@ -11,6 +11,11 @@ describe("backgroundWorkHoldsCompletion", () => { ["a dev server's watchdog monitor", false, [{ taskId: "watch", kind: "monitor" }]], ["a command the agent waits on", true, [{ taskId: "tests", kind: "command", awaited: true }]], ["a subagent", true, [{ taskId: "review", kind: "subagent" }]], + [ + "a subagent's own command", + true, + [{ taskId: "tests", kind: "command", parentTaskId: "review" }], + ], ] as const)("with %s pending, holds completion: %s", (_case, holds, tasks) => { expect(backgroundWorkHoldsCompletion(tasks)).toBe(holds); }); diff --git a/packages/shared/src/orchestrationV2PendingBackgroundWork.ts b/packages/shared/src/orchestrationV2PendingBackgroundWork.ts index 6dc635b5153a6..4d6a7b8232b57 100644 --- a/packages/shared/src/orchestrationV2PendingBackgroundWork.ts +++ b/packages/shared/src/orchestrationV2PendingBackgroundWork.ts @@ -87,15 +87,19 @@ export function deriveBlockedOnThreadWait(input: { } export function backgroundWorkHoldsCompletion( - tasks: ReadonlyArray>, + tasks: ReadonlyArray>, ): boolean { return tasks.some( - (task) => task.awaited === true || backgroundWorkKindHoldsCompletion(task.kind), + (task) => + task.awaited === true || + // A subagent that ends its turn on its own background work is still working. + task.parentTaskId !== undefined || + backgroundWorkKindHoldsCompletion(task.kind), ); } -// Commands and monitors are as often a dev server or its watchdog as a wait, so they hold -// only when the agent marks them awaited. +// A thread's own commands and monitors are as often a dev server or its watchdog as a wait, +// so they hold only when the agent marks them awaited. function backgroundWorkKindHoldsCompletion(kind: PendingBackgroundWorkTask["kind"]): boolean { switch (kind) { case "command": From 22e8debd0068c36b8ea3c768f1d25171a11ada58 Mon Sep 17 00:00:00 2001 From: Mandeep <97558331+khairm@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:39:25 +0000 Subject: [PATCH 2/3] docs: rewrap the Working section paragraph Co-Authored-By: Claude Opus 5.5 --- docs/user/thread-sidebar.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 448bf41e559d0..f87211e4c5a5a 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -137,8 +137,8 @@ Threads that are working or waiting on work they will resume from move into a co That includes subagents and any commands they leave running, watched pull requests, and background commands or monitors the agent says it is waiting on. A dev server or other process the thread's own agent leaves running does not keep a thread there. A thread returns to the top of the active list -when it finishes, fails, or needs an approval or answer. The Working section lists the thread you last sent work to first. Pinned threads stay in -the pinned section. +when it finishes, fails, or needs an approval or answer. The Working section lists the thread you +last sent work to first. Pinned threads stay in the pinned section. This is on by default. Turn it off with **Settings → General → Working section** on web and desktop, or **Settings → General → Working section** on iOS and Android. Each device keeps its own From ad275b2c87e46d2c6611dda531577b89e75bf31d Mon Sep 17 00:00:00 2001 From: Mandeep Date: Tue, 10 Nov 2026 17:00:44 +0000 Subject: [PATCH 3/3] test(server): act out a finished helper's background command lifecycle Replace the roster-only assertion with a dedicated test: a helper starts, its own local_bash task starts with parent_task_id, the helper reports completed, and the command still holds the thread (backgroundWorkHoldsCompletion and hasPendingBackgroundWork) until its own task_notification arrives. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 119 +++++++++++++++--- 1 file changed, 102 insertions(+), 17 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index c141bbe7cb7ae..512262935f651 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -10171,23 +10171,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { event.providerThread.pendingBackgroundTasks?.[0]?.parentTaskId === "agent-1", ), ); - // The helper ends its turn while its shell runs; the shell still holds the thread. - yield* harness.offerAndWait( - makeSubagentNotificationFrame({ - taskId: "agent-1", - toolUseId: "toolu_agent_1", - summary: "Agent is waiting on its own background work.", - uuid: "00000000-0000-4000-8000-000000000204", - }), - ); - const helperShell = - providerThreadRosterEvents(harness.events).at(-1)?.providerThread - .pendingBackgroundTasks ?? []; - assert.deepStrictEqual( - helperShell.map((task) => task.taskId), - ["subagent-shell"], - ); - assert.isTrue(backgroundWorkHoldsCompletion(helperShell)); yield* Queue.offer(harness.sdkMessages, emptyRoster); yield* harness.until(() => @@ -10211,6 +10194,108 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect("keeps a finished helper's own background command pending until it ends", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const latestRoster = () => + providerThreadRosterEvents(harness.events).at(-1)?.providerThread + .pendingBackgroundTasks ?? []; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-helper-shell"), + text: "Start the dev server from a helper.", + attachments: [], + }), + ); + yield* Queue.offer( + harness.sdkMessages, + makeSubagentTaskStartedFrame({ + taskId: "helper", + toolUseId: "toolu_helper", + uuid: "00000000-0000-4000-8000-000000000211", + }), + ); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000212", + result: "A helper is starting the dev server.", + }), + ); + yield* harness.awaitTerminal(1); + + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: "helper-shell", + tool_use_id: "toolu_helper_shell", + description: "npm run dev", + is_backgrounded: true, + task_type: "local_bash", + parent_task_id: "helper", + uuid: "00000000-0000-4000-8000-000000000213", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* harness.until(() => latestRoster()[0]?.parentTaskId === "helper"); + // The helper ends its turn while its command runs; the command still holds the thread. + yield* harness.offerAndWait( + makeSubagentNotificationFrame({ + taskId: "helper", + toolUseId: "toolu_helper", + summary: "The dev server is running.", + uuid: "00000000-0000-4000-8000-000000000214", + }), + ); + yield* harness.until(() => + harness.events.some( + (event) => event.type === "subagent.updated" && event.subagent.status === "completed", + ), + ); + assert.deepStrictEqual( + latestRoster().map((task) => [task.taskId, task.parentTaskId]), + [["helper-shell", "helper"]], + ); + assert.isTrue(backgroundWorkHoldsCompletion(latestRoster())); + assert.isTrue(yield* harness.hasPendingBackgroundWork); + + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "task_notification", + task_id: "helper-shell", + tool_use_id: "toolu_helper_shell", + status: "completed", + output_file: "/tmp/helper-shell.output", + summary: "npm run dev exited.", + uuid: "00000000-0000-4000-8000-000000000215", + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* harness.until(() => latestRoster().length === 0); + assert.isFalse(backgroundWorkHoldsCompletion(latestRoster())); + assert.isFalse(yield* harness.hasPendingBackgroundWork); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ), + ); + it.effect("holds only the background tasks the agent marks awaited, until they end", () => Effect.scoped( Effect.gen(function* () {