diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 1add2822a01e9..512262935f651 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"; @@ -10193,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* () { diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 09ce63e2a3e65..f87211e4c5a5a 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -134,11 +134,11 @@ 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 -the pinned section. +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, or **Settings → General → Working section** on iOS and Android. Each device keeps its own 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":