authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-03 03:41:49-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-03 03:41:49-07:00
log31a9da179ed0763335f05681c577474aec5d2309
tree74fdd26debf96fe9e34e02fa015787b9098029da
parent18b21325c3ab1ff4f0ee7b017d9c4eefc2b2d307
signature Signed by PGP key B5690EEEBB952194

fix(server): keep delegated review rounds on the task API (#15115)


7 files changed, 46 insertions(+), 7 deletions(-)

apps/server/src/mcp/OrchestratorMcpService.ts+2
...@@ -1510,6 +1510,8 @@ const make = Effect.gen(function* () {...@@ -1510,6 +1510,8 @@ const make = Effect.gen(function* () {
1510 ),1510 ),
1511 ),1511 ),
1512 );1512 );
1513 // Published task results stay terminal. Later child-thread messages do not
1514 // reopen the task, so cancelling it must not interrupt those separate runs.
1513 if (isTerminalTaskStatus(current.status)) {1515 if (isTerminalTaskStatus(current.status)) {
1514 yield* disposeCompletionDelivery;1516 yield* disposeCompletionDelivery;
1515 return {1517 return {
apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts+8
...@@ -1651,6 +1651,14 @@ describe("orchestrator MCP toolkit", () => {...@@ -1651,6 +1651,14 @@ describe("orchestrator MCP toolkit", () => {
1651 taskId: delegated.taskId,1651 taskId: delegated.taskId,
1652 status: "completed",1652 status: "completed",
1653 });1653 });
1654 expect(
1655 (yield* orchestrator.getThreadProjection(parentThreadId)).subagents.find(
1656 (task) => task.id === delegated.taskId,
1657 ),
1658 ).toMatchObject({
1659 result: delegatedResult,
1660 completionDelivery: { state: "disposed" },
1661 });
1654 expect(1662 expect(
1655 (yield* orchestrator.getThreadProjection(delegated.childThreadId)).runs.find(1663 (yield* orchestrator.getThreadProjection(delegated.childThreadId)).runs.find(
1656 (run) => run.id === activeChildFollowup.runId,1664 (run) => run.id === activeChildFollowup.runId,
apps/server/src/mcp/toolkits/orchestrator/tools.test.ts+11
...@@ -4,6 +4,7 @@ import { Tool } from "effect/unstable/ai";...@@ -4,6 +4,7 @@ import { Tool } from "effect/unstable/ai";
4import {4import {
5 CreateThreadsTool,5 CreateThreadsTool,
6 DelegateTaskTool,6 DelegateTaskTool,
7 OrchestratorToolkit,
7 ScheduleTaskTool,8 ScheduleTaskTool,
8 ThreadUpdateTool,9 ThreadUpdateTool,
9} from "./tools.ts";10} from "./tools.ts";
...@@ -17,6 +18,16 @@ describe("orchestrator MCP tool guidance", () => {...@@ -17,6 +18,16 @@ describe("orchestrator MCP tool guidance", () => {
17 assert.include(DelegateTaskTool.description ?? "", "waitTimedOut");18 assert.include(DelegateTaskTool.description ?? "", "waitTimedOut");
18 assert.include(DelegateTaskTool.description ?? "", "does not cancel the child");19 assert.include(DelegateTaskTool.description ?? "", "does not cancel the child");
19 assert.include(DelegateTaskTool.description ?? "", "keep that taskId");20 assert.include(DelegateTaskTool.description ?? "", "keep that taskId");
21 assert.include(DelegateTaskTool.description ?? "", "call delegate_task again");
22 assert.include(DelegateTaskTool.description ?? "", "childThreadId is backing storage");
23 assert.include(
24 OrchestratorToolkit.tools.t3_thread_send.description ?? "",
25 "Do not use a delegated task's childThreadId to start another review round",
26 );
27 assert.include(
28 OrchestratorToolkit.tools.task_cancel.description ?? "",
29 "without interrupting later child-thread runs",
30 );
20 });31 });
2132
22 it("documents wait timeout as a parent budget, not a child failure", () => {33 it("documents wait timeout as a parent budget, not a child failure", () => {
apps/server/src/mcp/toolkits/orchestrator/tools.ts+4-4
...@@ -57,7 +57,7 @@ const OrchestratorCapabilitiesTool = Tool.make("orchestrator_capabilities", {...@@ -57,7 +57,7 @@ const OrchestratorCapabilitiesTool = Tool.make("orchestrator_capabilities", {
5757
58export const DelegateTaskTool = Tool.make("delegate_task", {58export const DelegateTaskTool = Tool.make("delegate_task", {
59 description:59 description:
60 "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. Choose providers and models from orchestrator_capabilities, which uses the same live catalog as the composer. Prefer native subagent tools for same-provider work only when they support the chosen model. Use this for any model missing from the native tool, including same-provider work, for cross-provider work, or for explicitly T3-owned child tasks. 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. timeoutMs on mode=wait is only the parent's wait budget and does not cancel the child. waitTimedOut on that wait call means the timeout fired; keep that taskId and read status on later task_status. An async child's completion wakes this thread through a notification, steered into active turns where supported or queued otherwise, so end the turn instead of polling or spawning watchers; use task_status only when the result is needed mid-turn.",60 "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. Choose providers and models from orchestrator_capabilities, which uses the same live catalog as the composer. Prefer native subagent tools for same-provider work only when they support the chosen model. Use this for any model missing from the native tool, including same-provider work, for cross-provider work, or for explicitly T3-owned child tasks. For every T3 delegated review round, call delegate_task again with the original brief, prior findings, responses, and unresolved objections in the task prompt. Track each round by its own taskId and use a distinct clientRequestId per round, stable across retries of that round. The childThreadId is backing storage, not the target for starting another delegated review round through t3_thread_send. 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. timeoutMs on mode=wait is only the parent's wait budget and does not cancel the child. waitTimedOut on that wait call means the timeout fired; keep that taskId and read status on later task_status. An async child's completion wakes this thread through a notification, steered into active turns where supported or queued otherwise, so end the turn instead of polling or spawning watchers; use task_status only when the result is needed mid-turn.",
61 parameters: OrchestratorMcpDelegateTaskInput,61 parameters: OrchestratorMcpDelegateTaskInput,
62 success: OrchestratorMcpDelegateTaskResult,62 success: OrchestratorMcpDelegateTaskResult,
63 failure: OrchestratorMcpFailure,63 failure: OrchestratorMcpFailure,
...@@ -70,7 +70,7 @@ export const DelegateTaskTool = Tool.make("delegate_task", {...@@ -70,7 +70,7 @@ export const DelegateTaskTool = Tool.make("delegate_task", {
7070
71const TaskStatusTool = Tool.make("task_status", {71const TaskStatusTool = Tool.make("task_status", {
72 description:72 description:
73 "Read a T3-owned delegated task created by this parent thread. childRunId identifies the original delegated run. workState distinguishes working, waiting_for_children, and result_available; a completed turn with live nested work is not a completed task. summary is the final task result, including provider errors on failure, and remains stable after publication. hasPendingChildRuns reports later queued or executing turns; latestTerminal* provides later non-monitor turn results. Reading a terminal result acknowledges its automatic parent delivery.",73 "Read a T3-owned delegated task created by this parent thread. childRunId identifies the original delegated run. workState distinguishes working, waiting_for_children, and result_available; a completed turn with live nested work is not a completed task. summary is the final task result, including provider errors on failure, and remains stable after publication. hasPendingChildRuns reports later queued or executing turns in the backing child thread, even after the task is terminal; it does not reopen the task or extend task_cancel to those turns. latestTerminal* provides later non-monitor turn results. Reading a terminal result acknowledges its automatic parent delivery.",
74 parameters: OrchestratorMcpTaskStatusInput,74 parameters: OrchestratorMcpTaskStatusInput,
75 success: OrchestratorMcpDelegateTaskResult,75 success: OrchestratorMcpDelegateTaskResult,
76 failure: OrchestratorMcpFailure,76 failure: OrchestratorMcpFailure,
...@@ -84,7 +84,7 @@ const TaskStatusTool = Tool.make("task_status", {...@@ -84,7 +84,7 @@ const TaskStatusTool = Tool.make("task_status", {
8484
85const TaskCancelTool = Tool.make("task_cancel", {85const TaskCancelTool = Tool.make("task_cancel", {
86 description:86 description:
87 "Request interruption of an active T3-owned delegated task and dispose its automatic parent delivery. Completed task results remain available.",87 "Request interruption of an active T3-owned delegated task and dispose its automatic parent delivery. For a terminal task, return its existing status and dispose delivery without interrupting later child-thread runs, even when task_status reports hasPendingChildRuns=true. Published task results remain available. Use t3_thread_interrupt for a later active run.",
88 parameters: OrchestratorMcpTaskCancelInput,88 parameters: OrchestratorMcpTaskCancelInput,
89 success: OrchestratorMcpTaskCancelResult,89 success: OrchestratorMcpTaskCancelResult,
90 failure: OrchestratorMcpFailure,90 failure: OrchestratorMcpFailure,
...@@ -200,7 +200,7 @@ export const ThreadUpdateTool = Tool.make("t3_thread_update", {...@@ -200,7 +200,7 @@ export const ThreadUpdateTool = Tool.make("t3_thread_update", {
200200
201const ThreadSendTool = Tool.make("t3_thread_send", {201const ThreadSendTool = Tool.make("t3_thread_send", {
202 description:202 description:
203 "Send a message to a T3 thread in the calling project. mode='auto' starts an idle thread, steers a fully active turn, or queues behind a turn that is not yet steerable. Use queue for a separate follow-up turn, steer for an in-flight update, or restart to interrupt-and-restart the active turn. clientRequestId makes retries idempotent.",203 "Send a message to a T3 thread in the calling project. Do not use a delegated task's childThreadId to start another review round here; use delegate_task with the full review context and a new clientRequestId for that round. Thread messages do not create a new delegated task or reopen a completed task. mode='auto' starts an idle thread, steers a fully active turn, or queues behind a turn that is not yet steerable. Use queue for a separate follow-up turn, steer for an in-flight update, or restart to interrupt-and-restart the active turn. clientRequestId makes retries idempotent.",
204 parameters: OrchestratorMcpThreadSendInput,204 parameters: OrchestratorMcpThreadSendInput,
205 success: OrchestratorMcpThreadSendResult,205 success: OrchestratorMcpThreadSendResult,
206 failure: OrchestratorMcpFailure,206 failure: OrchestratorMcpFailure,
apps/server/src/provider/T3OrchestrationInstructions.test.ts+5
...@@ -13,6 +13,11 @@ describe("T3 orchestration provider instructions", () => {...@@ -13,6 +13,11 @@ describe("T3 orchestration provider instructions", () => {
13 assert.include(T3_CODE_ORCHESTRATION_INSTRUCTIONS, "ordinary top-level T3 conversations");13 assert.include(T3_CODE_ORCHESTRATION_INSTRUCTIONS, "ordinary top-level T3 conversations");
14 assert.include(T3_CODE_ORCHESTRATION_INSTRUCTIONS, "Never use them merely");14 assert.include(T3_CODE_ORCHESTRATION_INSTRUCTIONS, "Never use them merely");
15 assert.include(T3_CODE_ORCHESTRATION_INSTRUCTIONS, "cross-provider");15 assert.include(T3_CODE_ORCHESTRATION_INSTRUCTIONS, "cross-provider");
16 assert.include(T3_CODE_ORCHESTRATION_INSTRUCTIONS, "call `delegate_task` again");
17 assert.include(
18 T3_CODE_ORCHESTRATION_INSTRUCTIONS,
19 "Do not use `t3_thread_send` on `childThreadId`",
20 );
16 });21 });
1722
18 it("documents structured schedules instead of JSON strings", () => {23 it("documents structured schedules instead of JSON strings", () => {
apps/server/src/provider/T3OrchestrationInstructions.ts+2-1
...@@ -6,8 +6,9 @@ export const T3_CODE_ORCHESTRATION_INSTRUCTIONS = `...@@ -6,8 +6,9 @@ export const T3_CODE_ORCHESTRATION_INSTRUCTIONS = `
66
7The \`t3-code\` MCP server provides app-owned orchestration. Treat these concepts distinctly:7The \`t3-code\` MCP server provides app-owned orchestration. Treat these concepts distinctly:
88
9- A delegated task/subagent is child work owned by the current thread. Use \`orchestrator_capabilities\` to discover the current provider/model IDs from the same live catalog as the composer, including configured custom models. Do not treat a native tool's model list as the full list of available subagent models. Prefer native subagent tools for same-provider work only when they support the chosen model. Use \`delegate_task\` with that provider instance and model when native tools cannot, including for same-provider work. Also use \`delegate_task\` for cross-provider or explicitly T3-owned child tasks. Retain each returned \`taskId\`, and use \`task_status\` or \`task_cancel\` to manage it. The returned \`childThreadId\` is backing storage for the subagent; do not replace delegation with ordinary thread creation.9- A delegated task/subagent is child work owned by the current thread. Use \`orchestrator_capabilities\` to discover the current provider/model IDs from the same live catalog as the composer, including configured custom models. Do not treat a native tool's model list as the full list of available subagent models. Prefer native subagent tools for same-provider work only when they support the chosen model. Use \`delegate_task\` with that provider instance and model when native tools cannot, including for same-provider work. Also use \`delegate_task\` for cross-provider or explicitly T3-owned child tasks. Retain each returned \`taskId\`, and use \`task_status\` or \`task_cancel\` to manage it. The returned \`childThreadId\` is backing storage for the subagent, not the target for starting another delegated review round.
10- \`t3_thread_launch\` and \`create_threads\` create ordinary top-level T3 conversations. Use them only when the user explicitly asks for separate/new/top-level threads or conversations. Never use them merely because the user said "subagent" or requested parallel delegated work.10- \`t3_thread_launch\` and \`create_threads\` create ordinary top-level T3 conversations. Use them only when the user explicitly asks for separate/new/top-level threads or conversations. Never use them merely because the user said "subagent" or requested parallel delegated work.
11- For every T3 delegated review round, call \`delegate_task\` again. Include the original brief, prior findings, responses, and unresolved objections in each new task prompt. Track each round by its own \`taskId\`. Use a distinct \`clientRequestId\` per round, stable across retries of that round. Do not use \`t3_thread_send\` on \`childThreadId\` to continue a delegated review.
11- \`schedule_task\` creates persistent recurring work in the app scheduler. Pass \`schedule\` as a structured object, never as JSON text: \`{"type":"interval","everyMs":3600000}\` for an interval, or \`{"type":"fixed_time","timeOfDay":"09:00","weekdays":[1,2,3,4,5]}\` for a wall-clock schedule. By default runs return to the current thread; set \`bindToCurrentThread=false\` only when the user wants a fresh thread for every run. After scheduling, report the returned cadence and next run time.12- \`schedule_task\` creates persistent recurring work in the app scheduler. Pass \`schedule\` as a structured object, never as JSON text: \`{"type":"interval","everyMs":3600000}\` for an interval, or \`{"type":"fixed_time","timeOfDay":"09:00","weekdays":[1,2,3,4,5]}\` for a wall-clock schedule. By default runs return to the current thread; set \`bindToCurrentThread=false\` only when the user wants a fresh thread for every run. After scheduling, report the returned cadence and next run time.
1213
13### Choose the workspace before starting a new thread14### Choose the workspace before starting a new thread
docs/orchestration-v2/orchestrator-mcp-server.md+14-2
...@@ -228,6 +228,14 @@ that driver; an explicit `providerInstanceId` is honored exactly and fails...@@ -228,6 +228,14 @@ that driver; an explicit `providerInstanceId` is honored exactly and fails
228when unavailable. Selecting a different provider without a model uses that228when unavailable. Selecting a different provider without a model uses that
229provider's first advertised model.229provider's first advertised model.
230230
231Each delegated review round uses a new `delegate_task` call with the original brief,
232prior findings, responses, and unresolved objections. Track each round by its own `taskId` and use
233a distinct `clientRequestId` per round, stable across retries of that round.
234`childThreadId` is backing storage, not a target for another review round through
235`t3_thread_send`. Ordinary thread messaging remains available for user-requested
236conversations; it does not reopen a completed task. There is no task-level follow-up
237API for preserving the same reviewer session.
238
231Delegation requires an active parent run owned by the MCP credential's239Delegation requires an active parent run owned by the MCP credential's
232provider session. The request becomes the V2 command240provider session. The request becomes the V2 command
233`delegated_task.request`.241`delegated_task.request`.
...@@ -272,8 +280,12 @@ the published task result....@@ -272,8 +280,12 @@ the published task result.
272### `task_cancel`280### `task_cancel`
273281
274Interrupts the currently active task run through the normal V2 `run.interrupt`282Interrupts the currently active task run through the normal V2 `run.interrupt`
275command. Native background work between turns currently has no interruptible run. It is idempotent for terminal tasks and accepts an optional cancellation283command and disposes automatic parent delivery. Native background work between
276reason. Use `t3_thread_interrupt` to interrupt a later follow-up run.284turns currently has no interruptible run. For a terminal task, it returns the
285existing status and disposes delivery without interrupting later child-thread runs,
286even when `task_status` reports `hasPendingChildRuns: true`. Published task results
287remain available. It accepts an optional cancellation reason. Use
288`t3_thread_interrupt` to stop a later active run.
277289
278### `create_threads`290### `create_threads`
279291