| author | |
| committer | |
| log | 924fb0c4d4941f9d186e46030ab358ebbdb064f8 |
| tree | ad45a14f30b13acea93af6042e5f158c48b02f22 |
| parent | 755accf590bec6e81d1b2484d8501c95798c1cbe |
| signature | Signed by PGP key B5690EEEBB952194 |
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; |
| 888 | 897 |
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"; |
| 101 | 101 | ||
| 102 | const DEFAULT_WAIT_TIMEOUT_MS = 10 * 60 * 1_000; | 102 | const DEFAULT_WAIT_TIMEOUT_MS = 10 * 60 * 1_000; |
| 103 | const MAX_WAIT_TIMEOUT_MS = 60 * 60 * 1_000; | 103 | const 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. |
| 106 | const TASK_WAKE_EVENTS = [ | 106 | const 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. */ |
| 113 | const SECRET_REQUEST_POLL_INTERVAL_MS = 500; | 114 | const 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* threadManagement | 1352 | 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).text | 1464 | : 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: upgrade | 2108 | // An incomplete wait gives up delivery: upgrade so a later terminal |
| 2090 | // the task so a later terminal wakes the parent even mid-turn. Best | 2109 | // wakes the parent even mid-turn. Best effort; settled_only still |
| 2091 | // effort; on failure the settled_only policy still wakes a settled | 2110 | // wakes a settled parent if the upgrade fails. |
| 2092 | // parent. | ||
| 2093 | yield* threadManagement | 2111 | 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 failures | 2124 | // The tool result still returns after a timeout or question, so failures |
| 2107 | // stay warnings. Keep the two shapes apart: a rejected receipt | 2125 | // stay warnings. Keep the two shapes apart: a rejected receipt |
| 2108 | // means this exact command id already failed (a replay of a | 2126 | // 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); |
| 2724 | 2725 | ||
| 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 (child | 2786 | // The MCP tool cannot force the reverse interleaving (child |
| 2726 | // terminal before the upgrade lands), so dispatch the command | 2787 | // terminal before the upgrade lands), so dispatch the command |
| 2727 | // directly. The first wait-mode delegation completed while the | 2788 | // 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", { |
| 65 | 65 | ||
| 66 | export const DelegateTaskTool = Tool.make("delegate_task", { | 66 | export 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; |
| 1326 | 1326 | ||
| 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. | ||
| 1329 | const CODEX_T3_MCP_TOOL_TIMEOUT_SEC = 65 * 60; | ||
| 1330 | |||
| 1327 | export function codexThreadRuntimeParams(input: { | 1331 | export 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 until | 260 | `mode: "wait"` waits for the task result, including nested work and completion follow-ups, or until |
| 261 | the timeout expires. A wait timeout does not cancel the child; the result sets | 261 | the 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 |
| 263 | question also ends the wait with `hasPendingQuestion: true` and `waitTimedOut: false`. | ||
| 264 | If `hasPendingQuestion`, pass `threadId=childThreadId` to `t3_pending_request_list`, | ||
| 265 | `t3_pending_request_read` (with `requestId`) and `t3_pending_request_respond`, or ask | ||
| 266 | the user; the task's completion still wakes this thread. Async questions and approvals | ||
| 267 | do not end the wait. | ||
| 263 | 268 | ||
| 264 | ```ts | 269 | ```ts |
| 265 | type DelegateTaskResult = { | 270 | type 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 | }); |
| 63 | 64 |
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.", |