From f8214d44f6e1d7d320c159637fd4af1d61b205d7 Mon Sep 17 00:00:00 2001 From: lovelycodemonkey Date: Sat, 17 Oct 2026 17:16:28 -0700 Subject: [PATCH 1/3] fix(threads): Working holds only background work the agent waits on A thread left Working while the agent was still waiting on a background command (tests, a CI wait), and stayed stuck in Working while a dev-server watchdog Monitor ran. Both came from deciding "the agent will come back" by task kind: commands never held and monitors always did, but either can be a wait or a process left running. Agents now mark the background work they will resume from with the t3_background_wait MCP tool. Marked tasks hold the thread in Working, block completion alerts and auto-settle, and wake the agent after a restart. Unmarked commands and monitors do neither. Subagents and PR watches hold as before. Claude implements the mark on its roster entries, so it ends with the task and resets with the process, and its system prompt says when to call the tool, since Claude Code defers MCP tools behind ToolSearch. Other providers report the tool as unsupported. --- .../src/features/threads/threadListV2.test.ts | 11 +- .../src/mcp/toolkits/orchestrator/handlers.ts | 21 +++ .../src/mcp/toolkits/orchestrator/tools.ts | 21 +++ .../Adapters/ClaudeAdapterV2.test.ts | 136 ++++++++++++++++++ .../Adapters/ClaudeAdapterV2.ts | 135 ++++++++++++++--- .../DelegatedCompletionDelivery.test.ts | 2 +- .../orchestration-v2/ProjectionStore.test.ts | 5 +- .../RestartBackgroundNote.test.ts | 7 + .../orchestration-v2/RestartBackgroundNote.ts | 10 +- .../RestartContinuation.test.ts | 4 +- .../ThreadManagementService.test.ts | 104 ++++++++++++++ .../ThreadManagementService.ts | 78 ++++++++++ .../src/orchestration-v2/runtimeLayer.test.ts | 19 ++- .../src/relay/AgentAwarenessRelay.test.ts | 5 +- apps/web/src/components/Sidebar.logic.test.ts | 41 +++++- docs/user/thread-sidebar.md | 14 +- docs/user/updating.md | 7 +- .../client-runtime/src/state/entities.test.ts | 20 ++- packages/client-runtime/src/t3ToolSummary.ts | 3 + .../src/work-log/presentation.ts | 1 + packages/contracts/src/orchestrationV2.ts | 7 + .../src/server/ProviderAdapter.ts | 9 ++ packages/shared/src/agentAwareness.test.ts | 11 +- ...chestrationV2PendingBackgroundWork.test.ts | 16 ++- .../orchestrationV2PendingBackgroundWork.ts | 11 +- packages/shared/src/t3McpToolPresentation.ts | 5 + 26 files changed, 632 insertions(+), 71 deletions(-) diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 2c150561fde6c..a5fc77b2c6fed 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -101,11 +101,12 @@ describe("resolveThreadListV2Status", () => { }); it.each([ - { kind: "command", status: "ready" }, - { kind: "monitor", status: "waiting" }, + { roster: "dev server", task: { kind: "command" }, status: "ready" }, + { roster: "dev server watchdog", task: { kind: "monitor" }, status: "ready" }, + { roster: "awaited command", task: { kind: "command", awaited: true }, status: "waiting" }, ] as const)( - "presents an unseen completion with a $kind roster as $status", - ({ kind, status }) => { + "presents an unseen completion with a $roster roster as $status", + ({ task, status }) => { const thread = presentThreadShell( environmentId, makeRawThreadShell({ @@ -113,7 +114,7 @@ describe("resolveThreadListV2Status", () => { status: "completed", latestRunCompletedAt: DateTime.makeUnsafe(NOW), lastVisitedAt: DateTime.makeUnsafe("2026-06-01T23:59:00.000Z"), - pendingBackgroundTasks: [{ taskId: "background-work", kind }], + pendingBackgroundTasks: [{ taskId: "background-work", ...task }], }), ); diff --git a/apps/server/src/mcp/toolkits/orchestrator/handlers.ts b/apps/server/src/mcp/toolkits/orchestrator/handlers.ts index 0df604a850110..a6f9a1302c670 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/handlers.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/handlers.ts @@ -57,6 +57,27 @@ const handlers = { ); }), ), + t3_background_wait: McpToolAccess.actsAsCaller((input) => + Effect.gen(function* () { + const { scope, threads } = yield* readCaller(); + const caller = yield* McpInvocationContext.requireThreadScope(scope, "t3_background_wait"); + return yield* threads + .awaitBackgroundTasks({ + threadId: caller.thread.threadId, + taskIds: input.taskIds, + awaited: input.waiting ?? true, + }) + .pipe( + Effect.mapError( + (cause) => + new OrchestratorMcpFailure({ + code: cause.reason === "unsupported" ? "invalid_request" : "orchestration_error", + message: cause.message, + }), + ), + ); + }), + ), schedule_task: McpToolAccess.startsThreads( () => ({}), (input) => diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 567cb92727326..4bef40b270135 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -31,6 +31,7 @@ import { OrchestratorMcpThreadWaitResult, ThreadMetadataMcpUpdateInput, ThreadMetadataMcpUpdateResult, + TrimmedNonEmptyString, } from "@t3tools/contracts"; import { Tool, Toolkit } from "effect/ai"; import * as Schema from "effect/Schema"; @@ -116,6 +117,25 @@ const WorkflowStopTool = Tool.make("t3_workflow_stop", { .annotate(Tool.Title, "Stop a workflow") .annotate(Tool.Destructive, true); +const BackgroundWaitTool = Tool.make("t3_background_wait", { + description: + "Keep this thread in the user's Working list while you wait on your own background work. After starting a background command or Monitor whose result you will act on when you are woken, call this with its task ID (the ID shown when it started) before ending your turn. Until that task ends, the thread stays in Working and the user is not told it finished. Unmarked background commands and monitors, such as dev servers and their watchdogs, never keep the thread in Working, so do not mark those. Pass waiting=false to stop waiting without stopping the task. IDs that are not running come back in unmatched and change nothing. Only Claude threads support this.", + parameters: Schema.Struct({ + taskIds: Schema.Array(TrimmedNonEmptyString).check( + Schema.isMinLength(1), + Schema.isMaxLength(20), + ), + waiting: Schema.optional(Schema.Boolean), + }), + success: Schema.Struct({ + matched: Schema.Array(Schema.String), + unmatched: Schema.Array(Schema.String), + }), + failure: OrchestratorMcpFailure, + failureMode: "return", + dependencies, +}).annotate(Tool.Title, "Wait on background work"); + const ScheduleTaskTool = Tool.make("schedule_task", { description: "Create persistent work in the app scheduler that runs even when no turn is active. Pass schedule as a STRUCTURED OBJECT, never JSON text. Timers: {type:'interval', everyMs:3600000} is hourly; {type:'fixed_time', timeOfDay:'09:00', weekdays:[1,2,3,4,5]} is weekday mornings; report the returned nextRunAt. Webhook dedupe accepts replayKey and coalesceKey templates with coalesceWindowSeconds (10–3600, default 120); replay keys expire after 7 days and coalescing keeps the latest payload in a window measured from its first delivery. Webhooks: {type:'webhook'} runs once per request to a generated URL. The run sees the request ONLY through prompt placeholders: {{body.path}} (e.g. {{body.action}}, {{body.release.tag_name}}), {{headers.name}}, {{query.name}}, {{body}}, or {{request}} (method, headers with credentials redacted, and body). Each value arrives framed as untrusted request data, and $skill, @file or /command mentions inside it do not act; without a signature, anyone with the URL can trigger a run. For a sender that signs requests, first call request_secret so the user enters the secret privately (never ask for it in chat or invent one), then set signature with the returned secretRef, e.g. GitHub: {type:'webhook', signature:{header:'x-hub-signature-256', encoding:'hex', prefix:'sha256=', secretRef}}. To run only for some requests, add filters, all of which must match, e.g. filters:[{path:'headers.x-github-event', op:'equals', value:'pull_request'}, {path:'body.action', op:'in', value:['opened','reopened']}]; ops are equals, notEquals, in, startsWith and exists. A path segment ending in [] fans out over an array: {path:'body.issue.labels[].name', op:'equals', value:'needs more info'} matches when any label has that name, and notEquals only when none does. The result's webhookUrl is the public URL to give the user; if it is absent, this environment has no T3 Connect managed tunnel, so tell the user to enable T3 Connect remote access rather than sharing a path. Omit projectId for this thread's project. In this thread's project, runs post into THIS thread by default (bindToCurrentThread=true), which suits an orchestrator that sees every trigger, delegates work, and can dedupe against what is in flight; use false only when the user wants a fresh top-level thread per run. Elsewhere each run launches a fresh thread. Provider, model, and runtime settings inherit from this thread, or from the project default when there is no calling thread.", @@ -279,6 +299,7 @@ export const OrchestratorToolkit = Toolkit.make( TaskStatusTool, TaskCancelTool, WorkflowStopTool, + BackgroundWaitTool, ScheduleTaskTool, ListScheduledTasksTool, UpdateScheduledTaskTool, diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 0c8b2ad588acc..4610065440de5 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -35,6 +35,7 @@ import { type ModelSelection, NodeId, type OrchestrationV2AppThread, + type OrchestrationV2PendingBackgroundTask, type OrchestrationV2ProviderThread, ProjectId, ProviderInstanceId, @@ -565,6 +566,7 @@ describe("ClaudeAdapterV2 native protocol logging", () => { assert.equal(systemPrompt.type, "preset"); assert.equal(systemPrompt.preset, "claude_code"); assert.include(systemPrompt.append ?? "", "Use `delegate_task`"); + assert.include(systemPrompt.append ?? "", "call `t3_background_wait`"); const logged = ClaudeAdapterV2.loggedClaudeQueryOptions(options); assert.equal(logged.hasMcpServers, true); assert.notInclude(JSON.stringify(logged), "secret-claude-token"); @@ -2202,6 +2204,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* Queue.unbounded< Extract >(); + const providerThreadReceipts = + yield* Queue.unbounded< + Extract + >(); let openedOptions: ClaudeAdapterV2.ClaudeAgentSdkQueryOptions | undefined; const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, @@ -2329,6 +2335,9 @@ describe("ClaudeAdapterV2 background wake turns", () => { if (event.type === "plan.updated") { yield* Queue.offer(planReceipts, event); } + if (event.type === "provider_thread.updated") { + yield* Queue.offer(providerThreadReceipts, event); + } if (event.type === "turn_item.updated" && event.turnItem.type === "system_notice") { yield* Queue.offer(systemNoticeReceipts, event); } @@ -2372,6 +2381,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { systemNoticeReceipts, runtimeRequestReceipts, planReceipts, + providerThreadReceipts, getOpenedOptions: () => openedOptions, terminalEvents, hasPendingBackgroundWork, @@ -6334,6 +6344,132 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect("holds only the background tasks the agent marks awaited, until they end", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const awaitBackgroundTasks = harness.runtime.awaitBackgroundTasks; + if (awaitBackgroundTasks === undefined) { + throw new Error("Claude adapter runtime must expose awaitBackgroundTasks."); + } + const roster = (tasks: ReadonlyArray, uuid: string) => + claudeSdkFrame({ + type: "system", + subtype: "background_tasks_changed", + tasks: tasks.map((taskId) => ({ + task_id: taskId, + task_type: "local_bash", + description: taskId, + })), + uuid, + session_id: WAKE_NATIVE_SESSION, + }); + const nextRoster = Effect.fn("nextRoster")(function* ( + matches: (tasks: ReadonlyArray) => boolean, + ) { + while (true) { + const { providerThread } = yield* Queue.take(harness.providerThreadReceipts); + const tasks = providerThread.pendingBackgroundTasks ?? []; + if (matches(tasks)) return tasks; + } + }); + const marks = (tasks: ReadonlyArray) => + tasks.map((task) => [task.taskId, task.awaited === true]); + + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-awaited-roster"), + text: "Run the tests in the background and the dev server.", + attachments: [], + }), + ); + yield* Queue.offer( + harness.sdkMessages, + roster([WAKE_TASK_ID, "dev-server"], "00000000-0000-4000-8000-000000000301"), + ); + yield* nextRoster((tasks) => tasks.length === 2); + + const marked = yield* awaitBackgroundTasks({ + providerThread: harness.providerThread, + taskIds: [WAKE_TASK_ID, "already-finished"], + awaited: true, + }); + assert.deepStrictEqual(marked.matched, [WAKE_TASK_ID]); + assert.deepStrictEqual( + marks(yield* nextRoster((tasks) => tasks.some((task) => task.awaited === true))), + [ + [WAKE_TASK_ID, true], + ["dev-server", false], + ], + ); + yield* Queue.offer(harness.sdkMessages, turnOneResult); + yield* Queue.take(harness.terminalReceipts); + + // A replacing level rebuilds every entry; the mark must survive it. + yield* Queue.offer( + harness.sdkMessages, + roster(["dev-server", WAKE_TASK_ID], "00000000-0000-4000-8000-000000000302"), + ); + assert.deepStrictEqual( + marks(yield* nextRoster((tasks) => tasks[0]?.taskId === "dev-server")), + [ + ["dev-server", false], + [WAKE_TASK_ID, true], + ], + ); + + // Once the task leaves the roster, a reused id starts unmarked. + yield* Queue.offer( + harness.sdkMessages, + roster(["dev-server"], "00000000-0000-4000-8000-000000000303"), + ); + yield* nextRoster((tasks) => tasks.length === 1); + yield* Queue.offer( + harness.sdkMessages, + roster(["dev-server", WAKE_TASK_ID], "00000000-0000-4000-8000-000000000304"), + ); + assert.deepStrictEqual(marks(yield* nextRoster((tasks) => tasks.length === 2)), [ + ["dev-server", false], + [WAKE_TASK_ID, false], + ]); + + yield* awaitBackgroundTasks({ + providerThread: harness.providerThread, + taskIds: ["dev-server"], + awaited: true, + }); + assert.deepStrictEqual( + marks(yield* nextRoster((tasks) => tasks.some((task) => task.awaited === true))), + [ + ["dev-server", true], + [WAKE_TASK_ID, false], + ], + ); + const released = yield* awaitBackgroundTasks({ + providerThread: harness.providerThread, + taskIds: ["dev-server"], + awaited: false, + }); + assert.deepStrictEqual(released.matched, ["dev-server"]); + assert.deepStrictEqual( + marks(yield* nextRoster((tasks) => !tasks.some((task) => task.awaited === true))), + [ + ["dev-server", false], + [WAKE_TASK_ID, false], + ], + ); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + it.effect("keeps a running Claude monitor typed after many newer monitors end", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 0e0868918eaa4..37256ae54598d 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -1294,6 +1294,15 @@ export function claudeQueryEnvironment( return { ...environment, CLAUDE_CODE_PROMPT_CACHE_TTL: subagentPromptCacheTtl }; } +// Claude Code defers MCP tools behind ToolSearch, so the agent only finds +// t3_background_wait when the system prompt says when to reach for it. +const CLAUDE_BACKGROUND_WAIT_INSTRUCTIONS = ` + +### Background work you wait on + +Before you end your turn to wait for a background command or Monitor you started, such as tests, a build, or a CI wait, call \`t3_background_wait\` with its task ID. The thread then stays in the user's Working list until that task ends, instead of returning to them early. Do not mark a dev server, a watcher, or anything else you leave running. +`; + export function makeClaudeQueryOptions(input: { readonly modelSelection: ModelSelection; readonly nativeThreadId: string; @@ -1407,7 +1416,9 @@ export function makeClaudeQueryOptions(input: { preset: "claude_code" as const, append: [ buildRuntimeInstructions({ harness: "Claude Code" }) + - (input.mcpServers === undefined ? "" : T3_CODE_ORCHESTRATION_INSTRUCTIONS), + (input.mcpServers === undefined + ? "" + : T3_CODE_ORCHESTRATION_INSTRUCTIONS + CLAUDE_BACKGROUND_WAIT_INSTRUCTIONS), ...launchAppendSystemPrompt, ].join("\n\n"), }, @@ -2451,8 +2462,11 @@ function isClaudeRootTurnFrame(message: SDKMessage): boolean { function claudePendingBackgroundTasksFromRoster( roster: ReadonlyMap, + awaitedTaskIds: ReadonlySet, ): ReadonlyArray { - return Array.from(roster.values()); + return Array.from(roster.values(), (task) => + awaitedTaskIds.has(task.taskId) ? { ...task, awaited: true as const } : task, + ); } function parseClaudeBackgroundTaskEntry( @@ -4135,6 +4149,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const pendingBackgroundTasksByNativeThread = yield* Ref.make( new Map>(), ); + // Survives roster REPLACE levels, which rebuild every entry without the agent's mark. + const awaitedBackgroundTasksByNativeThread = yield* Ref.make( + new Map>(), + ); // Wake eligibility is separate from the Waiting roster. It survives empty // background_tasks_changed levels (SDK: empty level can precede task_notification) and // is consumed when the first idle notification is buffered/offered so duplicates cannot @@ -4568,12 +4586,16 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( yield* Ref.get(pendingBackgroundTasksByNativeThread), input.nativeThreadId, ); + const awaitedTaskIds = taskIdSetForNativeThread( + yield* Ref.get(awaitedBackgroundTasksByNativeThread), + input.nativeThreadId, + ); const now = yield* DateTime.now; const providerThread: OrchestrationV2ProviderThread = { ...input.providerThread, providerSessionId: session.id, ...(input.status === undefined ? {} : { status: input.status }), - pendingBackgroundTasks: claudePendingBackgroundTasksFromRoster(roster), + pendingBackgroundTasks: claudePendingBackgroundTasksFromRoster(roster, awaitedTaskIds), ...(goalsByNativeThread.has(input.nativeThreadId) ? { goal: goalsByNativeThread.get(input.nativeThreadId) ?? null } : {}), @@ -4655,6 +4677,17 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( tasks.map((task) => task.taskId), ); } + const listed = new Set(tasks.map((task) => task.taskId)); + yield* Ref.update(awaitedBackgroundTasksByNativeThread, (current) => { + const awaited = current.get(nativeThreadId); + if (awaited === undefined) return current; + const kept = new Set([...awaited].filter((taskId) => listed.has(taskId))); + if (kept.size === awaited.size) return current; + const updated = new Map(current); + if (kept.size === 0) updated.delete(nativeThreadId); + else updated.set(nativeThreadId, kept); + return updated; + }); }); const upsertPendingBackgroundTask = @@ -4687,7 +4720,15 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( updated.set(nativeThreadId, nextRoster); } return [true, updated] as const; - }); + }).pipe( + Effect.tap(() => + clearTaskIdFromNativeThreadSet( + awaitedBackgroundTasksByNativeThread, + nativeThreadId, + taskId, + ), + ), + ); const clearPendingBackgroundTasksForNativeThread = /* Provider events include streaming deltas; avoid a span for each update. */ @@ -4700,6 +4741,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( updated.delete(nativeThreadId); return updated; }); + yield* clearNativeThreadTaskIdSet(awaitedBackgroundTasksByNativeThread, nativeThreadId); yield* endClaudeMonitorTasks((_taskId, task) => task.nativeThreadId === nativeThreadId); }); @@ -6854,6 +6896,13 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( yield* Ref.get(pendingBackgroundTasksByNativeThread), nativeThreadId, ); + const awaitedTaskIds = + nativeThreadId === null + ? new Set() + : taskIdSetForNativeThread( + yield* Ref.get(awaitedBackgroundTasksByNativeThread), + nativeThreadId, + ); const clearConversationHead = input.status === "completed" && input.context.input.providerThread.nativeConversationHeadRef !== null; @@ -6891,7 +6940,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( input.context.input.providerThread.firstRunOrdinal ?? input.context.input.runOrdinal, lastRunOrdinal: input.context.input.runOrdinal, - pendingBackgroundTasks: claudePendingBackgroundTasksFromRoster(roster), + pendingBackgroundTasks: claudePendingBackgroundTasksFromRoster( + roster, + awaitedTaskIds, + ), status: input.status === "completed" ? "active" : "idle", updatedAt: input.completedAt, }; @@ -7278,6 +7330,26 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }); }); + // Untraced like the roster message handling: it runs for every streamed roster change. + const emitRosterUpdate = Effect.fnUntraced(function* ( + nativeThreadId: string, + activeContext: ActiveClaudeTurnContext | null, + ) { + const baseThread = + activeContext?.input.providerThread ?? + (yield* Ref.get(lastProviderThreadByNativeThread)).get(nativeThreadId); + if (baseThread === undefined) { + return; + } + const status = + activeContext === null + ? ("idle" as const) + : baseThread.status === "idle" + ? ("active" as const) + : baseThread.status; + yield* emitProviderThreadRoster({ nativeThreadId, providerThread: baseThread, status }); + }); + const applyBackgroundTaskRosterMessage = Effect.fnUntraced(function* (input: { readonly nativeThreadId: string; readonly message: SDKMessage; @@ -7351,24 +7423,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( return false; } - const baseThread = - input.activeContext?.input.providerThread ?? - (yield* Ref.get(lastProviderThreadByNativeThread)).get(input.nativeThreadId); - if (baseThread === undefined) { - return true; - } - - const status = - input.activeContext === null - ? ("idle" as const) - : baseThread.status === "idle" - ? ("active" as const) - : baseThread.status; - yield* emitProviderThreadRoster({ - nativeThreadId: input.nativeThreadId, - providerThread: baseThread, - status, - }); + yield* emitRosterUpdate(input.nativeThreadId, input.activeContext); return true; }); @@ -10207,6 +10262,40 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ), ); }), + awaitBackgroundTasks: Effect.fn("ClaudeAdapterV2.awaitBackgroundTasks")( + function* ({ providerThread, taskIds, awaited }) { + const nativeThreadId = providerThread.nativeThreadRef?.nativeId; + if (nativeThreadId == null) return { matched: [] }; + const roster = rosterForNativeThread( + yield* Ref.get(pendingBackgroundTasksByNativeThread), + nativeThreadId, + ); + const matched = [...new Set(taskIds)].filter((taskId) => roster.has(taskId)); + if (matched.length === 0) return { matched }; + if (awaited) { + yield* addTaskIdsToNativeThreadSet( + awaitedBackgroundTasksByNativeThread, + nativeThreadId, + matched, + ); + } else { + for (const taskId of matched) { + yield* clearTaskIdFromNativeThreadSet( + awaitedBackgroundTasksByNativeThread, + nativeThreadId, + taskId, + ); + } + } + const active = yield* Ref.get(activeTurn); + yield* emitRosterUpdate( + nativeThreadId, + active?.nativeThreadId === nativeThreadId ? active : null, + ); + return { matched }; + }, + (effect) => sdkMessageLock.withPermits(1)(effect), + ), interruptTurn, stopSubagent: Effect.fn("ClaudeAdapterV2.stopSubagent")(function* (input) { const existing = yield* Ref.get(queryContext); diff --git a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts index e58c8a1e528f8..f26a72365a69f 100644 --- a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts +++ b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts @@ -2035,7 +2035,7 @@ it.layer(layerTest)("delegated tasks across a server restart", (it) => { completionWake: "always", continuationPending: true, runStatus: "completed", - lostWork: [{ kind: "monitor", label: "Gate attempt 9" }], + lostWork: [{ kind: "monitor", label: "Gate attempt 9", awaited: true }], now, }); yield* orchestrator.recoverDelegatedTasks; diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index fcadd8036b924..5003f081552bf 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -5684,6 +5684,7 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { taskId: "pull-request-watch:github.com/pingdotgg/t3code#7", description: "Watching pull request #7", kind: "monitor", + awaited: true, }, ]); assert.equal(yield* phase, "running"); @@ -5716,7 +5717,9 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { lastRunOrdinal: 1, handoffIds: [], forkedFrom: null, - pendingBackgroundTasks: [{ taskId: "watch", kind: "monitor" as const }], + pendingBackgroundTasks: [ + { taskId: "watch", kind: "monitor" as const, awaited: true as const }, + ], createdAt: completedAt, updatedAt: completedAt, }; diff --git a/apps/server/src/orchestration-v2/RestartBackgroundNote.test.ts b/apps/server/src/orchestration-v2/RestartBackgroundNote.test.ts index df184a610822d..f9233931a6211 100644 --- a/apps/server/src/orchestration-v2/RestartBackgroundNote.test.ts +++ b/apps/server/src/orchestration-v2/RestartBackgroundNote.test.ts @@ -115,6 +115,13 @@ it("names a lost subagent by the id the agent resumes it with", () => { }).label; assert.lengthOf(longId, 160); assert.isFalse(isAwaitedWork(cancelledRosterTaskWork({ kind: "command", taskId: "shell-2" }))); + assert.isTrue( + isAwaitedWork(cancelledRosterTaskWork({ kind: "command", taskId: "tests", awaited: true })), + ); + assert.isFalse(isAwaitedWork(cancelledRosterTaskWork({ kind: "monitor", taskId: "watchdog" }))); + assert.isTrue( + isAwaitedWork(cancelledRosterTaskWork({ kind: "monitor", taskId: "ci", awaited: true })), + ); assert.equal( cancelledTurnItemWork({ id: "item-1", diff --git a/apps/server/src/orchestration-v2/RestartBackgroundNote.ts b/apps/server/src/orchestration-v2/RestartBackgroundNote.ts index d957daad6ff7b..e465de8b1e642 100644 --- a/apps/server/src/orchestration-v2/RestartBackgroundNote.ts +++ b/apps/server/src/orchestration-v2/RestartBackgroundNote.ts @@ -118,6 +118,7 @@ export function cancelledRosterTaskWork(task: OrchestrationV2PendingBackgroundTa description === undefined ? task.taskId : `${description} (id ${task.taskId})`, ) ?? "background task", id: task.taskId, + ...(task.awaited === true ? { awaited: true as const } : {}), }; } @@ -158,11 +159,12 @@ export function isRestartNoteSource( } /** - * A shell does not wake it: most are dev servers that nobody waits on, so their - * note waits for the next user message. + * Matches the Working-section hold: a shell or monitor wakes it only when the agent said it + * waits on it. Most are dev servers and their watchdogs, so their note waits for the next + * user message. */ -export function isAwaitedWork(work: Pick): boolean { - return work.kind !== "shell"; +export function isAwaitedWork(work: Pick): boolean { + return work.awaited === true || (work.kind !== "shell" && work.kind !== "monitor"); } export function runOutcome( diff --git a/apps/server/src/orchestration-v2/RestartContinuation.test.ts b/apps/server/src/orchestration-v2/RestartContinuation.test.ts index 2483534ed75d0..8529e17931a6b 100644 --- a/apps/server/src/orchestration-v2/RestartContinuation.test.ts +++ b/apps/server/src/orchestration-v2/RestartContinuation.test.ts @@ -191,12 +191,12 @@ it("does not continue settled root runs with restart-cancelled background work", }); it.effect.each(["completed", "waiting", "cancelled"] as const)( - "wakes a %s run whose monitor the restart cancelled, with the note as its prompt", + "wakes a %s run whose awaited monitor the restart cancelled, with the note as its prompt", (status) => Effect.gen(function* () { const base = makeProjection(); const work = [ - { kind: "monitor" as const, label: "Watch CI run 42" }, + { kind: "monitor" as const, label: "Watch CI run 42", awaited: true as const }, { kind: "shell" as const, label: "vp run dev" }, ]; const projection = { diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts index 8d1f613180a3a..0314008c6369f 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts @@ -3,6 +3,10 @@ import { CommandId, MessageId, type OrchestrationV2Run, + type OrchestrationV2ProviderThread, + ProviderThreadId, + ProviderDriverKind, + ProviderSessionId, type OrchestrationV2StoredEvent, type OrchestrationV2ThreadProjection, ProjectId, @@ -12,6 +16,7 @@ import { } from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; import * as DateTime from "effect/DateTime"; +import * as Option from "effect/Option"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; @@ -23,7 +28,9 @@ import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as CommandReceiptStore from "./CommandReceiptStore.ts"; import * as Orchestrator from "./Orchestrator.ts"; import * as ProviderSessionManager from "./ProviderSessionManager.ts"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import * as ThreadManagementService from "./ThreadManagementService.ts"; +import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; const layerReceiptsTest = CommandReceiptStore.layer.pipe( Layer.provide(SqlitePersistence.layerMemory), @@ -297,6 +304,103 @@ it.effect("waitForThread returns the cut run once its continuation is declined", }), ); +it.effect.each(["live", "no-session", "unsupported-runtime"] as const)( + "marks background work awaited on the thread's active provider thread with state %s", + (state) => + Effect.gen(function* () { + const threadId = ThreadId.make("thread:background-wait"); + const providerThreadId = ProviderThreadId.make("provider-thread:background-wait"); + const providerSessionId = ProviderSessionId.make("provider-session:background-wait"); + const driver = ProviderDriverKind.make("claudeAgent"); + const now = yield* DateTime.now; + const providerThread: OrchestrationV2ProviderThread = { + id: providerThreadId, + driver, + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + providerSessionId, + appThreadId: threadId, + ownerNodeId: null, + nativeThreadRef: { driver, nativeId: "native-thread", strength: "strong" }, + nativeConversationHeadRef: null, + status: "idle", + firstRunOrdinal: 1, + lastRunOrdinal: 1, + handoffIds: [], + forkedFrom: null, + pendingBackgroundTasks: [{ taskId: "tests", kind: "command" }], + contextUsage: null, + nativeMetadata: null, + createdAt: now, + updatedAt: now, + }; + const calls: Array<{ providerThreadId: ProviderThreadId; awaited: boolean }> = []; + const runtime = { + driver, + providerSessionId, + ...(state === "unsupported-runtime" + ? {} + : { + awaitBackgroundTasks: (input: { + readonly providerThread: OrchestrationV2ProviderThread; + readonly taskIds: ReadonlyArray; + readonly awaited: boolean; + }) => + Effect.sync(() => { + calls.push({ + providerThreadId: input.providerThread.id, + awaited: input.awaited, + }); + return { matched: input.taskIds.filter((taskId) => taskId === "tests") }; + }), + }), + } as unknown as ProviderAdapter.ProviderAdapterV2SessionRuntime; + const layerTest = ThreadManagementService.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(Orchestrator.OrchestratorV2)({ + getThreadRecords: () => + Effect.succeed({ + thread: { + id: threadId, + deletedAt: null, + activeProviderThreadId: providerThreadId, + }, + providerThreads: [providerThread], + } as unknown as OrchestrationV2ThreadProjection), + }), + Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({ + get: () => + Effect.succeed(state === "no-session" ? Option.none() : Option.some(runtime)), + }), + ), + ), + Layer.provide(ServerSettings.layerTest()), + Layer.provide(layerReceiptsTest), + ); + const result = yield* Effect.gen(function* () { + const service = yield* ThreadManagementService.ThreadManagementService; + return yield* service + .awaitBackgroundTasks({ threadId, taskIds: ["tests", "finished"], awaited: true }) + .pipe(Effect.result); + }).pipe(Effect.provide(Layer.merge(layerTest, ThreadCommandExecutor.layer))); + + if (state === "live") { + expect(result).toMatchObject({ + success: { matched: ["tests"], unmatched: ["finished"] }, + }); + expect(calls).toEqual([{ providerThreadId, awaited: true }]); + } else { + expect(result).toMatchObject({ + failure: { + _tag: "ThreadManagementBackgroundWaitError", + reason: state === "no-session" ? "unavailable" : "unsupported", + }, + }); + expect(calls).toEqual([]); + } + }), +); + it.effect.each([ { status: "completed" as const, settles: true }, { status: "failed" as const, settles: false }, diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index 78b3aa07d247b..f4c79c2721532 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -295,6 +295,26 @@ export class ThreadManagementProviderInstanceDisabledError extends Schema.Tagged } } +export class ThreadManagementBackgroundWaitError extends Schema.TaggedError()( + "ThreadManagementBackgroundWaitError", + { + threadId: ThreadId, + reason: Schema.Literals(["unsupported", "unavailable", "failed"]), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + switch (this.reason) { + case "unsupported": + return "This provider cannot mark background work as awaited."; + case "unavailable": + return "This thread has no live provider session."; + case "failed": + return "Failed to update the awaited background work."; + } + } +} + export const ThreadManagementError = Schema.Union([ ThreadManagementThreadNotFoundError, ThreadManagementRunNotFoundError, @@ -421,6 +441,14 @@ export interface ThreadManagementServiceShape { readonly stopWorkflow: ( input: OrchestrationV2StopWorkflowInput, ) => Effect.Effect; + readonly awaitBackgroundTasks: (input: { + readonly threadId: ThreadId; + readonly taskIds: ReadonlyArray; + readonly awaited: boolean; + }) => Effect.Effect< + { readonly matched: ReadonlyArray; readonly unmatched: ReadonlyArray }, + ThreadManagementBackgroundWaitError + >; readonly stopDelegatedTasks: (input: { readonly threadId: ThreadId; readonly commandId: CommandId; @@ -1097,6 +1125,55 @@ const make = Effect.gen(function* () { (effect, input) => effect.pipe((effect) => threadDispatch.withLock(input.threadId, effect)), ); + const awaitBackgroundTasks: ThreadManagementServiceShape["awaitBackgroundTasks"] = Effect.fn( + "ThreadManagementService.awaitBackgroundTasks", + )(function* (input) { + const unavailable = (cause?: unknown) => + new ThreadManagementBackgroundWaitError({ + threadId: input.threadId, + reason: "unavailable", + ...(cause === undefined ? {} : { cause }), + }); + const records = yield* orchestrator + .getThreadRecords(input.threadId, ["providerThreads"]) + .pipe(Effect.mapError(unavailable)); + const providerThread = records.providerThreads.find( + (candidate) => + candidate.id === records.thread.activeProviderThreadId && + candidate.appThreadId === input.threadId, + ); + if (records.thread.deletedAt !== null || providerThread?.providerSessionId == null) { + return yield* unavailable(); + } + const runtime = yield* sessions + .get(providerThread.providerSessionId) + .pipe(Effect.mapError(unavailable)); + if (Option.isNone(runtime)) { + return yield* unavailable(); + } + if (runtime.value.awaitBackgroundTasks === undefined) { + return yield* new ThreadManagementBackgroundWaitError({ + threadId: input.threadId, + reason: "unsupported", + }); + } + const { matched } = yield* runtime.value + .awaitBackgroundTasks({ providerThread, taskIds: input.taskIds, awaited: input.awaited }) + .pipe( + Effect.timeout("15 seconds"), + Effect.mapError( + (cause) => + new ThreadManagementBackgroundWaitError({ + threadId: input.threadId, + reason: "failed", + cause, + }), + ), + ); + const matchedIds = new Set(matched); + return { matched, unmatched: input.taskIds.filter((taskId) => !matchedIds.has(taskId)) }; + }); + const stopDelegatedTasks: ThreadManagementServiceShape["stopDelegatedTasks"] = Effect.fn( "ThreadManagementService.stopDelegatedTasks", )(function* (input) { @@ -1189,6 +1266,7 @@ const make = Effect.gen(function* () { interruptThread, stopDelegatedTasks, stopWorkflow, + awaitBackgroundTasks, getThreadEventSequence: orchestrator.getThreadEventSequence, recoverDelegatedTask: orchestrator.recoverDelegatedTask, delegatedTaskResultPending: orchestrator.delegatedTaskResultPending, diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 9f0edb83f0f3c..6901f5b8bc248 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -3478,10 +3478,11 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { ["completed", "shell", false], ["waiting", "shell", false], ["cancelled", "shell", false], - ["failed", "monitor", false], - ["completed", "monitor", true], - ["waiting", "monitor", true], - ["cancelled", "monitor", true], + ["completed", "watchdog-monitor", false], + ["failed", "awaited-monitor", false], + ["completed", "awaited-monitor", true], + ["waiting", "awaited-monitor", true], + ["cancelled", "awaited-monitor", true], ] as const)( "admits a restart continuation of a %s run that lost a %s: %s", ([status, kind, admitted]) => @@ -3539,7 +3540,15 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { occurredAt: now, payload: { runId: original.id, - restartCancelledBackgroundWork: [{ kind, label: "sleep 25" }], + restartCancelledBackgroundWork: [ + kind === "shell" + ? { kind, label: "sleep 25" } + : { + kind: "monitor", + label: "sleep 25", + ...(kind === "awaited-monitor" ? { awaited: true as const } : {}), + }, + ], }, }, ], diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index d1ad9e7a58a64..b5f400dc3432f 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -215,6 +215,7 @@ const makeTestRelay = Effect.fnUntraced(function* ( settleThread: unused, interruptThread: unused, stopWorkflow: () => Effect.die("unused stopWorkflow"), + awaitBackgroundTasks: unused, stopDelegatedTasks: unused, getThreadEventSequence: unused, recoverDelegatedTask: unused, @@ -418,14 +419,14 @@ describe("AgentAwarenessRelay", () => { }).pipe(Effect.scoped), ); - it.effect("keeps settled live work until its monitor finishes", () => + it.effect("keeps settled live work until its awaited monitor finishes", () => Effect.gen(function* () { const { relay, currentShell, publications } = yield* makeTestRelay(); yield* relay.publishThread(THREAD_ID); const held = shell({ status: "completed", settledOverride: "settled", - pendingBackgroundTasks: [{ taskId: "watch", kind: "monitor" }], + pendingBackgroundTasks: [{ taskId: "watch", kind: "monitor", awaited: true }], }); yield* Ref.set(currentShell, held); yield* relay.publishThread(THREAD_ID); diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 5bcda0795cc58..0b103763f1fce 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -446,7 +446,9 @@ describe("resolveThreadStatusPill", () => { resolveThreadStatusPill({ thread: { ...baseThread, - pendingBackgroundTasks: [{ taskId: "bg-1", description: "Watch build", kind: "monitor" }], + pendingBackgroundTasks: [ + { taskId: "bg-1", description: "Watch build", kind: "monitor", awaited: true }, + ], runtime: { ...baseThread.runtime, status: "idle", @@ -961,16 +963,38 @@ describe("navigation after parking a thread", () => { describe("unseen completion with background work", () => { it.each([ - { kind: "command", status: "ready", topStatus: "done", receded: false, pill: "Completed" }, - { kind: "monitor", status: "waiting", topStatus: "waiting", receded: true, pill: "Waiting" }, - ] as const)("presents a completed thread with a $kind roster", (expected) => { + { + roster: "dev server", + task: { kind: "command" }, + status: "ready", + topStatus: "done", + receded: false, + pill: "Completed", + }, + { + roster: "dev server watchdog", + task: { kind: "monitor" }, + status: "ready", + topStatus: "done", + receded: false, + pill: "Completed", + }, + { + roster: "awaited command", + task: { kind: "command", awaited: true }, + status: "waiting", + topStatus: "waiting", + receded: true, + pill: "Waiting", + }, + ] as const)("presents a completed thread with a $roster roster", (expected) => { const thread = presentThreadShell(localEnvironmentId, { ...makeThreadFixture().source, latestRunId: RunId.make("run-background-completion"), status: "completed", latestRunCompletedAt: DateTime.makeUnsafe("2026-06-20T01:00:00.000Z"), lastVisitedAt: DateTime.makeUnsafe("2026-06-20T00:59:00.000Z"), - pendingBackgroundTasks: [{ taskId: "background-work", kind: expected.kind }], + pendingBackgroundTasks: [{ taskId: "background-work", ...expected.task }], }); const status = resolveSidebarThreadStatus(thread); const isUnread = hasUnseenCompletion(thread); @@ -1001,7 +1025,12 @@ describe("Working shelf (beta)", () => { lastError: null, updatedAt: "2026-03-09T10:00:00.000Z", }; - const backgroundTask = { taskId: "bg-1", description: "Watch build", kind: "monitor" as const }; + const backgroundTask = { + taskId: "bg-1", + description: "Watch build", + kind: "monitor" as const, + awaited: true as const, + }; const idle = { hasActionableProposedPlan: false, hasPendingApprovals: false, diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 2103e4c565b57..7a88c251d5398 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -120,10 +120,12 @@ open. Turn on **Settings → General → Working section (beta)** on web and desktop, or **Settings → Thread behavior → Working section** on iOS and Android, to move threads that are working or -monitoring into a collapsed **Working** section below the active list. 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. Each -device keeps its own choice. +waiting on work they will resume from into a collapsed **Working** section below the active list. +That includes subagents, watched pull requests, and background commands 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. Each device keeps its own choice. While this is on, the active list is ordered by when each thread last came back to you, so you cannot drag or move threads within it. Your saved order returns when you turn it off. @@ -153,8 +155,8 @@ Press `Escape` while dragging to cancel. By default, environments settle inactive threads after three days and settle threads whose pull request merged. A closed pull request can also settle an idle thread. An empty thread's inactivity counts from when it was created. -Work in progress, pending questions or approvals, and live background work -prevent automatic settlement. An open pull request does not prevent inactivity +Work in progress, pending questions or approvals, and background work the agent +is waiting on prevent automatic settlement. An open pull request does not prevent inactivity settlement unless you turn on **Keep threads with a PR** in **Settings → General**, but an old closed or merged pull request does not settle work you resumed after it closed. Only your own messages count as resuming. A turn that diff --git a/docs/user/updating.md b/docs/user/updating.md index 706d8fad5e106..cb36732b5747b 100644 --- a/docs/user/updating.md +++ b/docs/user/updating.md @@ -49,9 +49,10 @@ different value, use **Apply to all** in Settings after it connects. T3 Code must start again on that machine; the setting does not enable automatic startup. Terminal commands may still be interrupted, and threads without saved provider resume state need a new message. -A thread that finished its turn to wait on a monitor, subagent, or background task -is woken and told that this work stopped. A background shell, such as a dev server, -does not wake it; the thread learns about it with your next message. +A thread that finished its turn to wait on a subagent, a background task, or a +command or monitor the agent said it was waiting on is woken and told that this work +stopped. Other background shells and monitors, such as a dev server and its +watchdog, do not wake it; the thread learns about them with your next message. A thread is not resumed while it waits for your approval, answer, or secret, or while it is archived, snoozed, or settled. After three automatic resumes in a row, the thread waits for your next message. diff --git a/packages/client-runtime/src/state/entities.test.ts b/packages/client-runtime/src/state/entities.test.ts index 0b89a79b2adb4..abc16b9a7df67 100644 --- a/packages/client-runtime/src/state/entities.test.ts +++ b/packages/client-runtime/src/state/entities.test.ts @@ -73,7 +73,11 @@ describe("V2 client presentation", () => { }); it("parks a thread that never ran at idle while a pull request watch holds it", () => { - const watch = { taskId: "pull-request-watch:github.com/acme/app#1", kind: "monitor" as const }; + const watch = { + taskId: "pull-request-watch:github.com/acme/app#1", + kind: "monitor" as const, + awaited: true as const, + }; const watched = presentThreadShell(environmentId, { ...v2ThreadShell, latestRunId: null, @@ -141,7 +145,9 @@ describe("V2 client presentation", () => { activeRunId: null, status: "failed", lastError: "Provider turn failed", - pendingBackgroundTasks: [{ taskId: "bg-1", description: "Watch build", kind: "monitor" }], + pendingBackgroundTasks: [ + { taskId: "bg-1", description: "Watch build", kind: "monitor", awaited: true }, + ], }); expect(shell.runtime).toMatchObject({ status: "failed", lastError: "Provider turn failed" }); @@ -192,7 +198,9 @@ describe("V2 client presentation", () => { latestRunId: runId, activeRunId: runId, status: "running", - pendingBackgroundTasks: [{ taskId: "bg-1", description: "Watch build", kind: "monitor" }], + pendingBackgroundTasks: [ + { taskId: "bg-1", description: "Watch build", kind: "monitor", awaited: true }, + ], }); expect(shell.latestRun).toMatchObject({ runId, status: "running" }); @@ -201,7 +209,7 @@ describe("V2 client presentation", () => { activeRunId: runId, }); expect(shell.pendingBackgroundTasks).toEqual([ - { taskId: "bg-1", description: "Watch build", kind: "monitor" }, + { taskId: "bg-1", description: "Watch build", kind: "monitor", awaited: true }, ]); }); @@ -403,7 +411,7 @@ describe("V2 client presentation", () => { ).toMatchObject({ status, activeRunId: null }); } - for (const kind of ["monitor", "background_task"] as const) { + for (const held of [{ kind: "monitor", awaited: true }, { kind: "background_task" }] as const) { const providerThread = { id: ProviderThreadId.make("provider-thread-background"), driver: ProviderDriverKind.make("claudeCode"), @@ -418,7 +426,7 @@ describe("V2 client presentation", () => { lastRunOrdinal: 1, handoffIds: [], forkedFrom: null, - pendingBackgroundTasks: [{ taskId: "background-task", kind }], + pendingBackgroundTasks: [{ taskId: "background-task", ...held }], createdAt: now, updatedAt: now, }; diff --git a/packages/client-runtime/src/t3ToolSummary.ts b/packages/client-runtime/src/t3ToolSummary.ts index df655c1d6c218..4176921005e8d 100644 --- a/packages/client-runtime/src/t3ToolSummary.ts +++ b/packages/client-runtime/src/t3ToolSummary.ts @@ -176,6 +176,9 @@ export function summarizeT3ToolCalls( case "task-status": label = phrase("Checked", "check", `task status ${times}`); break; + case "background-wait": + label = phrase("Marked", "mark", `background work as awaited ${times}`); + break; case "task-cancel": label = phrase( "Requested cancellation of", diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts index d6f3bac7ef7c1..a28ae17c26202 100644 --- a/packages/client-runtime/src/work-log/presentation.ts +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -490,6 +490,7 @@ function summaryActionPriority(action: ToolGroupAction | T3McpToolSummaryAction) case "thread-send": case "thread-interrupt": case "workflow-stop": + case "background-wait": case "schedule-create": case "schedule-update": case "schedule-delete": diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 550da8985dff1..788ac04922715 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -524,6 +524,8 @@ export const OrchestrationV2RestartCancelledBackgroundWork = Schema.Struct({ kind: Schema.Literals(["subagent", "shell", "monitor", "task"]), label: TrimmedNonEmptyString, id: Schema.optional(TrimmedNonEmptyString), + /** The agent was waiting on this work, so its restart note wakes the agent even for a shell. */ + awaited: Schema.optional(Schema.Literal(true)), }); export type OrchestrationV2RestartCancelledBackgroundWork = typeof OrchestrationV2RestartCancelledBackgroundWork.Type; @@ -842,6 +844,11 @@ const PendingBackgroundTaskFields = { description: Schema.optional(TrimmedNonEmptyString), /** The subagent task that started this work and waits on it. */ parentTaskId: Schema.optional(TrimmedNonEmptyString), + /** + * The agent declared it resumes when this work ends, so the thread holds like it does for a + * subagent. Only the agent knows whether a command is a wait or a dev server it left running. + */ + awaited: Schema.optional(Schema.Literal(true)), }; export const OrchestrationV2PendingBackgroundTask = kindUnionWithFallback( diff --git a/packages/provider-core/src/server/ProviderAdapter.ts b/packages/provider-core/src/server/ProviderAdapter.ts index 314705eca3afc..8f9fc32608459 100644 --- a/packages/provider-core/src/server/ProviderAdapter.ts +++ b/packages/provider-core/src/server/ProviderAdapter.ts @@ -609,6 +609,15 @@ export interface ProviderAdapterV2SessionRuntime { readonly providerThread: OrchestrationV2ProviderThread; readonly taskId: string; }) => Effect.Effect; + /** + * Marks or unmarks background tasks the agent will resume from, using the ids the agent saw. + * Ids not running on this provider thread come back unmatched and change nothing. + */ + readonly awaitBackgroundTasks?: (input: { + readonly providerThread: OrchestrationV2ProviderThread; + readonly taskIds: ReadonlyArray; + readonly awaited: boolean; + }) => Effect.Effect<{ readonly matched: ReadonlyArray }, ProviderAdapterV2Error>; readonly interruptTurn: ( input: ProviderAdapterV2InterruptInput, ) => Effect.Effect; diff --git a/packages/shared/src/agentAwareness.test.ts b/packages/shared/src/agentAwareness.test.ts index 8e60d263ff659..caf261bfbb1e9 100644 --- a/packages/shared/src/agentAwareness.test.ts +++ b/packages/shared/src/agentAwareness.test.ts @@ -139,7 +139,7 @@ describe("projectThreadAwarenessV2", () => { }, ); - it("keeps settled work held open by a monitor", () => { + it("keeps settled work held open by an awaited monitor", () => { expect( projectThreadAwarenessV2({ environmentId: "env-1" as EnvironmentId, @@ -147,7 +147,7 @@ describe("projectThreadAwarenessV2", () => { thread: v2Thread({ status: "completed", settledOverride: "settled", - pendingBackgroundTasks: [{ taskId: "watch", kind: "monitor" }], + pendingBackgroundTasks: [{ taskId: "watch", kind: "monitor", awaited: true }], }), }), ).toMatchObject({ phase: "running" }); @@ -155,7 +155,12 @@ describe("projectThreadAwarenessV2", () => { it.each([ ["only a dev server", "completed", [{ taskId: "dev", kind: "command" }]], - ["a monitor", "running", [{ taskId: "watch", kind: "monitor" }]], + ["a dev server's watchdog monitor", "completed", [{ taskId: "watch", kind: "monitor" }]], + [ + "a command the agent waits on", + "running", + [{ taskId: "tests", kind: "command", awaited: true }], + ], [ "a dev server and a subagent", "running", diff --git a/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts b/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts index 934d7f8175776..91c827ed81596 100644 --- a/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts +++ b/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts @@ -5,6 +5,17 @@ import { derivePendingBackgroundWork, } from "./orchestrationV2PendingBackgroundWork.ts"; +describe("backgroundWorkHoldsCompletion", () => { + it.each([ + ["a dev server", false, [{ taskId: "dev", kind: "command" }]], + ["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" }]], + ] as const)("with %s pending, holds completion: %s", (_case, holds, tasks) => { + expect(backgroundWorkHoldsCompletion(tasks)).toBe(holds); + }); +}); + describe("derivePendingBackgroundWork", () => { it("takes each subagent item's instance from its joined owner", () => { const owner = ProviderInstanceId.make("claude-owner"); @@ -309,6 +320,7 @@ describe("derivePendingBackgroundWork kinds", () => { id: "pt-1" as never, pendingBackgroundTasks: [ { taskId: "bash", kind: "command" }, + { taskId: "tests", kind: "command", awaited: true }, { taskId: "watch", kind: "monitor" }, { taskId: "workflow", kind: "background_task" }, ], @@ -335,6 +347,7 @@ describe("derivePendingBackgroundWork kinds", () => { }); expect(tasks).toEqual([ { taskId: "bash", kind: "command" }, + { taskId: "tests", kind: "command", awaited: true }, { taskId: "watch", kind: "monitor" }, { taskId: "workflow", kind: "background_task" }, { @@ -376,7 +389,7 @@ describe("derivePendingBackgroundWork kinds", () => { link(3, { source: "stack-dismissed" }), ]; - it("keeps a settled run waiting on each visible watch, as a monitor", () => { + it("keeps a settled run waiting on each visible watch, as an awaited monitor", () => { const tasks = derivePendingBackgroundWork({ latestRun: { id: "run-1" as never, ordinal: 1, status: "completed" }, providerThreads: [], @@ -388,6 +401,7 @@ describe("derivePendingBackgroundWork kinds", () => { taskId: "pull-request-watch:github.com/acme/app#1", description: "Watching pull request #1", kind: "monitor", + awaited: true, }, ]); expect(backgroundWorkHoldsCompletion(tasks)).toBe(true); diff --git a/packages/shared/src/orchestrationV2PendingBackgroundWork.ts b/packages/shared/src/orchestrationV2PendingBackgroundWork.ts index f96662e57b242..e0a6ab05ea55a 100644 --- a/packages/shared/src/orchestrationV2PendingBackgroundWork.ts +++ b/packages/shared/src/orchestrationV2PendingBackgroundWork.ts @@ -92,17 +92,21 @@ export function deriveBlockedOnThreadWait(input: { } export function backgroundWorkHoldsCompletion( - tasks: ReadonlyArray>, + tasks: ReadonlyArray>, ): boolean { - return tasks.some((task) => backgroundWorkKindHoldsCompletion(task.kind)); + return tasks.some( + (task) => task.awaited === true || 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. function backgroundWorkKindHoldsCompletion(kind: PendingBackgroundWorkTask["kind"]): boolean { switch (kind) { case "command": + case "monitor": return false; case "subagent": - case "monitor": case "background_task": return true; } @@ -307,6 +311,7 @@ function pullRequestWatchTasks( taskId: `pull-request-watch:${link.host.toLowerCase()}/${link.repository.toLowerCase()}#${link.number}`, description: `Watching pull request #${link.number}`, kind: "monitor" as const, + awaited: true as const, }, ], ); diff --git a/packages/shared/src/t3McpToolPresentation.ts b/packages/shared/src/t3McpToolPresentation.ts index 21f8be3941f95..2da482bccb606 100644 --- a/packages/shared/src/t3McpToolPresentation.ts +++ b/packages/shared/src/t3McpToolPresentation.ts @@ -22,6 +22,7 @@ export type T3McpToolSummaryAction = | "thread-wait" | "thread-interrupt" | "workflow-stop" + | "background-wait" | "thread-configuration" | "thread-configure" | "thread-fork" @@ -154,6 +155,10 @@ const T3_MCP_TOOLS: Readonly> = { ["Stop", "Stopping", "Requested a stop for", "a workflow"], "workflow-stop", ), + t3_background_wait: tool( + ["Mark", "Marking", "Marked", "background work as awaited"], + "background-wait", + ), t3_thread_interrupt: tool( ["Interrupt", "Interrupting", "Requested an interrupt of", "a T3 thread"], "thread-interrupt", From a5f4d87dc3ee4e8d2460659c5e8e8565e6ccf981 Mon Sep 17 00:00:00 2001 From: lovelycodemonkey Date: Mon, 19 Oct 2026 18:07:13 -0700 Subject: [PATCH 2/3] fix(threads): the work log no longer says a cleared background wait was marked --- packages/client-runtime/src/t3ToolSummary.ts | 2 +- packages/shared/src/t3McpToolPresentation.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/client-runtime/src/t3ToolSummary.ts b/packages/client-runtime/src/t3ToolSummary.ts index 4176921005e8d..d0fe65bbfc150 100644 --- a/packages/client-runtime/src/t3ToolSummary.ts +++ b/packages/client-runtime/src/t3ToolSummary.ts @@ -177,7 +177,7 @@ export function summarizeT3ToolCalls( label = phrase("Checked", "check", `task status ${times}`); break; case "background-wait": - label = phrase("Marked", "mark", `background work as awaited ${times}`); + label = phrase("Updated", "update", `background waits ${times}`); break; case "task-cancel": label = phrase( diff --git a/packages/shared/src/t3McpToolPresentation.ts b/packages/shared/src/t3McpToolPresentation.ts index 2da482bccb606..68f98ee5a725e 100644 --- a/packages/shared/src/t3McpToolPresentation.ts +++ b/packages/shared/src/t3McpToolPresentation.ts @@ -156,7 +156,7 @@ const T3_MCP_TOOLS: Readonly> = { "workflow-stop", ), t3_background_wait: tool( - ["Mark", "Marking", "Marked", "background work as awaited"], + ["Update", "Updating", "Updated", "background waits"], "background-wait", ), t3_thread_interrupt: tool( From c83bdea0b0aae28ee56151d85f16683f9729de70 Mon Sep 17 00:00:00 2001 From: lovelycodemonkey Date: Mon, 19 Oct 2026 18:20:09 -0700 Subject: [PATCH 3/3] docs: monitors the agent waits on also keep a thread in Working --- docs/user/thread-sidebar.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 7a88c251d5398..86d8edde64bf2 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -121,9 +121,9 @@ open. Turn on **Settings → General → Working section (beta)** on web and desktop, or **Settings → Thread behavior → Working section** on iOS and Android, to move threads that are working or waiting on work they will resume from into a collapsed **Working** section below the active list. -That includes subagents, watched pull requests, and background commands 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 +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. Each device keeps its own choice.