From ccf93a8961bc3b7b47192132112261b5fd84d102 Mon Sep 17 00:00:00 2001 From: Nathan Shan Date: Sun, 25 Oct 2026 11:53:06 +0800 Subject: [PATCH 01/17] =?UTF-8?q?fix(server):=20=E5=9C=A8=E7=BA=BF?= =?UTF-8?q?=E7=A8=8B=E7=AD=89=E5=BE=85=E7=94=A8=E6=88=B7=E8=BE=93=E5=85=A5?= =?UTF-8?q?=E6=97=B6=E5=8F=8A=E6=97=B6=E8=BF=94=E5=9B=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 监听待处理请求事件,并在同一投影快照中判断当前及先前运行的阻塞问题和审批。 保留真实运行状态、异步问题和内部等待结束的语义,CLI 直接消费等待结果。 补充已有请求、请求到达、跨运行边界及终止等待测试,针对性检查通过。 --- apps/server/src/cli/thread.test.ts | 57 +++++- apps/server/src/cli/thread.ts | 22 +-- apps/server/src/mcp/OrchestratorMcpService.ts | 2 + .../src/mcp/toolkits/orchestrator/tools.ts | 2 +- .../ThreadManagementService.test.ts | 179 ++++++++++++++++++ .../ThreadManagementService.ts | 116 ++++++++++-- .../orchestrator-mcp-server.md | 8 +- packages/contracts/src/orchestratorMcp.ts | 4 + 8 files changed, 342 insertions(+), 48 deletions(-) diff --git a/apps/server/src/cli/thread.test.ts b/apps/server/src/cli/thread.test.ts index c2e4bc5c8168c..645c0d12b3a57 100644 --- a/apps/server/src/cli/thread.test.ts +++ b/apps/server/src/cli/thread.test.ts @@ -23,8 +23,22 @@ it.effect("--wait follows the sent run to its continuation's terminal status", ( const continuation = RunId.make("run-2"); const { tools, calls } = scriptedTools({ t3_thread_wait: [ - { threadId, runId, status: "running", timedOut: true }, - { threadId, runId: continuation, status: "failed", timedOut: false }, + { + threadId, + runId, + status: "running", + timedOut: true, + hasPendingQuestion: false, + pendingApproval: null, + }, + { + threadId, + runId: continuation, + status: "failed", + timedOut: false, + hasPendingQuestion: false, + pendingApproval: null, + }, ], t3_thread_read: [{ thread: { pendingRequestCount: 0 } }], }); @@ -35,8 +49,8 @@ it.effect("--wait follows the sent run to its continuation's terminal status", ( assert.deepStrictEqual( calls.filter((entry) => entry.tool === "t3_thread_wait").map((entry) => entry.args), [ - { threadId, runId, timeoutMs: 15_000 }, - { threadId, runId, timeoutMs: 15_000 }, + { threadId, runId, timeoutMs: 600_000 }, + { threadId, runId, timeoutMs: 600_000 }, ], ); }), @@ -44,15 +58,24 @@ it.effect("--wait follows the sent run to its continuation's terminal status", ( it.effect("--wait stops when the thread waits for an answer instead of polling forever", () => Effect.gen(function* () { - const { tools } = scriptedTools({ + const { tools, calls } = scriptedTools({ t3_thread_wait: [ - { threadId, runId, status: "queued", timedOut: true }, - { threadId, runId, status: "queued", timedOut: true }, + { + threadId, + runId, + status: "queued", + timedOut: false, + hasPendingQuestion: true, + pendingApproval: null, + }, ], - t3_thread_read: [{ thread: { pendingRequestCount: 1 } }], }); assert.deepStrictEqual(yield* waitForRun(tools, threadId, runId), { type: "needs_input" }); + assert.deepStrictEqual( + calls.map((call) => call.tool), + ["t3_thread_wait"], + ); }), ); @@ -60,8 +83,22 @@ it.effect("--wait reports its run's result when a later run is the one waiting", Effect.gen(function* () { const { tools } = scriptedTools({ t3_thread_wait: [ - { threadId, runId, status: "running", timedOut: true }, - { threadId, runId, status: "completed", timedOut: false }, + { + threadId, + runId, + status: "running", + timedOut: true, + hasPendingQuestion: false, + pendingApproval: null, + }, + { + threadId, + runId, + status: "completed", + timedOut: false, + hasPendingQuestion: false, + pendingApproval: null, + }, ], t3_thread_read: [{ thread: { pendingRequestCount: 1 } }], }); diff --git a/apps/server/src/cli/thread.ts b/apps/server/src/cli/thread.ts index 989028afccb9d..a74a672272abc 100644 --- a/apps/server/src/cli/thread.ts +++ b/apps/server/src/cli/thread.ts @@ -32,11 +32,7 @@ import { mcpToolSession, type McpToolSession } from "../mcp/AcpMcpStdioBridge.ts import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; -/** - * Short enough that a run which stops to ask the user something is noticed - * soon, since `t3_thread_wait` only returns on a terminal run. - */ -const WAIT_SLICE_MS = 15_000; +const WAIT_SLICE_MS = 600_000; const READ_PAGE_LIMIT = 100; const TITLE_MAX_CHARS = 80; @@ -393,20 +389,10 @@ export const waitForRun = Effect.fn("cli.thread.waitForRun")(function* ( ({ type: "ended", runId: waited.runId, status: waited.status }) satisfies WaitOutcome; while (true) { const waited = yield* waitSlice(WAIT_SLICE_MS); - if (!waited.timedOut) return ended(waited); - // A pending request on this run, or on an earlier run this one is queued - // behind, blocks it until someone answers. - const { thread } = yield* tools.call( - "t3_thread_read", - { threadId, limit: 1, runLimit: 1 }, - OrchestratorMcpThreadReadResult, - ); - if (thread.pendingRequestCount > 0) { - // The count is thread-wide: if this run ended since the slice, the - // request belongs to a later run. - const recheck = yield* waitSlice(1); - return recheck.timedOut ? ({ type: "needs_input" } satisfies WaitOutcome) : ended(recheck); + if (waited.hasPendingQuestion || waited.pendingApproval !== null) { + return { type: "needs_input" } satisfies WaitOutcome; } + if (!waited.timedOut) return ended(waited); } }); diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index f339561044007..2506e355938d8 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -2864,6 +2864,8 @@ const make = Effect.gen(function* () { runId: result.run?.id ?? null, status: result.run === null ? "idle" : runOutcome(result.run), timedOut: result.timedOut, + hasPendingQuestion: result.hasPendingQuestion, + pendingApproval: result.pendingApproval, } satisfies OrchestratorMcpThreadWaitResult; }), interruptThread: Effect.fn("OrchestratorMcpService.interruptThread")(function* (scope, input) { diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 0aa0b430b7c79..8c089f18ffd30 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -269,7 +269,7 @@ const ThreadSendTool = Tool.make("t3_thread_send", { const ThreadWaitTool = Tool.make("t3_thread_wait", { description: - "Wait for a T3 thread run to reach a terminal durable state. Without runId, the latest run at call time is selected; an idle thread returns immediately. Timeout does not interrupt work, so call again or use t3_thread_read/list after timedOut=true. Waiting reports status only and does not acknowledge a delegated result.", + "Wait for a T3 thread run to reach a terminal durable state or need a blocking answer or approval. Without runId, the latest run at call time is selected; an idle thread returns immediately. hasPendingQuestion or pendingApproval reports a pending live request on the selected run or an earlier unfinished run blocking it, without changing the run's status. Async message-based questions do not end the wait. Read the fresh request with t3_pending_request_list/read; this grants no approval authority. Timeout does not interrupt work, so call again or use t3_thread_read/list after timedOut=true. Waiting does not acknowledge a delegated result.", parameters: OrchestratorMcpThreadWaitInput, success: OrchestratorMcpThreadWaitResult, failure: OrchestratorMcpFailure, diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts index 0314008c6369f..f3f04f66cb10a 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts @@ -22,6 +22,7 @@ import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import * as ServerSettings from "../serverSettings.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; @@ -186,6 +187,182 @@ it.effect("uses thread-not-found only after a projection loads outside the proje }).pipe(Effect.provide(layerTest)); }); +it.effect.each([ + { + kind: "user_input", + capability: "live", + requestOrdinal: 1, + selectedStatus: "running", + blocks: true, + }, + { + kind: "command", + capability: "live", + requestOrdinal: 1, + selectedStatus: "running", + blocks: true, + }, + { + kind: "user_input", + capability: "live", + requestOrdinal: 0, + selectedStatus: "queued", + blocks: true, + }, + { + kind: "user_input", + capability: "message", + requestOrdinal: 1, + selectedStatus: "running", + blocks: false, + }, + { + kind: "user_input", + capability: "live", + requestOrdinal: 2, + selectedStatus: "running", + blocks: false, + }, + { + kind: "user_input", + capability: "live", + requestOrdinal: 1, + selectedStatus: "completed", + blocks: false, + }, +] as const)("waitForThread scopes pending input to unfinished work: %j", (scenario) => + Effect.gen(function* () { + const projectId = ProjectId.make("project:wait-input"); + const threadId = ThreadId.make("thread:wait-input"); + const runId = RunId.make("run:wait-input"); + const requestRunId = scenario.requestOrdinal === 1 ? runId : RunId.make("run:other"); + const projection = { + thread: { id: threadId, projectId, deletedAt: null }, + runs: [ + { id: runId, ordinal: 1, status: scenario.selectedStatus }, + ...(requestRunId === runId + ? [] + : [{ id: requestRunId, ordinal: scenario.requestOrdinal, status: "running" }]), + ], + nodes: [{ id: "node:request", runId: requestRunId }], + runtimeRequests: [ + { + id: "request:input", + nodeId: "node:request", + kind: scenario.kind, + status: "pending", + responseCapability: { type: scenario.capability }, + createdAt: yield* DateTime.now, + }, + ], + } as unknown as OrchestrationV2ThreadProjection; + const layerTest = ThreadManagementService.layer.pipe( + Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})), + Layer.provide( + Layer.mock(Orchestrator.OrchestratorV2)({ + getThreadEventSequence: () => Effect.succeed(0), + getThreadRecords: () => Effect.succeed(projection), + restartContinuationPending: () => Effect.succeed(false), + streamStoredEventsFrom: () => Stream.never, + }), + ), + Layer.provide(ServerSettings.layerTest()), + Layer.provide(layerReceiptsTest), + ); + const service = yield* ThreadManagementService.ThreadManagementService.pipe( + Effect.provide(layerTest), + ); + const fiber = yield* service + .waitForThread({ projectId, threadId, runId, timeoutMs: 60_000 }) + .pipe(Effect.forkChild); + yield* TestClock.adjust("1 minute"); + const result = yield* Fiber.join(fiber); + expect(result).toMatchObject({ + run: { id: runId, status: scenario.selectedStatus }, + timedOut: !scenario.blocks && scenario.selectedStatus !== "completed", + hasPendingQuestion: scenario.blocks && scenario.kind === "user_input", + pendingApproval: + scenario.blocks && scenario.kind === "command" + ? { requestId: "request:input", kind: "command", runId: requestRunId } + : null, + }); + }), +); + +it.effect("waitForThread wakes on a new approval while terminal-only callers keep waiting", () => + Effect.gen(function* () { + const projectId = ProjectId.make("project:wait-event"); + const threadId = ThreadId.make("thread:wait-event"); + const runId = RunId.make("run:wait-event"); + const subscribed = yield* Deferred.make(); + const events = yield* Queue.unbounded(); + const now = yield* DateTime.now; + let requests: OrchestrationV2ThreadProjection["runtimeRequests"] = []; + const layerTest = ThreadManagementService.layer.pipe( + Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})), + Layer.provide( + Layer.mock(Orchestrator.OrchestratorV2)({ + getThreadEventSequence: () => Effect.succeed(7), + getThreadRecords: () => + Effect.sync( + () => + ({ + thread: { id: threadId, projectId, deletedAt: null }, + runs: [{ id: runId, ordinal: 1, status: "running" }], + nodes: [{ id: "node:request", runId }], + runtimeRequests: requests, + }) as unknown as OrchestrationV2ThreadProjection, + ), + streamStoredEventsFrom: (input) => + input?.eventType === "runtime-request.updated" + ? Stream.fromEffect(Deferred.succeed(subscribed, undefined)).pipe( + Stream.drain, + Stream.concat(Stream.fromQueue(events)), + ) + : Stream.never, + }), + ), + Layer.provide(ServerSettings.layerTest()), + Layer.provide(layerReceiptsTest), + ); + const service = yield* ThreadManagementService.ThreadManagementService.pipe( + Effect.provide(layerTest), + ); + const fiber = yield* service + .waitForThread({ projectId, threadId, runId, timeoutMs: 600_000 }) + .pipe(Effect.forkChild); + yield* Deferred.await(subscribed); + requests = [ + { + id: "request:approval", + nodeId: "node:request", + kind: "command", + status: "pending", + responseCapability: { type: "live" }, + createdAt: now, + }, + ] as unknown as OrchestrationV2ThreadProjection["runtimeRequests"]; + yield* Queue.offer(events, { + sequence: 8, + event: { type: "runtime-request.updated", threadId, payload: requests[0] }, + } as unknown as OrchestrationV2StoredEvent); + expect(yield* Fiber.join(fiber)).toMatchObject({ + timedOut: false, + hasPendingQuestion: false, + pendingApproval: { requestId: "request:approval", kind: "command", runId }, + }); + const terminalWait = yield* service + .waitForThread({ projectId, threadId, runId, timeoutMs: 60_000, terminalOnly: true }) + .pipe(Effect.forkChild); + yield* TestClock.adjust("1 minute"); + expect(yield* Fiber.join(terminalWait)).toMatchObject({ + timedOut: true, + hasPendingQuestion: false, + pendingApproval: null, + }); + }), +); + it.effect("waitForThread follows a restart continuation instead of the cut run", () => Effect.gen(function* () { const projectId = ProjectId.make("project:thread-management:wait-restart"); @@ -222,6 +399,8 @@ it.effect("waitForThread follows a restart continuation instead of the cut run", ({ thread: { id: threadId, projectId, deletedAt: null }, runs, + nodes: [], + runtimeRequests: [], }) as unknown as OrchestrationV2ThreadProjection, ), }), diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index 17ad6c6bbc6f8..caafde4c17d7f 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -30,6 +30,8 @@ import { ProjectId, PROVIDER_SEND_TURN_MAX_FILE_BYTES, ProviderInstanceId, + ProviderRequestKind, + type OrchestratorMcpThreadWaitResult, resolveProjectProviderInstanceEnabled, RunId, type ScheduledTaskId, @@ -59,6 +61,7 @@ import * as LegacyV1ThreadImporter from "./legacy/LegacyV1ThreadImporter.ts"; import * as ServerSettings from "../serverSettings.ts"; export type ThreadManagementSendMode = "auto" | "queue" | "steer" | "restart"; +const isApprovalRequestKind = Schema.is(ProviderRequestKind); export interface ThreadManagementProvenance { readonly createdBy: OrchestrationV2Actor; @@ -151,12 +154,15 @@ export interface ThreadManagementWaitInput { readonly threadId: ThreadId; readonly runId?: RunId; readonly timeoutMs: number; + readonly terminalOnly?: boolean; } export interface ThreadManagementWaitResult { readonly threadId: ThreadId; readonly run: OrchestrationV2Run | null; readonly timedOut: boolean; + readonly hasPendingQuestion: boolean; + readonly pendingApproval: OrchestratorMcpThreadWaitResult["pendingApproval"]; } export interface ThreadManagementInterruptInput { @@ -867,7 +873,7 @@ const make = Effect.gen(function* () { const afterSequence = yield* orchestrator .getThreadEventSequence(input.threadId) .pipe(Effect.mapError(loadError)); - const target = yield* getProjectThreadRecords(input, ["runs"]); + const target = yield* getProjectThreadRecords(input, ["runs", "nodes", "runtimeRequests"]); const selectedRun = input.runId === undefined ? latestRun(target) @@ -879,12 +885,21 @@ const make = Effect.gen(function* () { }); } if (selectedRun === undefined) { - return { threadId: input.threadId, run: null, timedOut: false }; + return { + threadId: input.threadId, + run: null, + timedOut: false, + hasPendingQuestion: false, + pendingApproval: null, + }; } // A restart cuts a run and continues it as a new run. Follow that chain, // so a cut run is not the answer while its continuation is pending. // Runs per matching event and is already covered by the waitForThread span. - const outcome = Effect.fnUntraced(function* (runs: ReadonlyArray) { + const outcome = Effect.fnUntraced(function* ( + projection: Pick, + ) { + const { runs } = projection; const current = runs.find((candidate) => candidate.id === selectedRun.id); if (current === undefined) { return yield* new ThreadManagementRunNotFoundError({ @@ -895,11 +910,53 @@ const make = Effect.gen(function* () { const run = latestRestartContinuation(runs, current); const pending = isTerminalRunStatus(run.status) && (yield* orchestrator.restartContinuationPending(run)); - return { run, pending, settled: isTerminalRunStatus(run.status) && !pending }; + let hasPendingQuestion = false; + let pendingApproval: ThreadManagementWaitResult["pendingApproval"] = null; + let oldestApproval = Infinity; + if (!input.terminalOnly && !isTerminalRunStatus(run.status)) { + const blockingRuns = new Set( + runs + .filter( + (candidate) => + !isTerminalRunStatus(candidate.status) && candidate.ordinal <= run.ordinal, + ) + .map((candidate) => candidate.id), + ); + const nodeRuns = new Map(projection.nodes.map((node) => [node.id, node.runId])); + for (const request of projection.runtimeRequests) { + if (request.status !== "pending" || request.responseCapability.type !== "live") continue; + const requestRunId = nodeRuns.get(request.nodeId); + if (requestRunId == null || !blockingRuns.has(requestRunId)) continue; + if (request.kind === "user_input") hasPendingQuestion = true; + else if (isApprovalRequestKind(request.kind)) { + const createdAt = DateTime.toEpochMillis(request.createdAt); + if (createdAt < oldestApproval) { + oldestApproval = createdAt; + pendingApproval = { requestId: request.id, kind: request.kind, runId: requestRunId }; + } + } + } + } + return { + run, + pending, + hasPendingQuestion, + pendingApproval, + settled: + (isTerminalRunStatus(run.status) && !pending) || + hasPendingQuestion || + pendingApproval !== null, + }; }); - const initial = yield* outcome(target.runs); + const initial = yield* outcome(target); if (initial.settled) { - return { threadId: input.threadId, run: initial.run, timedOut: false }; + return { + threadId: input.threadId, + run: initial.run, + timedOut: false, + hasPendingQuestion: initial.hasPendingQuestion, + pendingApproval: initial.pendingApproval, + }; } // A declined continuation records no event, so its effect settling wakes @@ -911,11 +968,18 @@ const make = Effect.gen(function* () { afterSequence, eventType: "run.updated", }), - orchestrator.streamStoredEventsFrom({ - threadId: input.threadId, - afterSequence, - eventType: "thread.deleted", - }), + Stream.merge( + orchestrator.streamStoredEventsFrom({ + threadId: input.threadId, + afterSequence, + eventType: "thread.deleted", + }), + orchestrator.streamStoredEventsFrom({ + threadId: input.threadId, + afterSequence, + eventType: "runtime-request.updated", + }), + ), ).pipe( Stream.mapError(loadError), Stream.filter( @@ -930,8 +994,8 @@ const make = Effect.gen(function* () { : Stream.empty, ).pipe( Stream.mapEffect(() => - getProjectThreadRecords(input, ["runs"]).pipe( - Effect.flatMap((current) => outcome(current.runs)), + getProjectThreadRecords(input, ["runs", "nodes", "runtimeRequests"]).pipe( + Effect.flatMap(outcome), ), ), Stream.filter((current) => current.settled), @@ -940,19 +1004,37 @@ const make = Effect.gen(function* () { ); const waited = Option.flatten(yield* wait); if (Option.isSome(waited)) { - return { threadId: input.threadId, run: waited.value.run, timedOut: false }; + return { + threadId: input.threadId, + run: waited.value.run, + timedOut: false, + hasPendingQuestion: waited.value.hasPendingQuestion, + pendingApproval: waited.value.pendingApproval, + }; } // The run may have reached a terminal status while the timeout was // winning the race; the final projection read decides what actually // happened, so only a still-active run counts as timed out. - const final = yield* outcome((yield* getProjectThreadRecords(input, ["runs"])).runs); - return { threadId: input.threadId, run: final.run, timedOut: !final.settled }; + const final = yield* outcome( + yield* getProjectThreadRecords(input, ["runs", "nodes", "runtimeRequests"]), + ); + return { + threadId: input.threadId, + run: final.run, + timedOut: !final.settled, + hasPendingQuestion: final.hasPendingQuestion, + pendingApproval: final.pendingApproval, + }; }); const settleAfterRun: ThreadManagementServiceShape["settleAfterRun"] = Effect.fn( "orchestrationV2.threadManagement.settleAfterRun", )(function* (input) { - const { run } = yield* waitForThread({ ...input, timeoutMs: SETTLE_AFTER_RUN_WAIT_MS }); + const { run } = yield* waitForThread({ + ...input, + timeoutMs: SETTLE_AFTER_RUN_WAIT_MS, + terminalOnly: true, + }); if (run?.status !== "completed") return; yield* dispatch({ type: "thread.settle", diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index 4a53d19b00871..926ba9b1ec6a9 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -436,8 +436,12 @@ idempotent retries. Waits for a selected run to become `completed`, `failed`, `cancelled`, `interrupted`, or `rolled_back`. Without `runId`, it pins the latest run at call -time; an idle thread returns immediately. A timeout reports the latest durable -status and does not cancel work. +time; an idle thread returns immediately. A pending live question or approval on +the selected run, or an earlier unfinished run blocking it, also returns with +`timedOut: false` and `hasPendingQuestion` or `pendingApproval`. The run's status +does not change. Message-based asynchronous questions do not end the wait. +Read the fresh request before responding; the result grants no approval authority. +A timeout reports the latest durable status and does not cancel work. ### `t3_thread_interrupt` diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index 55fe175237f4f..0c0a48f9bb262 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -495,6 +495,10 @@ export const OrchestratorMcpThreadWaitResult = Schema.Struct({ runId: Schema.NullOr(RunId), status: OrchestratorMcpThreadStatus, timedOut: Schema.Boolean, + hasPendingQuestion: Schema.Boolean, + pendingApproval: Schema.NullOr( + Schema.Struct({ requestId: RuntimeRequestId, kind: ProviderRequestKind, runId: RunId }), + ), }); export type OrchestratorMcpThreadWaitResult = typeof OrchestratorMcpThreadWaitResult.Type; From 6822f4efb4d34992194501bc5df784f3c6c51cc4 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 26 Oct 2026 02:50:34 +0000 Subject: [PATCH 02/17] fix(server): keep request-aware waits focused and preserve cli slices --- apps/server/src/cli/thread.test.ts | 6 +- apps/server/src/cli/thread.ts | 2 +- .../src/mcp/OrchestratorMcpService.test.ts | 2 + .../ThreadManagementService.test.ts | 146 +++++++++--------- .../ThreadManagementService.ts | 38 ++--- 5 files changed, 98 insertions(+), 96 deletions(-) diff --git a/apps/server/src/cli/thread.test.ts b/apps/server/src/cli/thread.test.ts index 645c0d12b3a57..762be100db7af 100644 --- a/apps/server/src/cli/thread.test.ts +++ b/apps/server/src/cli/thread.test.ts @@ -40,7 +40,6 @@ it.effect("--wait follows the sent run to its continuation's terminal status", ( pendingApproval: null, }, ], - t3_thread_read: [{ thread: { pendingRequestCount: 0 } }], }); const outcome = yield* waitForRun(tools, threadId, runId); @@ -49,8 +48,8 @@ it.effect("--wait follows the sent run to its continuation's terminal status", ( assert.deepStrictEqual( calls.filter((entry) => entry.tool === "t3_thread_wait").map((entry) => entry.args), [ - { threadId, runId, timeoutMs: 600_000 }, - { threadId, runId, timeoutMs: 600_000 }, + { threadId, runId, timeoutMs: 15_000 }, + { threadId, runId, timeoutMs: 15_000 }, ], ); }), @@ -100,7 +99,6 @@ it.effect("--wait reports its run's result when a later run is the one waiting", pendingApproval: null, }, ], - t3_thread_read: [{ thread: { pendingRequestCount: 1 } }], }); assert.deepStrictEqual(yield* waitForRun(tools, threadId, runId), { diff --git a/apps/server/src/cli/thread.ts b/apps/server/src/cli/thread.ts index eaba15656636e..a573dce9bf01a 100644 --- a/apps/server/src/cli/thread.ts +++ b/apps/server/src/cli/thread.ts @@ -32,7 +32,7 @@ import { mcpToolSession, type McpToolSession } from "../mcp/AcpMcpStdioBridge.ts import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; -const WAIT_SLICE_MS = 600_000; +const WAIT_SLICE_MS = 15_000; const READ_PAGE_LIMIT = 100; const TITLE_MAX_CHARS = 80; diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index 6ce02cbb32e2a..8f0165609ba40 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -349,6 +349,8 @@ describe("OrchestratorMcpService", () => { threadId: childThreadId, run: childRun() as unknown as OrchestrationV2ThreadProjection["runs"][number], timedOut: !finished, + hasPendingQuestion: false, + pendingApproval: null, }), dispatch: () => Effect.succeed({} as never), }), diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts index f3f04f66cb10a..5e53c89cb7695 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts @@ -289,78 +289,80 @@ it.effect.each([ }), ); -it.effect("waitForThread wakes on a new approval while terminal-only callers keep waiting", () => - Effect.gen(function* () { - const projectId = ProjectId.make("project:wait-event"); - const threadId = ThreadId.make("thread:wait-event"); - const runId = RunId.make("run:wait-event"); - const subscribed = yield* Deferred.make(); - const events = yield* Queue.unbounded(); - const now = yield* DateTime.now; - let requests: OrchestrationV2ThreadProjection["runtimeRequests"] = []; - const layerTest = ThreadManagementService.layer.pipe( - Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})), - Layer.provide( - Layer.mock(Orchestrator.OrchestratorV2)({ - getThreadEventSequence: () => Effect.succeed(7), - getThreadRecords: () => - Effect.sync( - () => - ({ - thread: { id: threadId, projectId, deletedAt: null }, - runs: [{ id: runId, ordinal: 1, status: "running" }], - nodes: [{ id: "node:request", runId }], - runtimeRequests: requests, - }) as unknown as OrchestrationV2ThreadProjection, - ), - streamStoredEventsFrom: (input) => - input?.eventType === "runtime-request.updated" - ? Stream.fromEffect(Deferred.succeed(subscribed, undefined)).pipe( - Stream.drain, - Stream.concat(Stream.fromQueue(events)), - ) - : Stream.never, - }), - ), - Layer.provide(ServerSettings.layerTest()), - Layer.provide(layerReceiptsTest), - ); - const service = yield* ThreadManagementService.ThreadManagementService.pipe( - Effect.provide(layerTest), - ); - const fiber = yield* service - .waitForThread({ projectId, threadId, runId, timeoutMs: 600_000 }) - .pipe(Effect.forkChild); - yield* Deferred.await(subscribed); - requests = [ - { - id: "request:approval", - nodeId: "node:request", - kind: "command", - status: "pending", - responseCapability: { type: "live" }, - createdAt: now, - }, - ] as unknown as OrchestrationV2ThreadProjection["runtimeRequests"]; - yield* Queue.offer(events, { - sequence: 8, - event: { type: "runtime-request.updated", threadId, payload: requests[0] }, - } as unknown as OrchestrationV2StoredEvent); - expect(yield* Fiber.join(fiber)).toMatchObject({ - timedOut: false, - hasPendingQuestion: false, - pendingApproval: { requestId: "request:approval", kind: "command", runId }, - }); - const terminalWait = yield* service - .waitForThread({ projectId, threadId, runId, timeoutMs: 60_000, terminalOnly: true }) - .pipe(Effect.forkChild); - yield* TestClock.adjust("1 minute"); - expect(yield* Fiber.join(terminalWait)).toMatchObject({ - timedOut: true, - hasPendingQuestion: false, - pendingApproval: null, - }); - }), +it.effect.each(["command", "user_input"] as const)( + "waitForThread wakes on a new %s while terminal-only callers keep waiting", + (kind) => + Effect.gen(function* () { + const projectId = ProjectId.make("project:wait-event"); + const threadId = ThreadId.make("thread:wait-event"); + const runId = RunId.make("run:wait-event"); + const subscribed = yield* Deferred.make(); + const events = yield* Queue.unbounded(); + const now = yield* DateTime.now; + let requests: OrchestrationV2ThreadProjection["runtimeRequests"] = []; + const layerTest = ThreadManagementService.layer.pipe( + Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})), + Layer.provide( + Layer.mock(Orchestrator.OrchestratorV2)({ + getThreadEventSequence: () => Effect.succeed(7), + getThreadRecords: () => + Effect.sync( + () => + ({ + thread: { id: threadId, projectId, deletedAt: null }, + runs: [{ id: runId, ordinal: 1, status: "running" }], + nodes: [{ id: "node:request", runId }], + runtimeRequests: requests, + }) as unknown as OrchestrationV2ThreadProjection, + ), + streamStoredEventsFrom: (input) => + input?.eventType === "runtime-request.updated" + ? Stream.fromEffect(Deferred.succeed(subscribed, undefined)).pipe( + Stream.drain, + Stream.concat(Stream.fromQueue(events)), + ) + : Stream.never, + }), + ), + Layer.provide(ServerSettings.layerTest()), + Layer.provide(layerReceiptsTest), + ); + const service = yield* ThreadManagementService.ThreadManagementService.pipe( + Effect.provide(layerTest), + ); + const fiber = yield* service + .waitForThread({ projectId, threadId, runId, timeoutMs: 600_000 }) + .pipe(Effect.forkChild); + yield* Deferred.await(subscribed); + requests = [ + { + id: "request:approval", + nodeId: "node:request", + kind, + status: "pending", + responseCapability: { type: "live" }, + createdAt: now, + }, + ] as unknown as OrchestrationV2ThreadProjection["runtimeRequests"]; + yield* Queue.offer(events, { + sequence: 8, + event: { type: "runtime-request.updated", threadId, payload: requests[0] }, + } as unknown as OrchestrationV2StoredEvent); + expect(yield* Fiber.join(fiber)).toMatchObject({ + timedOut: false, + hasPendingQuestion: kind === "user_input", + pendingApproval: kind === "command" ? { requestId: "request:approval", kind, runId } : null, + }); + const terminalWait = yield* service + .waitForThread({ projectId, threadId, runId, timeoutMs: 60_000, terminalOnly: true }) + .pipe(Effect.forkChild); + yield* TestClock.adjust("1 minute"); + expect(yield* Fiber.join(terminalWait)).toMatchObject({ + timedOut: true, + hasPendingQuestion: false, + pendingApproval: null, + }); + }), ); it.effect("waitForThread follows a restart continuation instead of the cut run", () => diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index 0621180ad359a..cd0b9ddb88069 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -873,7 +873,10 @@ const make = Effect.gen(function* () { const afterSequence = yield* orchestrator .getThreadEventSequence(input.threadId) .pipe(Effect.mapError(loadError)); - const target = yield* getProjectThreadRecords(input, ["runs", "nodes", "runtimeRequests"]); + const fields = input.terminalOnly + ? (["runs"] as const) + : (["runs", "nodes", "runtimeRequests"] as const); + const target = yield* getProjectThreadRecords(input, fields); const selectedRun = input.runId === undefined ? latestRun(target) @@ -962,24 +965,27 @@ const make = Effect.gen(function* () { // A declined continuation records no event, so its effect settling wakes // the wait too. const wait = Stream.merge( - Stream.merge( - orchestrator.streamStoredEventsFrom({ - threadId: input.threadId, - afterSequence, - eventType: "run.updated", - }), - Stream.merge( + Stream.mergeAll( + [ orchestrator.streamStoredEventsFrom({ threadId: input.threadId, afterSequence, - eventType: "thread.deleted", + eventType: "run.updated", }), orchestrator.streamStoredEventsFrom({ threadId: input.threadId, afterSequence, - eventType: "runtime-request.updated", + eventType: "thread.deleted", }), - ), + input.terminalOnly + ? Stream.empty + : orchestrator.streamStoredEventsFrom({ + threadId: input.threadId, + afterSequence, + eventType: "runtime-request.updated", + }), + ], + { concurrency: "unbounded" }, ).pipe( Stream.mapError(loadError), Stream.filter( @@ -993,11 +999,7 @@ const make = Effect.gen(function* () { ? Stream.fromEffect(orchestrator.awaitRestartContinuation(initial.run)) : Stream.empty, ).pipe( - Stream.mapEffect(() => - getProjectThreadRecords(input, ["runs", "nodes", "runtimeRequests"]).pipe( - Effect.flatMap(outcome), - ), - ), + Stream.mapEffect(() => getProjectThreadRecords(input, fields).pipe(Effect.flatMap(outcome))), Stream.filter((current) => current.settled), Stream.runHead, Effect.timeoutOption(Duration.millis(Math.max(1, input.timeoutMs))), @@ -1015,9 +1017,7 @@ const make = Effect.gen(function* () { // The run may have reached a terminal status while the timeout was // winning the race; the final projection read decides what actually // happened, so only a still-active run counts as timed out. - const final = yield* outcome( - yield* getProjectThreadRecords(input, ["runs", "nodes", "runtimeRequests"]), - ); + const final = yield* outcome(yield* getProjectThreadRecords(input, fields)); return { threadId: input.threadId, run: final.run, From 906caf33f5d5e5fd16ad71bb16fa3305a9adf1c7 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 26 Oct 2026 02:53:33 +0000 Subject: [PATCH 03/17] fix(server): recognize active blockers after queue reordering --- apps/server/src/mcp/toolkits/orchestrator/tools.ts | 2 +- .../src/orchestration-v2/ThreadManagementService.test.ts | 7 +++++++ .../server/src/orchestration-v2/ThreadManagementService.ts | 4 +++- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 88ebbe3a164b9..54e3edb1e387d 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -269,7 +269,7 @@ const ThreadSendTool = Tool.make("t3_thread_send", { const ThreadWaitTool = Tool.make("t3_thread_wait", { description: - "Wait for a thread's run to finish or need a blocking live answer or approval. No `runId`: latest. Idle: at once. `hasPendingQuestion` or `pendingApproval` identifies input on this run or earlier unfinished work blocking it; status is unchanged. Async message questions do not end the wait. Read the fresh request with t3_pending_request_list/read; this grants no approval authority. On timeout, with `next` end your turn; without, call again.", + "Wait for a thread's run to finish or need a blocking live answer or approval. No `runId`: latest. Idle: at once. `hasPendingQuestion` or `pendingApproval` identifies input on this run or earlier unfinished work blocking it; status is unchanged. Async message questions do not end the wait. Read questions with t3_pending_request_list/read. Approvals need the human client; this grants no approval authority. On timeout, with `next` end your turn; without, call again.", parameters: OrchestratorMcpThreadWaitInput, success: OrchestratorMcpThreadWaitResult, failure: OrchestratorMcpFailure, diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts index 5e53c89cb7695..23d8c0218a5ed 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts @@ -209,6 +209,13 @@ it.effect.each([ selectedStatus: "queued", blocks: true, }, + { + kind: "user_input", + capability: "live", + requestOrdinal: 2, + selectedStatus: "queued", + blocks: true, + }, { kind: "user_input", capability: "message", diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index cd0b9ddb88069..a4a5c02677666 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -921,7 +921,9 @@ const make = Effect.gen(function* () { runs .filter( (candidate) => - !isTerminalRunStatus(candidate.status) && candidate.ordinal <= run.ordinal, + !isTerminalRunStatus(candidate.status) && + (candidate.ordinal <= run.ordinal || + (run.status === "queued" && candidate.status !== "queued")), ) .map((candidate) => candidate.id), ); From 5dafde21ead656c66944f0e5498a11e7fb336bfb Mon Sep 17 00:00:00 2001 From: Nathan Shan Date: Tue, 27 Oct 2026 11:41:55 +0800 Subject: [PATCH 04/17] =?UTF-8?q?fix(mcp):=20=E7=AD=89=E5=BE=85=E7=BA=BF?= =?UTF-8?q?=E7=A8=8B=E6=97=B6=E8=AF=86=E5=88=AB=E5=BE=85=E5=A1=AB=E5=86=99?= =?UTF-8?q?=E7=9A=84=E7=A7=98=E5=AF=86=E8=BE=93=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将秘密输入卡片及其更新事件纳入等待结果,保持仅等待终态的内部调用语义。 - 任务状态只报告所属活跃运行的待填输入,不返回卡片内容或秘密值。 - 忽略普通流式消息更新,限定读取秘密输入记录。 - 复现修复前四项失败;修复后 69 项测试及受影响范围检查通过。 --- .../OrchestratorMcpService.activity.test.ts | 1 + .../src/mcp/OrchestratorMcpService.test.ts | 48 ++++++- apps/server/src/mcp/OrchestratorMcpService.ts | 42 ++++-- .../ThreadManagementService.test.ts | 122 ++++++++++++++---- .../ThreadManagementService.ts | 39 +++++- 5 files changed, 210 insertions(+), 42 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts b/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts index d7f9746f76ea5..bc4e95c4f32df 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts @@ -245,6 +245,7 @@ it("taskStatus returns task.providerInstanceId rather than the driver kind", asy ], visibleTurnItems: [], runtimeRequests: [], + turnItems: [], messages: [], contextTransfers: [ { diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index 8f0165609ba40..32c22aae14866 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -112,6 +112,7 @@ describe("OrchestratorMcpService", () => { subagents: [], providerThreads: [], runtimeRequests: [], + turnItems: [], } as unknown as OrchestrationV2ThreadProjection; let hasNestedWork = true; const layerDependencies = Layer.mergeAll( @@ -300,6 +301,9 @@ describe("OrchestratorMcpService", () => { const taskId = NodeId.make("node:mcp-next-task"); let finished = false; let pendingRequest = false; + let pendingSecret = false; + let secretCompleted = false; + let secretOwner: "task" | "finished" | "unrelated" = "task"; const parentProjection = () => ({ thread: { id: parentThreadId }, @@ -328,14 +332,28 @@ describe("OrchestratorMcpService", () => { const childProjection = () => ({ thread: { id: childThreadId, title: "Child task" }, - runs: [childRun()], + runs: [ + childRun(), + { id: "run:finished", ordinal: 0, status: "completed" }, + { id: "run:unrelated", ordinal: 2, status: "running" }, + ], contextTransfers: [], messages: [], subagents: [], providerThreads: [], runtimeRequests: pendingRequest ? [{ status: "pending" }] : [], nodes: [], - turnItems: [], + turnItems: pendingSecret + ? [ + { + type: "secret_request", + runId: secretOwner === "task" ? childRun().id : `run:${secretOwner}`, + nodeId: "node:secret", + status: secretCompleted ? "completed" : "waiting", + label: "Private credential request", + }, + ] + : [], }) as unknown as OrchestrationV2ThreadProjection; const layerDependencies = Layer.mergeAll( NodeServices.layer, @@ -392,9 +410,29 @@ describe("OrchestratorMcpService", () => { assert.notProperty(asking, "next"); pendingRequest = false; + pendingSecret = true; + const secret = yield* service.taskStatus(scope, taskId); + assert.isTrue(secret.hasPendingQuestion); + assert.notProperty(secret, "next"); + assert.notInclude(JSON.stringify(secret), "Private credential request"); + secretCompleted = true; + assert.isFalse((yield* service.taskStatus(scope, taskId)).hasPendingQuestion); + secretCompleted = false; + for (const owner of ["finished", "unrelated"] as const) { + secretOwner = owner; + const unrelated = yield* service.taskStatus(scope, taskId); + assert.isFalse(unrelated.hasPendingQuestion); + assert.equal(unrelated.next, ORCHESTRATOR_MCP_RUNNING_TASK_NEXT); + } + secretOwner = "task"; + pendingSecret = false; + assert.isFalse((yield* service.taskStatus(scope, taskId)).hasPendingQuestion); + finished = true; + pendingSecret = true; const completed = yield* service.taskStatus(scope, taskId); assert.equal(completed.status, "completed"); + assert.isFalse(completed.hasPendingQuestion); assert.notProperty(completed, "next"); const waited = yield* service.waitForThread(scope, { threadId: childThreadId }); assert.equal(waited.status, "completed"); @@ -434,6 +472,7 @@ describe("OrchestratorMcpService", () => { subagents: [], providerThreads: [], runtimeRequests: [], + turnItems: [], } as unknown as OrchestrationV2ThreadProjection; const layerDependencies = Layer.mergeAll( NodeServices.layer, @@ -621,6 +660,7 @@ describe("OrchestratorMcpService", () => { subagents: [], providerThreads: [], runtimeRequests: [], + turnItems: [], } as unknown as OrchestrationV2ThreadProjection; const layerDependencies = Layer.mergeAll( NodeServices.layer, @@ -701,6 +741,7 @@ describe("OrchestratorMcpService", () => { subagents: [], providerThreads: [], runtimeRequests: [], + turnItems: [], } as unknown as OrchestrationV2ThreadProjection; const layerDependencies = Layer.mergeAll( NodeServices.layer, @@ -787,6 +828,7 @@ describe("OrchestratorMcpService", () => { subagents: [], providerThreads: [], runtimeRequests: [], + turnItems: [], } as unknown as OrchestrationV2ThreadProjection; const layerDependencies = Layer.mergeAll( NodeServices.layer, @@ -884,6 +926,7 @@ describe("OrchestratorMcpService", () => { ], providerThreads: [], runtimeRequests: [], + turnItems: [], }, ], [ @@ -896,6 +939,7 @@ describe("OrchestratorMcpService", () => { subagents: [], providerThreads: [], runtimeRequests: [], + turnItems: [], }, ], ]) as unknown as ReadonlyMap; diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index feca376f71dfb..3d50919e45bfe 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -502,12 +502,12 @@ function endsTaskWait(result: OrchestratorMcpDelegateTaskResult): boolean { const isApprovalRequestKind = Schema.is(ProviderRequestKind); -function pendingTaskApproval( - child: Pick, +function activeTaskRunIds( + child: Pick, taskId: NodeId, originalRun: OrchestrationV2Run | undefined, ) { - const taskRunIds = new Set( + return new Set( child.runs .filter( (run) => @@ -518,6 +518,12 @@ function pendingTaskApproval( ) .map((run) => run.id), ); +} + +function pendingTaskApproval( + child: Pick, + taskRunIds: ReadonlySet, +) { let approval: OrchestratorMcpDelegateTaskResult["pendingApproval"] = null; let oldestCreatedAt = Infinity; for (const request of child.runtimeRequests) { @@ -1504,8 +1510,13 @@ const make = Effect.gen(function* () { "providerThreads", "runtimeRequests", "nodes", + "turnItems", ], - { messageRoles: ["user"], omitSettledLeafNodes: true }, + { + messageRoles: ["user"], + omitSettledLeafNodes: true, + turnItemTypes: ["secret_request"], + }, ) .pipe(Effect.mapError(threadManagementFailure)); const childRun = delegatedTaskRun(childControls, task); @@ -1598,6 +1609,7 @@ const make = Effect.gen(function* () { instanceId: task.providerInstanceId, model: task.model, }; + const taskRunIds = activeTaskRunIds(childControls, task.id, childRun); const result = { taskId: task.id, childThreadId: task.childThreadId, @@ -1622,16 +1634,24 @@ const make = Effect.gen(function* () { : latestTerminalResultContextTransferId, hasPendingQuestion: !isTerminalTaskStatus(status) && - childControls.runtimeRequests.some( - (request) => - request.kind === "user_input" && - request.status === "pending" && - request.responseCapability.type === "live", - ), + (childControls.turnItems.some( + (item) => + item.type === "secret_request" && + item.status === "waiting" && + item.nodeId !== null && + item.runId !== null && + taskRunIds.has(item.runId), + ) || + childControls.runtimeRequests.some( + (request) => + request.kind === "user_input" && + request.status === "pending" && + request.responseCapability.type === "live", + )), pendingApproval: isTerminalTaskStatus(status) || task.completionDelivery?.state === "disposed" ? null - : pendingTaskApproval(childControls, task.id, childRun), + : pendingTaskApproval(childControls, taskRunIds), waitTimedOut, } satisfies OrchestratorMcpDelegateTaskResult; const response: OrchestratorMcpDelegateTaskResult = diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts index 23d8c0218a5ed..9ac67e81b9dbd 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts @@ -252,6 +252,7 @@ it.effect.each([ : [{ id: requestRunId, ordinal: scenario.requestOrdinal, status: "running" }]), ], nodes: [{ id: "node:request", runId: requestRunId }], + turnItems: [], runtimeRequests: [ { id: "request:input", @@ -296,7 +297,64 @@ it.effect.each([ }), ); -it.effect.each(["command", "user_input"] as const)( +it.effect.each([ + { selected: "running", owner: "running", ordinal: 1, status: "waiting", blocks: true }, + { selected: "queued", owner: "running", ordinal: 0, status: "waiting", blocks: true }, + { selected: "queued", owner: "running", ordinal: 2, status: "waiting", blocks: true }, + { selected: "running", owner: "running", ordinal: 2, status: "waiting", blocks: false }, + { selected: "running", owner: "completed", ordinal: 0, status: "waiting", blocks: false }, + { selected: "running", owner: "running", ordinal: 1, status: "completed", blocks: false }, + { selected: "completed", owner: "completed", ordinal: 1, status: "waiting", blocks: false }, +] as const)("waitForThread scopes secret input to unfinished work: %j", (scenario) => + Effect.gen(function* () { + const projectId = ProjectId.make("project:wait-secret"); + const threadId = ThreadId.make("thread:wait-secret"); + const runId = RunId.make("run:wait-secret"); + const ownerId = scenario.ordinal === 1 ? runId : RunId.make("run:secret-owner"); + const projection = { + thread: { id: threadId, projectId, deletedAt: null }, + runs: [ + { id: runId, ordinal: 1, status: scenario.selected }, + ...(ownerId === runId + ? [] + : [{ id: ownerId, ordinal: scenario.ordinal, status: scenario.owner }]), + ], + nodes: [], + runtimeRequests: [], + turnItems: [ + { type: "secret_request", runId: ownerId, nodeId: "node:secret", status: scenario.status }, + ], + } as unknown as OrchestrationV2ThreadProjection; + const layerTest = ThreadManagementService.layer.pipe( + Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})), + Layer.provide( + Layer.mock(Orchestrator.OrchestratorV2)({ + getThreadEventSequence: () => Effect.succeed(0), + getThreadRecords: () => Effect.succeed(projection), + restartContinuationPending: () => Effect.succeed(false), + streamStoredEventsFrom: () => Stream.never, + }), + ), + Layer.provide(ServerSettings.layerTest()), + Layer.provide(layerReceiptsTest), + ); + const service = yield* ThreadManagementService.ThreadManagementService.pipe( + Effect.provide(layerTest), + ); + const fiber = yield* service + .waitForThread({ projectId, threadId, runId, timeoutMs: 60_000 }) + .pipe(Effect.forkChild); + yield* TestClock.adjust("1 minute"); + expect(yield* Fiber.join(fiber)).toMatchObject({ + run: { id: runId, status: scenario.selected }, + timedOut: !scenario.blocks && scenario.selected !== "completed", + hasPendingQuestion: scenario.blocks, + pendingApproval: null, + }); + }), +); + +it.effect.each(["command", "user_input", "secret_request"] as const)( "waitForThread wakes on a new %s while terminal-only callers keep waiting", (kind) => Effect.gen(function* () { @@ -307,23 +365,27 @@ it.effect.each(["command", "user_input"] as const)( const events = yield* Queue.unbounded(); const now = yield* DateTime.now; let requests: OrchestrationV2ThreadProjection["runtimeRequests"] = []; + let turnItems: OrchestrationV2ThreadProjection["turnItems"] = []; + let reads = 0; + const eventType = kind === "secret_request" ? "turn-item.updated" : "runtime-request.updated"; const layerTest = ThreadManagementService.layer.pipe( Layer.provide(Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({})), Layer.provide( Layer.mock(Orchestrator.OrchestratorV2)({ getThreadEventSequence: () => Effect.succeed(7), getThreadRecords: () => - Effect.sync( - () => - ({ - thread: { id: threadId, projectId, deletedAt: null }, - runs: [{ id: runId, ordinal: 1, status: "running" }], - nodes: [{ id: "node:request", runId }], - runtimeRequests: requests, - }) as unknown as OrchestrationV2ThreadProjection, - ), + Effect.sync(() => { + reads += 1; + return { + thread: { id: threadId, projectId, deletedAt: null }, + runs: [{ id: runId, ordinal: 1, status: "running" }], + nodes: [{ id: "node:request", runId }], + runtimeRequests: requests, + turnItems, + } as unknown as OrchestrationV2ThreadProjection; + }), streamStoredEventsFrom: (input) => - input?.eventType === "runtime-request.updated" + input?.eventType === eventType ? Stream.fromEffect(Deferred.succeed(subscribed, undefined)).pipe( Stream.drain, Stream.concat(Stream.fromQueue(events)), @@ -341,25 +403,36 @@ it.effect.each(["command", "user_input"] as const)( .waitForThread({ projectId, threadId, runId, timeoutMs: 600_000 }) .pipe(Effect.forkChild); yield* Deferred.await(subscribed); - requests = [ - { - id: "request:approval", - nodeId: "node:request", - kind, - status: "pending", - responseCapability: { type: "live" }, - createdAt: now, - }, - ] as unknown as OrchestrationV2ThreadProjection["runtimeRequests"]; + if (kind === "secret_request") { + turnItems = [ + { type: kind, runId, nodeId: "node:secret", status: "waiting" }, + ] as unknown as OrchestrationV2ThreadProjection["turnItems"]; + yield* Queue.offer(events, { + sequence: 8, + event: { type: eventType, threadId, payload: { type: "assistant_message" } }, + } as unknown as OrchestrationV2StoredEvent); + } else { + requests = [ + { + id: "request:approval", + nodeId: "node:request", + kind, + status: "pending", + responseCapability: { type: "live" }, + createdAt: now, + }, + ] as unknown as OrchestrationV2ThreadProjection["runtimeRequests"]; + } yield* Queue.offer(events, { - sequence: 8, - event: { type: "runtime-request.updated", threadId, payload: requests[0] }, + sequence: 9, + event: { type: eventType, threadId, payload: turnItems[0] ?? requests[0] }, } as unknown as OrchestrationV2StoredEvent); expect(yield* Fiber.join(fiber)).toMatchObject({ timedOut: false, - hasPendingQuestion: kind === "user_input", + hasPendingQuestion: kind !== "command", pendingApproval: kind === "command" ? { requestId: "request:approval", kind, runId } : null, }); + expect(reads).toBe(2); const terminalWait = yield* service .waitForThread({ projectId, threadId, runId, timeoutMs: 60_000, terminalOnly: true }) .pipe(Effect.forkChild); @@ -410,6 +483,7 @@ it.effect("waitForThread follows a restart continuation instead of the cut run", runs, nodes: [], runtimeRequests: [], + turnItems: [], }) as unknown as OrchestrationV2ThreadProjection, ), }), diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index 55f13f93e39d3..bedb9bf8d074f 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -873,8 +873,9 @@ const make = Effect.gen(function* () { .pipe(Effect.mapError(loadError)); const fields = input.terminalOnly ? (["runs"] as const) - : (["runs", "nodes", "runtimeRequests"] as const); - const target = yield* getProjectThreadRecords(input, fields); + : (["runs", "nodes", "runtimeRequests", "turnItems"] as const); + const filter = { turnItemTypes: ["secret_request"] } as const; + const target = yield* getProjectThreadRecords(input, fields, filter); const selectedRun = input.runId === undefined ? latestRun(target) @@ -898,7 +899,10 @@ const make = Effect.gen(function* () { // so a cut run is not the answer while its continuation is pending. // Runs per matching event and is already covered by the waitForThread span. const outcome = Effect.fnUntraced(function* ( - projection: Pick, + projection: Pick< + OrchestrationV2ThreadProjection, + "runs" | "nodes" | "runtimeRequests" | "turnItems" + >, ) { const { runs } = projection; const current = runs.find((candidate) => candidate.id === selectedRun.id); @@ -926,6 +930,14 @@ const make = Effect.gen(function* () { .map((candidate) => candidate.id), ); const nodeRuns = new Map(projection.nodes.map((node) => [node.id, node.runId])); + hasPendingQuestion = projection.turnItems.some( + (item) => + item.type === "secret_request" && + item.status === "waiting" && + item.nodeId !== null && + item.runId !== null && + blockingRuns.has(item.runId), + ); for (const request of projection.runtimeRequests) { if (request.status !== "pending" || request.responseCapability.type !== "live") continue; const requestRunId = nodeRuns.get(request.nodeId); @@ -984,6 +996,21 @@ const make = Effect.gen(function* () { afterSequence, eventType: "runtime-request.updated", }), + input.terminalOnly + ? Stream.empty + : orchestrator + .streamStoredEventsFrom({ + threadId: input.threadId, + afterSequence, + eventType: "turn-item.updated", + }) + .pipe( + Stream.filter( + (stored) => + stored.event.type === "turn-item.updated" && + stored.event.payload.type === "secret_request", + ), + ), ], { concurrency: "unbounded" }, ).pipe( @@ -999,7 +1026,9 @@ const make = Effect.gen(function* () { ? Stream.fromEffect(orchestrator.awaitRestartContinuation(initial.run)) : Stream.empty, ).pipe( - Stream.mapEffect(() => getProjectThreadRecords(input, fields).pipe(Effect.flatMap(outcome))), + Stream.mapEffect(() => + getProjectThreadRecords(input, fields, filter).pipe(Effect.flatMap(outcome)), + ), Stream.filter((current) => current.settled), Stream.runHead, Effect.timeoutOption(Duration.millis(Math.max(1, input.timeoutMs))), @@ -1017,7 +1046,7 @@ const make = Effect.gen(function* () { // The run may have reached a terminal status while the timeout was // winning the race; the final projection read decides what actually // happened, so only a still-active run counts as timed out. - const final = yield* outcome(yield* getProjectThreadRecords(input, fields)); + const final = yield* outcome(yield* getProjectThreadRecords(input, fields, filter)); return { threadId: input.threadId, run: final.run, From 1c603f46116a38d96b33762178de686a8ca63214 Mon Sep 17 00:00:00 2001 From: Nathan Shan Date: Wed, 28 Oct 2026 17:41:30 +0800 Subject: [PATCH 05/17] =?UTF-8?q?fix(mcp):=20=E8=A1=A5=E5=85=A8=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E7=AD=89=E5=BE=85=E5=94=A4=E9=86=92=E4=B8=8E=E6=97=A7?= =?UTF-8?q?=E8=BF=9C=E7=AB=AF=E5=85=BC=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 仅在子任务秘密输入更新时唤醒等待,过滤普通转录事件;为旧远端缺失的等待结果字段提供解码默认值。纠正并精简工具说明,将模型上下文预算降至主线以下。153 项范围测试及类型、格式和静态检查通过,额外验证实际上下文预算;真实模型评估因缺少 API 密钥无法运行。 --- .../src/mcp/OrchestratorMcpService.test.ts | 123 ++++++++++++++++++ apps/server/src/mcp/OrchestratorMcpService.ts | 37 ++++-- .../src/mcp/toolkits/orchestrator/tools.ts | 2 +- .../src/provider/agentContextBudget.json | 2 +- .../contracts/src/orchestratorMcp.test.ts | 18 +++ packages/contracts/src/orchestratorMcp.ts | 4 +- 6 files changed, 171 insertions(+), 15 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index dede0a4ae2bd1..d34ddb08313a3 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -1199,6 +1199,129 @@ describe("OrchestratorMcpService provider resolution", () => { turnItems: [], } as unknown as OrchestrationV2ThreadProjection; + it.effect("wakes a task wait for a newly posted secret card, not transcript updates", () => + Effect.gen(function* () { + let pendingSecret = false; + let childReads = 0; + const task = { + id: taskId, + threadId: parentThreadId, + origin: "app_owned", + childThreadId, + driver: "codex", + model: "gpt-5.4", + status: "running", + result: null, + completionDelivery: { state: "pending" }, + }; + const child = () => + ({ + ...childProjection, + runs: [ + { + id: RunId.make("run:secret-child"), + ordinal: 1, + status: "running", + delegatedTaskId: taskId, + }, + ], + nodes: [], + turnItems: pendingSecret + ? [ + { + type: "secret_request", + runId: RunId.make("run:secret-child"), + nodeId: "node:secret", + status: "waiting", + label: "Private credential request", + }, + ] + : [], + }) as unknown as OrchestrationV2ThreadProjection; + const layerDependencies = Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + hasReceipt: () => Effect.succeed(false), + getThreadRecords: (threadId) => + Effect.sync(() => { + if (threadId === parentThreadId) return parentProjection([task]); + childReads++; + return child(); + }), + getThreadShell: (threadId) => Effect.succeed(liveThreadShell(threadId)), + getThreadEventSequence: () => Effect.succeed(1), + isProviderInstanceEnabledForProject: () => Effect.succeed(true), + dispatch: () => + Effect.succeed({ + sequence: 1, + storedEvents: [ + { + sequence: 1, + commandId: null, + event: { type: "subagent.updated", payload: task }, + }, + ], + } as never), + streamStoredEventsFrom: (input) => { + if (input?.threadId !== childThreadId || input.eventType !== "turn-item.updated") + return Stream.empty; + let beforeTranscript = 0; + return Stream.fromEffect( + Effect.sync(() => { + beforeTranscript = childReads; + return { + event: { type: "turn-item.updated", payload: { type: "assistant_message" } }, + } as never; + }), + ).pipe( + Stream.concat( + Stream.fromEffect( + Effect.sync(() => { + assert.equal( + childReads, + beforeTranscript, + "transcript updates must not reload the projection", + ); + pendingSecret = true; + return { + event: { type: "turn-item.updated", payload: { type: "secret_request" } }, + } as never; + }), + ), + ), + ); + }, + }), + providerRegistryLayer([ + providerSnapshot({ + instanceId: codexInstanceId, + driver: ProviderDriverKind.make("codex"), + model: "gpt-5.4", + }), + ]), + adapterRegistryLayer([codexInstanceId]), + ServerSettings.layerTest(), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(RemoteDelegation.RemoteDelegation)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + ); + yield* Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + const waited = yield* service.delegateTask(scope, { + task: "Wait for credentials", + mode: "wait", + clientRequestId: "secret-wait", + }); + assert.isTrue(waited.hasPendingQuestion); + assert.isFalse(waited.waitTimedOut); + assert.equal(waited.status, "running"); + assert.notProperty(waited, "next"); + assert.notInclude(JSON.stringify(waited), "Private credential request"); + }).pipe(Effect.provide(layerOrchestratorService.pipe(Layer.provide(layerDependencies)))); + }), + ); + it.effect( "advertises orchestration capability from registered adapters rather than a driver allowlist", () => diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 3f4c00b25af23..224c836575a6d 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -1753,17 +1753,32 @@ const make = Effect.gen(function* () { childThreadId === null ? TASK_WAKE_EVENTS.filter(({ thread }) => thread === "parent") : TASK_WAKE_EVENTS; - return yield* Stream.mergeAll( - wakeEvents.map(({ thread, eventType }) => - threadManagement.streamStoredEventsFrom({ - threadId: - thread === "parent" || childThreadId === null ? scope.thread.threadId : childThreadId, - afterSequence, - eventType, - }), - ), - { concurrency: "unbounded" }, - ).pipe( + const streams = wakeEvents.map(({ thread, eventType }) => + threadManagement.streamStoredEventsFrom({ + threadId: + thread === "parent" || childThreadId === null ? scope.thread.threadId : childThreadId, + afterSequence, + eventType, + }), + ); + if (childThreadId !== null) { + streams.push( + threadManagement + .streamStoredEventsFrom({ + threadId: childThreadId, + afterSequence, + eventType: "turn-item.updated", + }) + .pipe( + Stream.filter( + (stored) => + stored.event.type === "turn-item.updated" && + stored.event.payload.type === "secret_request", + ), + ), + ); + } + return yield* Stream.mergeAll(streams, { concurrency: "unbounded" }).pipe( Stream.mapError(streamError), Stream.mapEffect(() => readTask(scope, taskId, false, true)), Stream.filter(endsTaskWait), diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 946d46826f263..15245d578417c 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -281,7 +281,7 @@ const ThreadSendTool = Tool.make("t3_thread_send", { const ThreadWaitTool = Tool.make("t3_thread_wait", { description: - "Wait for a thread's run to finish or need a blocking live answer or approval. No `runId`: latest. Idle: at once. `hasPendingQuestion` or `pendingApproval` identifies input on this run or earlier unfinished work blocking it; status is unchanged. Async message questions do not end the wait. Read questions with t3_pending_request_list/read. Approvals need the human client; this grants no approval authority. On timeout, with `next` end your turn; without, call again.", + "Wait for completion/input. No runId: latest. Read: t3_pending_request. Timeout: next means end turn; else retry.", parameters: OrchestratorMcpThreadWaitInput, success: OrchestratorMcpThreadWaitResult, failure: OrchestratorMcpFailure, diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index fdc39bc78bc0a..dca42487fc110 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5135, "codexAdditionalContextBytes": 5687, - "mcpToolListJsonBytes": 45861 + "mcpToolListJsonBytes": 45844 } diff --git a/packages/contracts/src/orchestratorMcp.test.ts b/packages/contracts/src/orchestratorMcp.test.ts index 951727e68e1d3..86c56fad15f4b 100644 --- a/packages/contracts/src/orchestratorMcp.test.ts +++ b/packages/contracts/src/orchestratorMcp.test.ts @@ -4,12 +4,30 @@ import * as Schema from "effect/Schema"; import { OrchestratorMcpDelegateTaskInput, OrchestratorMcpDelegateTaskResult, + OrchestratorMcpThreadWaitResult, } from "./orchestratorMcp.ts"; const decodeDelegateTaskInput = Schema.decodeUnknownSync(OrchestratorMcpDelegateTaskInput); const decodeDelegateTaskResult = Schema.decodeUnknownSync(OrchestratorMcpDelegateTaskResult); describe("orchestrator MCP contracts", () => { + it("decodes older peer waits while preserving explicit pending input", () => { + const decode = Schema.decodeUnknownSync(OrchestratorMcpThreadWaitResult); + const legacy = { + threadId: "thread-peer", + runId: "run-peer", + status: "running", + timedOut: true, + }; + expect(decode(legacy)).toEqual({ ...legacy, hasPendingQuestion: false, pendingApproval: null }); + const pending = { + ...legacy, + timedOut: false, + hasPendingQuestion: true, + pendingApproval: { requestId: "request-peer", kind: "command", runId: "run-peer" }, + }; + expect(decode(pending)).toEqual(pending); + }); it("decodes cross-provider delegated task requests and durable results", () => { const request = decodeDelegateTaskInput({ task: "Inspect the workspace and report the result.", diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index de167b3a14b63..46ccf040bba68 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -534,10 +534,10 @@ export const OrchestratorMcpThreadWaitResult = Schema.Struct({ runId: Schema.NullOr(RunId), status: OrchestratorMcpThreadStatus, timedOut: Schema.Boolean, - hasPendingQuestion: Schema.Boolean, + hasPendingQuestion: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), pendingApproval: Schema.NullOr( Schema.Struct({ requestId: RuntimeRequestId, kind: ProviderRequestKind, runId: RunId }), - ), + ).pipe(Schema.withDecodingDefault(Effect.succeed(null))), next: Schema.optional(OrchestratorMcpRunningTaskNext), /** With untilDelegatedWorkSettled, once settled: the run whose reply is the thread's result. */ resultRun: Schema.optional(Schema.Struct({ runId: RunId, status: OrchestrationV2RunStatus })), From fb1a99594e61980d3f5f15b2c4e6388e13a6b371 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 30 Oct 2026 00:51:36 +0000 Subject: [PATCH 06/17] fix(server): keep remote task waits open for pending input --- apps/server/src/peer/RemoteDelegation.test.ts | 98 +++++++++++++------ apps/server/src/peer/RemoteDelegation.ts | 5 + .../src/provider/agentContextBudget.json | 2 +- 3 files changed, 72 insertions(+), 33 deletions(-) diff --git a/apps/server/src/peer/RemoteDelegation.test.ts b/apps/server/src/peer/RemoteDelegation.test.ts index 814b4dafa5272..62d8d09b3460b 100644 --- a/apps/server/src/peer/RemoteDelegation.test.ts +++ b/apps/server/src/peer/RemoteDelegation.test.ts @@ -15,13 +15,16 @@ import { type Project, type RepositoryIdentity, RunId, + RuntimeRequestId, ThreadId, TurnItemId, } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; 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 Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -102,6 +105,7 @@ interface BoxThread { readonly waitedFor: Ref.Ref>; /** The thread's run ends with a task of its own still running, whose result wakes it again. */ readonly nested: Ref.Ref; + readonly nestedInput: Ref.Ref<"question" | "approval" | null>; /** Completed once a wait there blocks on that task. */ readonly nestedWaiting: Deferred.Deferred; /** That task settled and the run it woke answered, with `laterReply`. */ @@ -145,6 +149,7 @@ const makeBoxThread = Effect.gen(function* () { laterReply: yield* Ref.make(null), waitedFor: yield* Ref.make>([]), nested: yield* Ref.make(false), + nestedInput: yield* Ref.make<"question" | "approval" | null>(null), nestedWaiting: yield* Deferred.make(), nestedSettled: yield* Deferred.make(), gone: yield* Ref.make(false), @@ -280,6 +285,19 @@ const boxToolkitLayer = (thread: BoxThread) => { input.untilDelegatedWorkSettled === true && (yield* Ref.get(thread.nested)) ) { + const pendingInput = yield* Ref.getAndSet(thread.nestedInput, null); + if (pendingInput !== null) { + return { + threadId: input.threadId, + run: { id: runId, status: "completed" } as OrchestrationV2Run, + timedOut: false, + hasPendingQuestion: pendingInput === "question", + pendingApproval: + pendingInput === "approval" + ? { requestId: RuntimeRequestId.make("request:box"), kind: "file-change", runId } + : null, + }; + } yield* Deferred.succeed(thread.nestedWaiting, undefined); const settled = yield* Deferred.await(thread.nestedSettled).pipe( Effect.timeoutOption(input.timeoutMs), @@ -1105,40 +1123,56 @@ it.effect("a task's result is its own run's whole last reply, not a later run's" ).pipe(Effect.provide(NodeHttpServer.layerTest)), ); -it.effect("a task whose thread there waits on its own task ends with the answer after it", () => - Effect.scoped( - Effect.gen(function* () { - const thread = yield* makeBoxThread; - const b = yield* serveBox(thread); - const offers = yield* SubscriptionRef.make< - ReadonlyArray - >([]); - const database = yield* SqlitePersistence.layerMemory.pipe(Layer.build); - const a = yield* makeLaptop(database, offers); - yield* linkTo(a.links, b, "full-access"); - yield* seedParent(a); - const delegated = yield* delegateToBox(a); +it.effect.each([null, "question", "approval"] as const)( + "a task whose thread there waits on its own task ends with the answer after it (%s)", + (pendingInput) => + Effect.scoped( + Effect.gen(function* () { + const thread = yield* makeBoxThread; + const b = yield* serveBox(thread); + const offers = yield* SubscriptionRef.make< + ReadonlyArray + >([]); + const database = yield* SqlitePersistence.layerMemory.pipe(Layer.build); + const clock = yield* Clock.Clock; + const sleeps: Array = []; + const a = yield* makeLaptop(database, offers).pipe( + Effect.provideService(Clock.Clock, { + ...clock, + sleep: (duration) => + Duration.toMillis(duration) === 5_000 + ? Effect.sync(() => { + sleeps.push(5_000); + }) + : clock.sleep(duration), + }), + ); + yield* linkTo(a.links, b, "full-access"); + yield* seedParent(a); + const delegated = yield* delegateToBox(a); - // The task's run there ends after delegating a review of its own. - yield* Ref.set(thread.nested, true); - yield* Ref.set(thread.reply, "Started a review; I will answer when it reports."); - yield* Ref.set(thread.status, "completed"); - yield* Deferred.succeed(thread.finished, undefined); - const first = yield* Effect.raceFirst( - Deferred.await(thread.nestedWaiting).pipe(Effect.as("waiting there" as const)), - taskResult(a, delegated.taskId).pipe(Effect.as("completed here" as const)), - ); - expect(first).toBe("waiting there"); + // The task's run there ends after delegating a review of its own. + yield* Ref.set(thread.nested, true); + yield* Ref.set(thread.nestedInput, pendingInput); + yield* Ref.set(thread.reply, "Started a review; I will answer when it reports."); + yield* Ref.set(thread.status, "completed"); + yield* Deferred.succeed(thread.finished, undefined); + const first = yield* Effect.raceFirst( + Deferred.await(thread.nestedWaiting).pipe(Effect.as("waiting there" as const)), + taskResult(a, delegated.taskId).pipe(Effect.as("completed here" as const)), + ); + expect(first).toBe("waiting there"); + expect(sleeps).toEqual(pendingInput === null ? [] : [5_000]); - // The review reports and wakes the thread there, whose next run answers. - yield* Ref.set(thread.laterReply, "The review found two issues; both are fixed."); - yield* Deferred.succeed(thread.nestedSettled, undefined); - expect(yield* taskResult(a, delegated.taskId)).toMatchObject({ - status: "completed", - result: "The review found two issues; both are fixed.", - }); - }), - ).pipe(Effect.provide(NodeHttpServer.layerTest)), + // The review reports and wakes the thread there, whose next run answers. + yield* Ref.set(thread.laterReply, "The review found two issues; both are fixed."); + yield* Deferred.succeed(thread.nestedSettled, undefined); + expect(yield* taskResult(a, delegated.taskId)).toMatchObject({ + status: "completed", + result: "The review found two issues; both are fixed.", + }); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), ); it.effect("the same key from another provider session is another task", () => diff --git a/apps/server/src/peer/RemoteDelegation.ts b/apps/server/src/peer/RemoteDelegation.ts index 739edc0a6039b..1734ac3df665f 100644 --- a/apps/server/src/peer/RemoteDelegation.ts +++ b/apps/server/src/peer/RemoteDelegation.ts @@ -610,6 +610,11 @@ const make = Effect.gen(function* () { // The next look reads the result and stops it there. if ("endedHere" in waited) return false; if (waited.timedOut) return false; + if (waited.hasPendingQuestion || waited.pendingApproval !== null) { + // Input-aware waits return immediately; keep following without a hot loop. + yield* Effect.sleep(Duration.seconds(5)).pipe(Effect.raceFirst(endedHere)); + return false; + } // A task recorded without its run waits on the thread's latest; a // thread there with none has nothing left to wait for. if (waited.runId === null) { diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index 1711e148d4ca4..451b61f98040f 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5135, "codexAdditionalContextBytes": 6865, - "mcpToolListJsonBytes": 46007 + "mcpToolListJsonBytes": 45990 } From a33516dffd8a211e7ebb4ecf28597d506dee8699 Mon Sep 17 00:00:00 2001 From: Nathan Shan Date: Sat, 7 Nov 2026 21:59:22 +0800 Subject: [PATCH 07/17] =?UTF-8?q?test(mcp):=20=E6=9B=B4=E6=96=B0=E7=AD=89?= =?UTF-8?q?=E5=BE=85=E5=8D=8F=E8=AE=AE=E4=B8=8E=E8=B7=A8=E7=8E=AF=E5=A2=83?= =?UTF-8?q?=E5=9B=9E=E5=BD=92=E5=A4=B9=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 补全等待结果中的输入状态,并移除合并产生的重复字段。 - 在委派调用处注入测试时钟,让新建跟随任务继承确定性延迟。 - 更新工具列表实测字节预算;check:changed 通过 213 项测试及 36 个包的类型检查。 --- apps/server/src/mcp/OrchestratorMcpService.test.ts | 11 +++++++++-- apps/server/src/peer/RemoteDelegation.test.ts | 9 +++++---- apps/server/src/provider/agentContextBudget.json | 2 +- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index c29d699646049..86b833c0198d9 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -146,7 +146,6 @@ describe("OrchestratorMcpService", () => { subagents: [], providerThreads: [], runtimeRequests: [], - turnItems: [], } as unknown as OrchestrationV2ThreadProjection; let hasNestedWork = true; let settleAfterTaskRead = false; @@ -3677,6 +3676,8 @@ describe("OrchestratorMcpService thread lookup", () => { runId: null, status: "idle", timedOut: false, + hasPendingQuestion: false, + pendingApproval: null, }); }).pipe( Effect.provide( @@ -3684,7 +3685,13 @@ describe("OrchestratorMcpService thread lookup", () => { getThreadShell: () => Effect.die("Unexpected shell lookup for the caller"), getThreadRecords: () => Effect.succeed(idleThreadProjection(launched)), waitForThread: () => - Effect.succeed({ threadId: launched.id, run: null, timedOut: false }), + Effect.succeed({ + threadId: launched.id, + run: null, + timedOut: false, + hasPendingQuestion: false, + pendingApproval: null, + }), }), ), ); diff --git a/apps/server/src/peer/RemoteDelegation.test.ts b/apps/server/src/peer/RemoteDelegation.test.ts index 752f68123b053..bdda1d47eecc8 100644 --- a/apps/server/src/peer/RemoteDelegation.test.ts +++ b/apps/server/src/peer/RemoteDelegation.test.ts @@ -1318,7 +1318,11 @@ it.effect.each([null, "question", "approval"] as const)( const database = yield* SqlitePersistence.layerMemory.pipe(Layer.build); const clock = yield* Clock.Clock; const sleeps: Array = []; - const a = yield* makeLaptop(database, offers).pipe( + const a = yield* makeLaptop(database, offers); + yield* linkTo(a.links, b, "full-access"); + yield* seedParent(a); + // The follower is forked by delegation, so it inherits this call's clock. + const delegated = yield* delegateToBox(a).pipe( Effect.provideService(Clock.Clock, { ...clock, sleep: (duration) => @@ -1329,9 +1333,6 @@ it.effect.each([null, "question", "approval"] as const)( : clock.sleep(duration), }), ); - yield* linkTo(a.links, b, "full-access"); - yield* seedParent(a); - const delegated = yield* delegateToBox(a); // The task's run there ends after delegating a review of its own. yield* Ref.set(thread.nested, true); diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index 0d9f26d44c40a..e1f95ff5ec43a 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5366, "codexAdditionalContextBytes": 7096, - "mcpToolListJsonBytes": 48119 + "mcpToolListJsonBytes": 48102 } From 81b76460c7b8589577420fb780ad6f5609674c2c Mon Sep 17 00:00:00 2001 From: Nathan Shan Date: Sat, 7 Nov 2026 22:11:40 +0800 Subject: [PATCH 08/17] =?UTF-8?q?test(mcp):=20=E5=AF=B9=E9=BD=90=E9=9B=86?= =?UTF-8?q?=E6=88=90=E6=B5=8B=E8=AF=95=E7=9A=84=E7=BA=BF=E7=A8=8B=E7=AD=89?= =?UTF-8?q?=E5=BE=85=E7=BB=93=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 为已完成线程的预期结果补全问题与审批状态字段。 - 保留对子任务回复、无额外消息读取及结算等待的严格断言。 - 修复 CI Server 2 的三项失败,check:changed 通过 216 项测试及相关检查。 --- apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index 5ec8dfd49a17a..52b4bd2e50af4 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -636,6 +636,8 @@ describe("orchestrator MCP toolkit", () => { runId: queuedFollowup.runId, status: "completed", timedOut: false, + hasPendingQuestion: false, + pendingApproval: null, }; expect(wait.structuredContent).toEqual(expectedWait); expect(messageReads).toEqual([]); From b3727bc6f726522418c1cf8f997b44f9430b5f69 Mon Sep 17 00:00:00 2001 From: Nathan Shan Date: Sun, 8 Nov 2026 10:11:52 +0800 Subject: [PATCH 09/17] =?UTF-8?q?fix(mcp):=20=E4=BF=9D=E6=8C=81=E8=B7=A8?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E7=BA=BF=E7=A8=8B=E7=AD=89=E5=BE=85=E7=9A=84?= =?UTF-8?q?=E5=85=BC=E5=AE=B9=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 外部客户端显式启用输入感知等待,并保留线程调用者和跨环境转发的默认行为。 - CLI 兼容旧服务器的待回答查询与完成竞态,避免误报失败或无限等待。 - 区分普通问题、密钥输入卡和审批的操作指引,按实测更新上下文预算。 - 变更范围检查通过:225 项测试及 36 个受影响包的类型检查。 --- apps/server/src/cli/thread.test.ts | 31 ++++++- apps/server/src/cli/thread.ts | 28 +++++- apps/server/src/mcp/McpInvocationContext.ts | 6 ++ .../src/mcp/OrchestratorMcpService.test.ts | 90 +++++++++++++++++++ apps/server/src/mcp/OrchestratorMcpService.ts | 2 + .../src/mcp/toolkits/orchestrator/tools.ts | 2 +- apps/server/src/peer/PeerForwarding.test.ts | 13 +++ apps/server/src/peer/PeerForwarding.ts | 1 + .../src/provider/agentContextBudget.json | 2 +- packages/contracts/src/orchestratorMcp.ts | 6 ++ 10 files changed, 173 insertions(+), 8 deletions(-) diff --git a/apps/server/src/cli/thread.test.ts b/apps/server/src/cli/thread.test.ts index 04e2defafb323..f16f2f0705ff0 100644 --- a/apps/server/src/cli/thread.test.ts +++ b/apps/server/src/cli/thread.test.ts @@ -10,6 +10,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; import * as TestConsole from "effect/testing/TestConsole"; import { Command } from "effect/cli"; import { vi } from "vite-plus/test"; @@ -137,10 +138,14 @@ it.effect( /** Answers each tool call with the next scripted response for that tool. */ const scriptedTools = (script: Record>) => { const calls: Array<{ readonly tool: string; readonly args: unknown }> = []; - const call = ((tool: string, args: unknown) => { + const call = ((tool: string, args: unknown, schema: Schema.Decoder) => { calls.push({ tool, args }); const next = script[tool]?.shift(); - return next === undefined ? Effect.die(`unscripted ${tool} call`) : Effect.succeed(next); + return next === undefined + ? Effect.die(`unscripted ${tool} call`) + : tool === "t3_thread_wait" + ? Schema.decodeEffect(schema)(next).pipe(Effect.orDie) + : Effect.succeed(next); }) as ThreadTools["call"]; return { tools: { call } satisfies ThreadTools, calls }; }; @@ -175,8 +180,8 @@ it.effect("--wait follows the sent run to its continuation's terminal status", ( assert.deepStrictEqual( calls.filter((entry) => entry.tool === "t3_thread_wait").map((entry) => entry.args), [ - { threadId, runId, timeoutMs: 15_000 }, - { threadId, runId, timeoutMs: 15_000 }, + { threadId, runId, timeoutMs: 15_000, returnOnInput: true }, + { threadId, runId, timeoutMs: 15_000, returnOnInput: true }, ], ); }), @@ -236,6 +241,24 @@ it.effect("--wait reports its run's result when a later run is the one waiting", }), ); +it.effect.each(["running", "completed"] as const)( + "--wait reads legacy pending requests and rechecks its %s run", + (status) => + Effect.gen(function* () { + const { tools } = scriptedTools({ + t3_thread_wait: [ + { threadId, runId, status: "running", timedOut: true }, + { threadId, runId, status, timedOut: status === "running" }, + ], + t3_thread_read: [{ thread: { pendingRequestCount: 1 } }], + }); + assert.deepStrictEqual( + yield* waitForRun(tools, threadId, runId), + status === "running" ? { type: "needs_input" } : { type: "ended", runId, status }, + ); + }), +); + /** Serves t3_thread_read pages the way the server does: positions count every item. */ const timelineTools = (types: ReadonlyArray) => { const items = types.map((type, position) => ({ position, itemId: `item-${position}`, type })); diff --git a/apps/server/src/cli/thread.ts b/apps/server/src/cli/thread.ts index cb811c92d4005..6903946bdedfd 100644 --- a/apps/server/src/cli/thread.ts +++ b/apps/server/src/cli/thread.ts @@ -435,6 +435,12 @@ type WaitOutcome = } | { readonly type: "needs_input" }; +// Keep the missing flag observable: older servers need the thread-wide request fallback. +const CliThreadWaitResult = Schema.Struct({ + ...OrchestratorMcpThreadWaitResult.fields, + hasPendingQuestion: Schema.optional(Schema.Boolean), +}); + /** * Waits for this exact run, following a restart to its continuation, so a * message another client sends meanwhile cannot stand in for its result. @@ -445,8 +451,12 @@ export const waitForRun = Effect.fn("cli.thread.waitForRun")(function* ( runId: RunId, ) { const waitSlice = (timeoutMs: number) => - tools.call("t3_thread_wait", { threadId, runId, timeoutMs }, OrchestratorMcpThreadWaitResult); - const ended = (waited: OrchestratorMcpThreadWaitResult) => + tools.call( + "t3_thread_wait", + { threadId, runId, timeoutMs, returnOnInput: true }, + CliThreadWaitResult, + ); + const ended = (waited: typeof CliThreadWaitResult.Type) => ({ type: "ended", runId: waited.runId, status: waited.status }) satisfies WaitOutcome; while (true) { const waited = yield* waitSlice(WAIT_SLICE_MS); @@ -454,6 +464,20 @@ export const waitForRun = Effect.fn("cli.thread.waitForRun")(function* ( return { type: "needs_input" } satisfies WaitOutcome; } if (!waited.timedOut) return ended(waited); + if (waited.hasPendingQuestion === undefined) { + const { thread } = yield* tools.call( + "t3_thread_read", + { threadId, limit: 1, runLimit: 1 }, + OrchestratorMcpThreadReadResult, + ); + if (thread.pendingRequestCount > 0) { + // A later run can own the request if our run finished since the timeout. + const recheck = yield* waitSlice(1); + return recheck.hasPendingQuestion || recheck.pendingApproval !== null || recheck.timedOut + ? ({ type: "needs_input" } satisfies WaitOutcome) + : ended(recheck); + } + } } }); diff --git a/apps/server/src/mcp/McpInvocationContext.ts b/apps/server/src/mcp/McpInvocationContext.ts index 842d18ad9c8bc..481c7010246c9 100644 --- a/apps/server/src/mcp/McpInvocationContext.ts +++ b/apps/server/src/mcp/McpInvocationContext.ts @@ -133,6 +133,12 @@ export interface McpInvocationScope { readonly linkedEnvironments?: boolean; } +/** External clients must opt in: older CLIs treat every non-timeout as a terminal run. */ +export const threadWaitReturnsOnInput = ( + scope: McpInvocationScope, + returnOnInput: boolean | undefined, +): boolean => returnOnInput ?? (scope.thread !== undefined || scope.client?.linked === true); + /** * T3 Code renders `t3-thread://` links as thread chips. Other MCP clients cannot open them, so * they get the hosted link Copy thread copies, which opens the thread in either app or a browser. diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index 86b833c0198d9..341c31c11fc65 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -37,6 +37,8 @@ import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; +import * as ProviderSessionManager from "../orchestration-v2/ProviderSessionManager.ts"; import { CodexProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/CodexAdapterV2.ts"; import * as CommandReceiptStore from "../orchestration-v2/CommandReceiptStore.ts"; @@ -3655,6 +3657,94 @@ describe("OrchestratorMcpService thread lookup", () => { }), ); + it.effect.each([ + { name: "legacy client", returnOnInput: undefined, agent: false, pending: false }, + { name: "updated client", returnOnInput: true, agent: false, pending: true }, + { name: "thread caller", returnOnInput: undefined, agent: true, pending: true }, + { name: "terminal-only thread caller", returnOnInput: false, agent: true, pending: false }, + ])("preserves wait semantics for $name", (scenario) => + Effect.gen(function* () { + const runId = RunId.make("run:compat-wait"); + const projection = { + ...idleThreadProjection(launched), + runs: [{ id: runId, ordinal: 1, status: "running" }], + nodes: [{ id: "node:compat-wait", runId }], + runtimeRequests: [ + { + id: "request:compat-wait", + nodeId: "node:compat-wait", + kind: "command", + status: "pending", + responseCapability: { type: "live" }, + createdAt: DateTime.makeUnsafe(0), + }, + ], + } as unknown as OrchestrationV2ThreadProjection; + const management = yield* ThreadManagementService.ThreadManagementService.pipe( + Effect.provide( + ThreadManagementService.layer.pipe( + Layer.provide( + Layer.mergeAll( + Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({}), + Layer.mock(CommandReceiptStore.CommandReceiptStoreV2)({}), + ServerSettings.layerTest(), + Layer.mock(Orchestrator.OrchestratorV2)({ + getThreadEventSequence: () => Effect.succeed(0), + getThreadRecords: () => Effect.succeed(projection), + streamStoredEventsFrom: () => Stream.never, + }), + ), + ), + ), + ), + ); + const sent = yield* Ref.make(0); + yield* Effect.gen(function* () { + const mcp = yield* OrchestratorMcpService.OrchestratorMcpService; + const waiting = yield* mcp + .waitForThread( + { + ...client, + ...(scenario.agent + ? { + thread: { + threadId: launched.id, + providerSessionId: "session:compat-wait", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + } + : {}), + }, + { + threadId: launched.id, + runId, + timeoutMs: 60_000, + ...(scenario.returnOnInput === undefined + ? {} + : { returnOnInput: scenario.returnOnInput }), + }, + ) + .pipe(Effect.forkChild); + yield* TestClock.adjust("1 minute"); + const result = yield* Fiber.join(waiting); + assert.equal(result.timedOut, !scenario.pending); + assert.equal(result.status, "running"); + assert.equal( + result.pendingApproval?.requestId ?? null, + scenario.pending ? "request:compat-wait" : null, + ); + }).pipe( + Effect.provide( + service([launched], sent, { + getThreadRecords: () => Effect.succeed(projection), + getProjectThreadRecords: () => Effect.succeed(projection), + waitForThread: management.waitForThread, + }), + ), + ); + }), + ); + it.effect("waits on the caller's own thread without a separate shell lookup", () => Effect.gen(function* () { const sent = yield* Ref.make(0); diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index bc8a72b25e65d..2a06c342798fe 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -115,6 +115,7 @@ import { type McpThreadInvocationScope, requireThreadScope, threadLinkFor, + threadWaitReturnsOnInput, } from "./McpInvocationContext.ts"; import * as Metrics from "../observability/Metrics.ts"; import { @@ -3237,6 +3238,7 @@ const make = Effect.gen(function* () { projectId: target.thread.projectId, threadId: input.threadId, ...(input.runId === undefined ? {} : { runId: input.runId }), + terminalOnly: !threadWaitReturnsOnInput(scope, input.returnOnInput), timeoutMs: Math.min( MAX_WAIT_TIMEOUT_MS, Math.max(1, input.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS), diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 5f677ad7d85ce..0af5f28f63863 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -283,7 +283,7 @@ const ThreadSendTool = Tool.make("t3_thread_send", { const ThreadWaitTool = Tool.make("t3_thread_wait", { description: - "Wait for completion/input. No runId: latest. Read: t3_pending_request. Timeout: next means end turn; else retry.", + "Wait for completion/input. No runId: latest. Questions: t3_pending_request; secret cards: t3_thread_read activity. Ask the user to answer secret cards and approvals in the thread. Timeout: next means end turn; else retry.", parameters: OrchestratorMcpThreadWaitInput, success: OrchestratorMcpThreadWaitResult, failure: OrchestratorMcpFailure, diff --git a/apps/server/src/peer/PeerForwarding.test.ts b/apps/server/src/peer/PeerForwarding.test.ts index f0cdb7708369f..c35fb60597bd8 100644 --- a/apps/server/src/peer/PeerForwarding.test.ts +++ b/apps/server/src/peer/PeerForwarding.test.ts @@ -465,6 +465,19 @@ it.effect("keeps a forwarded wait short and its retries scoped to the caller", ( [PeerForwarding.FORWARDED_WAIT_CHUNK_MS, boxRun], ]); + // An explicit terminal-only wait must survive the linked-client boundary. + yield* a.call("thread:laptop-full", "t3_thread_wait", { + environmentId: box.environmentId, + threadId: boxThread, + returnOnInput: false, + }); + expect((yield* Ref.get(seen)).waits.map(({ terminalOnly }) => terminalOnly)).toEqual([ + false, + false, + false, + true, + ]); + const send = (caller: string, clientRequestId: string) => a.call(caller, "t3_thread_send", { environmentId: box.environmentId, diff --git a/apps/server/src/peer/PeerForwarding.ts b/apps/server/src/peer/PeerForwarding.ts index 07e05efd4448e..c063d875214be 100644 --- a/apps/server/src/peer/PeerForwarding.ts +++ b/apps/server/src/peer/PeerForwarding.ts @@ -199,6 +199,7 @@ const make = Effect.gen(function* () { threadId: input.threadId, ...(runId === undefined ? {} : { runId }), timeoutMs: chunk, + returnOnInput: McpInvocationContext.threadWaitReturnsOnInput(scope, input.returnOnInput), ...(input.untilDelegatedWorkSettled === undefined ? {} : { untilDelegatedWorkSettled: input.untilDelegatedWorkSettled }), diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index e1f95ff5ec43a..7dd985e94e259 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5366, "codexAdditionalContextBytes": 7096, - "mcpToolListJsonBytes": 48102 + "mcpToolListJsonBytes": 48358 } diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index 9a179ccc7651b..be93de66052b2 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -529,6 +529,12 @@ export const OrchestratorMcpThreadWaitInput = Schema.Struct({ threadId: ThreadId, runId: Schema.optional(RunId), timeoutMs: Schema.optional(Schema.Finite), + returnOnInput: Schema.optional( + Schema.Boolean.annotate({ + description: + "Return on questions/approvals. Defaults to true for thread callers, false for external clients.", + }), + ), untilDelegatedWorkSettled: Schema.optional( Schema.Boolean.annotate({ description: From 27fb24b8a449571134d7242d6f385bd66ebda084 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 10 Nov 2026 03:40:54 +0000 Subject: [PATCH 10/17] fix(server): keep thread wait tool text within the agent context budget --- apps/server/src/mcp/toolkits/orchestrator/tools.ts | 2 +- apps/server/src/provider/agentContextBudget.json | 2 +- packages/contracts/src/orchestratorMcp.ts | 7 +------ 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 1c56fc624e78a..a6a6261782762 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -283,7 +283,7 @@ const ThreadSendTool = Tool.make("t3_thread_send", { const ThreadWaitTool = Tool.make("t3_thread_wait", { description: - "Wait for completion/input. No runId: latest. Questions: t3_pending_request; secret cards: t3_thread_read activity. Ask the user to answer secret cards and approvals in the thread. Timeout: next means end turn; else retry.", + "Wait for a run to end or need input (t3_pending_request; else user). Timeout+next: end turn.", parameters: OrchestratorMcpThreadWaitInput, success: OrchestratorMcpThreadWaitResult, failure: OrchestratorMcpFailure, diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index 50ce63aa8a2b3..da31935c0b451 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5547, "codexAdditionalContextBytes": 7277, - "mcpToolListJsonBytes": 48217 + "mcpToolListJsonBytes": 48215 } diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index 3222131a03377..0f9db9c98b937 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -538,12 +538,7 @@ export const OrchestratorMcpThreadWaitInput = Schema.Struct({ threadId: ThreadId, runId: Schema.optional(RunId), timeoutMs: Schema.optional(Schema.Finite), - returnOnInput: Schema.optional( - Schema.Boolean.annotate({ - description: - "Return on questions/approvals. Defaults to true for thread callers, false for external clients.", - }), - ), + returnOnInput: Schema.optional(Schema.Boolean), untilDelegatedWorkSettled: Schema.optional( Schema.Boolean.annotate({ description: From bfb11f6e944d09b6a5ee1bba1efb0fcdd30c504f Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 10 Nov 2026 20:19:05 +0000 Subject: [PATCH 11/17] fix(server): remote task followers wait for the terminal state only --- apps/server/src/peer/RemoteDelegation.test.ts | 24 +++++-------------- apps/server/src/peer/RemoteDelegation.ts | 7 ++---- .../src/provider/agentContextBudget.json | 2 +- 3 files changed, 9 insertions(+), 24 deletions(-) diff --git a/apps/server/src/peer/RemoteDelegation.test.ts b/apps/server/src/peer/RemoteDelegation.test.ts index 64d58cf176606..6982c5576d1d2 100644 --- a/apps/server/src/peer/RemoteDelegation.test.ts +++ b/apps/server/src/peer/RemoteDelegation.test.ts @@ -20,11 +20,9 @@ import { TurnItemId, } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; -import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; 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 Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -298,7 +296,9 @@ const boxToolkitLayer = (thread: BoxThread) => { input.untilDelegatedWorkSettled === true && (yield* Ref.get(thread.nested)) ) { - const pendingInput = yield* Ref.getAndSet(thread.nestedInput, null); + const pendingInput = input.terminalOnly + ? null + : yield* Ref.getAndSet(thread.nestedInput, null); if (pendingInput !== null) { return { threadId: input.threadId, @@ -1330,23 +1330,10 @@ it.effect.each([null, "question", "approval"] as const)( ReadonlyArray >([]); const database = yield* SqlitePersistence.layerMemory.pipe(Layer.build); - const clock = yield* Clock.Clock; - const sleeps: Array = []; const a = yield* makeLaptop(database, offers); yield* linkTo(a.links, b, "full-access"); yield* seedParent(a); - // The follower is forked by delegation, so it inherits this call's clock. - const delegated = yield* delegateToBox(a).pipe( - Effect.provideService(Clock.Clock, { - ...clock, - sleep: (duration) => - Duration.toMillis(duration) === 5_000 - ? Effect.sync(() => { - sleeps.push(5_000); - }) - : clock.sleep(duration), - }), - ); + const delegated = yield* delegateToBox(a); // The task's run there ends after delegating a review of its own. yield* Ref.set(thread.nested, true); @@ -1359,7 +1346,8 @@ it.effect.each([null, "question", "approval"] as const)( taskResult(a, delegated.taskId).pipe(Effect.as("completed here" as const)), ); expect(first).toBe("waiting there"); - expect(sleeps).toEqual(pendingInput === null ? [] : [5_000]); + // The follower asks for a terminal-only wait, so pending input never ends it early. + expect(yield* Ref.get(thread.nestedInput)).toBe(pendingInput); // The review reports and wakes the thread there, whose next run answers. yield* Ref.set(thread.laterReply, "The review found two issues; both are fixed."); diff --git a/apps/server/src/peer/RemoteDelegation.ts b/apps/server/src/peer/RemoteDelegation.ts index aca28b6491722..38a35fae09e51 100644 --- a/apps/server/src/peer/RemoteDelegation.ts +++ b/apps/server/src/peer/RemoteDelegation.ts @@ -613,16 +613,13 @@ const make = Effect.gen(function* () { // tasks to wake it for the answer, which a task here waits for too. // An older peer ignores this and reports the run alone. untilDelegatedWorkSettled: true, + // Only the terminal state matters here; input-aware waits would return at once. + returnOnInput: false, }) .pipe(Effect.raceFirst(endedHere)); // The next look reads the result and stops it there. if ("endedHere" in waited) return false; if (waited.timedOut) return false; - if (waited.hasPendingQuestion || waited.pendingApproval !== null) { - // Input-aware waits return immediately; keep following without a hot loop. - yield* Effect.sleep(Duration.seconds(5)).pipe(Effect.raceFirst(endedHere)); - return false; - } // A task recorded without its run waits on the thread's latest; a // thread there with none has nothing left to wait for. if (waited.runId === null) { diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index 4439c50940722..e203437ed43c7 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5597, "codexAdditionalContextBytes": 7327, - "mcpToolListJsonBytes": 48217 + "mcpToolListJsonBytes": 48215 } From e33ddfb8cebb85fb73f8450d3c9df355b8a6f5aa Mon Sep 17 00:00:00 2001 From: Nathan Shan Date: Thu, 12 Nov 2026 10:50:48 +0800 Subject: [PATCH 12/17] =?UTF-8?q?perf(server):=20=E7=AD=89=E5=BE=85?= =?UTF-8?q?=E7=BA=BF=E7=A8=8B=E6=97=B6=E5=8F=AA=E8=AF=BB=E5=8F=96=E5=BE=85?= =?UTF-8?q?=E5=9B=9E=E7=AD=94=E8=AF=B7=E6=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 通过现有投影查询过滤已经处理的请求,减少长会话等待唤醒时的读取量。 - 保持输入和审批识别逻辑,变更范围检查的 243 项测试全部通过。 --- apps/server/src/orchestration-v2/ThreadManagementService.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index f224d23c017f9..4d32812c9ac32 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -946,7 +946,11 @@ const make = Effect.gen(function* () { const fields = input.terminalOnly ? (["runs"] as const) : (["runs", "nodes", "runtimeRequests", "turnItems"] as const); - const filter = { turnItemTypes: ["secret_request"], omitSettledLeafNodes: true } as const; + const filter = { + turnItemTypes: ["secret_request"], + omitSettledLeafNodes: true, + runtimeRequestStatuses: ["pending"], + } as const; const target = yield* getProjectThreadRecords(input, fields, filter); const selectedRun = input.runId === undefined From c2480e33211f990b52e13826cc1be471b29cbc51 Mon Sep 17 00:00:00 2001 From: Nathan Shan Date: Thu, 12 Nov 2026 11:05:37 +0800 Subject: [PATCH 13/17] =?UTF-8?q?docs(mcp):=20=E8=AF=B4=E6=98=8E=E5=A4=96?= =?UTF-8?q?=E9=83=A8=E5=AE=A2=E6=88=B7=E7=AB=AF=E7=9A=84=E8=BE=93=E5=85=A5?= =?UTF-8?q?=E7=AD=89=E5=BE=85=E9=80=89=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 说明线程内调用和已连接环境的默认等待行为,以及外部客户端的启用参数。 - 保留超时后的结束提示,更新实测工具预算。 - 变更范围检查通过,覆盖 243 项测试和 37 个受影响包的类型检查。 --- .../src/mcp/toolkits/orchestrator/tools.ts | 2 +- .../src/provider/agentContextBudget.json | 2 +- .../orchestrator-mcp-server.md | 21 +++++++++++++------ 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 8d06e62e5c277..13c436b51d9f9 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -290,7 +290,7 @@ const ThreadSendTool = Tool.make("t3_thread_send", { const ThreadWaitTool = Tool.make("t3_thread_wait", { description: - "Wait for a run to end or need input (t3_pending_request; else user). Timeout+next: end turn.", + "Wait. External input: returnOnInput=true. t3_pending_request or user. Timeout+next: end turn.", parameters: OrchestratorMcpThreadWaitInput, success: OrchestratorMcpThreadWaitResult, failure: OrchestratorMcpFailure, diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index 6cd0ac1a82e0a..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": 48129 + "mcpToolListJsonBytes": 48130 } diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index 2be38ffeac179..112c33e68be05 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -467,12 +467,21 @@ idempotent retries. Waits for a selected run to become `completed`, `failed`, `cancelled`, `interrupted`, or `rolled_back`. Without `runId`, it pins the latest run at call -time; an idle thread returns immediately. A pending live question or approval on -the selected run, or an earlier unfinished run blocking it, also returns with -`timedOut: false` and `hasPendingQuestion` or `pendingApproval`. The run's status -does not change. Message-based asynchronous questions do not end the wait. -Read the fresh request before responding; the result grants no approval authority. -A timeout reports the latest durable status and does not cancel work. +time. An idle thread returns immediately. + +Thread-scoped and linked callers return on blocking input by default. Unlinked +external MCP callers enable this behavior with `returnOnInput: true`. Setting +`returnOnInput: false` keeps the wait terminal-only. + +An input-aware wait returns `timedOut: false` and `hasPendingQuestion` or +`pendingApproval` for live input on the selected run or an earlier unfinished run +blocking it. The run keeps its current status. Asynchronous message-based +questions leave the wait active. Read a fresh question with `t3_pending_request`. +Secret cards appear in thread activity. Ask the user to answer secret cards and +approvals in the thread. The result grants no approval authority. + +A timeout reports the latest durable status and leaves the work running. A `next` +hint on a timed-out wait tells the caller to end its turn. ### `t3_thread_interrupt` From 87deeb34b63a15da1c29701bc288be0b1b7f9808 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 21:41:09 +0000 Subject: [PATCH 14/17] fix(server): only thread callers wait on input by default Linked peers arrive as client callers, so an older peer's task follower, which sends no returnOnInput, would get an input-aware wait and re-call it at once while the remote thread waits for an answer. Client callers now opt in; PeerForwarding already sends the caller's resolved choice. Also: the CLI's follow-wakes loop reports needs_input when the woken run asks for an answer, and t3_thread_wait keeps main's description. AI-assisted; verified with the tests and tools listed above. --- apps/server/src/cli/thread.test.ts | 34 +++++++++++++++++-- apps/server/src/cli/thread.ts | 6 +++- apps/server/src/mcp/McpInvocationContext.ts | 7 ++-- .../src/mcp/OrchestratorMcpService.test.ts | 12 +++++++ .../src/mcp/toolkits/orchestrator/tools.ts | 2 +- .../src/provider/agentContextBudget.json | 2 +- .../orchestrator-mcp-server.md | 5 +-- 7 files changed, 59 insertions(+), 9 deletions(-) diff --git a/apps/server/src/cli/thread.test.ts b/apps/server/src/cli/thread.test.ts index c8a2a54db52bc..04fe40ee4a3a5 100644 --- a/apps/server/src/cli/thread.test.ts +++ b/apps/server/src/cli/thread.test.ts @@ -267,8 +267,14 @@ it.effect("--wait lasts until background work the agent waits on wakes it and th assert.deepStrictEqual( calls.filter((entry) => entry.tool === "t3_thread_wait").map((entry) => entry.args), [ - { threadId, runId, timeoutMs: 15_000 }, - { threadId, runId, timeoutMs: 15_000, untilDelegatedWorkSettled: true }, + { threadId, runId, timeoutMs: 15_000, returnOnInput: true }, + { + threadId, + runId, + timeoutMs: 15_000, + returnOnInput: true, + untilDelegatedWorkSettled: true, + }, ], ); assert.deepStrictEqual(outcome, { type: "ended", runId: wakeRun, status: "completed" }); @@ -380,6 +386,30 @@ it.effect("--wait stops when the thread waits for an answer instead of polling f }), ); +it.effect("--wait stops when the run background work woke waits for an answer", () => + Effect.gen(function* () { + const wakeRun = RunId.make("run-2"); + const { tools } = scriptedTools({ + t3_thread_wait: [ + { threadId, runId, status: "completed", timedOut: false }, + { + threadId, + runId, + status: "completed", + timedOut: false, + hasPendingQuestion: true, + pendingApproval: null, + }, + ], + t3_thread_read: laterRunReads(wakeRun, []), + }); + + assert.deepStrictEqual(yield* waitForRun(tools, threadId, runId, true), { + type: "needs_input", + }); + }), +); + it.effect("--wait reports its run's result when a later run is the one waiting", () => Effect.gen(function* () { const { tools } = scriptedTools({ diff --git a/apps/server/src/cli/thread.ts b/apps/server/src/cli/thread.ts index 86e13dc161343..a491ab283e762 100644 --- a/apps/server/src/cli/thread.ts +++ b/apps/server/src/cli/thread.ts @@ -583,7 +583,11 @@ export const waitForRun = Effect.fn("cli.thread.waitForRun")(function* ( const pendingOnOlderServer = (waited: typeof CliThreadWaitResult.Type) => waited.hasPendingQuestion === undefined ? tools - .call("t3_thread_read", { threadId, limit: 1, runLimit: 1 }, OrchestratorMcpThreadReadResult) + .call( + "t3_thread_read", + { threadId, limit: 1, runLimit: 1 }, + OrchestratorMcpThreadReadResult, + ) .pipe(Effect.map(({ thread }) => thread.pendingRequestCount > 0)) : Effect.succeed(false); const ended = (waited: typeof CliThreadWaitResult.Type) => diff --git a/apps/server/src/mcp/McpInvocationContext.ts b/apps/server/src/mcp/McpInvocationContext.ts index 5f939626ec1f3..329d60a4e4fb6 100644 --- a/apps/server/src/mcp/McpInvocationContext.ts +++ b/apps/server/src/mcp/McpInvocationContext.ts @@ -143,11 +143,14 @@ export interface McpInvocationScope { readonly linkedEnvironments?: boolean; } -/** External clients must opt in: older CLIs treat every non-timeout as a terminal run. */ +/** + * Client callers opt in: older CLIs and linked peers' followers read every + * non-timeout as a terminal run. + */ export const threadWaitReturnsOnInput = ( scope: McpInvocationScope, returnOnInput: boolean | undefined, -): boolean => returnOnInput ?? (scope.thread !== undefined || scope.client?.linked === true); +): boolean => returnOnInput ?? scope.thread !== undefined; /** * T3 Code renders `t3-thread://` links as thread chips. Other MCP clients cannot open them, so diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index 8f528461476ef..d8c078bcf45db 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -44,6 +44,7 @@ import * as TestClock from "effect/testing/TestClock"; import { CodexProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/CodexAdapterV2.ts"; import * as CommandReceiptStore from "../orchestration-v2/CommandReceiptStore.ts"; +import * as ProviderSessionManager from "../orchestration-v2/ProviderSessionManager.ts"; import * as EffectWorker from "../orchestration-v2/EffectWorker.ts"; import * as LegacyV1ThreadImporter from "../orchestration-v2/legacy/LegacyV1ThreadImporter.ts"; import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; @@ -4102,6 +4103,7 @@ describe("OrchestratorMcpService thread lookup", () => { it.effect.each([ { name: "legacy client", returnOnInput: undefined, agent: false, pending: false }, { name: "updated client", returnOnInput: true, agent: false, pending: true }, + { name: "older linked peer", returnOnInput: undefined, linked: true, pending: false }, { name: "thread caller", returnOnInput: undefined, agent: true, pending: true }, { name: "terminal-only thread caller", returnOnInput: false, agent: true, pending: false }, ])("preserves wait semantics for $name", (scenario) => @@ -4147,6 +4149,16 @@ describe("OrchestratorMcpService thread lookup", () => { .waitForThread( { ...client, + ...(scenario.linked + ? { + client: { + sessionId: "link-compat", + label: "A", + access: "full-access", + linked: true, + }, + } + : {}), ...(scenario.agent ? { thread: { diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 13c436b51d9f9..38d8fb4e0ada5 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -290,7 +290,7 @@ const ThreadSendTool = Tool.make("t3_thread_send", { const ThreadWaitTool = Tool.make("t3_thread_wait", { description: - "Wait. External input: returnOnInput=true. t3_pending_request or user. Timeout+next: end turn.", + "Wait for a thread's run to finish. No `runId`: latest. Idle: at once. On timeout, with `next` end your turn; without, call again.", parameters: OrchestratorMcpThreadWaitInput, success: OrchestratorMcpThreadWaitResult, failure: OrchestratorMcpFailure, diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index e166af3f78340..4c1fb076e3331 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5597, "codexAdditionalContextBytes": 7327, - "mcpToolListJsonBytes": 48126 + "mcpToolListJsonBytes": 48161 } diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index c634e6cd46c5d..c13a6cfb29755 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -475,8 +475,9 @@ Waits for a selected run to become `completed`, `failed`, `cancelled`, `interrupted`, or `rolled_back`. Without `runId`, it pins the latest run at call time. An idle thread returns immediately. -Thread-scoped and linked callers return on blocking input by default. Unlinked -external MCP callers enable this behavior with `returnOnInput: true`. Setting +Agents in a T3 thread return on blocking input by default. Other MCP clients, +including linked environments, opt in with `returnOnInput: true`, because older +clients read any wait that did not time out as a finished run. Setting `returnOnInput: false` keeps the wait terminal-only. An input-aware wait returns `timedOut: false` and `hasPendingQuestion` or From 24ca25b94d93af804b25a32b862b0a90d5b2d6c0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 21:54:25 +0000 Subject: [PATCH 15/17] fix(server): make input-aware thread waits opt-in at the service ThreadManagementService.waitForThread returned early on pending input unless a caller passed terminalOnly. ScheduledTaskService.recordRunFailure did not, so a run that asked a question ended its monitoring and a later failure left the task marked succeeded. The service now waits for a terminal run unless the caller sets returnOnInput, which only the MCP thread wait does. AI-assisted; verified with the tests and tools listed above. --- apps/server/src/mcp/OrchestratorMcpService.ts | 2 +- .../ThreadManagementService.test.ts | 9 ++--- .../ThreadManagementService.ts | 33 +++++++++---------- apps/server/src/peer/PeerForwarding.test.ts | 8 ++--- apps/server/src/peer/RemoteDelegation.test.ts | 6 ++-- 5 files changed, 28 insertions(+), 30 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 7c903f58070ea..fe401124edddc 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -3266,7 +3266,7 @@ const make = Effect.gen(function* () { projectId: target.thread.projectId, threadId: input.threadId, ...(input.runId === undefined ? {} : { runId: input.runId }), - terminalOnly: !threadWaitReturnsOnInput(scope, input.returnOnInput), + returnOnInput: threadWaitReturnsOnInput(scope, input.returnOnInput), timeoutMs: Math.min( MAX_WAIT_TIMEOUT_MS, Math.max(1, input.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS), diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts index 1613be258f866..1aa1060fdbaf3 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts @@ -293,7 +293,7 @@ it.effect.each([ Effect.provide(layerTest), ); const fiber = yield* service - .waitForThread({ projectId, threadId, runId, timeoutMs: 60_000 }) + .waitForThread({ projectId, threadId, runId, timeoutMs: 60_000, returnOnInput: true }) .pipe(Effect.forkChild); yield* TestClock.adjust("1 minute"); const result = yield* Fiber.join(fiber); @@ -354,7 +354,7 @@ it.effect.each([ Effect.provide(layerTest), ); const fiber = yield* service - .waitForThread({ projectId, threadId, runId, timeoutMs: 60_000 }) + .waitForThread({ projectId, threadId, runId, timeoutMs: 60_000, returnOnInput: true }) .pipe(Effect.forkChild); yield* TestClock.adjust("1 minute"); expect(yield* Fiber.join(fiber)).toMatchObject({ @@ -412,7 +412,7 @@ it.effect.each(["command", "user_input", "secret_request"] as const)( Effect.provide(layerTest), ); const fiber = yield* service - .waitForThread({ projectId, threadId, runId, timeoutMs: 600_000 }) + .waitForThread({ projectId, threadId, runId, timeoutMs: 600_000, returnOnInput: true }) .pipe(Effect.forkChild); yield* Deferred.await(subscribed); if (kind === "secret_request") { @@ -446,7 +446,7 @@ it.effect.each(["command", "user_input", "secret_request"] as const)( }); expect(reads).toBe(2); const terminalWait = yield* service - .waitForThread({ projectId, threadId, runId, timeoutMs: 60_000, terminalOnly: true }) + .waitForThread({ projectId, threadId, runId, timeoutMs: 60_000 }) .pipe(Effect.forkChild); yield* TestClock.adjust("1 minute"); expect(yield* Fiber.join(terminalWait)).toMatchObject({ @@ -608,6 +608,7 @@ it.effect.each(["completion", "secret_request"] as const)( runId, timeoutMs: 60 * 60 * 1_000, untilDelegatedWorkSettled: true, + returnOnInput: true, }) .pipe(Effect.forkChild); // A wait that returns early fails below instead of hanging here. diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index a6a6be85c6c18..546cfe818722c 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -176,7 +176,8 @@ export interface ThreadManagementWaitInput { readonly threadId: ThreadId; readonly runId?: RunId; readonly timeoutMs: number; - readonly terminalOnly?: boolean; + /** Also return when live input blocks the run, leaving its status as is. */ + readonly returnOnInput?: boolean; /** * Also wait until the thread's own delegated work settles: an ended run * still owes the thread's answer while a child, a background task or a @@ -955,9 +956,9 @@ const make = Effect.gen(function* () { const afterSequence = yield* orchestrator .getThreadEventSequence(input.threadId) .pipe(Effect.mapError(loadError)); - const fields = input.terminalOnly - ? (["runs"] as const) - : (["runs", "nodes", "runtimeRequests", "turnItems"] as const); + const fields = input.returnOnInput + ? (["runs", "nodes", "runtimeRequests", "turnItems"] as const) + : (["runs"] as const); const filter = { turnItemTypes: ["secret_request"], omitSettledLeafNodes: true, @@ -1007,7 +1008,7 @@ const make = Effect.gen(function* () { let hasPendingQuestion = false; let pendingApproval: ThreadManagementWaitResult["pendingApproval"] = null; let oldestApproval = Infinity; - if (!input.terminalOnly && (!isTerminalRunStatus(run.status) || untilDelegatedWorkSettled)) { + if (input.returnOnInput && (!isTerminalRunStatus(run.status) || untilDelegatedWorkSettled)) { const blockingRuns = new Set( runs .filter( @@ -1123,16 +1124,15 @@ const make = Effect.gen(function* () { }), ) : []), - input.terminalOnly - ? Stream.empty - : orchestrator.streamStoredEventsFrom({ + input.returnOnInput + ? orchestrator.streamStoredEventsFrom({ threadId: input.threadId, afterSequence, eventType: "runtime-request.updated", - }), - input.terminalOnly - ? Stream.empty - : orchestrator + }) + : Stream.empty, + input.returnOnInput + ? orchestrator .streamStoredEventsFrom({ threadId: input.threadId, afterSequence, @@ -1144,7 +1144,8 @@ const make = Effect.gen(function* () { stored.event.type === "turn-item.updated" && stored.event.payload.type === "secret_request", ), - ), + ) + : Stream.empty, ], { concurrency: "unbounded" }, ).pipe( @@ -1180,11 +1181,7 @@ const make = Effect.gen(function* () { "orchestrationV2.threadManagement.settleAfterRun", )(function* (input) { const requestedAt = yield* DateTime.now; - const { run } = yield* waitForThread({ - ...input, - timeoutMs: SETTLE_AFTER_RUN_WAIT_MS, - terminalOnly: true, - }); + const { run } = yield* waitForThread({ ...input, timeoutMs: SETTLE_AFTER_RUN_WAIT_MS }); if (run?.status !== "completed") return; const afterSequence = yield* orchestrator.getThreadEventSequence(input.threadId); const trySettle = Effect.gen(function* () { diff --git a/apps/server/src/peer/PeerForwarding.test.ts b/apps/server/src/peer/PeerForwarding.test.ts index 93ee3ee5d77a9..5202ce7ec1bc2 100644 --- a/apps/server/src/peer/PeerForwarding.test.ts +++ b/apps/server/src/peer/PeerForwarding.test.ts @@ -495,11 +495,11 @@ it.effect("keeps a forwarded wait short and its retries scoped to the caller", ( threadId: boxThread, returnOnInput: false, }); - expect((yield* Ref.get(seen)).waits.map(({ terminalOnly }) => terminalOnly)).toEqual([ - false, - false, - false, + expect((yield* Ref.get(seen)).waits.map(({ returnOnInput }) => returnOnInput)).toEqual([ true, + true, + true, + false, ]); const send = (caller: string, clientRequestId: string) => diff --git a/apps/server/src/peer/RemoteDelegation.test.ts b/apps/server/src/peer/RemoteDelegation.test.ts index 7b0ef086369dc..bf728f19154fe 100644 --- a/apps/server/src/peer/RemoteDelegation.test.ts +++ b/apps/server/src/peer/RemoteDelegation.test.ts @@ -343,9 +343,9 @@ const boxToolkitLayer = (thread: BoxThread) => { input.untilDelegatedWorkSettled === true && (yield* Ref.get(thread.nested)) ) { - const pendingInput = input.terminalOnly - ? null - : yield* Ref.getAndSet(thread.nestedInput, null); + const pendingInput = input.returnOnInput + ? yield* Ref.getAndSet(thread.nestedInput, null) + : null; if (pendingInput !== null) { return { threadId: input.threadId, From e66f6fb66acb1be1f8508d48a2a055cf3a0157e9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 23:23:49 +0000 Subject: [PATCH 16/17] fix(server): thread agents' waits return on input without a new tool argument The returnOnInput argument grew the advertised MCP tool list past main's agent context budget. Waits now return on blocking input for thread callers only; the CLI, other MCP clients and linked peers' followers keep terminal-only waits, so the CLI and peer plumbing for the flag is reverted. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/thread.test.ts | 120 +++--------------- apps/server/src/cli/thread.ts | 39 ++---- apps/server/src/mcp/McpInvocationContext.ts | 9 -- .../src/mcp/OrchestratorMcpService.test.ts | 11 +- apps/server/src/mcp/OrchestratorMcpService.ts | 4 +- apps/server/src/peer/PeerForwarding.test.ts | 13 -- apps/server/src/peer/PeerForwarding.ts | 1 - apps/server/src/peer/RemoteDelegation.test.ts | 2 +- apps/server/src/peer/RemoteDelegation.ts | 2 - .../src/provider/agentContextBudget.json | 2 +- .../orchestrator-mcp-server.md | 8 +- packages/contracts/src/orchestratorMcp.ts | 1 - 12 files changed, 41 insertions(+), 171 deletions(-) diff --git a/apps/server/src/cli/thread.test.ts b/apps/server/src/cli/thread.test.ts index 04fe40ee4a3a5..5252d81b2a22f 100644 --- a/apps/server/src/cli/thread.test.ts +++ b/apps/server/src/cli/thread.test.ts @@ -11,7 +11,6 @@ import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; -import * as Schema from "effect/Schema"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; import * as TestClock from "effect/testing/TestClock"; import * as TestConsole from "effect/testing/TestConsole"; @@ -181,14 +180,10 @@ it.effect("does not sign in to whatever took over a dead server's port", () => /** Answers each tool call with the next scripted response for that tool. */ const scriptedTools = (script: Record>) => { const calls: Array<{ readonly tool: string; readonly args: unknown }> = []; - const call = ((tool: string, args: unknown, schema: Schema.Decoder) => { + const call = ((tool: string, args: unknown) => { calls.push({ tool, args }); const next = script[tool]?.shift(); - return next === undefined - ? Effect.die(`unscripted ${tool} call`) - : tool === "t3_thread_wait" - ? Schema.decodeEffect(schema)(next).pipe(Effect.orDie) - : Effect.succeed(next); + return next === undefined ? Effect.die(`unscripted ${tool} call`) : Effect.succeed(next); }) as ThreadTools["call"]; return { tools: { call } satisfies ThreadTools, calls }; }; @@ -198,23 +193,10 @@ it.effect("--wait follows the sent run to its continuation's terminal status", ( const continuation = RunId.make("run-2"); const { tools, calls } = scriptedTools({ t3_thread_wait: [ - { - threadId, - runId, - status: "running", - timedOut: true, - hasPendingQuestion: false, - pendingApproval: null, - }, - { - threadId, - runId: continuation, - status: "failed", - timedOut: false, - hasPendingQuestion: false, - pendingApproval: null, - }, + { threadId, runId, status: "running", timedOut: true }, + { threadId, runId: continuation, status: "failed", timedOut: false }, ], + t3_thread_read: [{ thread: { pendingRequestCount: 0 } }], }); const outcome = yield* waitForRun(tools, threadId, runId); @@ -223,8 +205,8 @@ it.effect("--wait follows the sent run to its continuation's terminal status", ( assert.deepStrictEqual( calls.filter((entry) => entry.tool === "t3_thread_wait").map((entry) => entry.args), [ - { threadId, runId, timeoutMs: 15_000, returnOnInput: true }, - { threadId, runId, timeoutMs: 15_000, returnOnInput: true }, + { threadId, runId, timeoutMs: 15_000 }, + { threadId, runId, timeoutMs: 15_000 }, ], ); }), @@ -267,14 +249,8 @@ it.effect("--wait lasts until background work the agent waits on wakes it and th assert.deepStrictEqual( calls.filter((entry) => entry.tool === "t3_thread_wait").map((entry) => entry.args), [ - { threadId, runId, timeoutMs: 15_000, returnOnInput: true }, - { - threadId, - runId, - timeoutMs: 15_000, - returnOnInput: true, - untilDelegatedWorkSettled: true, - }, + { threadId, runId, timeoutMs: 15_000 }, + { threadId, runId, timeoutMs: 15_000, untilDelegatedWorkSettled: true }, ], ); assert.deepStrictEqual(outcome, { type: "ended", runId: wakeRun, status: "completed" }); @@ -365,48 +341,15 @@ it.effect( it.effect("--wait stops when the thread waits for an answer instead of polling forever", () => Effect.gen(function* () { - const { tools, calls } = scriptedTools({ - t3_thread_wait: [ - { - threadId, - runId, - status: "queued", - timedOut: false, - hasPendingQuestion: true, - pendingApproval: null, - }, - ], - }); - - assert.deepStrictEqual(yield* waitForRun(tools, threadId, runId), { type: "needs_input" }); - assert.deepStrictEqual( - calls.map((call) => call.tool), - ["t3_thread_wait"], - ); - }), -); - -it.effect("--wait stops when the run background work woke waits for an answer", () => - Effect.gen(function* () { - const wakeRun = RunId.make("run-2"); const { tools } = scriptedTools({ t3_thread_wait: [ - { threadId, runId, status: "completed", timedOut: false }, - { - threadId, - runId, - status: "completed", - timedOut: false, - hasPendingQuestion: true, - pendingApproval: null, - }, + { threadId, runId, status: "queued", timedOut: true }, + { threadId, runId, status: "queued", timedOut: true }, ], - t3_thread_read: laterRunReads(wakeRun, []), + t3_thread_read: [{ thread: { pendingRequestCount: 1 } }], }); - assert.deepStrictEqual(yield* waitForRun(tools, threadId, runId, true), { - type: "needs_input", - }); + assert.deepStrictEqual(yield* waitForRun(tools, threadId, runId), { type: "needs_input" }); }), ); @@ -414,23 +357,10 @@ it.effect("--wait reports its run's result when a later run is the one waiting", Effect.gen(function* () { const { tools } = scriptedTools({ t3_thread_wait: [ - { - threadId, - runId, - status: "running", - timedOut: true, - hasPendingQuestion: false, - pendingApproval: null, - }, - { - threadId, - runId, - status: "completed", - timedOut: false, - hasPendingQuestion: false, - pendingApproval: null, - }, + { threadId, runId, status: "running", timedOut: true }, + { threadId, runId, status: "completed", timedOut: false }, ], + t3_thread_read: [{ thread: { pendingRequestCount: 1 } }], }); assert.deepStrictEqual(yield* waitForRun(tools, threadId, runId), { @@ -441,24 +371,6 @@ it.effect("--wait reports its run's result when a later run is the one waiting", }), ); -it.effect.each(["running", "completed"] as const)( - "--wait reads legacy pending requests and rechecks its %s run", - (status) => - Effect.gen(function* () { - const { tools } = scriptedTools({ - t3_thread_wait: [ - { threadId, runId, status: "running", timedOut: true }, - { threadId, runId, status, timedOut: status === "running" }, - ], - t3_thread_read: [{ thread: { pendingRequestCount: 1 } }], - }); - assert.deepStrictEqual( - yield* waitForRun(tools, threadId, runId), - status === "running" ? { type: "needs_input" } : { type: "ended", runId, status }, - ); - }), -); - /** Serves t3_thread_read pages the way the server does: positions count every item. */ const timelineTools = (types: ReadonlyArray) => { const items = types.map((type, position) => ({ position, itemId: `item-${position}`, type })); diff --git a/apps/server/src/cli/thread.ts b/apps/server/src/cli/thread.ts index 7a4149550c633..5218d0aa2576f 100644 --- a/apps/server/src/cli/thread.ts +++ b/apps/server/src/cli/thread.ts @@ -35,6 +35,10 @@ import * as ProcessRunner from "../processRunner.ts"; import { readLiveServerRuntimeState } from "../serverRuntimeState.ts"; import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; +/** + * Short enough that a run which stops to ask the user something is noticed + * soon, since `t3_thread_wait` only returns on a terminal run. + */ const WAIT_SLICE_MS = 15_000; const WAKE_GRACE_MS = 10_000; const WAKE_POLL_MS = 500; @@ -513,12 +517,6 @@ type WaitOutcome = } | { readonly type: "needs_input" }; -// Keep the missing flag observable: older servers need the thread-wide request fallback. -const CliThreadWaitResult = Schema.Struct({ - ...OrchestratorMcpThreadWaitResult.fields, - hasPendingQuestion: Schema.optional(Schema.Boolean), -}); - /** * Whether the thread has nothing left to do: no active run and no delegated * or background work. Only then is the work a later wait settles on this @@ -581,32 +579,24 @@ export const waitForRun = Effect.fn("cli.thread.waitForRun")(function* ( threadId, runId, timeoutMs, - returnOnInput: true, ...(untilDelegatedWorkSettled ? { untilDelegatedWorkSettled } : {}), }, - CliThreadWaitResult, + OrchestratorMcpThreadWaitResult, ); - const needsInput = (waited: typeof CliThreadWaitResult.Type) => - waited.hasPendingQuestion === true || waited.pendingApproval !== null; - // Servers that report input set `hasPendingQuestion`; older ones need the thread-wide count. - const pendingOnOlderServer = (waited: typeof CliThreadWaitResult.Type) => - waited.hasPendingQuestion === undefined - ? tools - .call( - "t3_thread_read", - { threadId, limit: 1, runLimit: 1 }, - OrchestratorMcpThreadReadResult, - ) - .pipe(Effect.map(({ thread }) => thread.pendingRequestCount > 0)) - : Effect.succeed(false); - const ended = (waited: typeof CliThreadWaitResult.Type) => + const ended = (waited: OrchestratorMcpThreadWaitResult) => ({ type: "ended", runId: waited.runId, status: waited.status }) satisfies WaitOutcome; const ownRun = Effect.gen(function* () { while (true) { const waited = yield* waitSlice(WAIT_SLICE_MS); - if (needsInput(waited)) return { type: "needs_input" } satisfies WaitOutcome; if (!waited.timedOut) return ended(waited); - if (yield* pendingOnOlderServer(waited)) { + // A pending request on this run, or on an earlier run this one is queued + // behind, blocks it until someone answers. + const { thread } = yield* tools.call( + "t3_thread_read", + { threadId, limit: 1, runLimit: 1 }, + OrchestratorMcpThreadReadResult, + ); + if (thread.pendingRequestCount > 0) { // The count is thread-wide: if this run ended since the slice, the // request belongs to a later run. const recheck = yield* waitSlice(1); @@ -635,7 +625,6 @@ export const waitForRun = Effect.fn("cli.thread.waitForRun")(function* ( const result = waited.timedOut ? undefined : waited.resultRun; const after = yield* runsAfter(tools, threadId, finalRunId); if (after.sent) return outcome; - if (needsInput(waited)) return { type: "needs_input" } satisfies WaitOutcome; if (!waited.timedOut) { if (result !== undefined && after.later.some((run) => run.runId === result.runId)) { return { type: "ended", runId: result.runId, status: result.status } satisfies WaitOutcome; diff --git a/apps/server/src/mcp/McpInvocationContext.ts b/apps/server/src/mcp/McpInvocationContext.ts index 329d60a4e4fb6..eb102be177782 100644 --- a/apps/server/src/mcp/McpInvocationContext.ts +++ b/apps/server/src/mcp/McpInvocationContext.ts @@ -143,15 +143,6 @@ export interface McpInvocationScope { readonly linkedEnvironments?: boolean; } -/** - * Client callers opt in: older CLIs and linked peers' followers read every - * non-timeout as a terminal run. - */ -export const threadWaitReturnsOnInput = ( - scope: McpInvocationScope, - returnOnInput: boolean | undefined, -): boolean => returnOnInput ?? scope.thread !== undefined; - /** * T3 Code renders `t3-thread://` links as thread chips. Other MCP clients cannot open them, so * they get the hosted link Copy thread copies, which opens the thread in either app or a browser. diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index d8c078bcf45db..a13275a9180fe 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -4101,11 +4101,9 @@ describe("OrchestratorMcpService thread lookup", () => { ); it.effect.each([ - { name: "legacy client", returnOnInput: undefined, agent: false, pending: false }, - { name: "updated client", returnOnInput: true, agent: false, pending: true }, - { name: "older linked peer", returnOnInput: undefined, linked: true, pending: false }, - { name: "thread caller", returnOnInput: undefined, agent: true, pending: true }, - { name: "terminal-only thread caller", returnOnInput: false, agent: true, pending: false }, + { name: "client", agent: false, pending: false }, + { name: "linked peer", linked: true, pending: false }, + { name: "thread caller", agent: true, pending: true }, ])("preserves wait semantics for $name", (scenario) => Effect.gen(function* () { const runId = RunId.make("run:compat-wait"); @@ -4173,9 +4171,6 @@ describe("OrchestratorMcpService thread lookup", () => { threadId: launched.id, runId, timeoutMs: 60_000, - ...(scenario.returnOnInput === undefined - ? {} - : { returnOnInput: scenario.returnOnInput }), }, ) .pipe(Effect.forkChild); diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index a1ec3b59e3980..6bfa15c204355 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -116,7 +116,6 @@ import { type McpThreadInvocationScope, requireThreadScope, threadLinkFor, - threadWaitReturnsOnInput, } from "./McpInvocationContext.ts"; import * as Metrics from "../observability/Metrics.ts"; import { @@ -3267,7 +3266,8 @@ const make = Effect.gen(function* () { projectId: target.thread.projectId, threadId: input.threadId, ...(input.runId === undefined ? {} : { runId: input.runId }), - returnOnInput: threadWaitReturnsOnInput(scope, input.returnOnInput), + // CLIs and linked peers' followers read every early return as a finished run. + returnOnInput: scope.thread !== undefined, timeoutMs: Math.min( MAX_WAIT_TIMEOUT_MS, Math.max(1, input.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS), diff --git a/apps/server/src/peer/PeerForwarding.test.ts b/apps/server/src/peer/PeerForwarding.test.ts index 5202ce7ec1bc2..4dbffcd0517b3 100644 --- a/apps/server/src/peer/PeerForwarding.test.ts +++ b/apps/server/src/peer/PeerForwarding.test.ts @@ -489,19 +489,6 @@ it.effect("keeps a forwarded wait short and its retries scoped to the caller", ( [PeerForwarding.FORWARDED_WAIT_CHUNK_MS, boxRun], ]); - // An explicit terminal-only wait must survive the linked-client boundary. - yield* a.call("thread:laptop-full", "t3_thread_wait", { - environmentId: box.environmentId, - threadId: boxThread, - returnOnInput: false, - }); - expect((yield* Ref.get(seen)).waits.map(({ returnOnInput }) => returnOnInput)).toEqual([ - true, - true, - true, - false, - ]); - const send = (caller: string, clientRequestId: string) => a.call(caller, "t3_thread_send", { environmentId: box.environmentId, diff --git a/apps/server/src/peer/PeerForwarding.ts b/apps/server/src/peer/PeerForwarding.ts index cee9d6139422b..e10d851f76913 100644 --- a/apps/server/src/peer/PeerForwarding.ts +++ b/apps/server/src/peer/PeerForwarding.ts @@ -208,7 +208,6 @@ const make = Effect.gen(function* () { threadId: input.threadId, ...(runId === undefined ? {} : { runId }), timeoutMs: chunk, - returnOnInput: McpInvocationContext.threadWaitReturnsOnInput(scope, input.returnOnInput), ...(input.untilDelegatedWorkSettled === undefined ? {} : { untilDelegatedWorkSettled: input.untilDelegatedWorkSettled }), diff --git a/apps/server/src/peer/RemoteDelegation.test.ts b/apps/server/src/peer/RemoteDelegation.test.ts index bf728f19154fe..d889fa5b37e92 100644 --- a/apps/server/src/peer/RemoteDelegation.test.ts +++ b/apps/server/src/peer/RemoteDelegation.test.ts @@ -1480,7 +1480,7 @@ it.effect.each([null, "question", "approval"] as const)( taskResult(a, delegated.taskId).pipe(Effect.as("completed here" as const)), ); expect(first).toBe("waiting there"); - // The follower asks for a terminal-only wait, so pending input never ends it early. + // A linked follower is a client caller there, so pending input never ends its wait early. expect(yield* Ref.get(thread.nestedInput)).toBe(pendingInput); // The review reports and wakes the thread there, whose next run answers. diff --git a/apps/server/src/peer/RemoteDelegation.ts b/apps/server/src/peer/RemoteDelegation.ts index 96324ce872595..016e825cefa80 100644 --- a/apps/server/src/peer/RemoteDelegation.ts +++ b/apps/server/src/peer/RemoteDelegation.ts @@ -772,8 +772,6 @@ const make = Effect.gen(function* () { // tasks to wake it for the answer, which a task here waits for too. // An older peer ignores this and reports the run alone. untilDelegatedWorkSettled: true, - // Only the terminal state matters here; input-aware waits would return at once. - returnOnInput: false, }) .pipe(Effect.raceFirst(endedHere)); // The next look reads the result and stops it there. diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index 4c1fb076e3331..e166af3f78340 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5597, "codexAdditionalContextBytes": 7327, - "mcpToolListJsonBytes": 48161 + "mcpToolListJsonBytes": 48126 } diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index c13a6cfb29755..98a868d6a6e7a 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -475,10 +475,10 @@ Waits for a selected run to become `completed`, `failed`, `cancelled`, `interrupted`, or `rolled_back`. Without `runId`, it pins the latest run at call time. An idle thread returns immediately. -Agents in a T3 thread return on blocking input by default. Other MCP clients, -including linked environments, opt in with `returnOnInput: true`, because older -clients read any wait that did not time out as a finished run. Setting -`returnOnInput: false` keeps the wait terminal-only. +For agents in a T3 thread, the wait also returns on blocking input. Waits from +other MCP clients, the `t3 thread` CLI and linked environments stay +terminal-only, because those callers read any wait that did not time out as a +finished run; the CLI checks for pending requests between short waits instead. An input-aware wait returns `timedOut: false` and `hasPendingQuestion` or `pendingApproval` for live input on the selected run or an earlier unfinished run diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index 284fe3b828272..4a75ae77d7753 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -564,7 +564,6 @@ export const OrchestratorMcpThreadWaitInput = Schema.Struct({ threadId: ThreadId, runId: Schema.optional(RunId), timeoutMs: Schema.optional(Schema.Finite), - returnOnInput: Schema.optional(Schema.Boolean), untilDelegatedWorkSettled: Schema.optional( Schema.Boolean.annotate({ description: From 8120e11cb9a5b0ab10e2c1c1038df1ffc327087f Mon Sep 17 00:00:00 2001 From: Nathan Shan Date: Sat, 14 Nov 2026 15:10:13 +0800 Subject: [PATCH 17/17] =?UTF-8?q?fix(server):=20=E6=8A=A5=E5=91=8A?= =?UTF-8?q?=E9=98=BB=E5=A1=9E=E6=8E=92=E9=98=9F=E4=BB=A3=E7=90=86=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E7=9A=84=E5=89=8D=E5=BA=8F=E8=AF=B7=E6=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 识别在排队任务之前仍占用线程的运行。 - 验证前序问题会解除等待,后续无关运行不影响结果。 --- .../src/mcp/OrchestratorMcpService.test.ts | 22 +++++++++++++++---- apps/server/src/mcp/OrchestratorMcpService.ts | 12 +++++++++- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index 6e87b1fa404ea..2539bd96446f3 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -357,8 +357,9 @@ describe("OrchestratorMcpService", () => { const childThreadId = ThreadId.make("thread:mcp-next-child"); const taskId = NodeId.make("node:mcp-next-task"); let finished = false; + let queued = false; let pendingRequest = false; - let requestOwner: "task" | "finished" | "unrelated" = "task"; + let requestOwner: "task" | "finished" | "unrelated" | "blocker" = "task"; let pendingSecret = false; let secretCompleted = false; let secretOwner: "task" | "finished" | "unrelated" = "task"; @@ -383,8 +384,12 @@ describe("OrchestratorMcpService", () => { }) as unknown as OrchestrationV2ThreadProjection; const childRun = () => ({ id: RunId.make("run:mcp-next-child"), - ordinal: 1, - status: finished ? ("completed" as const) : ("running" as const), + ordinal: 2, + status: finished + ? ("completed" as const) + : queued + ? ("queued" as const) + : ("running" as const), delegatedTaskId: taskId, }); const childProjection = () => @@ -393,7 +398,8 @@ describe("OrchestratorMcpService", () => { runs: [ childRun(), { id: "run:finished", ordinal: 0, status: "completed" }, - { id: "run:unrelated", ordinal: 2, status: "running" }, + { id: "run:unrelated", ordinal: 3, status: "running" }, + ...(queued ? [{ id: "run:blocker", ordinal: 1, status: "running" }] : []), ], contextTransfers: [], messages: [], @@ -492,6 +498,14 @@ describe("OrchestratorMcpService", () => { assert.isFalse(unrelated.hasPendingQuestion); assert.equal(unrelated.next, ORCHESTRATOR_MCP_RUNNING_TASK_NEXT); } + queued = true; + requestOwner = "blocker"; + const blocked = yield* service.taskStatus(scope, taskId); + assert.isTrue(blocked.hasPendingQuestion); + assert.notProperty(blocked, "next"); + requestOwner = "unrelated"; + assert.isFalse((yield* service.taskStatus(scope, taskId)).hasPendingQuestion); + queued = false; requestOwner = "task"; pendingRequest = false; diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index c359733a51895..0679fb4d203ab 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -534,6 +534,15 @@ function activeTaskRunIds( taskId: NodeId, originalRun: OrchestrationV2Run | undefined, ) { + const queuedOrdinal = child.runs.reduce( + (last, run) => + run.status === "queued" && + (run.delegatedTaskId === taskId || + (run.delegatedTaskId === undefined && run.id === originalRun?.id)) + ? Math.max(last, run.ordinal) + : last, + -1, + ); return new Set( child.runs .filter( @@ -541,7 +550,8 @@ function activeTaskRunIds( !ThreadManagementService.isTerminalRunStatus(run.status) && (run.delegatedTaskId === taskId || // Legacy tasks own only their original run, not later turns in the child thread. - (run.delegatedTaskId === undefined && run.id === originalRun?.id)), + (run.delegatedTaskId === undefined && run.id === originalRun?.id) || + (run.status !== "queued" && run.ordinal < queuedOrdinal)), ) .map((run) => run.id), );