diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index 5f5cae7563621..7bf7d2efe2c82 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -30,14 +30,17 @@ import { import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import { CodexProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/CodexAdapterV2.ts"; import * as CommandReceiptStore from "../orchestration-v2/CommandReceiptStore.ts"; @@ -1274,6 +1277,7 @@ describe("OrchestratorMcpService provider resolution", () => { "child run completion", "child question", "child approval", + "Node fetch's 300 s idle timeout", ] as const)("a task wait still returns on %s", (wake) => Effect.gen(function* () { const events = yield* Queue.unbounded(); @@ -1379,6 +1383,24 @@ describe("OrchestratorMcpService provider resolution", () => { .delegateTask(scope, { task: "Review the change.", mode: "wait" }) .pipe(Effect.forkChild); yield* Deferred.await(subscribed); + if (wake === "Node fetch's 300 s idle timeout") { + // Nothing is written during the wait, so undici drops the call at 300 s, before any handle. + const beforeClientTimeout = yield* Fiber.join(waiting).pipe( + Effect.timeoutOption(Duration.seconds(299)), + Effect.forkChild, + ); + yield* TestClock.adjust(Duration.seconds(299)); + const result = yield* Fiber.join(beforeClientTimeout); + assert.isTrue( + Option.isSome(result), + "a wait without timeoutMs was still silent at 299 s, so a Node MCP client loses the taskId", + ); + if (Option.isSome(result)) { + assert.equal(result.value.taskId, taskId); + assert.isTrue(result.value.waitTimedOut); + } + return; + } ready = true; if (wake === "parent completion") { yield* Queue.offer(events, { diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 71cf0efeda10a..b74619e7b28f4 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -131,6 +131,8 @@ import * as PeerForwarding from "../peer/PeerForwarding.ts"; import * as RemoteDelegation from "../peer/RemoteDelegation.ts"; const DEFAULT_WAIT_TIMEOUT_MS = 10 * 60 * 1_000; +// Nothing is written while a task wait blocks, and Node fetch drops a silent call at 300 s. +const DEFAULT_TASK_WAIT_TIMEOUT_MS = 4 * 60 * 1_000; const MAX_WAIT_TIMEOUT_MS = 60 * 60 * 1_000; const TASK_WAKE_EVENTS = [ { thread: "parent", eventType: "subagent.updated" }, @@ -1698,7 +1700,7 @@ const make = Effect.gen(function* () { ): Effect.fn.Return { const timeoutMs = Math.min( MAX_WAIT_TIMEOUT_MS, - Math.max(1, requestedTimeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS), + Math.max(1, requestedTimeoutMs ?? DEFAULT_TASK_WAIT_TIMEOUT_MS), ); const upgradeWake = threadManagement .dispatch({ diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index 486c4314927ad..8ff4546b354a4 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5597, "codexAdditionalContextBytes": 7327, - "mcpToolListJsonBytes": 48131 + "mcpToolListJsonBytes": 48130 } diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index 91f56d7b763f8..a036620022d19 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -184,7 +184,7 @@ export const OrchestratorMcpDelegateTaskInput = Schema.Struct({ }), ), timeoutMs: Schema.optional(Schema.Finite).annotate({ - description: "Mode 'wait' only. Default 10 min. A timeout does not cancel the child.", + description: "Mode 'wait' only. Default 4 min. A timeout does not cancel the child.", }), clientRequestId: Schema.optional(OrchestratorMcpClientRequestId), runtimeMode: Schema.optional(