From d372d3d027fd4a1570ffcbdf7ca540f592a21c5d Mon Sep 17 00:00:00 2001 From: Ford Lascari Date: Tue, 10 Nov 2026 07:02:44 -0600 Subject: [PATCH 1/2] fix(server): agents can see a thread's pending background work t3_thread_list and t3_thread_read reported a thread as completed while it still waited on a subagent, an awaited command or monitor, or another thread, so an agent could not tell it from a thread that was done. The sidebar shows these threads as Waiting from the shell's blockedOnThreadWait and pendingBackgroundTasks. List and read results now carry both fields from the thread shell. Each task exposes taskId, kind, description, awaited, and a subagent's childThreadId, and leaves out provider-internal links. Both fields are optional in the contract, so results forwarded from older linked environments still decode. Co-Authored-By: Claude Opus 5.5 --- .../OrchestratorMcpService.activity.test.ts | 18 +++++++- .../src/mcp/OrchestratorMcpService.test.ts | 36 ++++++++++++++- apps/server/src/mcp/OrchestratorMcpService.ts | 20 +++++++++ .../orchestrator-mcp-server.md | 6 +++ .../contracts/src/orchestratorMcp.test.ts | 45 +++++++++++++++++++ packages/contracts/src/orchestratorMcp.ts | 19 ++++++++ 6 files changed, 141 insertions(+), 3 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts b/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts index dfaebe9228d52..a039db70e4ba4 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts @@ -165,7 +165,19 @@ it("readThread preserves caller access errors and prefers activity-run status ov ? Effect.die("A failed caller read must not reach the target") : Effect.succeed( threadId === parentThreadId - ? (projection.thread as unknown as OrchestrationV2ThreadShell) + ? ({ + ...projection.thread, + blockedOnThreadWait: true, + pendingBackgroundTasks: [ + { + taskId: "task-subagent", + kind: "subagent", + parentTaskId: "task-parent", + providerInstanceId: parentInstanceId, + childThreadId, + }, + ], + } as unknown as OrchestrationV2ThreadShell) : null, ), getProjectThreadRecords: (input) => @@ -197,6 +209,10 @@ it("readThread preserves caller access errors and prefers activity-run status ov expect(result.thread.status).toBe("running"); expect(result.thread.latestRunId).toBe(cancelledRunId); expect(result.thread.activeRunId).toBe(activeRunId); + expect(result.thread.blockedOnThreadWait).toBe(true); + expect(result.thread.pendingBackgroundTasks).toEqual([ + { taskId: "task-subagent", kind: "subagent", childThreadId }, + ]); callerUnavailable = true; const denied = yield* service diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index df13d037005a4..b3450528790f3 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -3528,16 +3528,30 @@ describe("OrchestratorMcpService thread list", () => { capabilities: new Set(["orchestration"]), issuedAt: 1, }; - const shell = (id: string, projectId: string, snoozed: boolean) => ({ + const shell = (id: string, projectId: string, snoozed: boolean, waiting = false) => ({ ...liveThreadShell(ThreadId.make(id)), projectId: ProjectId.make(projectId), status: "completed" as const, snoozedUntil: snoozed ? DateTime.makeUnsafe("2099-01-01T00:00:00Z") : null, + blockedOnThreadWait: waiting, + pendingBackgroundTasks: waiting + ? [ + { + taskId: "task-subagent", + kind: "subagent" as const, + description: "Review the diff", + parentTaskId: "task-parent", + providerInstanceId: ProviderInstanceId.make("codex"), + childThreadId: ThreadId.make("thread:b-child"), + }, + { taskId: "task-tests", kind: "command" as const, awaited: true as const }, + ] + : [], }); const shells = [ shell("thread:a-snoozed", "project:a", true), shell("thread:a-awake", "project:a", false), - shell("thread:b-snoozed", "project:b", true), + shell("thread:b-snoozed", "project:b", true, true), ]; const service = layerOrchestratorService.pipe( Layer.provide( @@ -3581,6 +3595,24 @@ describe("OrchestratorMcpService thread list", () => { ["thread:b-snoozed", "project:b"], ], ); + assert.deepStrictEqual( + listed.threads.map((thread) => [thread.blockedOnThreadWait, thread.pendingBackgroundTasks]), + [ + [false, []], + [ + true, + [ + { + taskId: "task-subagent", + kind: "subagent", + description: "Review the diff", + childThreadId: ThreadId.make("thread:b-child"), + }, + { taskId: "task-tests", kind: "command", awaited: true }, + ], + ], + ], + ); }).pipe(Effect.provide(service)), ); }); diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index a1425d56e8e56..5ef1549edf9dd 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -783,6 +783,24 @@ function threadSnooze( }; } +// Provider-internal links (parentTaskId, providerInstanceId) stay out of agent results. +function threadBackgroundWork( + thread: Pick, +): Required> { + return { + blockedOnThreadWait: thread.blockedOnThreadWait === true, + pendingBackgroundTasks: (thread.pendingBackgroundTasks ?? []).map((task) => ({ + taskId: task.taskId, + kind: task.kind, + ...(task.description === undefined ? {} : { description: task.description }), + ...(task.awaited === undefined ? {} : { awaited: task.awaited }), + ...(task.kind === "subagent" && task.childThreadId !== undefined + ? { childThreadId: task.childThreadId } + : {}), + })), + }; +} + function threadPullRequests( links: OrchestrationV2ThreadShell["pullRequests"], ): OrchestratorMcpThreadPullRequest[] { @@ -818,6 +836,7 @@ function listItemFromShell( pullRequests: threadPullRequests(shell.pullRequests), ...threadSettlement(shell), ...threadSnooze(shell, nowMs), + ...threadBackgroundWork(shell), parentThreadId: shell.lineage.parentThreadId, relationshipToParent: shell.lineage.relationshipToParent, itemCount: shell.visibleItemCount, @@ -871,6 +890,7 @@ function threadDetail( archived: projection.thread.archivedAt !== null, ...threadSettlement(projection.thread), ...threadSnooze(shell, nowMs), + ...threadBackgroundWork(shell), createdAt: DateTime.formatIso(projection.thread.createdAt), updatedAt: DateTime.formatIso(projection.thread.updatedAt), }; diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index 2f6ba5363703a..3888eab1724fb 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -422,6 +422,12 @@ client's `effectiveSnoozed`, so agents and the sidebar agree: a snoozed thread wakes early only when it has a pending request or fails after snoozing. Completed runs, background wakes, and delegated completions keep it snoozed. +They also report `blockedOnThreadWait` and `pendingBackgroundTasks`, the shell +fields behind the sidebar's Waiting label, so an agent can tell a `completed` +thread that still waits on background work from one that is done. A subagent or +`background_task` holds the thread. A command or monitor holds it only with +`awaited: true`, since agents often leave a dev server running. + ### `t3_thread_update` Updates metadata for the calling thread or any other thread in the environment. diff --git a/packages/contracts/src/orchestratorMcp.test.ts b/packages/contracts/src/orchestratorMcp.test.ts index 951727e68e1d3..f69c72d6e28f9 100644 --- a/packages/contracts/src/orchestratorMcp.test.ts +++ b/packages/contracts/src/orchestratorMcp.test.ts @@ -4,12 +4,57 @@ import * as Schema from "effect/Schema"; import { OrchestratorMcpDelegateTaskInput, OrchestratorMcpDelegateTaskResult, + OrchestratorMcpThreadDetail, + OrchestratorMcpThreadListItem, } from "./orchestratorMcp.ts"; const decodeDelegateTaskInput = Schema.decodeUnknownSync(OrchestratorMcpDelegateTaskInput); const decodeDelegateTaskResult = Schema.decodeUnknownSync(OrchestratorMcpDelegateTaskResult); +const decodeThreadListItem = Schema.decodeUnknownSync(OrchestratorMcpThreadListItem); +const decodeThreadDetail = Schema.decodeUnknownSync(OrchestratorMcpThreadDetail); describe("orchestrator MCP contracts", () => { + it("decodes thread results from a linked environment that predates background work", () => { + const listItem = { + threadId: "thread-1", + projectId: "project-1", + title: "Fix the build", + createdBy: "user", + creationSource: "web", + status: "completed", + latestRunId: null, + providerInstanceId: "codex", + model: "gpt-5.4", + runtimeMode: "full-access", + interactionMode: "default", + linkedPullRequest: null, + settled: false, + settledAt: null, + snoozed: false, + snoozedUntil: null, + parentThreadId: null, + relationshipToParent: null, + itemCount: 0, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }; + const detail = { + ...listItem, + activeRunId: null, + titleRegeneration: null, + branch: null, + worktreePath: null, + runCount: 0, + pendingRequestCount: 0, + archived: false, + }; + + for (const decoded of [decodeThreadListItem(listItem), decodeThreadDetail(detail)]) { + expect(decoded.blockedOnThreadWait).toBeUndefined(); + expect(decoded.pendingBackgroundTasks).toBeUndefined(); + } + }); + it("decodes cross-provider delegated task requests and durable results", () => { const request = decodeDelegateTaskInput({ task: "Inspect the workspace and report the result.", diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index d1bb9961a0c68..f78f5ae11b00a 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -360,6 +360,17 @@ export const OrchestratorMcpThreadPullRequest = Schema.Struct({ }); export type OrchestratorMcpThreadPullRequest = typeof OrchestratorMcpThreadPullRequest.Type; +/** Background work still open on the thread, as its provider reported it. */ +export const OrchestratorMcpThreadBackgroundTask = Schema.Struct({ + taskId: TrimmedNonEmptyString, + kind: Schema.Literals(["subagent", "command", "monitor", "background_task"]), + description: Schema.optional(TrimmedNonEmptyString), + /** The agent waits on this command or monitor. Other kinds always hold the thread. */ + awaited: Schema.optional(Schema.Literal(true)), + childThreadId: Schema.optional(ThreadId), +}); +export type OrchestratorMcpThreadBackgroundTask = typeof OrchestratorMcpThreadBackgroundTask.Type; + export const OrchestratorMcpThreadListItem = Schema.Struct({ threadId: ThreadId, projectId: ProjectId, @@ -379,6 +390,10 @@ export const OrchestratorMcpThreadListItem = Schema.Struct({ settledAt: Schema.NullOr(IsoDateTime), snoozed: Schema.Boolean, snoozedUntil: Schema.NullOr(IsoDateTime), + /** Absent from linked environments that predate it. */ + blockedOnThreadWait: Schema.optional(Schema.Boolean), + /** Absent from linked environments that predate it. */ + pendingBackgroundTasks: Schema.optional(Schema.Array(OrchestratorMcpThreadBackgroundTask)), parentThreadId: Schema.NullOr(ThreadId), relationshipToParent: Schema.NullOr(Schema.Literals(["fork", "subagent"])), itemCount: NonNegativeInt, @@ -459,6 +474,10 @@ export const OrchestratorMcpThreadDetail = Schema.Struct({ settledAt: Schema.NullOr(IsoDateTime), snoozed: Schema.Boolean, snoozedUntil: Schema.NullOr(IsoDateTime), + /** Absent from linked environments that predate it. */ + blockedOnThreadWait: Schema.optional(Schema.Boolean), + /** Absent from linked environments that predate it. */ + pendingBackgroundTasks: Schema.optional(Schema.Array(OrchestratorMcpThreadBackgroundTask)), createdAt: IsoDateTime, updatedAt: IsoDateTime, }); From 23e418c94e2e1f0e60dc07f22a86f4b3c343b92e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 20:06:59 +0000 Subject: [PATCH 2/2] chore(server): trim background-work coverage to the service tests The contract test only re-checked that Schema.optional accepts a missing field. The doc no longer says list status reads completed while work is pending, since main now reports it as waiting. AI-assisted; verified with the tests and tools listed above. --- .../orchestrator-mcp-server.md | 8 ++-- .../contracts/src/orchestratorMcp.test.ts | 45 ------------------- 2 files changed, 4 insertions(+), 49 deletions(-) diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index 248ed23b48881..992fb93fcf782 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -430,10 +430,10 @@ wakes early only when it has a pending request or fails after snoozing. Complete runs, background wakes, and delegated completions keep it snoozed. They also report `blockedOnThreadWait` and `pendingBackgroundTasks`, the shell -fields behind the sidebar's Waiting label, so an agent can tell a `completed` -thread that still waits on background work from one that is done. A subagent or -`background_task` holds the thread. A command or monitor holds it only with -`awaited: true`, since agents often leave a dev server running. +fields behind the sidebar's Waiting label, so an agent can see what a thread +still waits on after its run completed. A subagent or `background_task` holds +the thread. A command or monitor holds it only with `awaited: true`, since agents +often leave a dev server running. ### `t3_thread_update` diff --git a/packages/contracts/src/orchestratorMcp.test.ts b/packages/contracts/src/orchestratorMcp.test.ts index f69c72d6e28f9..951727e68e1d3 100644 --- a/packages/contracts/src/orchestratorMcp.test.ts +++ b/packages/contracts/src/orchestratorMcp.test.ts @@ -4,57 +4,12 @@ import * as Schema from "effect/Schema"; import { OrchestratorMcpDelegateTaskInput, OrchestratorMcpDelegateTaskResult, - OrchestratorMcpThreadDetail, - OrchestratorMcpThreadListItem, } from "./orchestratorMcp.ts"; const decodeDelegateTaskInput = Schema.decodeUnknownSync(OrchestratorMcpDelegateTaskInput); const decodeDelegateTaskResult = Schema.decodeUnknownSync(OrchestratorMcpDelegateTaskResult); -const decodeThreadListItem = Schema.decodeUnknownSync(OrchestratorMcpThreadListItem); -const decodeThreadDetail = Schema.decodeUnknownSync(OrchestratorMcpThreadDetail); describe("orchestrator MCP contracts", () => { - it("decodes thread results from a linked environment that predates background work", () => { - const listItem = { - threadId: "thread-1", - projectId: "project-1", - title: "Fix the build", - createdBy: "user", - creationSource: "web", - status: "completed", - latestRunId: null, - providerInstanceId: "codex", - model: "gpt-5.4", - runtimeMode: "full-access", - interactionMode: "default", - linkedPullRequest: null, - settled: false, - settledAt: null, - snoozed: false, - snoozedUntil: null, - parentThreadId: null, - relationshipToParent: null, - itemCount: 0, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - }; - const detail = { - ...listItem, - activeRunId: null, - titleRegeneration: null, - branch: null, - worktreePath: null, - runCount: 0, - pendingRequestCount: 0, - archived: false, - }; - - for (const decoded of [decodeThreadListItem(listItem), decodeThreadDetail(detail)]) { - expect(decoded.blockedOnThreadWait).toBeUndefined(); - expect(decoded.pendingBackgroundTasks).toBeUndefined(); - } - }); - it("decodes cross-provider delegated task requests and durable results", () => { const request = decodeDelegateTaskInput({ task: "Inspect the workspace and report the result.",