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* () {
15101510 ),
15111511 ),
15121512 );
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.
15131515 if (isTerminalTaskStatus(current.status)) {
15141516 yield* disposeCompletionDelivery;
15151517 return {
apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts+8
......@@ -1651,6 +1651,14 @@ describe("orchestrator MCP toolkit", () => {
16511651 taskId: delegated.taskId,
16521652 status: "completed",
16531653 });
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 });
16541662 expect(
16551663 (yield* orchestrator.getThreadProjection(delegated.childThreadId)).runs.find(
16561664 (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";
44import {
55 CreateThreadsTool,
66 DelegateTaskTool,
7 OrchestratorToolkit,
78 ScheduleTaskTool,
89 ThreadUpdateTool,
910} from "./tools.ts";
......@@ -17,6 +18,16 @@ describe("orchestrator MCP tool guidance", () => {
1718 assert.include(DelegateTaskTool.description ?? "", "waitTimedOut");
1819 assert.include(DelegateTaskTool.description ?? "", "does not cancel the child");
1920 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 );
2031 });
2132
2233 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", {
5757
5858export const DelegateTaskTool = Tool.make("delegate_task", {
5959 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.",
6161 parameters: OrchestratorMcpDelegateTaskInput,
6262 success: OrchestratorMcpDelegateTaskResult,
6363 failure: OrchestratorMcpFailure,
......@@ -70,7 +70,7 @@ export const DelegateTaskTool = Tool.make("delegate_task", {
7070
7171const TaskStatusTool = Tool.make("task_status", {
7272 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.",
7474 parameters: OrchestratorMcpTaskStatusInput,
7575 success: OrchestratorMcpDelegateTaskResult,
7676 failure: OrchestratorMcpFailure,
......@@ -84,7 +84,7 @@ const TaskStatusTool = Tool.make("task_status", {
8484
8585const TaskCancelTool = Tool.make("task_cancel", {
8686 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.",
8888 parameters: OrchestratorMcpTaskCancelInput,
8989 success: OrchestratorMcpTaskCancelResult,
9090 failure: OrchestratorMcpFailure,
......@@ -200,7 +200,7 @@ export const ThreadUpdateTool = Tool.make("t3_thread_update", {
200200
201201const ThreadSendTool = Tool.make("t3_thread_send", {
202202 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.",
204204 parameters: OrchestratorMcpThreadSendInput,
205205 success: OrchestratorMcpThreadSendResult,
206206 failure: OrchestratorMcpFailure,
apps/server/src/provider/T3OrchestrationInstructions.test.ts+5
......@@ -13,6 +13,11 @@ describe("T3 orchestration provider instructions", () => {
1313 assert.include(T3_CODE_ORCHESTRATION_INSTRUCTIONS, "ordinary top-level T3 conversations");
1414 assert.include(T3_CODE_ORCHESTRATION_INSTRUCTIONS, "Never use them merely");
1515 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 );
1621 });
1722
1823 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 = `
66
77The \`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.
1010- \`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.
1112- \`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
1314### 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
228228when unavailable. Selecting a different provider without a model uses that
229229provider'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
231239Delegation requires an active parent run owned by the MCP credential's
232240provider session. The request becomes the V2 command
233241`delegated_task.request`.
......@@ -272,8 +280,12 @@ the published task result.
272280### `task_cancel`
273281
274282Interrupts 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 cancellation
276reason. Use `t3_thread_interrupt` to interrupt a later follow-up run.
283command and disposes automatic parent delivery. Native background work between
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
278290### `create_threads`
279291