From 46d51901ae3b26804b32d3fea946316a58025159 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 3 Oct 2026 13:35:19 -0700 Subject: [PATCH 1/2] feat: Wait keeps a thread working until its background command ends A background command reads as left running, like a dev server, so a thread waiting on a long benchmark showed as done. Wait on the command strip (or the wait_for_background_commands MCP tool) holds the thread's running commands: it stays in Working, its completion alert waits, and Don't wait undoes it. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../features/threads/ThreadDetailScreen.tsx | 22 +++ .../features/threads/ThreadRouteScreen.tsx | 15 ++ .../threads/floating-working-control.tsx | 9 +- .../threads/floating-working-status.ts | 5 +- .../src/environment/ServerEnvironment.ts | 1 + .../src/mcp/toolkits/thread/handlers.ts | 19 +++ apps/server/src/mcp/toolkits/thread/tools.ts | 11 ++ .../Orchestrator.backgroundWorkHold.test.ts | 156 ++++++++++++++++++ .../src/orchestration-v2/Orchestrator.ts | 58 +++++++ .../orchestration-v2/ProjectionMaintenance.ts | 1 + .../src/orchestration-v2/ProjectionStore.ts | 9 +- .../testkit/OrchestratorScenario.ts | 1 + apps/server/src/relay/AgentAwarenessRelay.ts | 6 +- apps/web/src/components/ChatView.tsx | 67 +++++++- apps/web/src/components/Sidebar.logic.ts | 3 +- .../ThreadNotificationCoordinator.test.tsx | 15 +- .../ThreadNotificationCoordinator.tsx | 16 +- docs/user/thread-sidebar.md | 9 + .../client-runtime/src/operations/commands.ts | 15 ++ packages/client-runtime/src/state/models.ts | 3 +- .../src/state/orchestrationV2Projection.ts | 4 +- .../src/state/threadCommands.ts | 18 ++ .../src/state/threadExecution.test.ts | 20 +++ .../src/state/threadExecution.ts | 23 ++- packages/client-runtime/src/t3ToolSummary.ts | 7 + .../src/work-log/presentation.ts | 1 + packages/contracts/src/environment.ts | 3 + packages/contracts/src/orchestrationV2.ts | 26 ++- ...chestrationV2PendingBackgroundWork.test.ts | 42 +++++ .../orchestrationV2PendingBackgroundWork.ts | 109 +++++++----- packages/shared/src/t3McpToolPresentation.ts | 5 + 31 files changed, 632 insertions(+), 67 deletions(-) create mode 100644 apps/server/src/orchestration-v2/Orchestrator.backgroundWorkHold.test.ts diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 8b9e061f39a2..6e239eb0f6bd 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -201,6 +201,8 @@ export interface ThreadDetailScreenProps { readonly onNativePasteText: (paste: ComposerTextPaste) => Promise; readonly onRemoveDraftImage: (imageId: string) => void; readonly onStopThread: () => void; + /** Waits for, or stops waiting for, the background commands the thread runs. */ + readonly onSetBackgroundWorkHeld: (held: boolean) => void; readonly onSendMessage: (followUp?: ActiveTurnComposerAction) => Promise; readonly onReconnectEnvironment: () => void; /** Whether the model picker may offer providers other than this thread's. */ @@ -439,6 +441,25 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const pendingBackgroundWork = presentPendingBackgroundWork( props.selectedThread.pendingBackgroundTasks, ); + // A command is not held by default, because a dev server can run for hours + // after the agent is done. Holding one keeps the thread in Working. + const backgroundCommandHold = + props.serverConfig?.environment.capabilities.threadBackgroundWorkHold === true + ? (pendingBackgroundWork?.commandHold ?? null) + : null; + const confirmBackgroundCommandHold = () => { + if (backgroundCommandHold === "wait") { + Alert.alert("Wait for this command?", "The thread stays in Working until it finishes.", [ + { text: "Cancel", style: "cancel" }, + { text: "Wait", onPress: () => props.onSetBackgroundWorkHeld(true) }, + ]); + } else if (backgroundCommandHold === "release") { + Alert.alert("Stop waiting?", "The command keeps running, and the thread shows as done.", [ + { text: "Cancel", style: "cancel" }, + { text: "Don't wait", onPress: () => props.onSetBackgroundWorkHeld(false) }, + ]); + } + }; const floatingStatus = ((): FloatingWorkingStatus | null => { const connectionStatus = connectionFloatingStatus({ connectionError: props.connectionError, @@ -480,6 +501,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread .map((item) => item.label) .join(", ")}`, waiting: pendingBackgroundWork.waiting, + onPress: backgroundCommandHold === null ? null : confirmBackgroundCommandHold, }; } return null; diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 72d868677ba8..316fa41b25bc 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -342,6 +342,10 @@ function ThreadRouteContent( const gitActions = useSelectedThreadGitActions(); const requests = useSelectedThreadRequests(); const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, "thread interrupt"); + const setBackgroundWorkHeld = useAtomCommand( + threadEnvironment.setBackgroundWorkHeld, + "wait for background commands", + ); const loadEarlierHistory = useAtomCommand(threadEnvironment.loadEarlierHistory, { label: "load earlier thread history", reportFailure: false, @@ -685,6 +689,16 @@ function ThreadRouteContent( }, }); }, [composer.interruptibleRunId, interruptThreadTurn, selectedThread]); + const handleSetBackgroundWorkHeld = useCallback( + (held: boolean) => { + if (!selectedThread) return; + void setBackgroundWorkHeld({ + environmentId: selectedThread.environmentId, + input: { threadId: selectedThread.id, held }, + }); + }, + [selectedThread, setBackgroundWorkHeld], + ); const handleOpenTerminal = useCallback( (nextTerminalId?: string | null) => { @@ -1066,6 +1080,7 @@ function ThreadRouteContent( onRemoveDraftImage={composer.onRemoveDraftImage} serverConfig={serverConfig} onStopThread={awaitingBootstrapTurn ? handleCancelWorktreeSetup : handleStopThread} + onSetBackgroundWorkHeld={handleSetBackgroundWorkHeld} onSendMessage={composer.onSendMessage} onReconnectEnvironment={handleReconnectEnvironment} canSwitchThreadProvider={composer.canSwitchThreadProvider} diff --git a/apps/mobile/src/features/threads/floating-working-control.tsx b/apps/mobile/src/features/threads/floating-working-control.tsx index 95ea8ca5534a..f1d67fe7ddcd 100644 --- a/apps/mobile/src/features/threads/floating-working-control.tsx +++ b/apps/mobile/src/features/threads/floating-working-control.tsx @@ -152,14 +152,16 @@ export function FloatingWorkingControl(props: { } // The queue, agents, and reconnect labels have separate tap targets. - const statusInteractive = props.status?.kind === "connection"; + const statusInteractive = + props.status?.kind === "connection" || + (props.status?.kind === "background" && props.status.onPress !== null); const capsuleInteractive = statusInteractive || hasQueue || hasAgents || hasDevicePreview; // The host stays centered on the capsule, but its measurement constraint // comes from the overlay, independent of the capsule's current width. const statusContent = props.status !== null ? ( @@ -390,6 +392,9 @@ function FloatingStatusLabel(props: { diff --git a/apps/mobile/src/features/threads/floating-working-status.ts b/apps/mobile/src/features/threads/floating-working-status.ts index 8d682cad8428..ca20c9dc6f33 100644 --- a/apps/mobile/src/features/threads/floating-working-status.ts +++ b/apps/mobile/src/features/threads/floating-working-status.ts @@ -10,12 +10,15 @@ export type FloatingWorkingStatus = | { readonly kind: "syncing"; readonly label: string } | { readonly kind: "compacting" } // The turn settled while background work it started still runs. `waiting` - // is false when only commands remain, such as a dev server: the agent is done. + // is false when only unheld commands remain, such as a dev server: the agent + // is done. + // `onPress` offers to wait, or stop waiting, for those commands. | { readonly kind: "background"; readonly label: string; readonly accessibilityLabel: string; readonly waiting: boolean; + readonly onPress: (() => void) | null; } // A task whose thread the server has not created yet: the worktree may // still be checking out, so there is no turn to time. diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 5381645e9fcf..d8324dddb649 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -237,6 +237,7 @@ export const make = Effect.gen(function* () { threadPinReorder: true, threadActiveReorder: true, threadAutoSettleOptOut: true, + threadBackgroundWorkHold: true, threadTitleRegeneration: true, threadVisitedTracking: true, threadPullRequests: true, diff --git a/apps/server/src/mcp/toolkits/thread/handlers.ts b/apps/server/src/mcp/toolkits/thread/handlers.ts index c0f5d135b6cc..e61aea08ebc2 100644 --- a/apps/server/src/mcp/toolkits/thread/handlers.ts +++ b/apps/server/src/mcp/toolkits/thread/handlers.ts @@ -272,6 +272,25 @@ export const ThreadToolkitHandlersLive = ThreadToolkit.toLayer({ queuedRunId: input.queuedRunId, targetRunId: input.targetRunId, })), + wait_for_background_commands: (input) => { + const held = input.wait ?? true; + return dispatch(undefined, (common) => ({ + ...common, + type: "thread.background-work.hold", + held, + })).pipe( + // The orchestrator rejects a hold when no background command runs. + Effect.mapError((error) => + held && error.code === "orchestration_error" + ? new OrchestratorMcpFailure({ + code: "invalid_request", + message: + "Could not wait: this thread has no background command running. Start the command in the background first.", + }) + : error, + ), + ); + }, t3_thread_organize: (input) => Effect.gen(function* () { const { threads, projection } = yield* readWritableThread(input.threadId); diff --git a/apps/server/src/mcp/toolkits/thread/tools.ts b/apps/server/src/mcp/toolkits/thread/tools.ts index 1517763b936e..03b355133866 100644 --- a/apps/server/src/mcp/toolkits/thread/tools.ts +++ b/apps/server/src/mcp/toolkits/thread/tools.ts @@ -123,6 +123,16 @@ const QueuePromoteTool = Tool.make("t3_queue_promote_to_steer", { parameters: Schema.Struct({ ...queueTarget, targetRunId: RunId }), }).annotate(Tool.Destructive, true); +const WaitForBackgroundCommandsTool = Tool.make("wait_for_background_commands", { + ...commandTool, + description: + "Keep this thread in the user's Working list until the background commands it runs now finish. Call it right after you start a background command that you will continue from when it exits, such as a build, test run, or benchmark. Without it, T3 Code treats a background command as one you leave running, such as a dev server, and shows the thread as done when your turn ends. Commands you start later are not included. Pass wait=false to stop waiting.", + parameters: Schema.Struct({ wait: Schema.optional(Schema.Boolean) }), +}) + .annotate(Tool.Title, "Wait for background commands") + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true); + const requestTarget = { threadId: Schema.optional(ThreadId), requestId: RuntimeRequestId }; const question = Schema.Struct({ id: Schema.String, @@ -276,4 +286,5 @@ export const ThreadToolkit = Toolkit.make( QueueCancelTool, QueueReorderTool, QueuePromoteTool, + WaitForBackgroundCommandsTool, ); diff --git a/apps/server/src/orchestration-v2/Orchestrator.backgroundWorkHold.test.ts b/apps/server/src/orchestration-v2/Orchestrator.backgroundWorkHold.test.ts new file mode 100644 index 000000000000..ef22753337ef --- /dev/null +++ b/apps/server/src/orchestration-v2/Orchestrator.backgroundWorkHold.test.ts @@ -0,0 +1,156 @@ +import { assert, it } from "@effect/vitest"; +import { + CommandId, + EventId, + MessageId, + NodeId, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + RunId, + ThreadId, + TurnItemId, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; +import * as Orchestrator from "./Orchestrator.ts"; +import * as ProjectionStore from "./ProjectionStore.ts"; +import type { ProviderAdapterV2Shape } from "./ProviderAdapter.ts"; +import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; +import { makeOrchestratorV2ReplayLayerWithRegistry } from "./testkit/ProviderReplayHarness.ts"; + +const instanceId = ProviderInstanceId.make("codex"); +const driver = ProviderDriverKind.make("codex"); +const modelSelection = { instanceId, model: "gpt-5.1-codex" }; +const adapter = { + instanceId, + driver, + getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2), + planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" as const }), + openSession: () => Effect.die("No provider process needed for background work holds"), +} as ProviderAdapterV2Shape; +const database = SqlitePersistenceMemory; +const testLayer = Layer.mergeAll( + database, + ProjectionStore.layer.pipe(Layer.provide(database)), + makeOrchestratorV2ReplayLayerWithRegistry( + { name: "background-work-hold" }, + ProviderAdapterRegistry.makeLayer([adapter]), + { databaseLayer: database, runEffectWorker: false }, + ), +); + +it.layer(testLayer)("thread.background-work.hold", (it) => { + it.effect("waits for the commands that run now, then stops waiting", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const threadId = ThreadId.make("thread:background-work-hold"); + const runId = RunId.make("run:background-work-hold"); + const nodeId = NodeId.make("node:background-work-hold"); + const now = yield* DateTime.now; + const hold = (commandId: string, held: boolean) => + orchestrator.dispatch({ + type: "thread.background-work.hold", + commandId: CommandId.make(commandId), + threadId, + held, + }); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create-background-work-hold"), + threadId, + projectId: ProjectId.make("project:background-work-hold"), + title: "Benchmarks", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + }); + + // Nothing runs yet, so there is nothing to wait for. + assert.equal((yield* Effect.exit(hold("hold-nothing", true)))._tag, "Failure"); + + // The turn ended and left its benchmark running in the background. + yield* projections.apply({ + id: EventId.make("event:background-work-hold:run"), + type: "run.created", + threadId, + runId, + occurredAt: now, + payload: { + id: runId, + threadId, + ordinal: 1, + providerInstanceId: instanceId, + modelSelection, + providerThreadId: null, + userMessageId: MessageId.make("message:background-work-hold"), + rootNodeId: nodeId, + activeAttemptId: null, + status: "completed", + requestedAt: now, + startedAt: now, + completedAt: now, + checkpointId: null, + contextHandoffId: null, + }, + }); + yield* projections.apply({ + id: EventId.make("event:background-work-hold:command"), + type: "turn-item.updated", + threadId, + runId, + nodeId, + driver, + occurredAt: now, + payload: { + id: TurnItemId.make("item:bench"), + threadId, + runId, + nodeId, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 1, + status: "running", + title: "Run benchmarks", + startedAt: now, + completedAt: null, + updatedAt: now, + type: "command_execution", + input: "cargo bench", + output: "", + }, + }); + const running = { taskId: "item:bench", description: "Run benchmarks", kind: "command" }; + assert.deepEqual((yield* projections.getThreadShell(threadId))?.pendingBackgroundTasks, [ + running, + ]); + + yield* hold("hold-bench", true); + assert.deepEqual( + (yield* projections.getThreadProjection(threadId)).thread.heldBackgroundTaskIds, + ["item:bench"], + ); + assert.deepEqual((yield* projections.getThreadShell(threadId))?.pendingBackgroundTasks, [ + { ...running, held: true }, + ]); + + yield* hold("release-bench", false); + assert.isUndefined( + (yield* projections.getThreadProjection(threadId)).thread.heldBackgroundTaskIds, + ); + assert.deepEqual((yield* projections.getThreadShell(threadId))?.pendingBackgroundTasks, [ + running, + ]); + }), + ); +}); diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 2707fe5b8344..a0cd37b8b7a5 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -50,6 +50,7 @@ import { } from "@t3tools/contracts"; import { modelSelectionsEqual } from "@t3tools/shared/model"; import { + collectBackgroundWork, derivePendingBackgroundWork, pendingBackgroundTurnItems, } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; @@ -363,6 +364,7 @@ function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId { case "thread.snooze": case "thread.unsnooze": case "thread.auto-settle.set": + case "thread.background-work.hold": case "thread.pin": case "thread.unpin": case "thread.pin.reorder": @@ -7820,6 +7822,59 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio } }); + // Holding reads background work without the settled-run gate: an agent holds + // the command it just started before its own turn settles. + const dispatchBackgroundWorkHold = ( + command: Extract, + events: Ref.Ref>, + ) => + Effect.gen(function* () { + const projection = yield* loadProjectionForCommand( + command, + ["runs", "providerThreads", "turnItems"], + { + turnItemTypes: ["command_execution"], + turnItemStatuses: ["pending", "running", "waiting"], + }, + ); + const { thread } = projection; + if (thread.deletedAt !== null) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Thread ${command.threadId} is deleted.`, + }); + } + const commandTaskIds = collectBackgroundWork({ + providerThreads: projection.providerThreads, + turnItems: projection.turnItems, + activeProviderThreadId: thread.activeProviderThreadId, + runs: projection.runs, + }).flatMap((task) => (task.kind === "command" ? [task.taskId] : [])); + if (command.held && commandTaskIds.length === 0) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Thread ${command.threadId} has no background commands running.`, + }); + } + const { heldBackgroundTaskIds: _released, ...released } = thread; + const updatedThread = command.held + ? { ...thread, heldBackgroundTaskIds: commandTaskIds } + : released; + // Not thread activity, like a visit: updatedAt stays. + yield* emit( + events, + command, + )({ + type: "thread.background-work-held", + threadId: command.threadId, + providerInstanceId: updatedThread.providerInstanceId, + occurredAt: yield* DateTime.now, + payload: updatedThread, + }); + }); + const dispatchBackgroundWorkSettle = ( command: Extract< OrchestrationV2InternalCommand, @@ -9410,6 +9465,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio case "thread.background-work.settle": yield* dispatchBackgroundWorkSettle(command, events); break; + case "thread.background-work.hold": + yield* dispatchBackgroundWorkHold(command, events); + break; case "thread.fork": yield* dispatchThreadFork(command, events); break; diff --git a/apps/server/src/orchestration-v2/ProjectionMaintenance.ts b/apps/server/src/orchestration-v2/ProjectionMaintenance.ts index e2386bb26930..e16c25292818 100644 --- a/apps/server/src/orchestration-v2/ProjectionMaintenance.ts +++ b/apps/server/src/orchestration-v2/ProjectionMaintenance.ts @@ -216,6 +216,7 @@ export const layer: Layer.Layer< "thread.unsnoozed", "thread.pinned", "thread.auto-settle-set", + "thread.background-work-held", "thread.unpinned", "thread.pin-reordered", "thread.active-reordered", diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index ac0624b05806..aae6f578640e 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -662,9 +662,11 @@ export function applyToProjection( thread: event.payload, }; // Visited tracking is read state, not activity: skip the updatedAt bump so - // viewing a thread does not surface it as recently active. + // viewing a thread does not surface it as recently active. Holding + // background work is not activity either. case "thread.visited": case "thread.marked-unread": + case "thread.background-work-held": return { ...projection, thread: event.payload, @@ -1334,6 +1336,7 @@ export function threadShellFromProjection( turnItems: projection.turnItems, activeProviderThreadId: projection.thread.activeProviderThreadId, runs: projection.runs, + heldTaskIds: projection.thread.heldBackgroundTaskIds, }); return { createdBy: projection.thread.createdBy, @@ -1672,6 +1675,7 @@ export const layer: Layer.Layer = case "thread.snoozed": case "thread.unsnoozed": case "thread.auto-settle-set": + case "thread.background-work-held": case "thread.pinned": case "thread.unpinned": case "thread.pin-reordered": @@ -2504,6 +2508,7 @@ export const layer: Layer.Layer = event.type !== "thread.snoozed" && event.type !== "thread.unsnoozed" && event.type !== "thread.auto-settle-set" && + event.type !== "thread.background-work-held" && event.type !== "thread.pinned" && event.type !== "thread.unpinned" && event.type !== "thread.pin-reordered" && @@ -5125,6 +5130,7 @@ export const layer: Layer.Layer = turnItems: pendingTurnItemsByThreadId.get(thread.id) ?? [], activeProviderThreadId: thread.activeProviderThreadId, hasActiveRun: false, + heldTaskIds: thread.heldBackgroundTaskIds, }), } satisfies ProjectionSettlementCandidate; }), @@ -5243,6 +5249,7 @@ export const layer: Layer.Layer = turnItems: pendingTurnItemsByThreadId.get(thread.id) ?? [], activeProviderThreadId: thread.activeProviderThreadId, hasActiveRun: row.active_run_id !== null, + heldTaskIds: thread.heldBackgroundTaskIds, }), ]; return { diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts index b9cad107409a..63431b323f41 100644 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts +++ b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts @@ -143,6 +143,7 @@ function commandThreadIds(command: OrchestrationV2Command): ReadonlyArray(null); + const isHoldingBackgroundWork = holdingBackgroundWorkKey === `${environmentId}:${activeThreadId}`; + const handleSetBackgroundWorkHeld = useCallback( + async (held: boolean) => { + if (!activeThread) return; + const requestKey = `${environmentId}:${activeThread.id}`; + setHoldingBackgroundWorkKey(requestKey); + const result = await setThreadBackgroundWorkHeld({ + environmentId, + input: { threadId: activeThread.id, held }, + }); + setHoldingBackgroundWorkKey((current) => (current === requestKey ? null : current)); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + setThreadError( + activeThread.id, + error instanceof Error ? error.message : "Failed to change background work waiting.", + ); + } + }, + [activeThread, environmentId, setThreadBackgroundWorkHeld, setThreadError], + ); const onOpenRelatedThread = useCallback( (threadId: ThreadId) => { void navigate({ @@ -6943,22 +6974,42 @@ export default function ChatView(props: ChatViewProps) { ); }), actions: ( - + <> + {supportsBackgroundWorkHold && presentation.commandHold !== null ? ( + + ) : null} + + ), }; }, [ activeBackgroundTasks, activeThread, + handleSetBackgroundWorkHeld, handleStopBackgroundWork, + isHoldingBackgroundWork, isStoppingBackgroundWork, onOpenRelatedThread, + supportsBackgroundWorkHold, ]); // A woken thread announces itself in the open view, not just the sidebar // pill. Dismissing marks the wake as seen (same acknowledgment as the diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 5a579bc503a4..8192278dd7a8 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -914,7 +914,8 @@ export function resolveThreadRowClassName(input: { // (runtime status "idle") is the agent stopped with background work that will // wake it (subagents, monitors): not the user's turn yet, so it renders grey // like working, not as a false Done. Commands it left running, such as a dev -// server, do not hold the thread; it reads as ready. +// server, do not hold the thread; it reads as ready. A command someone chose to +// wait for (thread.background-work.hold) holds it like a subagent. // Unread completion is tracked separately: it describes whether a ready // thread needs attention, not what the thread is currently doing. export type SidebarThreadStatus = diff --git a/apps/web/src/components/ThreadNotificationCoordinator.test.tsx b/apps/web/src/components/ThreadNotificationCoordinator.test.tsx index 36709813ef0e..8b1332fdde42 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.test.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.test.tsx @@ -20,7 +20,7 @@ const state = vi.hoisted(() => ({ turnError: false, limited: false, subagent: false, - background: [] as Array<{ taskId: string; kind: "command" | "monitor" }>, + background: [] as Array<{ taskId: string; kind: "command" | "monitor"; held?: boolean }>, add: vi.fn( (_toast: { title: string; description: string; actionProps: { onClick: () => void } }) => "toast-1", @@ -264,6 +264,19 @@ describe("thread notifications", () => { ); }); + it("alerts again when a command someone waits for ends without a new turn", async () => { + await render(); + state.background = [{ taskId: "bench", kind: "command" }]; + await complete(); + expect(state.add).toHaveBeenCalledTimes(1); + state.background = [{ taskId: "bench", kind: "command", held: true }]; + await render(); + expect(state.add).toHaveBeenCalledTimes(1); + state.background = []; + await render(); + expect(state.add).toHaveBeenCalledTimes(2); + }); + it("keeps background desktop alerts when in-app notifications are disabled", async () => { state.focused = false; state.inApp = false; diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index 741e850ca0d6..c45faad10eb3 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -125,13 +125,17 @@ function EnvironmentNotifications({ ? `${thread.latestRun?.runId ?? ""}:${status}` : null; const completedAt = Date.parse(thread.latestRun?.completedAt ?? ""); - // Commands left running (a dev server) read as ready; subagents and monitors wait. + // Commands left running (a dev server) read as ready; subagents, monitors, + // and commands someone waits for wait. Waiting re-arms the alert, so a + // completion that already alerted alerts again when the held work ends. const completion = - status === "ready" && - thread.latestRun?.status === "completed" && - Number.isFinite(completedAt) - ? completedAt - : (prior?.completion ?? null); + status === "waiting" + ? null + : status === "ready" && + thread.latestRun?.status === "completed" && + Number.isFinite(completedAt) + ? completedAt + : (prior?.completion ?? null); next.set(thread.id, { attention, completion }); if (!prior || thread.archivedAt !== null) continue; const kind = diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 1172678440c7..e9e04c9da49b 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -119,6 +119,15 @@ answer. Pinned threads stay in the pinned section. While this is on, the active list is ordered by when each thread last came back to you, so you cannot drag to reorder it. Your saved order returns when you turn it off. +### Wait for a background command + +A command the agent leaves running in the background, such as a dev server, does not count as +work: when the turn ends, the thread shows as finished. If the agent will continue after the +command exits, such as after a long build or benchmark, press **Wait** on the command's bar above +the composer. On mobile, tap the status above the composer. The thread then counts as working, and +its completion alert waits, until the command ends. **Don't wait** undoes this. Agents can also +wait for a command they start. + ## Settle finished work Choose **Settle thread** from its menu to move finished work out of the active list diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index c0cb8e67504c..80fbcea43593 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -469,6 +469,21 @@ export const setThreadAutoSettle = Effect.fn("EnvironmentCommands.setThreadAutoS }); }); +export interface SetThreadBackgroundWorkHeldInput extends ThreadCommandInput { + readonly held: boolean; +} +/** Waits for, or stops waiting for, the background commands the thread runs now. */ +export const setThreadBackgroundWorkHeld = Effect.fn( + "EnvironmentCommands.setThreadBackgroundWorkHeld", +)(function* (input: SetThreadBackgroundWorkHeldInput) { + return yield* dispatch({ + type: "thread.background-work.hold", + commandId: yield* allocateCommandId(input), + threadId: input.threadId, + held: input.held, + }); +}); + export const reorderPinnedThread = Effect.fn("EnvironmentCommands.reorderPinnedThread")(function* ( input: ReorderPinnedThreadInput, ) { diff --git a/packages/client-runtime/src/state/models.ts b/packages/client-runtime/src/state/models.ts index dc74aff6ff58..7c851fa42a3e 100644 --- a/packages/client-runtime/src/state/models.ts +++ b/packages/client-runtime/src/state/models.ts @@ -163,7 +163,8 @@ function terminalRunStatus(status: OrchestrationV2RunStatus): boolean { // Park runtime at idle when the post-settlement background roster holds the // run's completion, so #4415 waiting-presentation Waiting (session.idle) can // consume CTM runtime. Only work that wakes the agent holds it: commands it -// left running, such as a dev server, present the run's own status (#14872). +// left running, such as a dev server, present the run's own status (#14872), +// unless someone chose to wait for them. // The server suppresses the roster while an interruptible activity run exists, // so a remaining roster is stronger than checkpoint-oriented waiting. // latestRun keeps the latest run's status for history presentation. diff --git a/packages/client-runtime/src/state/orchestrationV2Projection.ts b/packages/client-runtime/src/state/orchestrationV2Projection.ts index 7e96a3039353..c08438207272 100644 --- a/packages/client-runtime/src/state/orchestrationV2Projection.ts +++ b/packages/client-runtime/src/state/orchestrationV2Projection.ts @@ -181,9 +181,11 @@ export function applyOrchestrationV2ProjectionEvent( case "thread.model-selection-updated": case "thread.provider-switched": return { ...base, thread: event.payload }; - // Visited tracking is read state, not activity: skip the updatedAt bump. + // Visited tracking and background-work holds are not activity: skip the + // updatedAt bump. case "thread.visited": case "thread.marked-unread": + case "thread.background-work-held": return { ...projection, thread: event.payload }; case "run.created": case "run.updated": { diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index e3ad5c07f583..51ba76f477ba 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -41,6 +41,7 @@ import { type ReorderPinnedThreadInput, type ReorderActiveThreadInput, type SetThreadAutoSettleInput, + type SetThreadBackgroundWorkHeldInput, type SettleThreadInput, type SnoozeThreadInput, type StartThreadTurnInput, @@ -76,6 +77,7 @@ import { reorderPinnedThread, reorderActiveThread, setThreadAutoSettle, + setThreadBackgroundWorkHeld, settleThread, snoozeThread, startThreadTurn, @@ -120,6 +122,7 @@ export type { ReorderPinnedThreadInput, ReorderActiveThreadInput, SetThreadAutoSettleInput, + SetThreadBackgroundWorkHeldInput, SettleThreadInput, SnoozeThreadInput, StartThreadTurnInput, @@ -218,6 +221,12 @@ export function createThreadEnvironmentAtoms( scheduler, concurrency, }), + setBackgroundWorkHeld: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:set-background-work-held", + execute: (input: SetThreadBackgroundWorkHeldInput) => setThreadBackgroundWorkHeld(input), + scheduler, + concurrency, + }), reorderActive: createEnvironmentCommand(runtime, { label: "environment-data:commands:thread:reorder-active", execute: (input: ReorderActiveThreadInput) => reorderActiveThread(input), @@ -448,6 +457,15 @@ export function createThreadEnvironmentAtoms( ...thread, autoSettleDisabledAt: input.enabled ? null : (thread.autoSettleDisabledAt ?? now), })), + // Commands started after the hold are not held; the server event corrects this. + setBackgroundWorkHeld: optimistic.wrap(commands.setBackgroundWorkHeld, (thread, input) => ({ + ...thread, + pendingBackgroundTasks: (thread.pendingBackgroundTasks ?? []).map((task) => { + if (task.kind !== "command") return task; + const { held: _held, ...released } = task; + return input.held ? { ...released, held: true } : released; + }), + })), pin: optimistic.wrap(commands.pin, (thread, input, now) => ({ ...thread, pinnedAt: thread.pinnedAt ?? now, diff --git a/packages/client-runtime/src/state/threadExecution.test.ts b/packages/client-runtime/src/state/threadExecution.test.ts index 6ee64d2eaf66..251203c0626a 100644 --- a/packages/client-runtime/src/state/threadExecution.test.ts +++ b/packages/client-runtime/src/state/threadExecution.test.ts @@ -521,6 +521,7 @@ describe("presentPendingBackgroundWork", () => { title: "Waiting on subagent Luna Window Properties", items: [{ taskId: "luna", kind: "subagent", label: "Luna Window Properties", childThreadId }], waiting: true, + commandHold: null, }); }); @@ -576,6 +577,25 @@ describe("presentPendingBackgroundWork", () => { ).toMatchObject({ title: "Waiting on 1 command and 1 monitor", waiting: true }); }); + it("offers to wait for a command, then to stop waiting while any command is held", () => { + const bench = { taskId: "bench", kind: "command" as const, description: "Run benchmarks" }; + const dev = { taskId: "dev", kind: "command" as const, description: "vp run dev" }; + expect(presentPendingBackgroundWork([bench])).toMatchObject({ + title: "Running: Run benchmarks", + waiting: false, + commandHold: "wait", + }); + expect(presentPendingBackgroundWork([{ ...bench, held: true }])).toMatchObject({ + title: "Waiting on command Run benchmarks", + waiting: true, + commandHold: "release", + }); + expect(presentPendingBackgroundWork([{ ...bench, held: true }, dev])).toMatchObject({ + title: "Waiting on 2 commands", + commandHold: "release", + }); + }); + it("groups work by kind, subagents first, and keeps each name", () => { const presentation = presentPendingBackgroundWork([ { taskId: "cmd", kind: "command", description: "npm test" }, diff --git a/packages/client-runtime/src/state/threadExecution.ts b/packages/client-runtime/src/state/threadExecution.ts index a7722af0391b..7c72272ca708 100644 --- a/packages/client-runtime/src/state/threadExecution.ts +++ b/packages/client-runtime/src/state/threadExecution.ts @@ -245,6 +245,7 @@ export function deriveThreadRuntime( turnItems: projection.turnItems, activeProviderThreadId: projection.thread.activeProviderThreadId, runs: projection.runs, + heldTaskIds: projection.thread.heldBackgroundTaskIds, }), ); return { @@ -308,10 +309,16 @@ export interface PendingBackgroundWorkPresentation { readonly title: string; readonly items: ReadonlyArray; /** - * True when the work will wake the agent (subagents, monitors). False when - * only commands remain, such as a dev server: the agent is done. + * True when the work will wake the agent (subagents, monitors, held + * commands). False when only unheld commands remain, such as a dev server: + * the agent is done. */ readonly waiting: boolean; + /** + * What the Wait control offers: "release" while any command is held, so it + * matches a "Waiting on" title, else "wait". Null when no command runs. + */ + readonly commandHold: "wait" | "release" | null; } function joinWithAnd(parts: ReadonlyArray): string { @@ -325,6 +332,9 @@ export function presentPendingBackgroundWork( ): PendingBackgroundWorkPresentation | null { if (tasks.length === 0) return null; const waiting = backgroundWorkHoldsCompletion(tasks); + const commands = tasks.filter((task) => task.kind === "command"); + const commandHold = + commands.length === 0 ? null : commands.some((task) => task.held === true) ? "release" : "wait"; const items = tasks .map((task): PendingBackgroundWorkItem => { const description = task.description?.trim(); @@ -358,7 +368,7 @@ export function presentPendingBackgroundWork( : named ? `Running: ${only.label}` : `Running a ${noun}`; - return { title, items, waiting }; + return { title, items, waiting, commandHold }; } const counts = new Map(); for (const item of items) counts.set(item.kind, (counts.get(item.kind) ?? 0) + 1); @@ -366,7 +376,12 @@ export function presentPendingBackgroundWork( const { singular, plural } = BACKGROUND_WORK_KINDS[kind]; return `${count} ${count === 1 ? singular : plural}`; }); - return { title: `${waiting ? "Waiting on" : "Running"} ${joinWithAnd(groups)}`, items, waiting }; + return { + title: `${waiting ? "Waiting on" : "Running"} ${joinWithAnd(groups)}`, + items, + waiting, + commandHold, + }; } /** The thread a notification row opens: that of the one subagent or delegated task it reports. */ diff --git a/packages/client-runtime/src/t3ToolSummary.ts b/packages/client-runtime/src/t3ToolSummary.ts index c6b40e5a62ff..a8593eb425cc 100644 --- a/packages/client-runtime/src/t3ToolSummary.ts +++ b/packages/client-runtime/src/t3ToolSummary.ts @@ -227,6 +227,13 @@ export function summarizeT3ToolCalls( case "thread-organize": label = phrase("Organized", "organize", `threads ${times}`); break; + case "background-wait": + label = phrase( + "Set the thread to wait for", + "set the thread to wait for", + "background commands", + ); + break; case "thread-update": label = phrase("Updated", "update", quantity(countEntities(threadIds), "thread")); break; diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts index 84229d3bac7f..1025880ea77b 100644 --- a/packages/client-runtime/src/work-log/presentation.ts +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -614,6 +614,7 @@ function summaryActionPriority(action: ToolGroupAction | T3McpToolSummaryAction) case "thread-fork": case "thread-merge": case "thread-organize": + case "background-wait": case "thread-update": case "queue-edit": case "queue-cancel": diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 6752fa2a62ee..7b756404b15c 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -147,6 +147,9 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ /** Server understands thread.auto-settle.set (per-thread auto-settle off). Same version-skew contract as threadSettlement. */ threadAutoSettleOptOut: Schema.optionalKey(Schema.Boolean), + /** Server understands thread.background-work.hold. Absent on older servers, + so clients hide the Wait control instead of sending it. */ + threadBackgroundWorkHold: Schema.optionalKey(Schema.Boolean), /** Server understands regenerateTitle on thread.meta.update. Absent on older servers, so clients hide the action instead of sending it. */ threadTitleRegeneration: Schema.optionalKey(Schema.Boolean), diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 6004ab119917..bc30215d2cdd 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -400,6 +400,12 @@ export const OrchestrationV2AppThread = Schema.Struct({ limitRecovery: Schema.optional(Schema.NullOr(OrchestrationV2LimitRecovery)), pinnedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), autoSettleDisabledAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), + /** + * Background commands someone chose to wait for (thread.background-work.hold). + * A held command keeps the thread working, like a subagent, until it ends. + * Ids of commands that already ended are harmless and drop on the next hold. + */ + heldBackgroundTaskIds: Schema.optional(Schema.Array(TrimmedNonEmptyString)), // Fractional-index slot in the user-arranged pinned order. Optional so // payloads from pre-reorder servers still decode. pinOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), @@ -782,7 +788,12 @@ export const OrchestrationV2PendingBackgroundTask = kindUnionWithFallback( /** The subagent's own thread, when it has one. */ childThreadId: Schema.optional(ThreadId), }), - Schema.Struct({ ...PendingBackgroundTaskFields, kind: Schema.Literal("command") }), + Schema.Struct({ + ...PendingBackgroundTaskFields, + kind: Schema.Literal("command"), + /** Someone chose to wait for it, so it holds the thread like a subagent. */ + held: Schema.optional(Schema.Boolean), + }), Schema.Struct({ ...PendingBackgroundTaskFields, kind: Schema.Literal("monitor") }), Schema.Struct({ ...PendingBackgroundTaskFields, kind: Schema.Literal("background_task") }), ], @@ -1515,6 +1526,7 @@ export const OrchestrationV2DomainEvent = Schema.Union([ "thread.unsnoozed", "thread.pinned", "thread.auto-settle-set", + "thread.background-work-held", "thread.unpinned", "thread.pin-reordered", "thread.active-reordered", @@ -2306,6 +2318,7 @@ export const OrchestrationV2DomainEventJson = Schema.Union([ "thread.unsnoozed", "thread.pinned", "thread.auto-settle-set", + "thread.background-work-held", "thread.unpinned", "thread.pin-reordered", "thread.active-reordered", @@ -2517,6 +2530,17 @@ export const OrchestrationV2Command = Schema.Union([ threadId: ThreadId, enabled: Schema.Boolean, }), + /** + * `held: true` waits for every background command the thread runs now: the + * thread stays working until they end. A command started later is not held. + * `held: false` stops waiting for all of them. + */ + Schema.Struct({ + type: Schema.Literal("thread.background-work.hold"), + commandId: CommandId, + threadId: ThreadId, + held: Schema.Boolean, + }), Schema.Struct({ type: Schema.Literal("thread.pin"), commandId: CommandId, diff --git a/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts b/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts index a766d5d30e43..0cb6264c4a5e 100644 --- a/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts +++ b/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import type { OrchestrationV2PendingBackgroundTask } from "@t3tools/contracts"; import { backgroundWorkHoldsCompletion, + collectBackgroundWork, derivePendingBackgroundWork, turnItemUpdateCanEndBackgroundWork, } from "./orchestrationV2PendingBackgroundWork.ts"; @@ -35,6 +36,7 @@ describe("backgroundWorkHoldsCompletion", () => { ["a command and a subagent", true, [task("dev", "command"), task("review", "subagent")]], // Also what an unknown or missing kind decodes to. ["work the provider cannot name", true, [task("opaque", "background_task")]], + ["a command someone waits for", true, [{ taskId: "bench", kind: "command", held: true }]], ] as const)("with %s pending, holds completion: %s", (_case, holds, tasks) => { expect(backgroundWorkHoldsCompletion(tasks)).toBe(holds); }); @@ -518,3 +520,43 @@ describe("derivePendingBackgroundWork kinds", () => { ]); }); }); + +describe("held background commands", () => { + const providerThreads = [ + { + id: "pt-1" as never, + pendingBackgroundTasks: [ + { taskId: "bench", description: "Run benchmarks", kind: "command" as const }, + { taskId: "dev", description: "Start the dev server", kind: "command" as const }, + { taskId: "review", kind: "subagent" as const }, + ], + }, + ]; + + it("marks only the held commands", () => { + const tasks = derivePendingBackgroundWork({ + latestRun: { id: "run-1" as never, ordinal: 1, status: "completed" }, + providerThreads, + turnItems: [], + // A held id that is not a command does not change the subagent. + heldTaskIds: ["bench", "review", "ended"], + }); + expect(tasks).toEqual([ + { taskId: "bench", description: "Run benchmarks", kind: "command", held: true }, + { taskId: "dev", description: "Start the dev server", kind: "command" }, + { taskId: "review", kind: "subagent" }, + ]); + }); + + it("collects work while the run that started it is still running", () => { + const running = { id: "run-1" as never, ordinal: 1, status: "running" as const }; + expect( + derivePendingBackgroundWork({ latestRun: running, providerThreads, turnItems: [] }), + ).toEqual([]); + expect( + collectBackgroundWork({ providerThreads, turnItems: [], runs: [running] }).map( + (task) => task.taskId, + ), + ).toEqual(["bench", "dev", "review"]); + }); +}); diff --git a/packages/shared/src/orchestrationV2PendingBackgroundWork.ts b/packages/shared/src/orchestrationV2PendingBackgroundWork.ts index 074b5feca534..dcd8c9d6dc5e 100644 --- a/packages/shared/src/orchestrationV2PendingBackgroundWork.ts +++ b/packages/shared/src/orchestrationV2PendingBackgroundWork.ts @@ -63,20 +63,28 @@ export function turnItemUpdateCanEndBackgroundWork( * Whether background work left behind by a completed root run holds back its * completion alert (desktop/web notification and the mobile push). Commands, * such as dev servers and other long-lived shells, do not: the agent is done - * and may leave them running for hours. Subagents and monitors do, because - * they wake the agent and it continues (#13625). Work the adapter cannot name, - * including kinds this build does not know, holds as the conservative choice. + * and may leave them running for hours. A command someone chose to wait for + * (`held`, see thread.background-work.hold) does, such as a long benchmark. + * Subagents and monitors do, because they wake the agent and it continues + * (#13625). Work the adapter cannot name, including kinds this build does not + * know, holds as the conservative choice. */ export function backgroundWorkHoldsCompletion( - tasks: ReadonlyArray>, + tasks: ReadonlyArray<{ + readonly kind: PendingBackgroundWorkTask["kind"]; + readonly held?: boolean | undefined; + }>, ): boolean { - return tasks.some((task) => backgroundWorkKindHoldsCompletion(task.kind)); + return tasks.some((task) => backgroundWorkTaskHoldsCompletion(task)); } -function backgroundWorkKindHoldsCompletion(kind: PendingBackgroundWorkTask["kind"]): boolean { - switch (kind) { +function backgroundWorkTaskHoldsCompletion(task: { + readonly kind: PendingBackgroundWorkTask["kind"]; + readonly held?: boolean | undefined; +}): boolean { + switch (task.kind) { case "command": - return false; + return task.held === true; case "subagent": case "monitor": case "background_task": @@ -194,46 +202,35 @@ export function pendingBackgroundTurnItems; readonly turnItems: ReadonlyArray; readonly activeProviderThreadId?: string | null; - readonly hasActiveRun?: boolean; /** * Run rows used to exclude items owned by rolled_back runs. Optional for * callers that already filtered (SQL shell path); in-memory callers should * pass projection runs so policy cannot drift. */ readonly runs?: ReadonlyArray; -}): ReadonlyArray { - const hasActiveRun = - input.hasActiveRun ?? - input.runs?.some( - (run) => run.status === "preparing" || run.status === "starting" || run.status === "running", - ) ?? - false; - if (hasActiveRun) { - return []; - } - if (!isLatestRunSettledForBackgroundWait(input.latestRun)) { - return []; - } +}; +/** + * Every piece of background work a thread names, without the settled-run gate. + * + * Sources: + * - Provider-thread roster (Claude SDK background tasks) + * - Active command_execution / dynamic_tool / subagent turn items + * + * Dedupes by native task ID. Excludes Grok persistent monitors (`dynamic_tool` + * input with `persistent: true`). Excludes turn items whose run resolves to + * `rolled_back` (abandoned work); items with a null or absent run id stay + * eligible (matches SQL shell path). Does not consult subagent entities (those + * double-count turn items). Holding commands reads this directly, because an + * agent holds its command before its own turn settles. + */ +export function collectBackgroundWork( + input: BackgroundWorkSources, +): ReadonlyArray { const byTaskId = new Map(); const providerThreads = @@ -266,3 +263,39 @@ export function derivePendingBackgroundWork(input: { return Array.from(byTaskId.values()); } + +/** + * Derive one normalized pending-background-work list for post-settlement UI: + * `collectBackgroundWork`, gated on latest root run settlement. Excludes the + * roster while any interruptible foreground run remains active. Commands in + * `heldTaskIds` (the thread's `heldBackgroundTaskIds`) come back `held`. + */ +export function derivePendingBackgroundWork( + input: BackgroundWorkSources & { + readonly latestRun: PendingBackgroundWorkRun | null | undefined; + readonly hasActiveRun?: boolean; + readonly heldTaskIds?: ReadonlyArray | undefined; + }, +): ReadonlyArray { + const hasActiveRun = + input.hasActiveRun ?? + input.runs?.some( + (run) => run.status === "preparing" || run.status === "starting" || run.status === "running", + ) ?? + false; + if (hasActiveRun) { + return []; + } + if (!isLatestRunSettledForBackgroundWait(input.latestRun)) { + return []; + } + + const tasks = collectBackgroundWork(input); + if (input.heldTaskIds === undefined || input.heldTaskIds.length === 0) { + return tasks; + } + const heldTaskIds = new Set(input.heldTaskIds); + return tasks.map((task) => + task.kind === "command" && heldTaskIds.has(task.taskId) ? { ...task, held: true } : task, + ); +} diff --git a/packages/shared/src/t3McpToolPresentation.ts b/packages/shared/src/t3McpToolPresentation.ts index d85104cf4bc1..26e3bad19436 100644 --- a/packages/shared/src/t3McpToolPresentation.ts +++ b/packages/shared/src/t3McpToolPresentation.ts @@ -28,6 +28,7 @@ export type T3McpToolSummaryAction = | "thread-search" | "thread-transfers" | "thread-organize" + | "background-wait" | "thread-update" | "queue-list" | "queue-read" @@ -256,6 +257,10 @@ const T3_MCP_TOOLS: Readonly> = { t3_thread_search: tool(["Search", "Searching", "Searched", "thread content"], "thread-search"), t3_thread_transfers: tool(["Read", "Reading", "Read", "thread transfers"], "thread-transfers"), t3_thread_organize: tool(["Organize", "Organizing", "Organized", "a thread"], "thread-organize"), + wait_for_background_commands: tool( + ["Wait for", "Waiting for", "Waiting for", "background commands"], + "background-wait", + ), t3_thread_update: tool(["Update", "Updating", "Updated", "T3 thread metadata"], "thread-update"), t3_worktree_list: tool(["List", "Listing", "Listed", "workspace branches"], "worktree-list"), t3_preview_list: tool(["List", "Listing", "Listed", "preview tabs"], "browser", "browser"), From f844ef93744dee126f00a123ab1d1e533c7eafde Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 3 Oct 2026 13:40:40 -0700 Subject: [PATCH 2/2] fix: tell a missing background command apart from an outage The wait_for_background_commands tool reported every dispatch failure as "no background command running". The orchestrator now rejects that case with its own error tag, and other failures stay retryable. Tool labels are neutral, since the same tool also stops waiting, and the work-log summary reads the wait input. Also fixes a test literal that failed typecheck. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/mcp/toolkits/thread/handlers.ts | 44 +++++++++++-------- .../Orchestrator.backgroundWorkHold.test.ts | 12 +++-- .../src/orchestration-v2/Orchestrator.ts | 3 +- .../client-runtime/src/t3ToolSummary.test.ts | 9 ++++ packages/client-runtime/src/t3ToolSummary.ts | 8 ++-- packages/shared/src/t3McpToolPresentation.ts | 3 +- 6 files changed, 50 insertions(+), 29 deletions(-) diff --git a/apps/server/src/mcp/toolkits/thread/handlers.ts b/apps/server/src/mcp/toolkits/thread/handlers.ts index e61aea08ebc2..d85f0fa186b0 100644 --- a/apps/server/src/mcp/toolkits/thread/handlers.ts +++ b/apps/server/src/mcp/toolkits/thread/handlers.ts @@ -272,25 +272,31 @@ export const ThreadToolkitHandlersLive = ThreadToolkit.toLayer({ queuedRunId: input.queuedRunId, targetRunId: input.targetRunId, })), - wait_for_background_commands: (input) => { - const held = input.wait ?? true; - return dispatch(undefined, (common) => ({ - ...common, - type: "thread.background-work.hold", - held, - })).pipe( - // The orchestrator rejects a hold when no background command runs. - Effect.mapError((error) => - held && error.code === "orchestration_error" - ? new OrchestratorMcpFailure({ - code: "invalid_request", - message: - "Could not wait: this thread has no background command running. Start the command in the background first.", - }) - : error, - ), - ); - }, + wait_for_background_commands: (input) => + Effect.gen(function* () { + const { threads, projection } = yield* readWritableThread(); + const result = yield* threads + .dispatch({ + type: "thread.background-work.hold", + commandId: yield* newCommandId(), + threadId: projection.thread.id, + held: input.wait ?? true, + }) + .pipe( + // The orchestrator rejects a hold before commit only when no + // background command runs. Anything else stays retryable. + Effect.mapError((error) => + error._tag === "OrchestratorCommandRejectedError" + ? new OrchestratorMcpFailure({ + code: "invalid_request", + message: + "Could not wait: this thread has no background command running. Start the command in the background first.", + }) + : unavailable(), + ), + ); + return { sequence: result.sequence }; + }), t3_thread_organize: (input) => Effect.gen(function* () { const { threads, projection } = yield* readWritableThread(input.threadId); diff --git a/apps/server/src/orchestration-v2/Orchestrator.backgroundWorkHold.test.ts b/apps/server/src/orchestration-v2/Orchestrator.backgroundWorkHold.test.ts index ef22753337ef..30248dc9e47d 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.backgroundWorkHold.test.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.backgroundWorkHold.test.ts @@ -74,8 +74,10 @@ it.layer(testLayer)("thread.background-work.hold", (it) => { creationSource: "web", }); - // Nothing runs yet, so there is nothing to wait for. - assert.equal((yield* Effect.exit(hold("hold-nothing", true)))._tag, "Failure"); + // Nothing runs yet, so there is nothing to wait for. The MCP tool relies + // on this tag to tell the rejection apart from an outage. + const nothing = yield* Effect.flip(hold("hold-nothing", true)); + assert.equal(nothing._tag, "OrchestratorCommandRejectedError"); // The turn ended and left its benchmark running in the background. yield* projections.apply({ @@ -130,7 +132,11 @@ it.layer(testLayer)("thread.background-work.hold", (it) => { output: "", }, }); - const running = { taskId: "item:bench", description: "Run benchmarks", kind: "command" }; + const running = { + taskId: "item:bench", + description: "Run benchmarks", + kind: "command" as const, + }; assert.deepEqual((yield* projections.getThreadShell(threadId))?.pendingBackgroundTasks, [ running, ]); diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index a0cd37b8b7a5..3be6f04df7d3 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -7851,8 +7851,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio activeProviderThreadId: thread.activeProviderThreadId, runs: projection.runs, }).flatMap((task) => (task.kind === "command" ? [task.taskId] : [])); + // Its own tag, so the MCP tool can tell this apart from an outage. if (command.held && commandTaskIds.length === 0) { - return yield* new OrchestratorDispatchError({ + return yield* new OrchestratorCommandRejectedError({ commandId: command.commandId, commandType: command.type, cause: `Thread ${command.threadId} has no background commands running.`, diff --git a/packages/client-runtime/src/t3ToolSummary.test.ts b/packages/client-runtime/src/t3ToolSummary.test.ts index 8ae001ea7769..16771143ea47 100644 --- a/packages/client-runtime/src/t3ToolSummary.test.ts +++ b/packages/client-runtime/src/t3ToolSummary.test.ts @@ -28,6 +28,15 @@ describe("summarizeT3ToolCalls", () => { ).toBe("Created 1 thread"); }); + it("names a release as the opposite of a wait for background commands", () => { + expect(summarizeT3ToolCalls("background-wait", [completed({})]).label).toBe( + "Set the thread to wait for background commands", + ); + expect(summarizeT3ToolCalls("background-wait", [completed({ wait: false })]).label).toBe( + "Stopped waiting for background commands", + ); + }); + it.each([ ["queue-read", "Read 1 queued message"], ["queue-edit", "Edited 1 queued message"], diff --git a/packages/client-runtime/src/t3ToolSummary.ts b/packages/client-runtime/src/t3ToolSummary.ts index a8593eb425cc..95e4ee1097d4 100644 --- a/packages/client-runtime/src/t3ToolSummary.ts +++ b/packages/client-runtime/src/t3ToolSummary.ts @@ -228,11 +228,9 @@ export function summarizeT3ToolCalls( label = phrase("Organized", "organize", `threads ${times}`); break; case "background-wait": - label = phrase( - "Set the thread to wait for", - "set the thread to wait for", - "background commands", - ); + label = selected.every((call) => call.input?.wait === false) + ? phrase("Stopped waiting for", "stop waiting for", "background commands") + : phrase("Set the thread to wait for", "set the thread to wait for", "background commands"); break; case "thread-update": label = phrase("Updated", "update", quantity(countEntities(threadIds), "thread")); diff --git a/packages/shared/src/t3McpToolPresentation.ts b/packages/shared/src/t3McpToolPresentation.ts index 26e3bad19436..cf111628be40 100644 --- a/packages/shared/src/t3McpToolPresentation.ts +++ b/packages/shared/src/t3McpToolPresentation.ts @@ -257,8 +257,9 @@ const T3_MCP_TOOLS: Readonly> = { t3_thread_search: tool(["Search", "Searching", "Searched", "thread content"], "thread-search"), t3_thread_transfers: tool(["Read", "Reading", "Read", "thread transfers"], "thread-transfers"), t3_thread_organize: tool(["Organize", "Organizing", "Organized", "a thread"], "thread-organize"), + // One tool both waits and stops waiting (wait: false), so the labels stay neutral. wait_for_background_commands: tool( - ["Wait for", "Waiting for", "Waiting for", "background commands"], + ["Update", "Updating", "Updated", "background command waiting"], "background-wait", ), t3_thread_update: tool(["Update", "Updating", "Updated", "T3 thread metadata"], "thread-update"),