1import {
2 CommandId,
3 type RunId,
4 isProviderAvailable,
5 MessageId,
6 type ModelSelection,
7 NodeId,
8 TurnItemId,
9 type OrchestrationV2Run,
10 type OrchestrationV2Subagent,
11 type OrchestrationV2ThreadProjection,
12 type OrchestrationV2ThreadShell,
13 type OrchestrationV2ThreadLaunchWorkspaceStrategy,
14 type OrchestrationV2TurnItem,
15 OrchestratorMcpFailure,
16 type OrchestratorMcpCapabilitiesResult,
17 type OrchestratorMcpCreateThreadsInput,
18 type OrchestratorMcpCreateThreadsResult,
19 type OrchestratorMcpCreatedThread,
20 type OrchestratorMcpDelegateTaskInput,
21 type OrchestratorMcpDelegateTaskResult,
22 type OrchestratorMcpInteractionMode,
23 type OrchestratorMcpDeleteScheduledTaskInput,
24 type OrchestratorMcpRequestSecretInput,
25 type OrchestratorMcpRequestSecretResult,
26 type OrchestratorMcpDeleteScheduledTaskResult,
27 type OrchestratorMcpListScheduledTasksResult,
28 type OrchestratorMcpRuntimeMode,
29 type OrchestratorMcpScheduledTask,
30 type OrchestratorMcpScheduleTaskInput,
31 type OrchestratorMcpScheduleTaskResult,
32 type OrchestratorMcpTarget,
33 type OrchestratorMcpTaskCancelInput,
34 type OrchestratorMcpTaskCancelResult,
35 type OrchestratorMcpUpdateScheduledTaskInput,
36 type OrchestratorMcpListScheduledTasksInput,
37 type ProjectId,
38 type OrchestratorMcpThreadDetail,
39 type OrchestratorMcpThreadInterruptInput,
40 type OrchestratorMcpThreadInterruptResult,
41 type OrchestratorMcpThreadListInput,
42 type OrchestratorMcpThreadListItem,
43 type OrchestratorMcpThreadListResult,
44 type OrchestratorMcpThreadReadInput,
45 type OrchestratorMcpThreadReadResult,
46 type OrchestratorMcpThreadRun,
47 type OrchestratorMcpThreadSendInput,
48 type OrchestratorMcpThreadSendResult,
49 type OrchestratorMcpThreadTimelineItem,
50 type OrchestratorMcpThreadWaitInput,
51 type OrchestratorMcpThreadWaitResult,
52 type ProviderInteractionMode,
53 type ProviderOptionDescriptor,
54 type ProviderOptionSelection,
55 type RuntimeMode,
56 type ScheduledTask,
57 type ScheduledTaskUpsertInput,
58 type ServerProvider,
59 ThreadId,
60} from "@t3tools/contracts";
61import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError";
62import * as Clock from "effect/Clock";
63import * as Context from "effect/Context";
64import * as Crypto from "effect/Crypto";
65import * as DateTime from "effect/DateTime";
66import * as Duration from "effect/Duration";
67import * as Effect from "effect/Effect";
68import * as Exit from "effect/Exit";
69import * as Layer from "effect/Layer";
70import * as Option from "effect/Option";
71import * as Ref from "effect/Ref";
72import * as Schema from "effect/Schema";
73import * as Stream from "effect/Stream";
74
75import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts";
76import {
77 subagentResultForRun,
78 delegatedTaskProgress,
79} from "@t3tools/provider-core/server/subagentProjection";
80import {
81 DispatchModeLimit,
82 type DispatchModeRefusal,
83} from "../orchestration-v2/DispatchModeLimit.ts";
84import { runOutcome } from "../orchestration-v2/RestartBackgroundNote.ts";
85import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
86import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts";
87import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
88import { isSnoozed } from "../orchestration-v2/ThreadSettlementService.ts";
89import * as ProjectService from "../project/ProjectService.ts";
90import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
91import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
92import * as ServerSettings from "../serverSettings.ts";
93import {
94 clientRuntimeModeCeiling,
95 type McpInvocationScope,
96 type McpThreadInvocationScope,
97 requireThreadScope,
98} from "./McpInvocationContext.ts";
99import * as Metrics from "../observability/Metrics.ts";
100import * as SecretRequests from "../secrets/SecretRequests.ts";
101
102const DEFAULT_WAIT_TIMEOUT_MS = 10 * 60 * 1_000;
103const MAX_WAIT_TIMEOUT_MS = 60 * 60 * 1_000;
104// Events that can end a delegated task wait: the parent's task record,
105// and the child's runs, nested tasks, provider background work, and questions.
106const TASK_WAKE_EVENTS = [
107 { thread: "parent", eventType: "subagent.updated" },
108 { thread: "child", eventType: "run.updated" },
109 { thread: "child", eventType: "subagent.updated" },
110 { thread: "child", eventType: "provider-thread.updated" },
111 { thread: "child", eventType: "runtime-request.updated" },
112] as const;
113/** A person answers the card, so a slower poll is plenty. */
114const SECRET_REQUEST_POLL_INTERVAL_MS = 500;
115const DEFAULT_THREAD_LIST_LIMIT = 50;
116const DEFAULT_THREAD_READ_LIMIT = 50;
117const DEFAULT_THREAD_RUN_LIMIT = 10;
118const DEFAULT_THREAD_ITEM_MAX_CHARS = 20_000;
119
120interface ResolvedTarget {
121 readonly modelSelection: ModelSelection;
122 readonly supportedRuntimeModes: ReadonlyArray<RuntimeMode> | undefined;
123}
124
125type TerminalTaskStatus = Extract<
126 OrchestratorMcpDelegateTaskResult["status"],
127 "completed" | "failed" | "cancelled" | "interrupted"
128>;
129
130export interface OrchestratorMcpServiceShape {
131 readonly capabilities: (
132 scope: McpInvocationScope,
133 ) => Effect.Effect<OrchestratorMcpCapabilitiesResult, OrchestratorMcpFailure>;
134 readonly delegateTask: (
135 scope: McpInvocationScope,
136 input: OrchestratorMcpDelegateTaskInput,
137 ) => Effect.Effect<OrchestratorMcpDelegateTaskResult, OrchestratorMcpFailure>;
138 readonly taskStatus: (
139 scope: McpInvocationScope,
140 taskId: NodeId,
141 ) => Effect.Effect<OrchestratorMcpDelegateTaskResult, OrchestratorMcpFailure>;
142 readonly cancelTask: (
143 scope: McpInvocationScope,
144 input: OrchestratorMcpTaskCancelInput,
145 ) => Effect.Effect<OrchestratorMcpTaskCancelResult, OrchestratorMcpFailure>;
146 readonly createThreads: (
147 scope: McpInvocationScope,
148 input: OrchestratorMcpCreateThreadsInput,
149 ) => Effect.Effect<OrchestratorMcpCreateThreadsResult, OrchestratorMcpFailure>;
150 readonly scheduleTask: (
151 scope: McpInvocationScope,
152 input: OrchestratorMcpScheduleTaskInput,
153 ) => Effect.Effect<OrchestratorMcpScheduleTaskResult, OrchestratorMcpFailure>;
154 readonly listScheduledTasks: (
155 scope: McpInvocationScope,
156 input: OrchestratorMcpListScheduledTasksInput,
157 ) => Effect.Effect<OrchestratorMcpListScheduledTasksResult, OrchestratorMcpFailure>;
158 readonly updateScheduledTask: (
159 scope: McpInvocationScope,
160 input: OrchestratorMcpUpdateScheduledTaskInput,
161 ) => Effect.Effect<OrchestratorMcpScheduleTaskResult, OrchestratorMcpFailure>;
162 readonly deleteScheduledTask: (
163 scope: McpInvocationScope,
164 input: OrchestratorMcpDeleteScheduledTaskInput,
165 ) => Effect.Effect<OrchestratorMcpDeleteScheduledTaskResult, OrchestratorMcpFailure>;
166 /**
167 * Asks the user for a secret through a card in the calling thread and waits
168 * for the answer. The value never reaches the agent: the result is a status.
169 */
170 readonly requestSecret: (
171 scope: McpInvocationScope,
172 input: OrchestratorMcpRequestSecretInput,
173 ) => Effect.Effect<OrchestratorMcpRequestSecretResult, OrchestratorMcpFailure>;
174 readonly listThreads: (
175 scope: McpInvocationScope,
176 input: OrchestratorMcpThreadListInput,
177 ) => Effect.Effect<OrchestratorMcpThreadListResult, OrchestratorMcpFailure>;
178 readonly readThread: (
179 scope: McpInvocationScope,
180 input: OrchestratorMcpThreadReadInput,
181 ) => Effect.Effect<OrchestratorMcpThreadReadResult, OrchestratorMcpFailure>;
182 readonly sendToThread: (
183 scope: McpInvocationScope,
184 input: OrchestratorMcpThreadSendInput,
185 ) => Effect.Effect<OrchestratorMcpThreadSendResult, OrchestratorMcpFailure>;
186 readonly waitForThread: (
187 scope: McpInvocationScope,
188 input: OrchestratorMcpThreadWaitInput,
189 ) => Effect.Effect<OrchestratorMcpThreadWaitResult, OrchestratorMcpFailure>;
190 readonly interruptThread: (
191 scope: McpInvocationScope,
192 input: OrchestratorMcpThreadInterruptInput,
193 ) => Effect.Effect<OrchestratorMcpThreadInterruptResult, OrchestratorMcpFailure>;
194}
195
196export class OrchestratorMcpService extends Context.Service<
197 OrchestratorMcpService,
198 OrchestratorMcpServiceShape
199>()("t3/mcp/OrchestratorMcpService") {}
200
201const isThreadManagementError = Schema.is(ThreadManagementService.ThreadManagementError);
202
203function failure(code: OrchestratorMcpFailure["code"], message: string): OrchestratorMcpFailure {
204 return new OrchestratorMcpFailure({ code, message });
205}
206
207function threadManagementFailure(error: unknown): OrchestratorMcpFailure {
208 if (!isThreadManagementError(error)) return failure("orchestration_error", errorMessage(error));
209 switch (error._tag) {
210 case "ThreadManagementThreadNotFoundError":
211 return failure("thread_not_found", error.message);
212 case "ThreadManagementRunNotFoundError":
213 return failure("run_not_found", error.message);
214 case "ThreadManagementThreadArchivedError":
215 case "ThreadManagementNoSteerableRunError":
216 return failure("thread_not_sendable", error.message);
217 case "ThreadManagementThreadNotInterruptibleError":
218 return failure("thread_not_interruptible", error.message);
219 case "ThreadManagementProjectionLoadError":
220 case "ThreadManagementProjectThreadsListError":
221 case "ThreadManagementDurableRunProjectionError":
222 return failure("orchestration_error", error.message);
223 case "ThreadManagementProviderInstanceDisabledError":
224 return failure("invalid_request", error.message);
225 }
226}
227
228function errorMessage(error: unknown): string {
229 return error instanceof Error ? error.message : String(error);
230}
231
232/**
233 * Workspace strategy for a scheduled task created/updated over MCP: bound runs
234 * post into the existing thread (the strategy is unused, keep root); unbound
235 * runs launch a fresh worktree per run.
236 */
237function scheduledTaskWorkspaceStrategy(
238 boundToThread: boolean,
239): ScheduledTask["workspaceStrategy"] {
240 return boundToThread
241 ? { type: "root" }
242 : { type: "worktree", baseRef: "main", startFromOrigin: true };
243}
244
245/**
246 * A scheduled task as an agent sees it. `mayRun` says whether the caller may
247 * run it: a webhook's URL carries the secret that starts the task's runs, so
248 * only such a caller sees it.
249 */
250function scheduledTaskSummary(task: ScheduledTask, mayRun: boolean): OrchestratorMcpScheduledTask {
251 return {
252 scheduledTaskId: task.id,
253 title: task.title,
254 prompt: task.prompt,
255 enabled: task.enabled,
256 projectId: task.projectId,
257 boundThreadId: task.threadId,
258 schedule: task.schedule,
259 nextRunAt: task.nextRunAt,
260 lastRunStatus: task.lastRunStatus,
261 // A bare path is not a URL anyone can call, so agents never get one to share.
262 ...(task.webhook?.url == null || !mayRun ? {} : { webhookUrl: task.webhook.url }),
263 ...(task.webhook === undefined
264 ? {}
265 : { webhookSignature: task.webhook.hasSecret ? "set" : "none" }),
266 };
267}
268
269function providerConstraints(
270 provider: ServerProvider | undefined,
271 supportsOrchestrationV2: boolean,
272): ReadonlyArray<string> {
273 const constraints: Array<string> = [];
274 if (!supportsOrchestrationV2) {
275 constraints.push("No V2 provider adapter is registered.");
276 }
277 if (provider === undefined) return constraints;
278 if (!provider.enabled) constraints.push("Provider instance is disabled.");
279 if (!provider.installed) constraints.push("Provider executable is not installed.");
280 if (!isProviderAvailable(provider)) {
281 constraints.push(provider.unavailableReason ?? "Provider driver is unavailable.");
282 }
283 if (provider.status === "error" || provider.status === "disabled") {
284 constraints.push(provider.message ?? `Provider status is ${provider.status}.`);
285 }
286 if (provider.auth.status === "unauthenticated") {
287 constraints.push("Provider is not authenticated.");
288 }
289 return constraints;
290}
291
292/**
293 * Checks requested option selections for duplicates and, when the model
294 * advertises option descriptors, against those descriptors. Models without
295 * descriptors skip the descriptor checks (mirroring how model slugs are only
296 * validated when the provider advertises models), but duplicate ids always
297 * fail: downstream consumers disagree on whether the first or last value of
298 * a duplicated id wins.
299 */
300function invalidOptionSelections(
301 selections: ReadonlyArray<ProviderOptionSelection>,
302 descriptors: ReadonlyArray<ProviderOptionDescriptor> | undefined,
303): ReadonlyArray<string> {
304 const problems: Array<string> = [];
305 const seen = new Set<string>();
306 for (const selection of selections) {
307 if (seen.has(selection.id)) {
308 problems.push(`Option ${selection.id} was specified more than once.`);
309 continue;
310 }
311 seen.add(selection.id);
312 if (descriptors === undefined) continue;
313 const descriptor = descriptors.find((candidate) => candidate.id === selection.id);
314 if (descriptor === undefined) {
315 const known = descriptors.map((candidate) => candidate.id).join(", ");
316 problems.push(`Unknown option ${selection.id}; supported options: ${known || "none"}.`);
317 continue;
318 }
319 if (descriptor.type === "boolean" && typeof selection.value !== "boolean") {
320 problems.push(`Option ${selection.id} expects a boolean value.`);
321 continue;
322 }
323 if (
324 descriptor.type === "select" &&
325 !descriptor.options.some((choice) => choice.id === selection.value)
326 ) {
327 const choices = descriptor.options.map((choice) => choice.id).join(", ");
328 problems.push(`Option ${selection.id} must be one of: ${choices}.`);
329 }
330 }
331 return problems;
332}
333
334function taskStatusForRun(
335 run: Pick<OrchestrationV2Run, "status" | "restartCancelledBackgroundWork"> | undefined,
336): OrchestratorMcpDelegateTaskResult["status"] {
337 switch (run === undefined ? undefined : runOutcome(run)) {
338 case "queued":
339 return "queued";
340 case "waiting":
341 return "waiting";
342 case "completed":
343 return "completed";
344 case "failed":
345 return "failed";
346 case "cancelled":
347 case "rolled_back":
348 return "cancelled";
349 case "interrupted":
350 return "interrupted";
351 case "preparing":
352 case "starting":
353 case "running":
354 case undefined:
355 return "running";
356 }
357}
358
359export function delegatedTaskRun(
360 childProjection: Pick<OrchestrationV2ThreadProjection, "runs" | "contextTransfers">,
361 task: OrchestrationV2Subagent,
362): OrchestrationV2Run | undefined {
363 const taskRun = childProjection.runs.find((run) => run.delegatedTaskId === task.id);
364 if (taskRun !== undefined) return taskRun;
365 const spawnTransfer = childProjection.contextTransfers.find(
366 (transfer) =>
367 transfer.type === "subagent_spawn" &&
368 transfer.sourceThreadId === task.threadId &&
369 transfer.targetThreadId === task.childThreadId,
370 );
371 if (spawnTransfer === undefined) {
372 // Legacy delegated-task projections predate the durable spawn transfer.
373 return childProjection.runs[0];
374 }
375 return spawnTransfer.targetRunId === null
376 ? undefined
377 : childProjection.runs.find((run) => run.id === spawnTransfer.targetRunId);
378}
379
380export function hasPendingChildRuns(
381 childProjection: Pick<OrchestrationV2ThreadProjection, "runs">,
382 delegatedRun: OrchestrationV2Run | undefined,
383): boolean {
384 // Held queued runs wait for the user to resume the child; task_cancel holds them.
385 return childProjection.runs.some(
386 (run) =>
387 !ThreadManagementService.isTerminalRunStatus(run.status) &&
388 !(run.status === "queued" && run.queueHeld === true) &&
389 (delegatedRun === undefined || run.ordinal > delegatedRun.ordinal),
390 );
391}
392
393function isTerminalTaskStatus(
394 status: OrchestratorMcpDelegateTaskResult["status"],
395): status is TerminalTaskStatus {
396 return (
397 status === "completed" ||
398 status === "failed" ||
399 status === "cancelled" ||
400 status === "interrupted"
401 );
402}
403
404function directAppOwnedChildTasks(
405 parent: Pick<OrchestrationV2ThreadProjection, "thread" | "subagents">,
406 target: Pick<OrchestrationV2ThreadProjection, "thread" | "runs">,
407 returnedRunIds: ReadonlySet<RunId | null>,
408): ReadonlyArray<OrchestrationV2Subagent> {
409 if (
410 target.thread.lineage.parentThreadId !== parent.thread.id ||
411 target.thread.lineage.relationshipToParent !== "subagent"
412 ) {
413 return [];
414 }
415 const original = parent.subagents.find(
416 (task) =>
417 task.origin === "app_owned" &&
418 task.threadId === parent.thread.id &&
419 task.id ===
420 (target.thread.forkedFrom?.type === "node" ? target.thread.forkedFrom.nodeId : undefined) &&
421 task.childThreadId === target.thread.id,
422 );
423 const taskIds = new Set(
424 target.runs
425 .filter((run) => returnedRunIds.has(run.id))
426 .map((run) => run.delegatedTaskId ?? original?.id),
427 );
428 return parent.subagents.filter(
429 (task) =>
430 taskIds.has(task.id) &&
431 task.origin === "app_owned" &&
432 task.childThreadId === target.thread.id,
433 );
434}
435
436function taskResultTransfer(
437 parent: Pick<OrchestrationV2ThreadProjection, "thread" | "contextTransfers">,
438 child: Pick<OrchestrationV2ThreadProjection, "runs" | "contextTransfers">,
439 task: OrchestrationV2Subagent,
440) {
441 const originalRun = delegatedTaskRun(child, task);
442 const resultRunIds = new Set(
443 child.runs
444 .filter(
445 (run) =>
446 run.delegatedTaskId === task.id ||
447 (originalRun?.delegatedTaskId === undefined && run.delegatedTaskId === undefined),
448 )
449 .map((run) => run.id),
450 );
451 if (originalRun !== undefined) resultRunIds.add(originalRun.id);
452 return parent.contextTransfers.find(
453 (transfer) =>
454 transfer.type === "subagent_result" &&
455 transfer.sourceThreadId === task.childThreadId &&
456 transfer.targetThreadId === parent.thread.id &&
457 ((transfer.sourcePoint.runId !== undefined && resultRunIds.has(transfer.sourcePoint.runId)) ||
458 (transfer.sourcePoint.runId === undefined && originalRun?.delegatedTaskId === undefined)),
459 );
460}
461
462function pageIncludesTerminalTaskResult(input: {
463 readonly parent: Pick<OrchestrationV2ThreadProjection, "thread" | "contextTransfers">;
464 readonly page: ReadonlyArray<OrchestrationV2ThreadProjection["visibleTurnItems"][number]>;
465 readonly task: OrchestrationV2Subagent;
466 readonly target: Pick<
467 OrchestrationV2ThreadProjection,
468 "thread" | "runs" | "contextTransfers" | "messages" | "turnItems"
469 >;
470 readonly maxChars: number;
471}): boolean {
472 const transfer = taskResultTransfer(input.parent, input.target, input.task);
473 if (transfer === undefined) return false;
474 const run =
475 transfer.sourcePoint.runId === undefined
476 ? delegatedTaskRun(input.target, input.task)
477 : input.target.runs.find((run) => run.id === transfer.sourcePoint.runId);
478 if (run === undefined || !isTerminalTaskStatus(taskStatusForRun(run))) return false;
479
480 const result = subagentResultForRun(input.target, run);
481 if (result.messageId === null && result.turnItemId === null) return false;
482
483 return input.page.some((row) => {
484 if (row.sourceThreadId !== input.target.thread.id) return false;
485 const matchesResult =
486 (result.turnItemId !== null && row.sourceItemId === result.turnItemId) ||
487 (result.messageId !== null &&
488 row.item.type === "assistant_message" &&
489 row.item.messageId === result.messageId);
490 if (!matchesResult) return false;
491
492 const text = turnItemText(row.item);
493 return text !== null && text.length <= input.maxChars;
494 });
495}
496
497function latestTerminalResultRun(
498 projection: Pick<OrchestrationV2ThreadProjection, "messages" | "runs">,
499 delegatedRun: OrchestrationV2Run | undefined,
500): OrchestrationV2Run | undefined {
501 const monitorRunIds = new Set(
502 projection.messages
503 .filter((message) => message.notification?.source.kind === "monitor")
504 .map((message) => message.runId),
505 );
506 return projection.runs
507 .filter(
508 (run) =>
509 ThreadManagementService.isTerminalRunStatus(run.status) &&
510 !monitorRunIds.has(run.id) &&
511 run.status !== "rolled_back" &&
512 (run.id === delegatedRun?.id || run.startedAt !== null),
513 )
514 .reduce<OrchestrationV2Run | undefined>(
515 (latest, run) => (latest === undefined || runRanAfter(run, latest) ? run : latest),
516 undefined,
517 );
518}
519
520function canExposeTaskRunResult(run: OrchestrationV2Run | undefined): run is OrchestrationV2Run {
521 return (
522 run !== undefined && run.status !== "rolled_back" && isTerminalTaskStatus(taskStatusForRun(run))
523 );
524}
525
526function runtimeModeRank(mode: RuntimeMode): number {
527 switch (mode) {
528 case "approval-required":
529 return 0;
530 case "auto-accept-edits":
531 return 1;
532 case "auto":
533 return 2;
534 case "full-access":
535 return 3;
536 }
537}
538
539function interactionModeRank(mode: ProviderInteractionMode): number {
540 return mode === "plan" ? 0 : 1;
541}
542
543/**
544 * The mode a new thread runs in: the requested mode, else the parent's, never
545 * broader than the parent's. Pass `target` when the thread runs on a known
546 * provider. A provider runs a mode it does not offer as Supervised, so an
547 * explicit request for one is refused, and an inherited one steps down to the
548 * broadest mode the provider offers.
549 */
550export function resolveRuntimeMode(
551 parentMode: RuntimeMode,
552 requested: OrchestratorMcpRuntimeMode | undefined,
553 target?: Pick<ResolvedTarget, "modelSelection" | "supportedRuntimeModes">,
554): Effect.Effect<RuntimeMode, OrchestratorMcpFailure> {
555 const inherited = requested === undefined || requested === "inherit";
556 const resolved = inherited ? parentMode : requested;
557 if (runtimeModeRank(resolved) > runtimeModeRank(parentMode)) {
558 return Effect.fail(
559 failure(
560 "runtime_mode_escalation_denied",
561 `Child runtime mode ${resolved} is broader than parent mode ${parentMode}.`,
562 ),
563 );
564 }
565 const supported = target?.supportedRuntimeModes;
566 if (supported === undefined || supported.length === 0 || supported.includes(resolved)) {
567 return Effect.succeed(resolved);
568 }
569 if (!inherited) {
570 return Effect.fail(
571 failure(
572 "invalid_request",
573 `Provider ${target?.modelSelection.instanceId} does not offer runtime mode ${resolved}, so it would run as approval-required. Use one of: ${supported.join(", ")}.`,
574 ),
575 );
576 }
577 return Effect.succeed(
578 supported
579 .filter((mode) => runtimeModeRank(mode) <= runtimeModeRank(resolved))
580 .toSorted((left, right) => runtimeModeRank(right) - runtimeModeRank(left))[0] ??
581 "approval-required",
582 );
583}
584
585export function resolveInteractionMode(
586 parentMode: ProviderInteractionMode,
587 requested: OrchestratorMcpInteractionMode | undefined,
588): Effect.Effect<ProviderInteractionMode, OrchestratorMcpFailure> {
589 const resolved = requested === undefined || requested === "inherit" ? parentMode : requested;
590 return interactionModeRank(resolved) > interactionModeRank(parentMode)
591 ? Effect.fail(
592 failure(
593 "interaction_mode_escalation_denied",
594 `Child interaction mode ${resolved} is broader than parent mode ${parentMode}.`,
595 ),
596 )
597 : Effect.succeed(resolved);
598}
599
600function stablePart(value: string): string {
601 return encodeURIComponent(value);
602}
603
604function stableCommandId(input: {
605 readonly scope: McpInvocationScope;
606 readonly requestKey: string;
607 readonly operation: string;
608 readonly index?: number;
609}): CommandId {
610 return CommandId.make(
611 [
612 "command",
613 "mcp",
614 stablePart(input.scope.requestNamespace),
615 stablePart(input.operation),
616 stablePart(input.requestKey),
617 ...(input.index === undefined ? [] : [String(input.index)]),
618 ].join(":"),
619 );
620}
621
622function stableThreadId(input: {
623 readonly scope: McpInvocationScope;
624 readonly requestKey: string;
625 readonly index: number;
626}): ThreadId {
627 return ThreadId.make(
628 [
629 "thread",
630 "mcp",
631 stablePart(input.scope.requestNamespace),
632 stablePart(input.requestKey),
633 String(input.index),
634 ].join(":"),
635 );
636}
637
638function stableMessageId(input: {
639 readonly scope: McpInvocationScope;
640 readonly requestKey: string;
641 readonly index: number;
642}): MessageId {
643 return MessageId.make(
644 [
645 "message",
646 "mcp",
647 stablePart(input.scope.requestNamespace),
648 stablePart(input.requestKey),
649 String(input.index),
650 ].join(":"),
651 );
652}
653
654function stableOperationMessageId(input: {
655 readonly scope: McpInvocationScope;
656 readonly requestKey: string;
657 readonly operation: string;
658}): MessageId {
659 return MessageId.make(
660 [
661 "message",
662 "mcp",
663 stablePart(input.scope.requestNamespace),
664 stablePart(input.operation),
665 stablePart(input.requestKey),
666 ].join(":"),
667 );
668}
669
670function threadTitle(input: {
671 readonly parentTitle: string;
672 readonly prompt: string | undefined;
673 readonly title: string | undefined;
674 readonly index: number;
675}): string {
676 const detail = input.title?.trim() || input.prompt?.trim();
677 if (!detail) return `${input.parentTitle} thread ${input.index + 1}`;
678 return detail.length > 80 ? `${detail.slice(0, 77)}...` : detail;
679}
680
681function taskPrompt(input: OrchestratorMcpDelegateTaskInput): string {
682 return input.role === undefined || input.role === "general"
683 ? input.task
684 : `Act as the ${input.role} sub-agent for this task.\n\n${input.task}`;
685}
686
687function threadSettlement(
688 thread: Pick<OrchestrationV2ThreadShell, "settledOverride" | "settledAt">,
689): Pick<OrchestratorMcpThreadListItem, "settled" | "settledAt"> {
690 const settled = thread.settledOverride === "settled";
691 return {
692 settled,
693 settledAt: settled && thread.settledAt !== null ? DateTime.formatIso(thread.settledAt) : null,
694 };
695}
696
697function threadSnooze(
698 thread: Parameters<typeof isSnoozed>[0],
699 nowMs: number,
700): Pick<OrchestratorMcpThreadListItem, "snoozed" | "snoozedUntil"> {
701 const snoozed = isSnoozed(thread, nowMs);
702 return {
703 snoozed,
704 snoozedUntil:
705 snoozed && thread.snoozedUntil != null ? DateTime.formatIso(thread.snoozedUntil) : null,
706 };
707}
708
709function listItemFromShell(
710 shell: OrchestrationV2ThreadShell,
711 nowMs: number,
712): OrchestratorMcpThreadListItem {
713 return {
714 threadId: shell.id,
715 title: shell.title,
716 createdBy: shell.createdBy,
717 creationSource: shell.creationSource,
718 status: shell.activityRunStatus ?? shell.status,
719 latestRunId: shell.latestRunId,
720 providerInstanceId: shell.modelSelection.instanceId,
721 model: shell.modelSelection.model,
722 runtimeMode: shell.runtimeMode,
723 interactionMode: shell.interactionMode,
724 linkedPullRequest: shell.linkedPullRequest ?? null,
725 ...threadSettlement(shell),
726 ...threadSnooze(shell, nowMs),
727 parentThreadId: shell.lineage.parentThreadId,
728 relationshipToParent: shell.lineage.relationshipToParent,
729 itemCount: shell.visibleItemCount,
730 createdAt: DateTime.formatIso(shell.createdAt),
731 updatedAt: DateTime.formatIso(shell.updatedAt),
732 };
733}
734
735function threadDetail(
736 projection: Pick<OrchestrationV2ThreadProjection, "thread" | "runs" | "runtimeRequests">,
737 itemCount: number,
738 shell: OrchestrationV2ThreadShell,
739 nowMs: number,
740): OrchestratorMcpThreadDetail {
741 const latest = ThreadManagementService.latestRun(projection);
742 const active = ThreadManagementService.latestActiveRun(projection);
743 return {
744 threadId: projection.thread.id,
745 projectId: projection.thread.projectId,
746 title: projection.thread.title,
747 createdBy: projection.thread.createdBy,
748 creationSource: projection.thread.creationSource,
749 status: active?.status ?? latest?.status ?? "idle",
750 latestRunId: latest?.id ?? null,
751 activeRunId: active?.id ?? null,
752 providerInstanceId: projection.thread.modelSelection.instanceId,
753 model: projection.thread.modelSelection.model,
754 runtimeMode: projection.thread.runtimeMode,
755 interactionMode: projection.thread.interactionMode,
756 linkedPullRequest: projection.thread.linkedPullRequest ?? null,
757 titleRegeneration:
758 projection.thread.titleRegeneration === undefined ||
759 projection.thread.titleRegeneration === null
760 ? null
761 : {
762 requestId: projection.thread.titleRegeneration.requestId,
763 startedAt: DateTime.formatIso(projection.thread.titleRegeneration.startedAt),
764 },
765 branch: projection.thread.branch,
766 worktreePath: projection.thread.worktreePath,
767 parentThreadId: projection.thread.lineage.parentThreadId,
768 relationshipToParent: projection.thread.lineage.relationshipToParent,
769 runCount: projection.runs.length,
770 itemCount,
771 pendingRequestCount: projection.runtimeRequests.filter(
772 (request) => request.status === "pending",
773 ).length,
774 archived: projection.thread.archivedAt !== null,
775 ...threadSettlement(projection.thread),
776 // From the shell, like the list, so read and list agree on snooze state.
777 ...threadSnooze(shell, nowMs),
778 createdAt: DateTime.formatIso(projection.thread.createdAt),
779 updatedAt: DateTime.formatIso(projection.thread.updatedAt),
780 };
781}
782
783function threadRun(run: OrchestrationV2Run): OrchestratorMcpThreadRun {
784 return {
785 runId: run.id,
786 ordinal: run.ordinal,
787 status: run.status,
788 providerInstanceId: run.modelSelection.instanceId,
789 model: run.modelSelection.model,
790 requestedAt: DateTime.formatIso(run.requestedAt),
791 startedAt: run.startedAt === null ? null : DateTime.formatIso(run.startedAt),
792 completedAt: run.completedAt === null ? null : DateTime.formatIso(run.completedAt),
793 };
794}
795
796function jsonText(value: unknown): string {
797 try {
798 return JSON.stringify(value, null, 2);
799 } catch {
800 return String(value);
801 }
802}
803
804function turnItemText(item: OrchestrationV2TurnItem): string | null {
805 switch (item.type) {
806 case "notification":
807 return [item.summary, item.detail].filter((part) => part !== undefined).join("\n");
808 case "user_message":
809 case "assistant_message":
810 case "reasoning":
811 return item.text;
812 case "proposed_plan":
813 return item.markdown;
814 case "todo_list":
815 return [item.explanation, ...item.steps.map((step) => `[${step.status}] ${step.text}`)]
816 .filter((line): line is string => line !== undefined)
817 .join("\n");
818 case "user_input_request":
819 return jsonText(item.questions);
820 case "file_change":
821 return [
822 item.fileName,
823 item.additions === undefined && item.deletions === undefined
824 ? undefined
825 : `+${item.additions ?? 0} -${item.deletions ?? 0}`,
826 item.diffStr ?? item.newStr,
827 ]
828 .filter((line): line is string => line !== undefined)
829 .join("\n");
830 case "command_execution":
831 return [`$ ${item.input}`, item.output]
832 .filter((line): line is string => line !== undefined)
833 .join("\n");
834 case "file_search":
835 return jsonText({ pattern: item.pattern, results: item.results });
836 case "web_search":
837 return jsonText({ patterns: item.patterns, results: item.results });
838 case "approval_request":
839 return item.prompt ?? item.requestKind;
840 case "checkpoint":
841 return jsonText(item.files);
842 case "run_interrupt_request":
843 case "run_interrupt_result":
844 case "system_notice":
845 return item.message;
846 case "error":
847 return item.failure.message;
848 case "compaction":
849 return item.summary ?? null;
850 case "handoff":
851 return item.summary ?? `${item.strategy} handoff to ${item.toProviderInstanceId}`;
852 case "fork":
853 return `Forked to thread ${item.targetThreadId}.`;
854 case "thread_created":
855 return `Created thread ${item.targetThreadId} with ${item.targetProviderInstanceId} (${item.targetModel}).`;
856 case "secret_request":
857 return `Asked the user for ${item.label}: ${item.secretStatus}.`;
858 case "subagent":
859 return item.result ?? item.progress ?? item.prompt;
860 case "dynamic_tool":
861 return jsonText({ toolName: item.toolName, input: item.input, output: item.output });
862 }
863}
864
865function timelineItem(input: {
866 readonly row: OrchestrationV2ThreadProjection["visibleTurnItems"][number];
867 readonly maxChars: number;
868 readonly textOffset?: number;
869 readonly messagesByThreadId: ReadonlyMap<ThreadId, OrchestrationV2ThreadProjection["messages"]>;
870}): OrchestratorMcpThreadTimelineItem {
871 const text = turnItemText(input.row.item);
872 const offset = input.textOffset ?? 0;
873 const end = offset + input.maxChars;
874 const textTruncated = text !== null && text.length > end;
875 const messageId =
876 input.row.item.type === "user_message" || input.row.item.type === "assistant_message"
877 ? input.row.item.messageId
878 : null;
879 const message =
880 messageId === null
881 ? undefined
882 : input.messagesByThreadId
883 .get(input.row.sourceThreadId)
884 ?.find((candidate) => candidate.id === messageId);
885 return {
886 position: input.row.position,
887 visibility: input.row.visibility,
888 sourceThreadId: input.row.sourceThreadId,
889 itemId: input.row.sourceItemId,
890 ...(input.row.item.type === "subagent" ? { subagentId: input.row.item.subagentId } : {}),
891 runId: input.row.item.runId,
892 messageId,
893 createdBy: message?.createdBy ?? null,
894 creationSource: message?.creationSource ?? null,
895 type: input.row.item.type,
896 status: input.row.item.status,
897 title: input.row.item.title,
898 text: text === null ? null : text.slice(offset, end),
899 textTruncated,
900 nextTextOffset: textTruncated ? end : null,
901 updatedAt: DateTime.formatIso(input.row.item.updatedAt),
902 };
903}
904
905const make = Effect.gen(function* () {
906 const crypto = yield* Crypto.Crypto;
907 const threadManagement = yield* ThreadManagementService.ThreadManagementService;
908 const providerRegistry = yield* ProviderRegistry.ProviderRegistry;
909 const providerAdapters = yield* ProviderAdapterRegistry.ProviderAdapterRegistryV2;
910 const scheduledTasks = yield* ScheduledTaskService.ScheduledTaskService;
911 const projects = yield* ProjectService.ProjectService;
912 const threadLaunch = yield* ThreadLaunchService.ThreadLaunchService;
913 const gitDriver = yield* GitVcsDriver.GitVcsDriver;
914 const serverSettings = yield* ServerSettings.ServerSettingsService;
915
916 /** A caller-named project, which must exist before anything is recorded against it. */
917 const requireProject = (projectId: ProjectId) =>
918 projects.getById(projectId).pipe(
919 Effect.mapError((error) =>
920 failure("orchestration_error", `Unable to read project ${projectId}: ${error.message}`),
921 ),
922 Effect.flatMap(
923 Option.match({
924 onNone: () =>
925 Effect.fail(failure("invalid_request", `Project ${projectId} was not found.`)),
926 onSome: Effect.succeed,
927 }),
928 ),
929 );
930
931 /** A client has no thread to inherit a model from, so it falls back to the project default. */
932 const projectDefaultModelSelection = (
933 project: Effect.Success<ReturnType<typeof requireProject>>,
934 ) =>
935 project.defaultModelSelection === null
936 ? Effect.fail(
937 failure(
938 "invalid_request",
939 `Project ${project.id} has no default model, so this caller cannot pick one for it.`,
940 ),
941 )
942 : Effect.succeed(project.defaultModelSelection);
943 const secretRequests = yield* SecretRequests.SecretRequests;
944
945 const requireCapability = (scope: McpInvocationScope) =>
946 scope.capabilities.has("orchestration")
947 ? Effect.void
948 : Effect.fail(
949 failure(
950 "capability_denied",
951 "This MCP credential does not grant orchestration capabilities.",
952 ),
953 );
954
955 const loadProjection = (threadId: ThreadId) =>
956 threadManagement
957 .getThreadRecords(
958 threadId,
959 [
960 "runs",
961 "messages",
962 "subagents",
963 "providerThreads",
964 "providerTurns",
965 "runtimeRequests",
966 "contextTransfers",
967 "turnItems",
968 ],
969 { turnItemTypes: [], messageRoles: ["user"] },
970 )
971 .pipe(
972 Effect.mapError((error) =>
973 failure(
974 "orchestration_error",
975 `Unable to read thread ${threadId}: ${errorMessage(error)}`,
976 ),
977 ),
978 );
979
980 const loadProjectThread = (
981 projectId: OrchestrationV2ThreadProjection["thread"]["projectId"],
982 threadId: ThreadId,
983 ) =>
984 threadManagement
985 .getProjectThreadRecords(
986 { projectId, threadId },
987 [
988 "runs",
989 "messages",
990 "subagents",
991 "providerThreads",
992 "providerTurns",
993 "runtimeRequests",
994 "contextTransfers",
995 "turnItems",
996 ],
997 { turnItemTypes: [], messageRoles: ["user"] },
998 )
999 .pipe(Effect.mapError(threadManagementFailure));
1000
1001 /**
1002 * The caller's own thread and the most it may hand to threads it targets. A
1003 * thread caller is capped by its own modes; an OAuth client by the ceiling
1004 * chosen when it was approved.
1005 */
1006 const loadCaller = (scope: McpInvocationScope) =>
1007 Effect.gen(function* () {
1008 yield* requireCapability(scope);
1009 if (scope.thread === undefined) {
1010 return {
1011 parent: undefined,
1012 limits: {
1013 runtimeMode: clientRuntimeModeCeiling(scope.client),
1014 interactionMode: "default",
1015 } satisfies { runtimeMode: RuntimeMode; interactionMode: ProviderInteractionMode },
1016 } as const;
1017 }
1018 const parent = yield* loadProjection(scope.thread.threadId);
1019 return {
1020 parent,
1021 limits: {
1022 runtimeMode: parent.thread.runtimeMode,
1023 interactionMode: parent.thread.interactionMode,
1024 },
1025 } as const;
1026 });
1027
1028 /** The caller's own thread, for operations that act as the caller. */
1029 const loadThreadCaller = (scope: McpInvocationScope, operation: string) =>
1030 Effect.gen(function* () {
1031 yield* requireCapability(scope);
1032 const threadScope = yield* requireThreadScope(scope, operation);
1033 const parent = yield* loadProjection(threadScope.thread.threadId);
1034 return { scope: threadScope, parent } as const;
1035 });
1036
1037 /** A target project: the one passed, else the calling thread's. */
1038 const resolveProjectTarget = (
1039 parent: Pick<OrchestrationV2ThreadProjection, "thread"> | undefined,
1040 projectId: ProjectId | undefined,
1041 ) =>
1042 projectId !== undefined
1043 ? Effect.succeed(projectId)
1044 : parent !== undefined
1045 ? Effect.succeed(parent.thread.projectId)
1046 : Effect.fail(
1047 failure(
1048 "target_required",
1049 "Pass projectId: this MCP client is not running inside a T3 thread.",
1050 ),
1051 );
1052
1053 /** Any live thread in the environment. */
1054 const loadTargetThread = (threadId: ThreadId) =>
1055 Effect.gen(function* () {
1056 const shell = yield* threadManagement
1057 .getThreadShell(threadId)
1058 .pipe(Effect.mapError(threadManagementFailure));
1059 if (shell === null || shell.deletedAt !== null) {
1060 return yield* failure("thread_not_found", `Thread ${threadId} was not found.`);
1061 }
1062 return yield* loadProjectThread(shell.projectId, threadId);
1063 });
1064
1065 const loadScopedThread = (scope: McpInvocationScope, threadId: ThreadId) =>
1066 Effect.gen(function* () {
1067 const caller = yield* loadCaller(scope);
1068 const target =
1069 caller.parent !== undefined && threadId === caller.parent.thread.id
1070 ? caller.parent
1071 : yield* loadTargetThread(threadId);
1072 return { ...caller, target } as const;
1073 });
1074
1075 /**
1076 * A thread caller writes to another thread only while its own run is live,
1077 * so a credential that outlived its session cannot reach across threads.
1078 */
1079 const assertLiveCallerForOtherThread = (
1080 scope: McpInvocationScope,
1081 parent: Pick<OrchestrationV2ThreadProjection, "thread" | "runs"> | undefined,
1082 target: Pick<OrchestrationV2ThreadProjection, "thread">,
1083 ) =>
1084 parent === undefined || target.thread.id === parent.thread.id
1085 ? Effect.void
1086 : assertLiveCaller(scope, parent);
1087
1088 /** Scheduled work outside the caller's own project needs the same live run. */
1089 const assertLiveCallerForOtherProject = (
1090 scope: McpInvocationScope,
1091 parent: Pick<OrchestrationV2ThreadProjection, "thread" | "runs"> | undefined,
1092 projectId: ProjectId,
1093 ) =>
1094 parent === undefined || projectId === parent.thread.projectId
1095 ? Effect.void
1096 : assertLiveCaller(scope, parent);
1097
1098 const assertLiveCaller = (
1099 scope: McpInvocationScope,
1100 parent: Pick<OrchestrationV2ThreadProjection, "thread" | "runs">,
1101 ) => {
1102 const activeRun = ThreadManagementService.latestActiveRun(parent);
1103 return parent.thread.archivedAt !== null ||
1104 activeRun === undefined ||
1105 activeRun.providerInstanceId !== scope.thread?.providerInstanceId
1106 ? Effect.fail(
1107 failure("parent_not_active", "The calling provider no longer owns an active thread run."),
1108 )
1109 : Effect.void;
1110 };
1111
1112 const loadReadableThread = (scope: McpInvocationScope, threadId: ThreadId) =>
1113 Effect.gen(function* () {
1114 const { parent } = yield* loadCaller(scope);
1115 const shell = yield* threadManagement
1116 .getThreadShell(threadId)
1117 .pipe(Effect.mapError(threadManagementFailure));
1118 if (shell === null || shell.deletedAt !== null) {
1119 return yield* failure("thread_not_found", `Thread ${threadId} is no longer available.`);
1120 }
1121 const target = yield* threadManagement
1122 .getProjectThreadRecords({ projectId: shell.projectId, threadId }, [
1123 "runs",
1124 "runtimeRequests",
1125 "contextTransfers",
1126 ])
1127 .pipe(Effect.mapError(threadManagementFailure));
1128 return { parent, target, shell } as const;
1129 });
1130
1131 const loadProviders = providerRegistry.getProviders;
1132
1133 /**
1134 * Instance ids the adapter registry resolves — the same lookup a
1135 * `delegated_task.request` performs when it runs. Capability reporting and
1136 * target resolution must not advertise a set narrower (or wider) than what
1137 * dispatch can actually serve.
1138 */
1139 const loadOrchestrationCapableInstanceIds = () =>
1140 providerAdapters.list().pipe(Effect.map((instanceIds) => new Set(instanceIds)));
1141
1142 /**
1143 * Provider snapshots only re-probe while a client is in the foreground, so an
1144 * unattended agent can see a provider as unavailable after it was fixed.
1145 * Re-probe the requested instance once before refusing it.
1146 */
1147 const assertTargetEnabledForProject = (
1148 parent: Pick<OrchestrationV2ThreadProjection, "thread">,
1149 commandId: CommandId,
1150 target: ResolvedTarget,
1151 ) =>
1152 threadManagement
1153 .assertProviderInstanceEnabled({
1154 projectId: parent.thread.projectId,
1155 commandId,
1156 instanceId: target.modelSelection.instanceId,
1157 })
1158 .pipe(Effect.mapError(threadManagementFailure));
1159
1160 const resolveTargetRechecking = (input: Parameters<typeof resolveTarget>[0]) => {
1161 const instanceId =
1162 input.target?.providerInstanceId ??
1163 (input.target?.driverKind === undefined
1164 ? input.parent.thread.modelSelection.instanceId
1165 : undefined);
1166 const resolved = resolveTarget(input);
1167 if (instanceId === undefined) return resolved;
1168 return resolved.pipe(
1169 Effect.catchIf(
1170 (error) => error.code === "provider_unavailable",
1171 () =>
1172 providerRegistry
1173 .refreshInstance(instanceId)
1174 .pipe(Effect.flatMap((providers) => resolveTarget({ ...input, providers }))),
1175 ),
1176 );
1177 };
1178
1179 const resolveTarget = (input: {
1180 readonly parent: Pick<OrchestrationV2ThreadProjection, "thread">;
1181 readonly target: OrchestratorMcpTarget | undefined;
1182 readonly providers: ReadonlyArray<ServerProvider>;
1183 /**
1184 * Skips the per-candidate project-enablement check in driver-only
1185 * resolution. Set only when the command that will dispatch this target
1186 * already has a receipt: that dispatch can only replay the recorded
1187 * result, never start anything new, so filtering candidates by the
1188 * project's current settings would just refuse a retry that does no new
1189 * work. Never set this for a resolution whose dispatch uses a different
1190 * command id than the one checked for a receipt.
1191 */
1192 readonly skipProjectEnablementFilter?: boolean;
1193 }): Effect.Effect<ResolvedTarget, OrchestratorMcpFailure> =>
1194 Effect.gen(function* () {
1195 const requestedInstanceId = input.target?.providerInstanceId;
1196 const requestedDriver = input.target?.driverKind;
1197 const orchestrationCapableInstanceIds = yield* loadOrchestrationCapableInstanceIds();
1198 let instanceId = requestedInstanceId;
1199
1200 if (instanceId === undefined && requestedDriver !== undefined) {
1201 const candidates = input.providers.filter(
1202 (provider) =>
1203 provider.driver === requestedDriver &&
1204 orchestrationCapableInstanceIds.has(provider.instanceId),
1205 );
1206 if (candidates.length === 0) {
1207 return yield* failure(
1208 "provider_unavailable",
1209 `No V2 provider adapter is registered for driver ${requestedDriver}.`,
1210 );
1211 }
1212 // Inherit the parent's instance only when it can actually serve the
1213 // child; an unavailable parent yields to a healthy instance of the
1214 // requested driver rather than failing the delegation. An instance
1215 // the project turned off cannot serve either, so selection skips it
1216 // for the next candidate instead of failing the enablement check.
1217 const healthyCandidates = candidates.filter(
1218 (candidate) => providerConstraints(candidate, true).length === 0,
1219 );
1220 const inheritedCandidate = healthyCandidates.find(
1221 (candidate) => candidate.instanceId === input.parent.thread.modelSelection.instanceId,
1222 );
1223 const orderedCandidates = [
1224 ...(inheritedCandidate === undefined ? [] : [inheritedCandidate]),
1225 ...healthyCandidates.filter((candidate) => candidate !== inheritedCandidate),
1226 ];
1227 for (const candidate of orderedCandidates) {
1228 const enabledForProject =
1229 input.skipProjectEnablementFilter === true ||
1230 (yield* threadManagement.isProviderInstanceEnabledForProject({
1231 projectId: input.parent.thread.projectId,
1232 instanceId: candidate.instanceId,
1233 }));
1234 if (enabledForProject) {
1235 instanceId = candidate.instanceId;
1236 break;
1237 }
1238 }
1239 if (instanceId === undefined) {
1240 return yield* failure(
1241 "provider_unavailable",
1242 healthyCandidates.length === 0
1243 ? `No available V2 provider instance for driver ${requestedDriver}.`
1244 : `No instance of driver ${requestedDriver} is enabled for this project.`,
1245 );
1246 }
1247 }
1248 instanceId ??= input.parent.thread.modelSelection.instanceId;
1249
1250 const provider = input.providers.find((candidate) => candidate.instanceId === instanceId);
1251 if (provider === undefined) {
1252 return yield* failure(
1253 "provider_unavailable",
1254 `Provider instance ${instanceId} is not registered.`,
1255 );
1256 }
1257 if (requestedDriver !== undefined && provider.driver !== requestedDriver) {
1258 return yield* failure(
1259 "invalid_request",
1260 `Provider instance ${instanceId} uses driver ${provider.driver}, not ${requestedDriver}.`,
1261 );
1262 }
1263 const constraints = providerConstraints(
1264 provider,
1265 orchestrationCapableInstanceIds.has(provider.instanceId),
1266 );
1267 if (constraints.length > 0) {
1268 return yield* failure(
1269 "provider_unavailable",
1270 `Provider ${instanceId} cannot run a child task: ${constraints.join(" ")}`,
1271 );
1272 }
1273
1274 const inheritedSelection = input.parent.thread.modelSelection;
1275 const requestedModel = input.target?.model;
1276 const model =
1277 requestedModel ??
1278 (instanceId === inheritedSelection.instanceId
1279 ? inheritedSelection.model
1280 : provider?.models[0]?.slug);
1281 if (model === undefined) {
1282 return yield* failure(
1283 "model_unavailable",
1284 `Provider ${instanceId} has no model available for inheritance.`,
1285 );
1286 }
1287 if (
1288 requestedModel !== undefined &&
1289 provider !== undefined &&
1290 provider.models.length > 0 &&
1291 !provider.models.some((candidate) => candidate.slug === requestedModel)
1292 ) {
1293 return yield* failure(
1294 "model_unavailable",
1295 `Model ${requestedModel} is not advertised by provider ${instanceId}.`,
1296 );
1297 }
1298
1299 const requestedOptions = input.target?.options;
1300 if (requestedOptions !== undefined) {
1301 const descriptors = provider.models.find((candidate) => candidate.slug === model)
1302 ?.capabilities?.optionDescriptors;
1303 const invalid = invalidOptionSelections(requestedOptions, descriptors);
1304 if (invalid.length > 0) {
1305 return yield* failure(
1306 "invalid_request",
1307 `Model ${model} on provider ${instanceId} rejected options: ${invalid.join(" ")}`,
1308 );
1309 }
1310 }
1311
1312 return {
1313 modelSelection:
1314 instanceId === inheritedSelection.instanceId &&
1315 model === inheritedSelection.model &&
1316 requestedOptions === undefined
1317 ? inheritedSelection
1318 : requestedOptions === undefined
1319 ? { instanceId, model }
1320 : { instanceId, model, options: requestedOptions },
1321 supportedRuntimeModes: provider.supportedRuntimeModes,
1322 };
1323 });
1324
1325 const requestKey = (clientRequestId: string | undefined): Effect.Effect<string> =>
1326 clientRequestId === undefined
1327 ? crypto.randomUUIDv4().pipe(Effect.orDie)
1328 : Effect.succeed(clientRequestId);
1329
1330 const readTask = (
1331 scope: McpThreadInvocationScope,
1332 taskId: NodeId,
1333 waitTimedOut = false,
1334 acknowledgeTerminal = false,
1335 acknowledgementOperation = "task-status-acknowledge",
1336 ): Effect.Effect<OrchestratorMcpDelegateTaskResult, OrchestratorMcpFailure> =>
1337 Effect.gen(function* () {
1338 yield* requireCapability(scope);
1339 const parentProjection = yield* loadProjection(scope.thread.threadId);
1340 const task = parentProjection.subagents.find(
1341 (candidate) =>
1342 candidate.id === taskId &&
1343 candidate.origin === "app_owned" &&
1344 candidate.threadId === scope.thread.threadId,
1345 );
1346 if (task === undefined || task.childThreadId === null) {
1347 return yield* failure(
1348 "task_not_found",
1349 `Delegated task ${taskId} does not belong to thread ${scope.thread.threadId}.`,
1350 );
1351 }
1352 const childControls = yield* threadManagement
1353 .getThreadRecords(
1354 task.childThreadId,
1355 [
1356 "runs",
1357 "messages",
1358 "contextTransfers",
1359 "subagents",
1360 "providerThreads",
1361 "runtimeRequests",
1362 ],
1363 { messageRoles: ["user"] },
1364 )
1365 .pipe(Effect.mapError(threadManagementFailure));
1366 const childRun = delegatedTaskRun(childControls, task);
1367 const terminalRun = latestTerminalResultRun(childControls, childRun);
1368 const progress = delegatedTaskProgress(childControls);
1369 const taskResultRun =
1370 childRun?.delegatedTaskId === task.id
1371 ? childControls.runs
1372 .filter((run) => run.delegatedTaskId === task.id)
1373 .toSorted((a, b) => b.ordinal - a.ordinal)[0]
1374 : progress.resultRun;
1375 const taskWorkState =
1376 childRun?.delegatedTaskId === task.id
1377 ? isTerminalTaskStatus(taskStatusForRun(taskResultRun))
1378 ? "result_available"
1379 : "working"
1380 : progress.state;
1381 const resultRunIds = [
1382 ...new Set(
1383 [taskResultRun?.id, terminalRun?.id].filter((id): id is RunId => id !== undefined),
1384 ),
1385 ];
1386 const resultRecords = yield* threadManagement
1387 .getThreadRecords(task.childThreadId, ["messages", "turnItems"], {
1388 messageRoles: ["assistant"],
1389 messageRunIds: resultRunIds,
1390 turnItemRunIds: resultRunIds,
1391 turnItemTypes: ["assistant_message", "error"],
1392 })
1393 .pipe(Effect.mapError(threadManagementFailure));
1394 const childProjection = {
1395 ...childControls,
1396 messages: [...childControls.messages, ...resultRecords.messages],
1397 turnItems: resultRecords.turnItems,
1398 };
1399 // A restart cut the child's run and its continuation has not settled, or
1400 // the child started working again after this read.
1401 const heldForRestart =
1402 task.result === null &&
1403 taskWorkState === "result_available" &&
1404 (yield* threadManagement
1405 .delegatedTaskResultPending(task.childThreadId, childRun?.delegatedTaskId)
1406 .pipe(Effect.mapError(threadManagementFailure)));
1407 const workState =
1408 task.result !== null ? "result_available" : heldForRestart ? "working" : taskWorkState;
1409 const status =
1410 task.result !== null
1411 ? taskStatusForRun(
1412 task.status === "completed" ||
1413 task.status === "failed" ||
1414 task.status === "cancelled" ||
1415 task.status === "interrupted"
1416 ? { status: task.status }
1417 : childRun,
1418 )
1419 : workState === "result_available"
1420 ? taskStatusForRun(taskResultRun ?? childRun)
1421 : taskStatusForRun(taskResultRun ?? childRun) === "queued"
1422 ? "queued"
1423 : "running";
1424 const derivedResult =
1425 task.result !== null
1426 ? task.result
1427 : taskResultRun !== undefined && isTerminalTaskStatus(status)
1428 ? subagentResultForRun(childProjection, taskResultRun).text
1429 : null;
1430 const resultTransfers = parentProjection.contextTransfers.filter(
1431 (transfer) =>
1432 transfer.type === "subagent_result" &&
1433 transfer.sourceThreadId === task.childThreadId &&
1434 transfer.targetThreadId === scope.thread.threadId,
1435 );
1436 const resultTransferForRun = (run: OrchestrationV2Run | undefined) =>
1437 !canExposeTaskRunResult(run)
1438 ? null
1439 : (resultTransfers.find((transfer) => transfer.sourcePoint.runId === run.id) ??
1440 (run.id === childRun?.id
1441 ? resultTransfers.find((transfer) => transfer.sourcePoint.runId === undefined)
1442 : undefined) ??
1443 null);
1444 const resultTransfer = taskResultTransfer(parentProjection, childProjection, task) ?? null;
1445 const terminalStatus = terminalRun === undefined ? null : taskStatusForRun(terminalRun);
1446 const response = {
1447 taskId: task.id,
1448 childThreadId: task.childThreadId,
1449 childRunId: childRun?.id ?? null,
1450 childNodeId: task.id,
1451 status,
1452 workState,
1453 hasPendingChildRuns: hasPendingChildRuns(childProjection, childRun),
1454 providerInstanceId: task.providerInstanceId,
1455 model: task.model,
1456 summary: derivedResult,
1457 resultContextTransferId: resultTransfer?.id ?? null,
1458 latestTerminalRunId: terminalRun?.id ?? null,
1459 latestTerminalStatus:
1460 terminalStatus !== null && isTerminalTaskStatus(terminalStatus) ? terminalStatus : null,
1461 latestTerminalSummary: canExposeTaskRunResult(terminalRun)
1462 ? terminalRun.id === childRun?.id
1463 ? derivedResult
1464 : subagentResultForRun(childProjection, terminalRun).text
1465 : null,
1466 latestTerminalResultContextTransferId: resultTransferForRun(terminalRun)?.id ?? null,
1467 // A finished task has no question of its own; later child runs belong to other tasks.
1468 hasPendingQuestion:
1469 !isTerminalTaskStatus(status) &&
1470 childControls.runtimeRequests.some(
1471 (request) =>
1472 request.kind === "user_input" &&
1473 request.status === "pending" &&
1474 request.responseCapability.type === "live",
1475 ),
1476 waitTimedOut,
1477 } satisfies OrchestratorMcpDelegateTaskResult;
1478 if (
1479 acknowledgeTerminal &&
1480 isTerminalTaskStatus(status) &&
1481 task.completionDelivery?.state !== "acknowledged" &&
1482 task.completionDelivery?.state !== "disposed"
1483 ) {
1484 const observingRun = ThreadManagementService.latestActiveRun(parentProjection);
1485 const acknowledgementRequestKey = yield* requestKey(undefined);
1486 yield* threadManagement
1487 .dispatch({
1488 type: "delegated_task.completion-delivery.acknowledge",
1489 commandId: stableCommandId({
1490 scope,
1491 requestKey: acknowledgementRequestKey,
1492 operation: acknowledgementOperation,
1493 }),
1494 parentThreadId: scope.thread.threadId,
1495 taskId,
1496 observedByRunId:
1497 observingRun?.providerInstanceId === scope.thread.providerInstanceId
1498 ? observingRun.id
1499 : null,
1500 })
1501 .pipe(
1502 Effect.mapError((error) =>
1503 failure(
1504 "orchestration_error",
1505 `Unable to acknowledge delegated task ${taskId}: ${errorMessage(error)}`,
1506 ),
1507 ),
1508 );
1509 }
1510 return response;
1511 });
1512
1513 // Re-read the task only when an event on the parent or child thread can
1514 // change its status, instead of polling the projections every 50 ms.
1515 const waitForTask = (scope: McpThreadInvocationScope, taskId: NodeId, timeoutMs: number) =>
1516 Effect.gen(function* () {
1517 const streamError = (error: unknown) =>
1518 failure(
1519 "orchestration_error",
1520 `Unable to watch delegated task ${taskId}: ${errorMessage(error)}`,
1521 );
1522 // Sequences are global, so one cursor taken before the first read
1523 // replays anything either thread records after it.
1524 const afterSequence = yield* threadManagement
1525 .getThreadEventSequence(scope.thread.threadId)
1526 .pipe(Effect.mapError(streamError));
1527 const initial = yield* readTask(scope, taskId, false, true);
1528 if (initial.hasPendingQuestion || isTerminalTaskStatus(initial.status)) {
1529 return Option.some(initial);
1530 }
1531 // One stream per event type, so transcript events never fill a buffer.
1532 return yield* Stream.mergeAll(
1533 TASK_WAKE_EVENTS.map(({ thread, eventType }) =>
1534 threadManagement.streamStoredEventsFrom({
1535 threadId: thread === "parent" ? scope.thread.threadId : initial.childThreadId,
1536 afterSequence,
1537 eventType,
1538 }),
1539 ),
1540 { concurrency: "unbounded" },
1541 ).pipe(
1542 Stream.mapError(streamError),
1543 Stream.mapEffect(() => readTask(scope, taskId, false, true)),
1544 Stream.filter((result) => result.hasPendingQuestion || isTerminalTaskStatus(result.status)),
1545 Stream.runHead,
1546 );
1547 }).pipe(Effect.timeoutOption(Duration.millis(timeoutMs)), Effect.map(Option.flatten));
1548
1549 /**
1550 * The modes a task's runs execute at: its own, or for a task bound to a
1551 * thread, also that thread's modes as they are now, since its runs are
1552 * messages to that thread.
1553 */
1554 const scheduledTaskRunModes = (task: ScheduledTask) =>
1555 Effect.gen(function* () {
1556 const modes = [{ runtimeMode: task.runtimeMode, interactionMode: task.interactionMode }];
1557 if (task.threadId === null) return modes;
1558 const bound = yield* threadManagement
1559 .getThreadShell(task.threadId)
1560 .pipe(Effect.mapError(threadManagementFailure));
1561 return bound === null || bound.deletedAt !== null ? modes : [...modes, bound];
1562 });
1563
1564 const withinLimits = (
1565 limits: {
1566 readonly runtimeMode: RuntimeMode;
1567 readonly interactionMode: ProviderInteractionMode;
1568 },
1569 modes: { readonly runtimeMode: RuntimeMode; readonly interactionMode: ProviderInteractionMode },
1570 ) =>
1571 runtimeModeRank(modes.runtimeMode) <= runtimeModeRank(limits.runtimeMode) &&
1572 interactionModeRank(modes.interactionMode) <= interactionModeRank(limits.interactionMode);
1573
1574 /**
1575 * A task as the caller may see it. Its webhook URL starts runs, so only a
1576 * caller that may start one sees it: never a client approved for read-only
1577 * access, and a thread caller only with a live turn, at modes covering every
1578 * mode the task runs at.
1579 */
1580 const summarizeScheduledTask = (
1581 scope: McpInvocationScope,
1582 caller: {
1583 readonly parent: Pick<OrchestrationV2ThreadProjection, "thread" | "runs"> | undefined;
1584 readonly limits: {
1585 readonly runtimeMode: RuntimeMode;
1586 readonly interactionMode: ProviderInteractionMode;
1587 };
1588 },
1589 task: ScheduledTask,
1590 ) =>
1591 Effect.gen(function* () {
1592 const live =
1593 caller.parent === undefined
1594 ? scope.client?.access !== "read-only"
1595 : Exit.isSuccess(yield* Effect.exit(assertLiveCaller(scope, caller.parent)));
1596 // This runs after a save, so a failed lookup of the bound thread hides
1597 // the webhook URL instead of reporting a saved task as an error.
1598 const modes = yield* scheduledTaskRunModes(task).pipe(Effect.option);
1599 return scheduledTaskSummary(
1600 task,
1601 live &&
1602 Option.isSome(modes) &&
1603 modes.value.every((mode) => withinLimits(caller.limits, mode)),
1604 );
1605 });
1606
1607 /**
1608 * A scheduled task the caller may change: one whose runs execute at modes no
1609 * broader than the caller's own, so editing its prompt cannot run work above
1610 * the caller's limits.
1611 */
1612 const loadScheduledTask = (
1613 scheduledTaskId: ScheduledTask["id"],
1614 limits: {
1615 readonly runtimeMode: RuntimeMode;
1616 readonly interactionMode: ProviderInteractionMode;
1617 },
1618 ): Effect.Effect<ScheduledTask, OrchestratorMcpFailure> =>
1619 Effect.gen(function* () {
1620 const { tasks } = yield* scheduledTasks
1621 .list()
1622 .pipe(
1623 Effect.mapError((error) =>
1624 failure("orchestration_error", `Could not load scheduled task: ${error.message}`),
1625 ),
1626 );
1627 const task = tasks.find((candidate) => candidate.id === scheduledTaskId);
1628 if (task === undefined) {
1629 return yield* failure("task_not_found", `Scheduled task ${scheduledTaskId} was not found.`);
1630 }
1631 for (const modes of yield* scheduledTaskRunModes(task)) {
1632 yield* resolveRuntimeMode(limits.runtimeMode, modes.runtimeMode);
1633 yield* resolveInteractionMode(limits.interactionMode, modes.interactionMode);
1634 }
1635 return task;
1636 });
1637
1638 return OrchestratorMcpService.of({
1639 scheduleTask: (scope, input) =>
1640 Effect.gen(function* () {
1641 const { parent, limits } = yield* loadCaller(scope);
1642 const projectId = yield* resolveProjectTarget(parent, input.projectId);
1643 yield* assertLiveCallerForOtherProject(scope, parent, projectId);
1644 const project = yield* requireProject(projectId);
1645 // Binding means "wake this thread", which only a thread caller in that project has.
1646 const bindToCurrentThread =
1647 input.bindToCurrentThread ??
1648 (parent !== undefined && parent.thread.projectId === projectId);
1649 if (
1650 bindToCurrentThread &&
1651 (parent === undefined || parent.thread.projectId !== projectId)
1652 ) {
1653 return yield* failure(
1654 "invalid_request",
1655 parent === undefined
1656 ? "bindToCurrentThread needs an agent running inside a T3 thread."
1657 : "bindToCurrentThread binds to this thread, which belongs to a different project.",
1658 );
1659 }
1660 const modelSelection =
1661 parent?.thread.modelSelection ?? (yield* projectDefaultModelSelection(project));
1662 const derivedTitle = input.prompt.split("\n")[0]?.trim() ?? "";
1663 const title =
1664 input.title ?? (derivedTitle.length > 0 ? derivedTitle.slice(0, 80) : "Scheduled task");
1665 const upsertInput: ScheduledTaskUpsertInput = {
1666 title,
1667 prompt: input.prompt,
1668 enabled: input.enabled ?? true,
1669 schedule: input.schedule,
1670 projectId,
1671 threadId: bindToCurrentThread && parent !== undefined ? parent.thread.id : null,
1672 workspaceStrategy: scheduledTaskWorkspaceStrategy(bindToCurrentThread),
1673 modelSelection,
1674 runtimeMode: limits.runtimeMode,
1675 interactionMode: limits.interactionMode,
1676 createdBy: "agent",
1677 creationSource: "mcp",
1678 // Scope the idempotency key by provider session so two callers
1679 // reusing the same clientRequestId cannot collide on one task row.
1680 ...(input.clientRequestId === undefined
1681 ? {}
1682 : {
1683 commandId: stableCommandId({
1684 scope,
1685 requestKey: input.clientRequestId,
1686 operation: "schedule-task",
1687 }),
1688 }),
1689 };
1690 const { task } = yield* scheduledTasks
1691 .upsert(upsertInput)
1692 .pipe(
1693 Effect.mapError((error) =>
1694 failure("orchestration_error", `Could not schedule task: ${error.message}`),
1695 ),
1696 );
1697 return yield* summarizeScheduledTask(scope, { parent, limits }, task);
1698 }),
1699 listScheduledTasks: (scope, input) =>
1700 Effect.gen(function* () {
1701 const { parent, limits } = yield* loadCaller(scope);
1702 const projectId = input.projectId ?? parent?.thread.projectId;
1703 const { tasks } = yield* scheduledTasks
1704 .list()
1705 .pipe(
1706 Effect.mapError((error) =>
1707 failure("orchestration_error", `Could not list scheduled tasks: ${error.message}`),
1708 ),
1709 );
1710 return {
1711 tasks: yield* Effect.forEach(
1712 tasks.filter((task) => projectId === undefined || task.projectId === projectId),
1713 (task) => summarizeScheduledTask(scope, { parent, limits }, task),
1714 ),
1715 };
1716 }),
1717 updateScheduledTask: (scope, input) =>
1718 Effect.gen(function* () {
1719 const { parent, limits } = yield* loadCaller(scope);
1720 const existing = yield* loadScheduledTask(input.scheduledTaskId, limits);
1721 yield* assertLiveCallerForOtherProject(scope, parent, existing.projectId);
1722 if (
1723 input.bindToCurrentThread === true &&
1724 (parent === undefined || parent.thread.projectId !== existing.projectId)
1725 ) {
1726 return yield* failure(
1727 "invalid_request",
1728 parent === undefined
1729 ? "bindToCurrentThread needs an agent running inside a T3 thread."
1730 : "bindToCurrentThread binds to this thread, which belongs to a different project.",
1731 );
1732 }
1733 const threadId =
1734 input.bindToCurrentThread === undefined
1735 ? existing.threadId
1736 : input.bindToCurrentThread && parent !== undefined
1737 ? parent.thread.id
1738 : null;
1739 // Rebinding changes where runs execute, so the workspace strategy must
1740 // follow: unbinding a root-strategy task would otherwise run loose
1741 // prompts in the shared project checkout.
1742 const workspaceStrategy =
1743 input.bindToCurrentThread === undefined
1744 ? existing.workspaceStrategy
1745 : scheduledTaskWorkspaceStrategy(input.bindToCurrentThread);
1746 const upsertInput: ScheduledTaskUpsertInput = {
1747 id: existing.id,
1748 title: input.title ?? existing.title,
1749 prompt: input.prompt ?? existing.prompt,
1750 enabled: input.enabled ?? existing.enabled,
1751 schedule: input.schedule ?? existing.schedule,
1752 projectId: existing.projectId,
1753 threadId,
1754 workspaceStrategy,
1755 modelSelection: existing.modelSelection,
1756 runtimeMode: existing.runtimeMode,
1757 interactionMode: existing.interactionMode,
1758 createdBy: existing.createdBy,
1759 creationSource: existing.creationSource,
1760 };
1761 const { task } = yield* scheduledTasks
1762 .upsert(upsertInput)
1763 .pipe(
1764 Effect.mapError((error) =>
1765 failure("orchestration_error", `Could not update scheduled task: ${error.message}`),
1766 ),
1767 );
1768 return yield* summarizeScheduledTask(scope, { parent, limits }, task);
1769 }),
1770 deleteScheduledTask: (scope, input) =>
1771 Effect.gen(function* () {
1772 const { parent, limits } = yield* loadCaller(scope);
1773 const existing = yield* loadScheduledTask(input.scheduledTaskId, limits);
1774 yield* assertLiveCallerForOtherProject(scope, parent, existing.projectId);
1775 yield* scheduledTasks
1776 .delete({ id: existing.id })
1777 .pipe(
1778 Effect.mapError((error) =>
1779 failure("orchestration_error", `Could not delete scheduled task: ${error.message}`),
1780 ),
1781 );
1782 return { scheduledTaskId: existing.id, deleted: true };
1783 }),
1784 requestSecret: (scope, input) =>
1785 Effect.gen(function* () {
1786 // The card is shown in, and answered from, the caller's own thread.
1787 const { scope: threadScope, parent } = yield* loadThreadCaller(scope, "request_secret");
1788 const threadId = threadScope.thread.threadId;
1789 const run = ThreadManagementService.latestActiveRun(parent);
1790 if (
1791 run === undefined ||
1792 run.rootNodeId === null ||
1793 run.providerInstanceId !== threadScope.thread.providerInstanceId
1794 ) {
1795 return yield* failure(
1796 "parent_not_active",
1797 "Asking for a secret requires an active run owned by this MCP provider session.",
1798 );
1799 }
1800 const runId = run.id;
1801 const nodeId = run.rootNodeId;
1802 const key = yield* requestKey(input.clientRequestId);
1803 // Turn item ids are global; scope the key to this thread. A retry with
1804 // the same clientRequestId finds this card, answered or not.
1805 const turnItemId = TurnItemId.make(
1806 `turn-item:secret-request:${stablePart(threadId)}:${stablePart(key)}`,
1807 );
1808 const record = (secretStatus: "pending" | "cancelled") =>
1809 threadManagement
1810 .dispatch({
1811 type: "secret_request.record",
1812 commandId: stableCommandId({
1813 scope,
1814 requestKey: key,
1815 operation: `secret-${secretStatus}`,
1816 }),
1817 threadId: threadId,
1818 runId,
1819 nodeId,
1820 turnItemId,
1821 label: input.label,
1822 reason: input.reason,
1823 ...(input.placeholder === undefined ? {} : { placeholder: input.placeholder }),
1824 secretStatus,
1825 })
1826 .pipe(
1827 Effect.mapError((error) =>
1828 failure(
1829 "orchestration_error",
1830 `Could not record the secret request: ${errorMessage(error)}`,
1831 ),
1832 ),
1833 );
1834 yield* record("pending");
1835 // Only this call can hand the agent its ref, so the card must not
1836 // outlive it: a timeout, a failed wait or an aborted call closes it as
1837 // cancelled. If even that fails, the server still refuses an answer
1838 // once the run ends, and an unused value expires.
1839 const closeCard = record("cancelled").pipe(
1840 Effect.catch((error) =>
1841 Effect.logWarning("Could not close a secret request card", { error: error.message }),
1842 ),
1843 );
1844
1845 // The user answers the card (secrets.answerRequest), or it ends with
1846 // the run; poll it like a delegated task.
1847 const answered = yield* Effect.gen(function* () {
1848 while (true) {
1849 const projection = yield* threadManagement
1850 .getThreadRecords(threadId, ["runs", "turnItems"], {
1851 turnItemTypes: ["secret_request"],
1852 messageRoles: [],
1853 })
1854 .pipe(
1855 Effect.mapError((error) =>
1856 failure(
1857 "orchestration_error",
1858 `Unable to read the secret request: ${errorMessage(error)}`,
1859 ),
1860 ),
1861 );
1862 const item = projection.turnItems.find((candidate) => candidate.id === turnItemId);
1863 if (item?.type === "secret_request" && item.secretStatus !== "pending") {
1864 return item.secretStatus;
1865 }
1866 const current = projection.runs.find((candidate) => candidate.id === runId);
1867 if (
1868 current === undefined ||
1869 ThreadManagementService.isTerminalRunStatus(current.status)
1870 ) {
1871 yield* record("cancelled");
1872 return "cancelled" as const;
1873 }
1874 yield* Effect.sleep(Duration.millis(SECRET_REQUEST_POLL_INTERVAL_MS));
1875 }
1876 }).pipe(
1877 Effect.timeoutOption(
1878 Duration.millis(
1879 Math.min(input.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS, MAX_WAIT_TIMEOUT_MS),
1880 ),
1881 ),
1882 Effect.onExit((exit) => (Exit.isSuccess(exit) ? Effect.void : closeCard)),
1883 );
1884 if (Option.isNone(answered)) yield* closeCard;
1885 // An answer that raced the timeout still wins: the card is answered once.
1886 const status = Option.isSome(answered)
1887 ? answered.value
1888 : yield* threadManagement
1889 .getThreadRecords(threadId, ["turnItems"], {
1890 turnItemTypes: ["secret_request"],
1891 messageRoles: [],
1892 })
1893 .pipe(
1894 Effect.map((records) => {
1895 const item = records.turnItems.find((candidate) => candidate.id === turnItemId);
1896 return item?.type === "secret_request" &&
1897 (item.secretStatus === "saved" || item.secretStatus === "declined")
1898 ? item.secretStatus
1899 : ("timed_out" as const);
1900 }),
1901 Effect.orElseSucceed(() => "timed_out" as const),
1902 );
1903 yield* Effect.annotateCurrentSpan({ "secret_request.status": status });
1904 yield* Metrics.increment(Metrics.secretRequestsTotal, { status });
1905 if (status !== "saved") return { status };
1906 // Saved means the value was stored before the card said so; a missing
1907 // value is a storage fault, not an answer the agent can act on.
1908 const secretRef = yield* secretRequests.savedRef({ threadId: threadId, turnItemId });
1909 if (Option.isNone(secretRef)) {
1910 return yield* failure(
1911 "orchestration_error",
1912 "The user saved the secret, but it could not be read. Ask again with a new clientRequestId.",
1913 );
1914 }
1915 return { status, secretRef: secretRef.value };
1916 }).pipe(Effect.withSpan("OrchestratorMcpService.requestSecret")),
1917
1918 capabilities: (scope) =>
1919 Effect.gen(function* () {
1920 const { parent, limits } = yield* loadCaller(scope);
1921 const providers = yield* loadProviders;
1922 const orchestrationCapableInstanceIds = yield* loadOrchestrationCapableInstanceIds();
1923 // Models the user hid from pickers stay out of the catalog agents choose from.
1924 const modelPreferences = yield* serverSettings.getSettings.pipe(
1925 Effect.map((settings) => settings.providerModelPreferences ?? {}),
1926 Effect.mapError(() => failure("orchestration_error", "Unable to read server settings.")),
1927 );
1928 return {
1929 parentThreadId: parent?.thread.id ?? null,
1930 inheritedProviderInstanceId: parent?.thread.modelSelection.instanceId ?? null,
1931 inheritedModel: parent?.thread.modelSelection.model ?? null,
1932 runtimeMode: limits.runtimeMode,
1933 interactionMode: limits.interactionMode,
1934 providers: providers.map((provider) => {
1935 const constraints = providerConstraints(
1936 provider,
1937 orchestrationCapableInstanceIds.has(provider.instanceId),
1938 );
1939 return {
1940 providerInstanceId: provider.instanceId,
1941 driverKind: provider.driver,
1942 displayName: provider?.displayName ?? null,
1943 models:
1944 provider?.models
1945 .filter(
1946 (model) =>
1947 model.isCustom ||
1948 !modelPreferences[provider.instanceId]?.hiddenModels.includes(model.slug),
1949 )
1950 .map((model) => ({
1951 id: model.slug,
1952 label: model.name ?? null,
1953 ...(model.capabilities?.optionDescriptors === undefined
1954 ? {}
1955 : { options: model.capabilities.optionDescriptors }),
1956 })) ?? [],
1957 ...(provider.supportedRuntimeModes === undefined ||
1958 provider.supportedRuntimeModes.length === 0
1959 ? {}
1960 : { runtimeModes: [...provider.supportedRuntimeModes] }),
1961 canRunChildTask: constraints.length === 0,
1962 canRunCrossProviderChildTask: constraints.length === 0,
1963 constraints: [...constraints],
1964 };
1965 }),
1966 features: {
1967 appOwnedSubagents: true,
1968 asyncPolling: true,
1969 cancellation: true,
1970 batchThreadCreation: true,
1971 threadManagement: true,
1972 incrementalThreadRead: true,
1973 scheduledTasks: true,
1974 maxBatchThreads: 20,
1975 },
1976 };
1977 }),
1978 delegateTask: (callerScope, input) =>
1979 Effect.gen(function* () {
1980 const { scope, parent } = yield* loadThreadCaller(callerScope, "delegate_task");
1981 const parentRun = parent.runs
1982 .filter(ThreadManagementService.isActiveRun)
1983 .toSorted((left, right) => right.ordinal - left.ordinal)[0];
1984 if (
1985 parentRun === undefined ||
1986 parentRun.rootNodeId === null ||
1987 parentRun.providerInstanceId !== scope.thread.providerInstanceId
1988 ) {
1989 return yield* failure(
1990 "parent_not_active",
1991 "Delegated tasks require an active run owned by this MCP provider session.",
1992 );
1993 }
1994 const providers = yield* loadProviders;
1995 // Computed before resolving the target so a retry of an already
1996 // accepted delegation can be recognized: dispatching under the same
1997 // command id only replays the recorded result, so candidate
1998 // selection must not refuse it over the project's current settings.
1999 const key = yield* requestKey(input.clientRequestId);
2000 const commandId = stableCommandId({
2001 scope,
2002 requestKey: key,
2003 operation: "delegate-task",
2004 });
2005 const isRetryOfAcceptedDelegation = yield* threadManagement.hasReceipt(commandId);
2006 const target = yield* resolveTargetRechecking({
2007 parent,
2008 target: input.target,
2009 providers,
2010 skipProjectEnablementFilter: isRetryOfAcceptedDelegation,
2011 });
2012 const runtimeMode = yield* resolveRuntimeMode(
2013 parent.thread.runtimeMode,
2014 input.runtimeMode,
2015 target,
2016 );
2017 const interactionMode = yield* resolveInteractionMode(
2018 parent.thread.interactionMode,
2019 input.interactionMode,
2020 );
2021 let workspaceStrategy: OrchestrationV2ThreadLaunchWorkspaceStrategy | undefined;
2022 if (input.isolation === "worktree" && !isRetryOfAcceptedDelegation) {
2023 const project = yield* requireProject(parent.thread.projectId);
2024 const base = yield* gitDriver
2025 .resolveCommit({
2026 cwd: parent.thread.worktreePath ?? project.workspaceRoot,
2027 revision: "HEAD",
2028 })
2029 .pipe(
2030 Effect.mapError(() =>
2031 failure("invalid_request", "Unable to resolve the parent checkout commit."),
2032 ),
2033 );
2034 workspaceStrategy = { type: "worktree", baseRef: base.commitSha, startFromOrigin: false };
2035 }
2036 // ThreadManagementService.dispatch is the chokepoint for this command
2037 // type's own enablement check, using the same command id and its
2038 // receipt-replay exemption, so no separate assert runs here.
2039 const result = yield* threadManagement
2040 .dispatch({
2041 type: "delegated_task.request",
2042 createdBy: "agent",
2043 creationSource: "mcp",
2044 commandId,
2045 parentThreadId: scope.thread.threadId,
2046 parentRunId: parentRun.id,
2047 parentNodeId: parentRun.rootNodeId,
2048 task: taskPrompt(input),
2049 ...(input.title === undefined ? {} : { title: input.title }),
2050 modelSelection: target.modelSelection,
2051 runtimeMode,
2052 interactionMode,
2053 ...(workspaceStrategy === undefined ? {} : { workspaceStrategy }),
2054 // Async delegations wake the parent on every child terminal; wait
2055 // delegations deliver through the blocking tool call, so a wake is
2056 // only needed if the parent settled first (timeout, disconnect).
2057 completionWake: input.mode === "wait" ? "settled_only" : "always",
2058 })
2059 .pipe(
2060 Effect.mapError((error) =>
2061 error._tag === "ThreadManagementProviderInstanceDisabledError"
2062 ? threadManagementFailure(error)
2063 : failure(
2064 "orchestration_error",
2065 `Unable to create delegated task: ${errorMessage(error)}`,
2066 ),
2067 ),
2068 );
2069 const taskEvent = result.storedEvents.find(
2070 (stored) =>
2071 stored.event.type === "subagent.updated" && stored.event.payload.origin === "app_owned",
2072 );
2073 if (taskEvent?.event.type !== "subagent.updated") {
2074 return yield* failure(
2075 "orchestration_error",
2076 "Delegated task command did not produce a task projection.",
2077 );
2078 }
2079 const taskId = taskEvent.event.payload.id;
2080 const childThreadId = taskEvent.event.payload.childThreadId;
2081 const childRunEvent = result.storedEvents.find(
2082 (stored) =>
2083 stored.event.type === "run.created" && stored.event.threadId === childThreadId,
2084 );
2085 if (
2086 childThreadId !== null &&
2087 childRunEvent?.event.type === "run.created" &&
2088 childRunEvent.event.payload.workspacePreparation !== undefined
2089 ) {
2090 yield* threadLaunch.prepareRun({
2091 commandId,
2092 threadId: childThreadId,
2093 runId: childRunEvent.event.payload.id,
2094 });
2095 }
2096
2097 if (input.mode !== "wait") {
2098 return yield* readTask(scope, taskId, false, true);
2099 }
2100 const timeoutMs = Math.min(
2101 MAX_WAIT_TIMEOUT_MS,
2102 Math.max(1, input.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS),
2103 );
2104 const waited = yield* waitForTask(scope, taskId, timeoutMs);
2105 if (Option.isSome(waited) && isTerminalTaskStatus(waited.value.status)) {
2106 return waited.value;
2107 }
2108 // An incomplete wait gives up delivery: upgrade so a later terminal
2109 // wakes the parent even mid-turn. Best effort; settled_only still
2110 // wakes a settled parent if the upgrade fails.
2111 yield* threadManagement
2112 .dispatch({
2113 type: "delegated_task.wake-policy",
2114 commandId: stableCommandId({
2115 scope,
2116 requestKey: key,
2117 operation: "delegate-task-wake-policy",
2118 }),
2119 parentThreadId: scope.thread.threadId,
2120 taskId,
2121 completionWake: "always",
2122 })
2123 .pipe(
2124 // The tool result still returns after a timeout or question, so failures
2125 // stay warnings. Keep the two shapes apart: a rejected receipt
2126 // means this exact command id already failed (a replay of a
2127 // no-op upgrade), while anything else is a fresh dispatch fault.
2128 Effect.catch((error) =>
2129 Effect.logWarning("orchestrator-mcp.delegate-task.wake-policy-failed", {
2130 taskId,
2131 outcome:
2132 error._tag === "OrchestratorCommandPreviouslyRejectedError"
2133 ? "previously_rejected"
2134 : "dispatch_failed",
2135 error,
2136 }),
2137 ),
2138 Effect.catchCause((cause) =>
2139 Effect.logWarning("orchestrator-mcp.delegate-task.wake-policy-failed", {
2140 taskId,
2141 outcome: "defect",
2142 cause,
2143 }),
2144 ),
2145 );
2146 return yield* readTask(scope, taskId, Option.isNone(waited), true);
2147 }),
2148 taskStatus: (callerScope, taskId) =>
2149 Effect.gen(function* () {
2150 const scope = yield* requireThreadScope(callerScope, "task_status");
2151 return yield* readTask(scope, taskId, false, true);
2152 }),
2153 cancelTask: (callerScope, input) =>
2154 Effect.gen(function* () {
2155 const scope = yield* requireThreadScope(callerScope, "task_cancel");
2156 const current = yield* readTask(scope, input.taskId);
2157 const key = yield* requestKey(input.clientRequestId);
2158 const parentProjection = yield* loadProjection(scope.thread.threadId);
2159 // Cancelling stops the child and every task under it, each a write to a
2160 // thread its user may have raised above the parent's modes since it was
2161 // delegated. All of them are checked before anything is stopped.
2162 const assertStoppable = (threadId: ThreadId): Effect.Effect<void, OrchestratorMcpFailure> =>
2163 Effect.gen(function* () {
2164 const shell = yield* threadManagement
2165 .getThreadShell(threadId)
2166 .pipe(Effect.mapError(threadManagementFailure));
2167 // A deleted thread takes no stop, but the tasks under it still do.
2168 if (shell !== null && shell.deletedAt === null) {
2169 yield* resolveRuntimeMode(parentProjection.thread.runtimeMode, shell.runtimeMode);
2170 yield* resolveInteractionMode(
2171 parentProjection.thread.interactionMode,
2172 shell.interactionMode,
2173 );
2174 }
2175 const { subagents } = yield* threadManagement
2176 .getThreadRecords(threadId, ["subagents"])
2177 .pipe(Effect.mapError(threadManagementFailure));
2178 for (const task of subagents) {
2179 if (task.origin === "app_owned" && task.childThreadId !== null) {
2180 yield* assertStoppable(task.childThreadId);
2181 }
2182 }
2183 });
2184 yield* assertStoppable(current.childThreadId);
2185 const parentTask = parentProjection.subagents.find(
2186 (task) => task.id === input.taskId && task.origin === "app_owned",
2187 );
2188 const disposeCompletionDelivery =
2189 parentTask?.completionDelivery?.state === "disposed"
2190 ? Effect.void
2191 : threadManagement
2192 .dispatch({
2193 type: "delegated_task.completion-delivery.dispose",
2194 commandId: stableCommandId({
2195 scope,
2196 requestKey: key,
2197 operation: "cancel-task-completion-delivery",
2198 }),
2199 parentThreadId: scope.thread.threadId,
2200 taskId: input.taskId,
2201 })
2202 .pipe(
2203 Effect.asVoid,
2204 Effect.mapError((error) =>
2205 failure(
2206 "orchestration_error",
2207 `Unable to dispose delegated task ${input.taskId} completion delivery: ${errorMessage(error)}`,
2208 ),
2209 ),
2210 );
2211 // Cancelling stops the child thread the way Stop does, including work that came
2212 // after a published result: follow-up runs, pull request watch wakes, and the
2213 // tasks it delegated. The published result stays terminal.
2214 const stopChild = Effect.gen(function* () {
2215 const commandId = stableCommandId({ scope, requestKey: key, operation: "cancel-task" });
2216 const reason = input.reason;
2217 yield* threadManagement
2218 .dispatch({
2219 type: "thread.stop",
2220 commandId,
2221 threadId: current.childThreadId,
2222 ...(reason === undefined ? {} : { reason }),
2223 })
2224 .pipe(
2225 Effect.mapError((error) =>
2226 failure(
2227 "task_not_cancellable",
2228 `Unable to stop delegated task ${input.taskId}: ${errorMessage(error)}`,
2229 ),
2230 ),
2231 );
2232 // A retry with the same clientRequestId repeats only the stops that failed.
2233 yield* threadManagement
2234 .stopDelegatedTasks({ threadId: current.childThreadId, commandId, reason })
2235 .pipe(
2236 Effect.mapError((error) =>
2237 failure(
2238 "task_not_cancellable",
2239 `Stopped delegated task ${input.taskId}, but not every task it delegated: ${errorMessage(error)}`,
2240 ),
2241 ),
2242 );
2243 });
2244 // Checked above; a user raising one of these threads meanwhile is
2245 // caught again under that thread's lock, which leaves it running.
2246 const refused = yield* Ref.make<DispatchModeRefusal | undefined>(undefined);
2247 const stopWithinLimit = stopChild.pipe(
2248 Effect.provideService(DispatchModeLimit, {
2249 runtimeMode: parentProjection.thread.runtimeMode,
2250 interactionMode: parentProjection.thread.interactionMode,
2251 refused,
2252 }),
2253 Effect.catch((error) =>
2254 Effect.flatMap(Ref.get(refused), (refusal) =>
2255 Effect.fail(
2256 refusal === undefined
2257 ? error
2258 : failure(
2259 refusal.mode === "runtime"
2260 ? "runtime_mode_escalation_denied"
2261 : "interaction_mode_escalation_denied",
2262 `Thread ${refusal.threadId} now runs in ${refusal.runtimeMode}/${refusal.interactionMode} mode, above this thread's; its user changed it while the task was being cancelled.`,
2263 ),
2264 ),
2265 ),
2266 ),
2267 );
2268 if (isTerminalTaskStatus(current.status)) {
2269 yield* stopWithinLimit;
2270 yield* disposeCompletionDelivery;
2271 return {
2272 taskId: input.taskId,
2273 status: current.status,
2274 } satisfies OrchestratorMcpTaskCancelResult;
2275 }
2276 // A task waiting on its own delegates has work to stop below it. One with neither
2277 // a running turn nor delegates (such as one awaiting a restart) keeps its delivery.
2278 const child = yield* loadProjection(current.childThreadId);
2279 if (
2280 current.workState !== "waiting_for_children" &&
2281 ThreadManagementService.latestActiveRun(child) === undefined
2282 ) {
2283 return yield* failure(
2284 "task_not_cancellable",
2285 `Delegated task ${input.taskId} has no interruptible child run.`,
2286 );
2287 }
2288 yield* stopWithinLimit;
2289 yield* disposeCompletionDelivery.pipe(
2290 Effect.catchCause((cause) =>
2291 Effect.logWarning("orchestrator-mcp.cancel-task.delivery-dispose-failed", {
2292 taskId: input.taskId,
2293 cause,
2294 }),
2295 ),
2296 );
2297 return {
2298 taskId: input.taskId,
2299 status: "cancel_requested",
2300 };
2301 }),
2302 createThreads: (callerScope, input) =>
2303 Effect.gen(function* () {
2304 const { scope, parent } = yield* loadThreadCaller(callerScope, "create_threads");
2305 const parentRun = ThreadManagementService.latestActiveRun(parent);
2306 if (
2307 parentRun === undefined ||
2308 parentRun.rootNodeId === null ||
2309 parentRun.providerInstanceId !== scope.thread.providerInstanceId
2310 ) {
2311 return yield* failure(
2312 "parent_not_active",
2313 "Thread creation requires an active run owned by this MCP provider session.",
2314 );
2315 }
2316 const parentNodeId = parentRun.rootNodeId;
2317 const providers = yield* loadProviders;
2318 const key = yield* requestKey(input.clientRequestId);
2319 const created = yield* Effect.forEach(
2320 input.threads,
2321 (request, index) =>
2322 Effect.gen(function* () {
2323 const target = yield* resolveTargetRechecking({
2324 parent,
2325 target: request.target,
2326 providers,
2327 });
2328 const runtimeMode = yield* resolveRuntimeMode(
2329 parent.thread.runtimeMode,
2330 request.runtimeMode,
2331 target,
2332 );
2333 const interactionMode = yield* resolveInteractionMode(
2334 parent.thread.interactionMode,
2335 request.interactionMode,
2336 );
2337 const threadId = stableThreadId({
2338 scope,
2339 requestKey: key,
2340 index,
2341 });
2342 yield* assertTargetEnabledForProject(
2343 parent,
2344 stableCommandId({ scope, requestKey: key, operation: "create-thread", index }),
2345 target,
2346 );
2347 const title = threadTitle({
2348 parentTitle: parent.thread.title,
2349 prompt: request.prompt,
2350 title: request.title,
2351 index,
2352 });
2353 yield* threadManagement
2354 .dispatch({
2355 type: "thread.create",
2356 createdBy: "agent",
2357 creationSource: "mcp",
2358 commandId: stableCommandId({
2359 scope,
2360 requestKey: key,
2361 operation: "create-thread",
2362 index,
2363 }),
2364 threadId,
2365 projectId: parent.thread.projectId,
2366 title,
2367 modelSelection: target.modelSelection,
2368 runtimeMode,
2369 interactionMode,
2370 branch: parent.thread.branch,
2371 worktreePath: parent.thread.worktreePath,
2372 })
2373 .pipe(
2374 Effect.mapError((error) =>
2375 failure(
2376 "orchestration_error",
2377 `Unable to create thread ${index + 1}: ${errorMessage(error)}`,
2378 ),
2379 ),
2380 );
2381 if (request.prompt !== undefined) {
2382 yield* threadManagement
2383 .dispatch({
2384 type: "message.dispatch",
2385 createdBy: "agent",
2386 creationSource: "mcp",
2387 commandId: stableCommandId({
2388 scope,
2389 requestKey: key,
2390 operation: "dispatch-thread",
2391 index,
2392 }),
2393 threadId,
2394 senderThreadId: scope.thread.threadId,
2395 messageId: stableMessageId({
2396 scope,
2397 requestKey: key,
2398 index,
2399 }),
2400 text: request.prompt,
2401 attachments: [],
2402 modelSelection: target.modelSelection,
2403 dispatchMode: { type: "start_immediately" },
2404 })
2405 .pipe(
2406 Effect.mapError((error) =>
2407 failure(
2408 "orchestration_error",
2409 `Unable to start thread ${index + 1}: ${errorMessage(error)}`,
2410 ),
2411 ),
2412 );
2413 }
2414 const projection = yield* loadProjection(threadId);
2415 const run = projection.runs.at(-1);
2416 yield* threadManagement
2417 .dispatch({
2418 type: "thread.created.record",
2419 commandId: stableCommandId({
2420 scope,
2421 requestKey: key,
2422 operation: "record-created-thread",
2423 index,
2424 }),
2425 parentThreadId: scope.thread.threadId,
2426 parentRunId: parentRun.id,
2427 parentNodeId,
2428 targetThreadId: threadId,
2429 targetRunId: run?.id ?? null,
2430 })
2431 .pipe(
2432 Effect.mapError((error) =>
2433 failure(
2434 "orchestration_error",
2435 `Unable to record thread ${index + 1} in the parent timeline: ${errorMessage(error)}`,
2436 ),
2437 ),
2438 );
2439 return {
2440 threadId,
2441 runId: run?.id ?? null,
2442 status: run?.status ?? "idle",
2443 title: projection.thread.title,
2444 createdBy: projection.thread.createdBy,
2445 creationSource: projection.thread.creationSource,
2446 providerInstanceId: target.modelSelection.instanceId,
2447 model: target.modelSelection.model,
2448 } satisfies OrchestratorMcpCreatedThread;
2449 }),
2450 { concurrency: 1 },
2451 );
2452 return { threads: created };
2453 }),
2454 listThreads: (scope, input) =>
2455 Effect.gen(function* () {
2456 const { parent } = yield* loadCaller(scope);
2457 const projectId = yield* resolveProjectTarget(parent, input.projectId);
2458 const projectThreads = yield* threadManagement
2459 .listProjectThreads({
2460 projectId,
2461 includeSubagents: input.includeSubagents !== false,
2462 })
2463 .pipe(
2464 Effect.mapError((error) =>
2465 failure("orchestration_error", `Unable to list threads: ${errorMessage(error)}`),
2466 ),
2467 );
2468 const nowMs = yield* Clock.currentTimeMillis;
2469 const statuses = input.statuses === undefined ? null : new Set(input.statuses);
2470 const titleContains = input.titleContains?.toLocaleLowerCase();
2471 const filtered = projectThreads
2472 .filter(
2473 (thread) =>
2474 statuses === null || statuses.has(thread.activityRunStatus ?? thread.status),
2475 )
2476 .filter(
2477 (thread) =>
2478 input.settled === undefined || threadSettlement(thread).settled === input.settled,
2479 )
2480 .filter(
2481 (thread) => input.snoozed === undefined || isSnoozed(thread, nowMs) === input.snoozed,
2482 )
2483 .filter(
2484 (thread) =>
2485 titleContains === undefined ||
2486 thread.title.toLocaleLowerCase().includes(titleContains),
2487 );
2488 const cursor = input.cursor ?? 0;
2489 const limit = input.limit ?? DEFAULT_THREAD_LIST_LIMIT;
2490 const page = filtered.slice(cursor, cursor + limit);
2491 const nextCursor = cursor + page.length < filtered.length ? cursor + page.length : null;
2492 return {
2493 projectId,
2494 currentThreadId: parent?.thread.id ?? null,
2495 threads: page.map((shell) => listItemFromShell(shell, nowMs)),
2496 nextCursor,
2497 total: filtered.length,
2498 } satisfies OrchestratorMcpThreadListResult;
2499 }),
2500 readThread: (scope, input) =>
2501 Effect.gen(function* () {
2502 const { parent, target, shell } = yield* loadReadableThread(scope, input.threadId);
2503 const view = input.view ?? "messages";
2504 const afterPosition = input.afterPosition ?? -1;
2505 const limit = input.limit ?? DEFAULT_THREAD_READ_LIMIT;
2506 const maxChars = input.maxCharsPerItem ?? DEFAULT_THREAD_ITEM_MAX_CHARS;
2507 const timeline = yield* threadManagement
2508 .getTimelinePage(input.threadId, {
2509 afterPosition,
2510 limit,
2511 view,
2512 ...(input.itemId === undefined ? {} : { itemId: input.itemId }),
2513 })
2514 .pipe(Effect.mapError(threadManagementFailure));
2515 const page = timeline.items;
2516 const messageIdsByThread = new Map<ThreadId, Array<MessageId>>();
2517 for (const row of page) {
2518 if (row.item.type !== "user_message" && row.item.type !== "assistant_message") continue;
2519 const ids = messageIdsByThread.get(row.sourceThreadId) ?? [];
2520 ids.push(row.item.messageId);
2521 messageIdsByThread.set(row.sourceThreadId, ids);
2522 }
2523 const sourceMessages = yield* Effect.forEach(
2524 [...messageIdsByThread],
2525 ([threadId, messageIds]) =>
2526 threadManagement.getThreadRecords(threadId, ["messages"], { messageIds }).pipe(
2527 Effect.map((records) => [threadId, records.messages] as const),
2528 Effect.mapError(threadManagementFailure),
2529 ),
2530 { concurrency: 1 },
2531 );
2532 const messagesByThreadId = new Map(sourceMessages);
2533 const returnedRunIds = new Set(
2534 page
2535 .filter((row) => row.sourceThreadId === target.thread.id)
2536 .map((row) => row.item.runId),
2537 );
2538 const tasks =
2539 parent === undefined ? [] : directAppOwnedChildTasks(parent, target, returnedRunIds);
2540 if (
2541 parent !== undefined &&
2542 scope.thread !== undefined &&
2543 tasks.length > 0 &&
2544 (input.textOffset ?? 0) === 0
2545 ) {
2546 for (const task of tasks) {
2547 const transfer = taskResultTransfer(parent, target, task);
2548 const resultRunId = transfer?.sourcePoint.runId ?? delegatedTaskRun(target, task)?.id;
2549 const resultRunIds = resultRunId === undefined ? [] : [resultRunId];
2550 const resultRecords = yield* threadManagement
2551 .getThreadRecords(target.thread.id, ["messages", "turnItems"], {
2552 messageRoles: ["assistant"],
2553 messageRunIds: resultRunIds,
2554 turnItemRunIds: resultRunIds,
2555 turnItemTypes: ["assistant_message", "error"],
2556 })
2557 .pipe(Effect.mapError(threadManagementFailure));
2558 if (
2559 pageIncludesTerminalTaskResult({
2560 parent,
2561 page,
2562 task,
2563 target: { ...target, ...resultRecords },
2564 maxChars,
2565 })
2566 ) {
2567 yield* readTask(
2568 scope as McpThreadInvocationScope,
2569 task.id,
2570 false,
2571 true,
2572 "thread-read-acknowledge",
2573 );
2574 }
2575 }
2576 }
2577 return {
2578 thread: threadDetail(target, timeline.totalItems, shell, yield* Clock.currentTimeMillis),
2579 recentRuns: target.runs
2580 .toSorted((left, right) => right.ordinal - left.ordinal)
2581 .slice(0, input.runLimit ?? DEFAULT_THREAD_RUN_LIMIT)
2582 .map(threadRun),
2583 items: page.map((row) =>
2584 timelineItem({
2585 row,
2586 maxChars,
2587 messagesByThreadId,
2588 ...(input.itemId === undefined ? {} : { textOffset: input.textOffset ?? 0 }),
2589 }),
2590 ),
2591 nextPosition: page.at(-1)?.position ?? null,
2592 hasMore: timeline.hasMore,
2593 } satisfies OrchestratorMcpThreadReadResult;
2594 }),
2595 sendToThread: (scope, input) =>
2596 Effect.gen(function* () {
2597 const { parent, limits, target } = yield* loadScopedThread(scope, input.threadId);
2598 yield* assertLiveCallerForOtherThread(scope, parent, target);
2599 yield* resolveRuntimeMode(limits.runtimeMode, target.thread.runtimeMode);
2600 yield* resolveInteractionMode(limits.interactionMode, target.thread.interactionMode);
2601
2602 const mode = input.mode ?? "auto";
2603 const key = yield* requestKey(input.clientRequestId);
2604 const messageId = stableOperationMessageId({
2605 scope,
2606 requestKey: key,
2607 operation: "thread-send",
2608 });
2609 const result = yield* threadManagement
2610 .sendToThread({
2611 projectId: target.thread.projectId,
2612 commandId: stableCommandId({
2613 scope,
2614 requestKey: key,
2615 operation: "thread-send",
2616 }),
2617 threadId: input.threadId,
2618 ...(parent === undefined ? {} : { senderThreadId: parent.thread.id }),
2619 messageId,
2620 text: input.message,
2621 attachments: [],
2622 mode,
2623 createdBy: "agent",
2624 creationSource: "mcp",
2625 })
2626 .pipe(
2627 Effect.mapError((error) =>
2628 isThreadManagementError(error)
2629 ? threadManagementFailure(error)
2630 : failure(
2631 "orchestration_error",
2632 `Unable to send to thread ${input.threadId}: ${errorMessage(error)}`,
2633 ),
2634 ),
2635 );
2636 return {
2637 threadId: input.threadId,
2638 messageId,
2639 runId: result.run.id,
2640 ...(result.run.delegatedTaskId === undefined
2641 ? {}
2642 : { taskId: result.run.delegatedTaskId }),
2643 status: result.run.status,
2644 delivery: result.delivery,
2645 } satisfies OrchestratorMcpThreadSendResult;
2646 }),
2647 waitForThread: (scope, input) =>
2648 Effect.gen(function* () {
2649 const { target } = yield* loadScopedThread(scope, input.threadId);
2650 const result = yield* threadManagement
2651 .waitForThread({
2652 projectId: target.thread.projectId,
2653 threadId: input.threadId,
2654 ...(input.runId === undefined ? {} : { runId: input.runId }),
2655 timeoutMs: Math.min(
2656 MAX_WAIT_TIMEOUT_MS,
2657 Math.max(1, input.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS),
2658 ),
2659 })
2660 .pipe(Effect.mapError(threadManagementFailure));
2661 return {
2662 threadId: input.threadId,
2663 runId: result.run?.id ?? null,
2664 status: result.run === null ? "idle" : runOutcome(result.run),
2665 timedOut: result.timedOut,
2666 } satisfies OrchestratorMcpThreadWaitResult;
2667 }),
2668 interruptThread: (scope, input) =>
2669 Effect.gen(function* () {
2670 const { parent, limits, target } = yield* loadScopedThread(scope, input.threadId);
2671 yield* assertLiveCallerForOtherThread(scope, parent, target);
2672 // Stopping another thread's work is a write: it must run within the caller's modes.
2673 yield* resolveRuntimeMode(limits.runtimeMode, target.thread.runtimeMode);
2674 yield* resolveInteractionMode(limits.interactionMode, target.thread.interactionMode);
2675 const key = yield* requestKey(input.clientRequestId);
2676 const result = yield* threadManagement
2677 .interruptThread({
2678 projectId: target.thread.projectId,
2679 commandId: stableCommandId({
2680 scope,
2681 requestKey: key,
2682 operation: "thread-interrupt",
2683 }),
2684 threadId: input.threadId,
2685 ...(input.runId === undefined ? {} : { runId: input.runId }),
2686 ...(input.reason === undefined ? {} : { reason: input.reason }),
2687 })
2688 .pipe(
2689 Effect.mapError((error) =>
2690 isThreadManagementError(error)
2691 ? threadManagementFailure(error)
2692 : failure(
2693 "orchestration_error",
2694 `Unable to interrupt thread ${input.threadId}: ${errorMessage(error)}`,
2695 ),
2696 ),
2697 );
2698 if (result.type === "no_active_run") {
2699 return {
2700 threadId: input.threadId,
2701 runId: null,
2702 status: "no_active_run",
2703 } satisfies OrchestratorMcpThreadInterruptResult;
2704 }
2705 return {
2706 threadId: input.threadId,
2707 runId: result.run.id,
2708 status: result.type === "already_terminal" ? result.run.status : "interrupt_requested",
2709 } satisfies OrchestratorMcpThreadInterruptResult;
2710 }),
2711 });
2712});
2713
2714export const layer: Layer.Layer<
2715 OrchestratorMcpService,
2716 never,
2717 | Crypto.Crypto
2718 | ThreadManagementService.ThreadManagementService
2719 | ThreadLaunchService.ThreadLaunchService
2720 | GitVcsDriver.GitVcsDriver
2721 | ProviderRegistry.ProviderRegistry
2722 | ProviderAdapterRegistry.ProviderAdapterRegistryV2
2723 | ScheduledTaskService.ScheduledTaskService
2724 | ProjectService.ProjectService
2725 | SecretRequests.SecretRequests
2726 | ServerSettings.ServerSettingsService
2727> = Layer.effect(OrchestratorMcpService, make);