diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index c41b4a59493..7074ad32157 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -65,6 +65,7 @@ import { isBuiltInProviderAdapterDriverV2 } from "../orchestration-v2/builtInPro import { subagentResultForRun } from "../orchestration-v2/SubagentProjection.ts"; import { isActiveRun, + isTerminalRunStatus, latestActiveRun, latestRun, ThreadManagementError, @@ -297,7 +298,7 @@ function taskStatusForRun( } } -function delegatedTaskRun( +export function delegatedTaskRun( childProjection: OrchestrationV2ThreadProjection, task: OrchestrationV2Subagent, ): OrchestrationV2Run | undefined { @@ -309,13 +310,24 @@ function delegatedTaskRun( ); if (spawnTransfer === undefined) { // Legacy delegated-task projections predate the durable spawn transfer. - return latestRun(childProjection); + return childProjection.runs[0]; } return spawnTransfer.targetRunId === null ? undefined : childProjection.runs.find((run) => run.id === spawnTransfer.targetRunId); } +export function hasPendingChildRuns( + childProjection: OrchestrationV2ThreadProjection, + delegatedRun: OrchestrationV2Run | undefined, +): boolean { + return childProjection.runs.some( + (run) => + !isTerminalRunStatus(run.status) && + (delegatedRun === undefined || run.ordinal > delegatedRun.ordinal), + ); +} + function isTerminalTaskStatus( status: OrchestratorMcpDelegateTaskResult["status"], ): status is TerminalTaskStatus { @@ -327,6 +339,26 @@ function isTerminalTaskStatus( ); } +function latestTerminalResultRun( + projection: OrchestrationV2ThreadProjection, + delegatedRun: OrchestrationV2Run | undefined, +): OrchestrationV2Run | undefined { + return projection.runs + .filter( + (run) => + isTerminalRunStatus(run.status) && + run.status !== "rolled_back" && + (run.id === delegatedRun?.id || run.startedAt !== null), + ) + .toSorted((left, right) => right.ordinal - left.ordinal)[0]; +} + +function canExposeTaskRunResult(run: OrchestrationV2Run | undefined): run is OrchestrationV2Run { + return ( + run !== undefined && run.status !== "rolled_back" && isTerminalTaskStatus(taskStatusForRun(run)) + ); +} + function runtimeModeRank(mode: RuntimeMode): number { switch (mode) { case "approval-required": @@ -809,6 +841,7 @@ const make = Effect.gen(function* () { } const childProjection = yield* loadProjection(task.childThreadId); const childRun = delegatedTaskRun(childProjection, task); + const terminalRun = latestTerminalResultRun(childProjection, childRun); const status = taskStatusForRun(childRun); const derivedResult = task.result !== null @@ -816,23 +849,42 @@ const make = Effect.gen(function* () { : childRun !== undefined && isTerminalTaskStatus(status) ? subagentResultForRun(childProjection, childRun).text : null; - const resultTransfer = - parentProjection.contextTransfers.find( - (transfer) => - transfer.type === "subagent_result" && - transfer.sourceThreadId === task.childThreadId && - transfer.targetThreadId === scope.threadId, - ) ?? null; + const resultTransfers = parentProjection.contextTransfers.filter( + (transfer) => + transfer.type === "subagent_result" && + transfer.sourceThreadId === task.childThreadId && + transfer.targetThreadId === scope.threadId, + ); + const resultTransferForRun = (run: OrchestrationV2Run | undefined) => + !canExposeTaskRunResult(run) + ? null + : (resultTransfers.find((transfer) => transfer.sourcePoint.runId === run.id) ?? + (run.id === childRun?.id + ? resultTransfers.find((transfer) => transfer.sourcePoint.runId === undefined) + : undefined) ?? + null); + const resultTransfer = resultTransferForRun(childRun); + const terminalStatus = terminalRun === undefined ? null : taskStatusForRun(terminalRun); return { taskId: task.id, childThreadId: task.childThreadId, childRunId: childRun?.id ?? null, childNodeId: task.id, status, + hasPendingChildRuns: hasPendingChildRuns(childProjection, childRun), providerInstanceId: ProviderInstanceId.make(task.driver), model: task.model, summary: derivedResult, resultContextTransferId: resultTransfer?.id ?? null, + latestTerminalRunId: terminalRun?.id ?? null, + latestTerminalStatus: + terminalStatus !== null && isTerminalTaskStatus(terminalStatus) ? terminalStatus : null, + latestTerminalSummary: canExposeTaskRunResult(terminalRun) + ? terminalRun.id === childRun?.id + ? derivedResult + : subagentResultForRun(childProjection, terminalRun).text + : null, + latestTerminalResultContextTransferId: resultTransferForRun(terminalRun)?.id ?? null, waitTimedOut, }; }); diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index 7499850d576..b3fd89eea5e 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -62,6 +62,7 @@ import { makeProviderRegistryLayer } from "../provider/testUtils/providerRegistr import { ScheduledTaskService } from "../scheduledTasks/ScheduledTaskService.ts"; import * as McpHttpServer from "./McpHttpServer.ts"; import * as McpInvocationContext from "./McpInvocationContext.ts"; +import { delegatedTaskRun, hasPendingChildRuns } from "./OrchestratorMcpService.ts"; const parentThreadId = ThreadId.make("thread:mcp-orchestrator-parent"); const projectId = ProjectId.make("project:mcp-orchestrator"); @@ -814,8 +815,16 @@ describe("orchestrator MCP toolkit", () => { const delegatedStatus = yield* decodeDelegateTaskResult( delegatedStatusCall.structuredContent, ).pipe(Effect.orDie); - expect(delegatedStatus.status).toBe("completed"); + expect(delegatedStatus).toMatchObject({ + childRunId: delegated.childRunId, + status: "completed", + hasPendingChildRuns: false, + latestTerminalRunId: delegated.childRunId, + latestTerminalStatus: "completed", + latestTerminalSummary: delegatedResult, + }); expect(delegatedStatus.resultContextTransferId).not.toBeNull(); + expect(delegatedStatus.latestTerminalResultContextTransferId).not.toBeNull(); const childFollowupCall = yield* invoke("t3_thread_send", { threadId: delegated.childThreadId, @@ -848,7 +857,11 @@ describe("orchestrator MCP toolkit", () => { childRunId: delegated.childRunId, status: "completed", summary: delegatedResult, + hasPendingChildRuns: false, + latestTerminalRunId: childFollowup.runId, + latestTerminalStatus: "completed", }); + expect(delegatedStatusAfterFollowup.latestTerminalSummary).not.toBeNull(); const activeChildFollowupCall = yield* invoke("t3_thread_send", { threadId: delegated.childThreadId, @@ -863,6 +876,43 @@ describe("orchestrator MCP toolkit", () => { (run) => run.id === activeChildFollowup.runId && run.status === "running", ), ); + const delegatedStatusDuringFollowupCall = yield* invoke("task_status", { + taskId: delegated.taskId, + }); + const delegatedStatusDuringFollowup = yield* decodeDelegateTaskResult( + delegatedStatusDuringFollowupCall.structuredContent, + ).pipe(Effect.orDie); + expect(delegatedStatusDuringFollowup).toMatchObject({ + childRunId: delegated.childRunId, + status: "completed", + summary: delegatedResult, + hasPendingChildRuns: true, + latestTerminalRunId: childFollowup.runId, + latestTerminalStatus: "completed", + }); + const activeChildProjection = yield* orchestrator.getThreadProjection( + delegated.childThreadId, + ); + const legacyChildProjection = { + ...activeChildProjection, + contextTransfers: activeChildProjection.contextTransfers.filter( + (transfer) => transfer.type !== "subagent_spawn", + ), + }; + const legacyDelegatedRun = delegatedTaskRun(legacyChildProjection, completedTask!); + expect(legacyDelegatedRun?.id).toBe(delegated.childRunId); + expect(hasPendingChildRuns(legacyChildProjection, legacyDelegatedRun)).toBe(true); + expect( + hasPendingChildRuns( + { + ...legacyChildProjection, + runs: legacyChildProjection.runs.map((run) => + run.id === activeChildFollowup.runId ? { ...run, status: "queued" } : run, + ), + }, + legacyDelegatedRun, + ), + ).toBe(true); const completedTaskCancelCall = yield* invoke("task_cancel", { taskId: delegated.taskId, reason: "Must not interrupt a later unrelated child run.", @@ -898,6 +948,20 @@ describe("orchestrator MCP toolkit", () => { (run) => run.id === activeChildFollowup.runId && run.status === "interrupted", ), ); + const delegatedStatusAfterCleanupCall = yield* invoke("task_status", { + taskId: delegated.taskId, + }); + const delegatedStatusAfterCleanup = yield* decodeDelegateTaskResult( + delegatedStatusAfterCleanupCall.structuredContent, + ).pipe(Effect.orDie); + expect(delegatedStatusAfterCleanup).toMatchObject({ + childRunId: delegated.childRunId, + status: "completed", + summary: delegatedResult, + hasPendingChildRuns: false, + latestTerminalRunId: activeChildFollowup.runId, + latestTerminalStatus: "interrupted", + }); // A wait-mode child (completionWake settled_only) that completes // while the parent run is live does not offer a wake: the @@ -977,9 +1041,24 @@ describe("orchestrator MCP toolkit", () => { cancellableCall.structuredContent, ).pipe(Effect.orDie); expect(cancellable.status).toBe("running"); + // Original delegated run alone is not "later" work. + expect(cancellable.hasPendingChildRuns).toBe(false); yield* waitForProjection(orchestrator, cancellable.childThreadId, (projection) => projection.providerTurns.some((turn) => turn.status === "running"), ); + const statusWhileOriginalRunningCall = yield* invoke("task_status", { + taskId: cancellable.taskId, + }); + const statusWhileOriginalRunning = yield* decodeDelegateTaskResult( + statusWhileOriginalRunningCall.structuredContent, + ).pipe(Effect.orDie); + expect(statusWhileOriginalRunning).toMatchObject({ + childRunId: cancellable.childRunId, + status: "running", + hasPendingChildRuns: false, + latestTerminalRunId: null, + latestTerminalStatus: null, + }); // The requested model options reach the child thread's selection. const optionedChild = yield* orchestrator.getThreadProjection( cancellable.childThreadId, diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 8cb692a182d..917d16411bb 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -49,7 +49,7 @@ export const OrchestratorCapabilitiesTool = Tool.make("orchestrator_capabilities export const DelegateTaskTool = Tool.make("delegate_task", { description: - "Delegate one task to a T3-owned child agent/subagent of THIS thread and run it with only the supplied task prompt, without copying parent conversation history. Use this whenever the user asks for an agent, subagent, worker, delegated task, or parallel help—including cross-provider work. The childThreadId is backing storage, not an ordinary top-level thread. Provider, model, model options (see orchestrator_capabilities), runtime mode, and interaction mode inherit unless target overrides them. Prefer mode='async' for long work; mode='wait' blocks until completion or timeout. An async child's completion wakes this thread with a continuation message naming the task (queued behind any turn in progress), so end the turn instead of polling or spawning watchers; use task_status only when the result is needed mid-turn.", + "Delegate one task to a T3-owned child agent/subagent of THIS thread and run it with only the supplied task prompt, without copying parent conversation history. Use this whenever the user asks for an agent, subagent, worker, delegated task, or parallel help—including cross-provider work. The childThreadId is backing storage, not an ordinary top-level thread. Provider, model, model options (see orchestrator_capabilities), runtime mode, and interaction mode inherit unless target overrides them. Prefer mode='async' for long work; mode='wait' blocks on the original delegated run until completion or timeout. An async child's completion wakes this thread with a continuation message naming the task (queued behind any turn in progress), so end the turn instead of polling or spawning watchers; use task_status only when the result is needed mid-turn.", parameters: OrchestratorMcpDelegateTaskInput, success: OrchestratorMcpDelegateTaskResult, failure: OrchestratorMcpFailure, @@ -62,7 +62,7 @@ export const DelegateTaskTool = Tool.make("delegate_task", { export const TaskStatusTool = Tool.make("task_status", { description: - "Read the latest durable state and final summary for a T3-owned delegated task created by this parent thread.", + "Read a T3-owned delegated task created by this parent thread. The primary status, childRunId, summary, and resultContextTransferId stay tied to the original delegated run. hasPendingChildRuns reports whether later work is still queued or executing, while latestTerminal* exposes the newest terminal child run that began execution.", parameters: OrchestratorMcpTaskStatusInput, success: OrchestratorMcpDelegateTaskResult, failure: OrchestratorMcpFailure, diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index 3aa9a6cb401..06a5156a269 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -187,8 +187,8 @@ provider session. The request becomes the V2 command `delegated_task.request`. `mode: "async"` returns the current durable state immediately. -`mode: "wait"` polls the same durable state until it becomes terminal or the -timeout expires. A wait timeout does not cancel the child; the result sets +`mode: "wait"` polls the original delegated run until it becomes terminal or +the timeout expires. A wait timeout does not cancel the child; the result sets `waitTimedOut: true`, and the caller can continue with `task_status`. ```ts @@ -198,10 +198,15 @@ type DelegateTaskResult = { childRunId: string | null; childNodeId: string; status: "queued" | "running" | "waiting" | "completed" | "failed" | "cancelled" | "interrupted"; + hasPendingChildRuns: boolean; providerInstanceId: string; model: string | null; summary: string | null; resultContextTransferId: string | null; + latestTerminalRunId: string | null; + latestTerminalStatus: "completed" | "failed" | "cancelled" | "interrupted" | null; + latestTerminalSummary: string | null; + latestTerminalResultContextTransferId: string | null; waitTimedOut: boolean; }; ``` @@ -209,8 +214,13 @@ type DelegateTaskResult = { ### `task_status` Reads a delegated task from the parent thread's durable projection. A task ID -from another parent thread is rejected. Terminal results include the child -summary and the durable `subagent_result` context transfer ID when available. +from another parent thread is rejected. The primary `childRunId`, `status`, +`summary`, and `resultContextTransferId` fields stay tied to the original +delegated run. `hasPendingChildRuns` remains true while any later child run is +queued or executing. The `latestTerminal*` fields expose the original run or +the highest-ordinal later terminal run that began execution. Rolled-back runs +and never-started later cancellations do not displace the latest meaningful +result. ### `task_cancel` diff --git a/packages/contracts/src/orchestratorMcp.test.ts b/packages/contracts/src/orchestratorMcp.test.ts index c668de287a9..3e698f882c7 100644 --- a/packages/contracts/src/orchestratorMcp.test.ts +++ b/packages/contracts/src/orchestratorMcp.test.ts @@ -43,10 +43,15 @@ describe("orchestrator MCP contracts", () => { childRunId: "run-child-1", childNodeId: "node-task-1", status: "completed", + hasPendingChildRuns: false, providerInstanceId: "claudeAgent", model: "claude-sonnet-4-6", summary: "Workspace inspected.", resultContextTransferId: "context-transfer-result-1", + latestTerminalRunId: "run-child-1", + latestTerminalStatus: "completed", + latestTerminalSummary: "Workspace inspected.", + latestTerminalResultContextTransferId: "context-transfer-result-1", waitTimedOut: false, }); diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index 7e62e9351d3..e6e0f054be1 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -155,6 +155,15 @@ export const OrchestratorMcpDelegatedTaskStatus = Schema.Literals([ ]); export type OrchestratorMcpDelegatedTaskStatus = typeof OrchestratorMcpDelegatedTaskStatus.Type; +export const OrchestratorMcpTerminalDelegatedTaskStatus = Schema.Literals([ + "completed", + "failed", + "cancelled", + "interrupted", +]); +export type OrchestratorMcpTerminalDelegatedTaskStatus = + typeof OrchestratorMcpTerminalDelegatedTaskStatus.Type; + export const OrchestratorMcpDelegateTaskInput = Schema.Struct({ task: OrchestratorMcpPrompt.annotate({ description: "Self-contained task for one delegated child agent/subagent.", @@ -176,10 +185,15 @@ export const OrchestratorMcpDelegateTaskResult = Schema.Struct({ childRunId: Schema.NullOr(RunId), childNodeId: NodeId, status: OrchestratorMcpDelegatedTaskStatus, + hasPendingChildRuns: Schema.Boolean, providerInstanceId: ProviderInstanceId, model: Schema.NullOr(Schema.String), summary: Schema.NullOr(Schema.String), resultContextTransferId: Schema.NullOr(ContextTransferId), + latestTerminalRunId: Schema.NullOr(RunId), + latestTerminalStatus: Schema.NullOr(OrchestratorMcpTerminalDelegatedTaskStatus), + latestTerminalSummary: Schema.NullOr(Schema.String), + latestTerminalResultContextTransferId: Schema.NullOr(ContextTransferId), waitTimedOut: Schema.Boolean, }); export type OrchestratorMcpDelegateTaskResult = typeof OrchestratorMcpDelegateTaskResult.Type;