From bc7c4a6317028693506d9b0bd03000cf8685895c Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 25 Oct 2026 00:48:58 -0700 Subject: [PATCH 1/3] fix(server): running task results tell the agent to end its turn task_status, async delegate_task, and a timed-out t3_thread_wait on a delegated child now return next while the task still runs and its completion will wake the parent. Terminal results, pending questions, pending approvals, and cancelled deliveries omit it. Co-Authored-By: Claude Opus 5.5 --- .../src/mcp/OrchestratorMcpService.test.ts | 101 ++++++++++++++++++ apps/server/src/mcp/OrchestratorMcpService.ts | 25 ++++- packages/contracts/src/orchestratorMcp.ts | 7 ++ 3 files changed, 130 insertions(+), 3 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index cfb6408b79c03..f88fd7a6fa0b9 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -5,6 +5,7 @@ import { EnvironmentId, MessageId, NodeId, + ORCHESTRATOR_MCP_RUNNING_TASK_NEXT, type OrchestratorMcpCapabilitiesResult, OrchestratorMcpTarget, type OrchestrationV2ThreadShell, @@ -292,6 +293,106 @@ describe("OrchestratorMcpService", () => { }), ); + it.effect("tells the parent to end its turn only while its task still runs", () => + Effect.gen(function* () { + const parentThreadId = ThreadId.make("thread:mcp-next-parent"); + const childThreadId = ThreadId.make("thread:mcp-next-child"); + const taskId = NodeId.make("node:mcp-next-task"); + let finished = false; + const parentProjection = () => + ({ + thread: { id: parentThreadId }, + runs: [], + contextTransfers: [], + subagents: [ + { + id: taskId, + threadId: parentThreadId, + origin: "app_owned", + childThreadId, + driver: "codex", + model: "gpt-5.6-terra", + status: finished ? "completed" : "running", + result: finished ? "done" : null, + completionDelivery: { state: "pending" }, + }, + ], + }) as unknown as OrchestrationV2ThreadProjection; + const childRun = () => ({ + id: RunId.make("run:mcp-next-child"), + ordinal: 1, + status: finished ? ("completed" as const) : ("running" as const), + delegatedTaskId: taskId, + }); + const childProjection = () => + ({ + thread: { id: childThreadId }, + runs: [childRun()], + contextTransfers: [], + messages: [], + subagents: [], + providerThreads: [], + runtimeRequests: [], + nodes: [], + turnItems: [], + }) as unknown as OrchestrationV2ThreadProjection; + const layerDependencies = Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadRecords: (threadId) => + Effect.succeed(threadId === parentThreadId ? parentProjection() : childProjection()), + getThreadShell: (threadId) => Effect.succeed(liveThreadShell(threadId)), + getProjectThreadRecords: () => Effect.succeed(childProjection()), + waitForThread: () => + Effect.succeed({ + threadId: childThreadId, + run: childRun() as unknown as OrchestrationV2ThreadProjection["runs"][number], + timedOut: !finished, + }), + dispatch: () => Effect.succeed({} as never), + }), + ServerSettings.layerTest(), + Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }), + Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({ + list: () => Effect.succeed([]), + }), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + ); + const scope: McpInvocationScope = { + environmentId: EnvironmentId.make("environment:mcp-next"), + requestNamespace: "provider-session:mcp-next", + thread: { + threadId: parentThreadId, + providerSessionId: "provider-session:mcp-next", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, + capabilities: new Set(["orchestration"]), + issuedAt: 1, + }; + + yield* Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + const running = yield* service.taskStatus(scope, taskId); + assert.equal(running.status, "running"); + assert.equal(running.next, ORCHESTRATOR_MCP_RUNNING_TASK_NEXT); + const waiting = yield* service.waitForThread(scope, { threadId: childThreadId }); + assert.isTrue(waiting.timedOut); + assert.equal(waiting.next, ORCHESTRATOR_MCP_RUNNING_TASK_NEXT); + + finished = true; + const completed = yield* service.taskStatus(scope, taskId); + assert.equal(completed.status, "completed"); + assert.notProperty(completed, "next"); + const waited = yield* service.waitForThread(scope, { threadId: childThreadId }); + assert.equal(waited.status, "completed"); + assert.notProperty(waited, "next"); + }).pipe(Effect.provide(layerOrchestratorService.pipe(Layer.provide(layerDependencies)))); + }), + ); + it.effect("does not dispose delivery when a nonterminal task has no active child run", () => Effect.gen(function* () { const parentThreadId = ThreadId.make("thread:mcp-cancel-parent"); diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index f339561044007..ca9a09ae021fe 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -5,6 +5,7 @@ import { MessageId, type ModelSelection, NodeId, + ORCHESTRATOR_MCP_RUNNING_TASK_NEXT, TurnItemId, type OrchestrationV2Run, type OrchestrationV2Subagent, @@ -1594,7 +1595,7 @@ const make = Effect.gen(function* () { instanceId: task.providerInstanceId, model: task.model, }; - const response = { + const result = { taskId: task.id, childThreadId: task.childThreadId, childRunId: childRun?.id ?? null, @@ -1629,6 +1630,10 @@ const make = Effect.gen(function* () { : pendingTaskApproval(childControls, task.id, childRun), waitTimedOut, } satisfies OrchestratorMcpDelegateTaskResult; + const response: OrchestratorMcpDelegateTaskResult = + endsTaskWait(result) || task.completionDelivery?.state === "disposed" + ? result + : { ...result, next: ORCHESTRATOR_MCP_RUNNING_TASK_NEXT }; if ( acknowledgeTerminal && isTerminalTaskStatus(status) && @@ -2847,7 +2852,7 @@ const make = Effect.gen(function* () { } satisfies OrchestratorMcpThreadSendResult; }), waitForThread: Effect.fn("OrchestratorMcpService.waitForThread")(function* (scope, input) { - const { target } = yield* loadScopedThread(scope, input.threadId); + const { parent, target } = yield* loadScopedThread(scope, input.threadId); const result = yield* threadManagement .waitForThread({ projectId: target.thread.projectId, @@ -2859,11 +2864,25 @@ const make = Effect.gen(function* () { ), }) .pipe(Effect.mapError(threadManagementFailure)); + const status = result.run === null ? "idle" : runOutcome(result.run); + const taskId = result.run?.delegatedTaskId; + const wakesCaller = + result.timedOut && + status !== "waiting" && + parent?.subagents.some( + (task) => + task.id === taskId && + task.origin === "app_owned" && + task.threadId === parent.thread.id && + task.childThreadId === input.threadId && + task.completionDelivery?.state !== "disposed", + ) === true; return { threadId: input.threadId, runId: result.run?.id ?? null, - status: result.run === null ? "idle" : runOutcome(result.run), + status, timedOut: result.timedOut, + ...(wakesCaller ? { next: ORCHESTRATOR_MCP_RUNNING_TASK_NEXT } : {}), } satisfies OrchestratorMcpThreadWaitResult; }), interruptThread: Effect.fn("OrchestratorMcpService.interruptThread")(function* (scope, input) { diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index 55fe175237f4f..e5ab1902dce2b 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -187,6 +187,11 @@ export const OrchestratorMcpDelegateTaskInput = Schema.Struct({ }); export type OrchestratorMcpDelegateTaskInput = typeof OrchestratorMcpDelegateTaskInput.Type; +// Set only on results for still-running tasks whose completion will wake the parent. +export const ORCHESTRATOR_MCP_RUNNING_TASK_NEXT = + "Completion wakes this thread. End your turn unless you need this result now."; +const OrchestratorMcpRunningTaskNext = Schema.Literal(ORCHESTRATOR_MCP_RUNNING_TASK_NEXT); + export const OrchestratorMcpDelegateTaskResult = Schema.Struct({ taskId: NodeId, childThreadId: ThreadId, @@ -223,6 +228,7 @@ export const OrchestratorMcpDelegateTaskResult = Schema.Struct({ description: "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.", }), + next: Schema.optional(OrchestratorMcpRunningTaskNext), }); export type OrchestratorMcpDelegateTaskResult = typeof OrchestratorMcpDelegateTaskResult.Type; @@ -495,6 +501,7 @@ export const OrchestratorMcpThreadWaitResult = Schema.Struct({ runId: Schema.NullOr(RunId), status: OrchestratorMcpThreadStatus, timedOut: Schema.Boolean, + next: Schema.optional(OrchestratorMcpRunningTaskNext), }); export type OrchestratorMcpThreadWaitResult = typeof OrchestratorMcpThreadWaitResult.Type; From 4b82e198d3cefd66871e78a038fd3c07723959e3 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 25 Oct 2026 00:55:59 -0700 Subject: [PATCH 2/3] test(server): give the running-task test's child thread a title #24784 builds a t3-thread link from the child's title, so this mock needs one once both land. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/mcp/OrchestratorMcpService.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index f88fd7a6fa0b9..e7aeca7b168d4 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -326,7 +326,7 @@ describe("OrchestratorMcpService", () => { }); const childProjection = () => ({ - thread: { id: childThreadId }, + thread: { id: childThreadId, title: "Child task" }, runs: [childRun()], contextTransfers: [], messages: [], From 314236a7b266845b8b3d1f649a8b4150db9a3fe2 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 25 Oct 2026 01:03:00 -0700 Subject: [PATCH 3/3] fix(server): no end-turn hint while a child waits on a question or approval waitForThread does not look at pending runtime requests, so a timed-out wait on a child that is asking a question could tell the parent to end its turn. The wait path now checks the child's pending requests first, like the task path does. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/mcp/OrchestratorMcpService.test.ts | 10 +++++++++- apps/server/src/mcp/OrchestratorMcpService.ts | 10 +++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index e7aeca7b168d4..703efc84beab3 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -299,6 +299,7 @@ describe("OrchestratorMcpService", () => { const childThreadId = ThreadId.make("thread:mcp-next-child"); const taskId = NodeId.make("node:mcp-next-task"); let finished = false; + let pendingRequest = false; const parentProjection = () => ({ thread: { id: parentThreadId }, @@ -332,7 +333,7 @@ describe("OrchestratorMcpService", () => { messages: [], subagents: [], providerThreads: [], - runtimeRequests: [], + runtimeRequests: pendingRequest ? [{ status: "pending" }] : [], nodes: [], turnItems: [], }) as unknown as OrchestrationV2ThreadProjection; @@ -382,6 +383,13 @@ describe("OrchestratorMcpService", () => { assert.isTrue(waiting.timedOut); assert.equal(waiting.next, ORCHESTRATOR_MCP_RUNNING_TASK_NEXT); + // A child waiting on a question or approval needs an answer, not a wake. + pendingRequest = true; + const asking = yield* service.waitForThread(scope, { threadId: childThreadId }); + assert.isTrue(asking.timedOut); + assert.notProperty(asking, "next"); + pendingRequest = false; + finished = true; const completed = yield* service.taskStatus(scope, taskId); assert.equal(completed.status, "completed"); diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index ca9a09ae021fe..24cbe633ca471 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -2866,7 +2866,7 @@ const make = Effect.gen(function* () { .pipe(Effect.mapError(threadManagementFailure)); const status = result.run === null ? "idle" : runOutcome(result.run); const taskId = result.run?.delegatedTaskId; - const wakesCaller = + const ownedTask = result.timedOut && status !== "waiting" && parent?.subagents.some( @@ -2877,6 +2877,14 @@ const make = Effect.gen(function* () { task.childThreadId === input.threadId && task.completionDelivery?.state !== "disposed", ) === true; + // A child waiting on a question or approval needs an answer, not a wake. + const childRequests = ownedTask + ? (yield* threadManagement + .getThreadRecords(input.threadId, ["runtimeRequests"]) + .pipe(Effect.mapError(threadManagementFailure))).runtimeRequests + : []; + const wakesCaller = + ownedTask && !childRequests.some((request) => request.status === "pending"); return { threadId: input.threadId, runId: result.run?.id ?? null,