Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 61 additions & 9 deletions apps/server/src/mcp/OrchestratorMcpService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import { isBuiltInProviderAdapterDriverV2 } from "../orchestration-v2/builtInPro
import { subagentResultForRun } from "../orchestration-v2/SubagentProjection.ts";
import {
isActiveRun,
isTerminalRunStatus,
latestActiveRun,
latestRun,
ThreadManagementError,
Expand Down Expand Up @@ -297,7 +298,7 @@ function taskStatusForRun(
}
}

function delegatedTaskRun(
export function delegatedTaskRun(
childProjection: OrchestrationV2ThreadProjection,
task: OrchestrationV2Subagent,
): OrchestrationV2Run | undefined {
Expand All @@ -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 {
Expand All @@ -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":
Expand Down Expand Up @@ -809,30 +841,50 @@ 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
? task.result
: 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,
};
});
Expand Down
81 changes: 80 additions & 1 deletion apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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.",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions apps/server/src/mcp/toolkits/orchestrator/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
18 changes: 14 additions & 4 deletions docs/orchestration-v2/orchestrator-mcp-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -198,19 +198,29 @@ 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;
};
```

### `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`

Expand Down
5 changes: 5 additions & 0 deletions packages/contracts/src/orchestratorMcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});

Expand Down
14 changes: 14 additions & 0 deletions packages/contracts/src/orchestratorMcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand All @@ -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;
Expand Down
Loading