authorgravatar for 97427849+flamboh@users.noreply.github.comoliver <97427849+flamboh@users.noreply.github.com> 2026-10-14 20:17:08-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-15 03:17:08+00:00
log924fb0c4d4941f9d186e46030ab358ebbdb064f8
treead45a14f30b13acea93af6042e5f158c48b02f22
parent755accf590bec6e81d1b2484d8501c95798c1cbe
signature Signed by PGP key B5690EEEBB952194

fix(server): a delegated task's question ends the parent's blocking wait (#19538)

Co-authored-by: maria-rcks <maria@kuuro.net>

9 files changed, 119 insertions(+), 14 deletions(-)

apps/server/src/mcp/OrchestratorMcpService.test.ts+9
...@@ -96,6 +96,7 @@ describe("OrchestratorMcpService", () => {...@@ -96,6 +96,7 @@ describe("OrchestratorMcpService", () => {
96 messages: [],96 messages: [],
97 subagents: [],97 subagents: [],
98 providerThreads: [],98 providerThreads: [],
99 runtimeRequests: [],
99 } as unknown as OrchestrationV2ThreadProjection;100 } as unknown as OrchestrationV2ThreadProjection;
100 let hasNestedWork = true;101 let hasNestedWork = true;
101 const layerDependencies = Layer.mergeAll(102 const layerDependencies = Layer.mergeAll(
...@@ -223,6 +224,7 @@ describe("OrchestratorMcpService", () => {...@@ -223,6 +224,7 @@ describe("OrchestratorMcpService", () => {
223 messages: [],224 messages: [],
224 subagents: [],225 subagents: [],
225 providerThreads: [],226 providerThreads: [],
227 runtimeRequests: [],
226 turnItems: [],228 turnItems: [],
227 } as unknown as OrchestrationV2ThreadProjection;229 } as unknown as OrchestrationV2ThreadProjection;
228 const layerDependencies = Layer.mergeAll(230 const layerDependencies = Layer.mergeAll(
...@@ -308,6 +310,7 @@ describe("OrchestratorMcpService", () => {...@@ -308,6 +310,7 @@ describe("OrchestratorMcpService", () => {
308 messages: [],310 messages: [],
309 subagents: [],311 subagents: [],
310 providerThreads: [],312 providerThreads: [],
313 runtimeRequests: [],
311 } as unknown as OrchestrationV2ThreadProjection;314 } as unknown as OrchestrationV2ThreadProjection;
312 const layerDependencies = Layer.mergeAll(315 const layerDependencies = Layer.mergeAll(
313 NodeServices.layer,316 NodeServices.layer,
...@@ -385,6 +388,7 @@ describe("OrchestratorMcpService", () => {...@@ -385,6 +388,7 @@ describe("OrchestratorMcpService", () => {
385 messages: [],388 messages: [],
386 subagents: [],389 subagents: [],
387 providerThreads: [],390 providerThreads: [],
391 runtimeRequests: [],
388 } as unknown as OrchestrationV2ThreadProjection;392 } as unknown as OrchestrationV2ThreadProjection;
389 const layerDependencies = Layer.mergeAll(393 const layerDependencies = Layer.mergeAll(
390 NodeServices.layer,394 NodeServices.layer,
...@@ -465,6 +469,7 @@ describe("OrchestratorMcpService", () => {...@@ -465,6 +469,7 @@ describe("OrchestratorMcpService", () => {
465 messages: [],469 messages: [],
466 subagents: [],470 subagents: [],
467 providerThreads: [],471 providerThreads: [],
472 runtimeRequests: [],
468 } as unknown as OrchestrationV2ThreadProjection;473 } as unknown as OrchestrationV2ThreadProjection;
469 const layerDependencies = Layer.mergeAll(474 const layerDependencies = Layer.mergeAll(
470 NodeServices.layer,475 NodeServices.layer,
...@@ -552,6 +557,7 @@ describe("OrchestratorMcpService", () => {...@@ -552,6 +557,7 @@ describe("OrchestratorMcpService", () => {
552 messages: [],557 messages: [],
553 subagents: [],558 subagents: [],
554 providerThreads: [],559 providerThreads: [],
560 runtimeRequests: [],
555 } as unknown as OrchestrationV2ThreadProjection;561 } as unknown as OrchestrationV2ThreadProjection;
556 const layerDependencies = Layer.mergeAll(562 const layerDependencies = Layer.mergeAll(
557 NodeServices.layer,563 NodeServices.layer,
...@@ -655,6 +661,7 @@ describe("OrchestratorMcpService", () => {...@@ -655,6 +661,7 @@ describe("OrchestratorMcpService", () => {
655 appOwnedTask("node:mcp-cancel-grandchild-below", childThreadId, grandchildThreadId),661 appOwnedTask("node:mcp-cancel-grandchild-below", childThreadId, grandchildThreadId),
656 ],662 ],
657 providerThreads: [],663 providerThreads: [],
664 runtimeRequests: [],
658 },665 },
659 ],666 ],
660 [667 [
...@@ -666,6 +673,7 @@ describe("OrchestratorMcpService", () => {...@@ -666,6 +673,7 @@ describe("OrchestratorMcpService", () => {
666 messages: [],673 messages: [],
667 subagents: [],674 subagents: [],
668 providerThreads: [],675 providerThreads: [],
676 runtimeRequests: [],
669 },677 },
670 ],678 ],
671 ]) as unknown as ReadonlyMap<ThreadId, OrchestrationV2ThreadProjection>;679 ]) as unknown as ReadonlyMap<ThreadId, OrchestrationV2ThreadProjection>;
...@@ -883,6 +891,7 @@ describe("OrchestratorMcpService provider resolution", () => {...@@ -883,6 +891,7 @@ describe("OrchestratorMcpService provider resolution", () => {
883 messages: [],891 messages: [],
884 subagents: [],892 subagents: [],
885 providerThreads: [],893 providerThreads: [],
894 runtimeRequests: [],
886 turnItems: [],895 turnItems: [],
887 } as unknown as OrchestrationV2ThreadProjection;896 } as unknown as OrchestrationV2ThreadProjection;
888897
apps/server/src/mcp/OrchestratorMcpService.ts+30-12
...@@ -101,13 +101,14 @@ import * as SecretRequests from "../secrets/SecretRequests.ts";...@@ -101,13 +101,14 @@ import * as SecretRequests from "../secrets/SecretRequests.ts";
101101
102const DEFAULT_WAIT_TIMEOUT_MS = 10 * 60 * 1_000;102const DEFAULT_WAIT_TIMEOUT_MS = 10 * 60 * 1_000;
103const MAX_WAIT_TIMEOUT_MS = 60 * 60 * 1_000;103const MAX_WAIT_TIMEOUT_MS = 60 * 60 * 1_000;
104// Events that can make a delegated task terminal: the parent's task record,104// Events that can end a delegated task wait: the parent's task record,
105// and the child's runs, nested tasks, and pending provider background work.105// and the child's runs, nested tasks, provider background work, and questions.
106const TASK_WAKE_EVENTS = [106const TASK_WAKE_EVENTS = [
107 { thread: "parent", eventType: "subagent.updated" },107 { thread: "parent", eventType: "subagent.updated" },
108 { thread: "child", eventType: "run.updated" },108 { thread: "child", eventType: "run.updated" },
109 { thread: "child", eventType: "subagent.updated" },109 { thread: "child", eventType: "subagent.updated" },
110 { thread: "child", eventType: "provider-thread.updated" },110 { thread: "child", eventType: "provider-thread.updated" },
111 { thread: "child", eventType: "runtime-request.updated" },
111] as const;112] as const;
112/** A person answers the card, so a slower poll is plenty. */113/** A person answers the card, so a slower poll is plenty. */
113const SECRET_REQUEST_POLL_INTERVAL_MS = 500;114const SECRET_REQUEST_POLL_INTERVAL_MS = 500;
...@@ -1351,7 +1352,14 @@ const make = Effect.gen(function* () {...@@ -1351,7 +1352,14 @@ const make = Effect.gen(function* () {
1351 const childControls = yield* threadManagement1352 const childControls = yield* threadManagement
1352 .getThreadRecords(1353 .getThreadRecords(
1353 task.childThreadId,1354 task.childThreadId,
1354 ["runs", "messages", "contextTransfers", "subagents", "providerThreads"],1355 [
1356 "runs",
1357 "messages",
1358 "contextTransfers",
1359 "subagents",
1360 "providerThreads",
1361 "runtimeRequests",
1362 ],
1355 { messageRoles: ["user"] },1363 { messageRoles: ["user"] },
1356 )1364 )
1357 .pipe(Effect.mapError(threadManagementFailure));1365 .pipe(Effect.mapError(threadManagementFailure));
...@@ -1456,6 +1464,15 @@ const make = Effect.gen(function* () {...@@ -1456,6 +1464,15 @@ const make = Effect.gen(function* () {
1456 : subagentResultForRun(childProjection, terminalRun).text1464 : subagentResultForRun(childProjection, terminalRun).text
1457 : null,1465 : null,
1458 latestTerminalResultContextTransferId: resultTransferForRun(terminalRun)?.id ?? null,1466 latestTerminalResultContextTransferId: resultTransferForRun(terminalRun)?.id ?? null,
1467 // A finished task has no question of its own; later child runs belong to other tasks.
1468 hasPendingQuestion:
1469 !isTerminalTaskStatus(status) &&
1470 childControls.runtimeRequests.some(
1471 (request) =>
1472 request.kind === "user_input" &&
1473 request.status === "pending" &&
1474 request.responseCapability.type === "live",
1475 ),
1459 waitTimedOut,1476 waitTimedOut,
1460 } satisfies OrchestratorMcpDelegateTaskResult;1477 } satisfies OrchestratorMcpDelegateTaskResult;
1461 if (1478 if (
...@@ -1508,7 +1525,9 @@ const make = Effect.gen(function* () {...@@ -1508,7 +1525,9 @@ const make = Effect.gen(function* () {
1508 .getThreadEventSequence(scope.thread.threadId)1525 .getThreadEventSequence(scope.thread.threadId)
1509 .pipe(Effect.mapError(streamError));1526 .pipe(Effect.mapError(streamError));
1510 const initial = yield* readTask(scope, taskId, false, true);1527 const initial = yield* readTask(scope, taskId, false, true);
1511 if (isTerminalTaskStatus(initial.status)) return Option.some(initial);1528 if (initial.hasPendingQuestion || isTerminalTaskStatus(initial.status)) {
1529 return Option.some(initial);
1530 }
1512 // One stream per event type, so transcript events never fill a buffer.1531 // One stream per event type, so transcript events never fill a buffer.
1513 return yield* Stream.mergeAll(1532 return yield* Stream.mergeAll(
1514 TASK_WAKE_EVENTS.map(({ thread, eventType }) =>1533 TASK_WAKE_EVENTS.map(({ thread, eventType }) =>
...@@ -1522,7 +1541,7 @@ const make = Effect.gen(function* () {...@@ -1522,7 +1541,7 @@ const make = Effect.gen(function* () {
1522 ).pipe(1541 ).pipe(
1523 Stream.mapError(streamError),1542 Stream.mapError(streamError),
1524 Stream.mapEffect(() => readTask(scope, taskId, false, true)),1543 Stream.mapEffect(() => readTask(scope, taskId, false, true)),
1525 Stream.filter((result) => isTerminalTaskStatus(result.status)),1544 Stream.filter((result) => result.hasPendingQuestion || isTerminalTaskStatus(result.status)),
1526 Stream.runHead,1545 Stream.runHead,
1527 );1546 );
1528 }).pipe(Effect.timeoutOption(Duration.millis(timeoutMs)), Effect.map(Option.flatten));1547 }).pipe(Effect.timeoutOption(Duration.millis(timeoutMs)), Effect.map(Option.flatten));
...@@ -2083,13 +2102,12 @@ const make = Effect.gen(function* () {...@@ -2083,13 +2102,12 @@ const make = Effect.gen(function* () {
2083 Math.max(1, input.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS),2102 Math.max(1, input.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS),
2084 );2103 );
2085 const waited = yield* waitForTask(scope, taskId, timeoutMs);2104 const waited = yield* waitForTask(scope, taskId, timeoutMs);
2086 if (Option.isSome(waited)) {2105 if (Option.isSome(waited) && isTerminalTaskStatus(waited.value.status)) {
2087 return waited.value;2106 return waited.value;
2088 }2107 }
2089 // The blocking wait timed out, so it no longer owns delivery: upgrade2108 // An incomplete wait gives up delivery: upgrade so a later terminal
2090 // the task so a later terminal wakes the parent even mid-turn. Best2109 // wakes the parent even mid-turn. Best effort; settled_only still
2091 // effort; on failure the settled_only policy still wakes a settled2110 // wakes a settled parent if the upgrade fails.
2092 // parent.
2093 yield* threadManagement2111 yield* threadManagement
2094 .dispatch({2112 .dispatch({
2095 type: "delegated_task.wake-policy",2113 type: "delegated_task.wake-policy",
...@@ -2103,7 +2121,7 @@ const make = Effect.gen(function* () {...@@ -2103,7 +2121,7 @@ const make = Effect.gen(function* () {
2103 completionWake: "always",2121 completionWake: "always",
2104 })2122 })
2105 .pipe(2123 .pipe(
2106 // The tool result is the timed-out task either way, so failures2124 // The tool result still returns after a timeout or question, so failures
2107 // stay warnings. Keep the two shapes apart: a rejected receipt2125 // stay warnings. Keep the two shapes apart: a rejected receipt
2108 // means this exact command id already failed (a replay of a2126 // means this exact command id already failed (a replay of a
2109 // no-op upgrade), while anything else is a fresh dispatch fault.2127 // no-op upgrade), while anything else is a fresh dispatch fault.
...@@ -2125,7 +2143,7 @@ const make = Effect.gen(function* () {...@@ -2125,7 +2143,7 @@ const make = Effect.gen(function* () {
2125 }),2143 }),
2126 ),2144 ),
2127 );2145 );
2128 return yield* readTask(scope, taskId, true, true);2146 return yield* readTask(scope, taskId, Option.isNone(waited), true);
2129 }),2147 }),
2130 taskStatus: (callerScope, taskId) =>2148 taskStatus: (callerScope, taskId) =>
2131 Effect.gen(function* () {2149 Effect.gen(function* () {
apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts+61
...@@ -30,6 +30,7 @@ import {...@@ -30,6 +30,7 @@ import {
30 ProviderThreadId,30 ProviderThreadId,
31 ProviderTurnId,31 ProviderTurnId,
32 type ScheduledTask,32 type ScheduledTask,
33 RuntimeRequestId,
33 ScheduledTaskId,34 ScheduledTaskId,
34 type ScheduledTaskUpsertInput,35 type ScheduledTaskUpsertInput,
35 type ServerProvider,36 type ServerProvider,
...@@ -2722,6 +2723,66 @@ describe("orchestrator MCP toolkit", () => {...@@ -2722,6 +2723,66 @@ describe("orchestrator MCP toolkit", () => {
2722 ).toMatchObject({ state: "disposed" });2723 ).toMatchObject({ state: "disposed" });
2723 yield* expectOffersToStay(0);2724 yield* expectOffersToStay(0);
27242725
2726 // A live question ends the wait and leaves completion delivery armed.
2727 const questionWait = yield* invoke("delegate_task", {
2728 task: cancellationPrompt,
2729 title: "Wait question",
2730 target: { providerInstanceId: codexInstanceId, model: codexModel },
2731 mode: "wait",
2732 timeoutMs: 30_000,
2733 clientRequestId: "delegate-wait-question-1",
2734 }).pipe(Effect.forkChild);
2735 const question = (yield* waitForProjection(orchestrator, parentThreadId, (p) =>
2736 p.subagents.some(
2737 (task) => task.title === "Wait question" && task.childThreadId !== null,
2738 ),
2739 )).subagents.find((task) => task.title === "Wait question")!;
2740 const asking = yield* waitForProjection(orchestrator, question.childThreadId!, (p) =>
2741 p.providerTurns.some((turn) => turn.status === "running"),
2742 );
2743 const askedAt = yield* DateTime.now;
2744 yield* (yield* EventSink.EventSinkV2).write({
2745 events: [
2746 {
2747 id: EventId.make("event:wait-question:live"),
2748 type: "runtime-request.updated",
2749 threadId: question.childThreadId!,
2750 occurredAt: askedAt,
2751 payload: {
2752 id: RuntimeRequestId.make("request:wait-question"),
2753 nodeId: question.id,
2754 providerTurnId: null,
2755 nativeRequestRef: null,
2756 kind: "user_input",
2757 status: "pending",
2758 responseCapability: {
2759 type: "live",
2760 providerSessionId: asking.providerSessions[0]!.id,
2761 },
2762 createdAt: askedAt,
2763 resolvedAt: null,
2764 },
2765 },
2766 ],
2767 });
2768 expect((yield* Fiber.join(questionWait)).structuredContent).toMatchObject({
2769 status: "running",
2770 hasPendingQuestion: true,
2771 waitTimedOut: false,
2772 });
2773 expect(
2774 (yield* orchestrator.getThreadProjection(parentThreadId)).subagents.find(
2775 (task) => task.id === question.id,
2776 )?.completionWake,
2777 ).toBe("always");
2778 yield* invoke("task_cancel", {
2779 taskId: question.id,
2780 clientRequestId: "cancel-wait-question-1",
2781 });
2782 yield* waitForProjection(orchestrator, parentThreadId, (p) =>
2783 p.subagents.some((task) => task.id === question.id && task.status === "interrupted"),
2784 );
2785
2725 // The MCP tool cannot force the reverse interleaving (child2786 // The MCP tool cannot force the reverse interleaving (child
2726 // terminal before the upgrade lands), so dispatch the command2787 // terminal before the upgrade lands), so dispatch the command
2727 // directly. The first wait-mode delegation completed while the2788 // directly. The first wait-mode delegation completed while the
apps/server/src/mcp/toolkits/orchestrator/tools.ts+1-1
...@@ -65,7 +65,7 @@ const OrchestratorCapabilitiesTool = Tool.make("orchestrator_capabilities", {...@@ -65,7 +65,7 @@ const OrchestratorCapabilitiesTool = Tool.make("orchestrator_capabilities", {
6565
66export const DelegateTaskTool = Tool.make("delegate_task", {66export const DelegateTaskTool = Tool.make("delegate_task", {
67 description:67 description:
68 "Needs an agent running inside a T3 thread. 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. Set isolation to worktree for a separate checkout based on the parent local commit, with project setup completed before the child starts. Uncommitted edits are not copied; omitted isolation shares the parent checkout. Provider, model, model options (see orchestrator_capabilities), runtime mode, and interaction mode inherit unless target overrides them. A requested runtimeMode must be one the target provider lists in orchestrator_capabilities runtimeModes; an inherited one steps down to the broadest mode it offers. Leave runtimeMode unset unless the user asks for a supervised child: approval-required and auto-accept-edits usually make the child wait for a person at each action that needs approval. 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.",68 "Needs an agent running inside a T3 thread. 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. Set isolation to worktree for a separate checkout based on the parent local commit, with project setup completed before the child starts. Uncommitted edits are not copied; omitted isolation shares the parent checkout. Provider, model, model options (see orchestrator_capabilities), runtime mode, and interaction mode inherit unless target overrides them. A requested runtimeMode must be one the target provider lists in orchestrator_capabilities runtimeModes; an inherited one steps down to the broadest mode it offers. Leave runtimeMode unset unless the user asks for a supervised child: approval-required and auto-accept-edits usually make the child wait for a person at each action that needs approval. Prefer mode='async' for long work; mode='wait' blocks until completion, a blocking question, or timeout. If hasPendingQuestion, pass threadId=childThreadId to t3_pending_request_list, t3_pending_request_read (with requestId) and t3_pending_request_respond, or ask the user; the task's completion still wakes this thread. 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.",
69 parameters: OrchestratorMcpDelegateTaskInput,69 parameters: OrchestratorMcpDelegateTaskInput,
70 success: OrchestratorMcpDelegateTaskResult,70 success: OrchestratorMcpDelegateTaskResult,
71 failure: OrchestratorMcpFailure,71 failure: OrchestratorMcpFailure,
apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts+1
...@@ -670,6 +670,7 @@ describe("CodexAdapterV2 process spawning", () => {...@@ -670,6 +670,7 @@ describe("CodexAdapterV2 process spawning", () => {
670 mcp_servers: {670 mcp_servers: {
671 "t3-code": {671 "t3-code": {
672 url: "http://127.0.0.1:43123/mcp",672 url: "http://127.0.0.1:43123/mcp",
673 tool_timeout_sec: 3_900,
673 http_headers: {674 http_headers: {
674 Authorization: "Bearer secret-codex-token",675 Authorization: "Bearer secret-codex-token",
675 },676 },
apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts+5
...@@ -1324,6 +1324,10 @@ export const CODEX_THREAD_CONFIG = {...@@ -1324,6 +1324,10 @@ export const CODEX_THREAD_CONFIG = {
1324 mcp_optional_startup_grace_ms: 10_000,1324 mcp_optional_startup_grace_ms: 10_000,
1325} as const;1325} as const;
13261326
1327// T3 wait tools can block for up to an hour. Keep Codex's MCP timeout above
1328// that limit so the server's own wait timeout ends a long call.
1329const CODEX_T3_MCP_TOOL_TIMEOUT_SEC = 65 * 60;
1330
1327export function codexThreadRuntimeParams(input: {1331export function codexThreadRuntimeParams(input: {
1328 readonly mcpSession: McpProviderSession.McpProviderSessionConfig | undefined;1332 readonly mcpSession: McpProviderSession.McpProviderSessionConfig | undefined;
1329 readonly modelSelection?: { readonly model: string };1333 readonly modelSelection?: { readonly model: string };
...@@ -1345,6 +1349,7 @@ export function codexThreadRuntimeParams(input: {...@@ -1345,6 +1349,7 @@ export function codexThreadRuntimeParams(input: {
1345 mcp_servers: {1349 mcp_servers: {
1346 "t3-code": {1350 "t3-code": {
1347 url: mcpSession.endpoint,1351 url: mcpSession.endpoint,
1352 tool_timeout_sec: CODEX_T3_MCP_TOOL_TIMEOUT_SEC,
1348 http_headers: {1353 http_headers: {
1349 Authorization: mcpSession.authorizationHeader,1354 Authorization: mcpSession.authorizationHeader,
1350 },1355 },
docs/orchestration-v2/orchestrator-mcp-server.md+7-1
...@@ -259,7 +259,12 @@ provider session. The request becomes the V2 command...@@ -259,7 +259,12 @@ provider session. The request becomes the V2 command
259`mode: "async"` returns the current durable state immediately.259`mode: "async"` returns the current durable state immediately.
260`mode: "wait"` waits for the task result, including nested work and completion follow-ups, or until260`mode: "wait"` waits for the task result, including nested work and completion follow-ups, or until
261the timeout expires. A wait timeout does not cancel the child; the result sets261the timeout expires. A wait timeout does not cancel the child; the result sets
262`waitTimedOut: true`, and the caller can continue with `task_status`.262`waitTimedOut: true`, and the caller can continue with `task_status`. A pending blocking
263question also ends the wait with `hasPendingQuestion: true` and `waitTimedOut: false`.
264If `hasPendingQuestion`, pass `threadId=childThreadId` to `t3_pending_request_list`,
265`t3_pending_request_read` (with `requestId`) and `t3_pending_request_respond`, or ask
266the user; the task's completion still wakes this thread. Async questions and approvals
267do not end the wait.
263268
264```ts269```ts
265type DelegateTaskResult = {270type DelegateTaskResult = {
...@@ -278,6 +283,7 @@ type DelegateTaskResult = {...@@ -278,6 +283,7 @@ type DelegateTaskResult = {
278 latestTerminalStatus: "completed" | "failed" | "cancelled" | "interrupted" | null;283 latestTerminalStatus: "completed" | "failed" | "cancelled" | "interrupted" | null;
279 latestTerminalSummary: string | null;284 latestTerminalSummary: string | null;
280 latestTerminalResultContextTransferId: string | null;285 latestTerminalResultContextTransferId: string | null;
286 hasPendingQuestion: boolean;
281 waitTimedOut: boolean;287 waitTimedOut: boolean;
282};288};
283```289```
packages/contracts/src/orchestratorMcp.test.ts+1
...@@ -58,6 +58,7 @@ describe("orchestrator MCP contracts", () => {...@@ -58,6 +58,7 @@ describe("orchestrator MCP contracts", () => {
58 latestTerminalStatus: "completed",58 latestTerminalStatus: "completed",
59 latestTerminalSummary: "Workspace inspected.",59 latestTerminalSummary: "Workspace inspected.",
60 latestTerminalResultContextTransferId: "context-transfer-result-1",60 latestTerminalResultContextTransferId: "context-transfer-result-1",
61 hasPendingQuestion: false,
61 waitTimedOut: false,62 waitTimedOut: false,
62 });63 });
6364
packages/contracts/src/orchestratorMcp.ts+4
...@@ -217,6 +217,10 @@ export const OrchestratorMcpDelegateTaskResult = Schema.Struct({...@@ -217,6 +217,10 @@ export const OrchestratorMcpDelegateTaskResult = Schema.Struct({
217 model: Schema.NullOr(Schema.String),217 model: Schema.NullOr(Schema.String),
218 summary: Schema.NullOr(Schema.String),218 summary: Schema.NullOr(Schema.String),
219 resultContextTransferId: Schema.NullOr(ContextTransferId),219 resultContextTransferId: Schema.NullOr(ContextTransferId),
220 hasPendingQuestion: Schema.Boolean.annotate({
221 description:
222 "Blocking question ends mode=wait. Pass threadId=childThreadId to t3_pending_request_list, t3_pending_request_read (with requestId) and t3_pending_request_respond, or ask the user; the task's completion still wakes this thread.",
223 }),
220 waitTimedOut: Schema.Boolean.annotate({224 waitTimedOut: Schema.Boolean.annotate({
221 description:225 description:
222 "True only on that mode=wait call when timeoutMs elapsed. The timeout does not cancel the child. Later task_status reads return false and use status for liveness.",226 "True only on that mode=wait call when timeoutMs elapsed. The timeout does not cancel the child. Later task_status reads return false and use status for liveness.",