diff --git a/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts b/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts index 123b5e6fcb8dc..019851ec969b0 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts @@ -193,7 +193,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) => @@ -225,6 +237,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 }, + ]); // Every page read must not repeat each linked PR, and an open PR shows whether it is watched. const listed = result.thread.pullRequests ?? []; expect(listed.map((link) => link.number)).not.toContain(1); diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index 7bf7d2efe2c82..8f349f77b71aa 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -3948,16 +3948,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), { ...shell("thread:a-awaiting-command", "project:a", false), activityRunStatus: null, @@ -4022,6 +4036,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 7c55c6a4ba900..3a43e2a1751e9 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -803,6 +803,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"], includeLinks = true, @@ -856,6 +874,7 @@ function listItemFromShell( ...threadPullRequests(shell.pullRequests), ...threadSettlement(shell), ...threadSnooze(shell, nowMs), + ...threadBackgroundWork(shell), parentThreadId: shell.lineage.parentThreadId, relationshipToParent: shell.lineage.relationshipToParent, itemCount: shell.visibleItemCount, @@ -917,6 +936,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 f3ef6ff6ddaf9..992fb93fcf782 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -429,6 +429,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 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` Updates metadata for the calling thread or any other thread in the environment. diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index 06aeafeb0b101..65f2c5a0a00af 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -362,6 +362,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, @@ -383,6 +394,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, @@ -466,6 +481,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, });