Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions apps/server/src/mcp/OrchestratorMcpService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
EnvironmentId,
MessageId,
NodeId,
ORCHESTRATOR_MCP_RUNNING_TASK_NEXT,
type OrchestratorMcpCapabilitiesResult,
OrchestratorMcpTarget,
type OrchestrationV2ThreadShell,
Expand Down Expand Up @@ -292,6 +293,114 @@ describe("OrchestratorMcpService", () => {
}),
);

it.effect("tells the parent to end its turn only while its task still runs", () =>
Effect.gen(function* () {
const parentThreadId = ThreadId.make("thread:mcp-next-parent");
const childThreadId = ThreadId.make("thread:mcp-next-child");
const taskId = NodeId.make("node:mcp-next-task");
let finished = false;
let pendingRequest = false;
const parentProjection = () =>
({
thread: { id: parentThreadId },
runs: [],
contextTransfers: [],
subagents: [
{
id: taskId,
threadId: parentThreadId,
origin: "app_owned",
childThreadId,
driver: "codex",
model: "gpt-5.6-terra",
status: finished ? "completed" : "running",
result: finished ? "done" : null,
completionDelivery: { state: "pending" },
},
],
}) as unknown as OrchestrationV2ThreadProjection;
const childRun = () => ({
id: RunId.make("run:mcp-next-child"),
ordinal: 1,
status: finished ? ("completed" as const) : ("running" as const),
delegatedTaskId: taskId,
});
const childProjection = () =>
({
thread: { id: childThreadId, title: "Child task" },
runs: [childRun()],
contextTransfers: [],
messages: [],
subagents: [],
providerThreads: [],
runtimeRequests: pendingRequest ? [{ status: "pending" }] : [],
nodes: [],
turnItems: [],
}) as unknown as OrchestrationV2ThreadProjection;
const layerDependencies = Layer.mergeAll(
NodeServices.layer,
Layer.mock(ThreadManagementService.ThreadManagementService)({
getThreadRecords: (threadId) =>
Effect.succeed(threadId === parentThreadId ? parentProjection() : childProjection()),
getThreadShell: (threadId) => Effect.succeed(liveThreadShell(threadId)),
getProjectThreadRecords: () => Effect.succeed(childProjection()),
waitForThread: () =>
Effect.succeed({
threadId: childThreadId,
run: childRun() as unknown as OrchestrationV2ThreadProjection["runs"][number],
timedOut: !finished,
}),
dispatch: () => Effect.succeed({} as never),
}),
ServerSettings.layerTest(),
Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }),
Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({
list: () => Effect.succeed([]),
}),
Layer.mock(ProjectService.ProjectService)({}),
Layer.mock(SecretRequests.SecretRequests)({}),
Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
);
const scope: McpInvocationScope = {
environmentId: EnvironmentId.make("environment:mcp-next"),
requestNamespace: "provider-session:mcp-next",
thread: {
threadId: parentThreadId,
providerSessionId: "provider-session:mcp-next",
providerInstanceId: ProviderInstanceId.make("codex"),
},
client: undefined,
capabilities: new Set(["orchestration"]),
issuedAt: 1,
};

yield* Effect.gen(function* () {
const service = yield* OrchestratorMcpService.OrchestratorMcpService;
const running = yield* service.taskStatus(scope, taskId);
assert.equal(running.status, "running");
assert.equal(running.next, ORCHESTRATOR_MCP_RUNNING_TASK_NEXT);
const waiting = yield* service.waitForThread(scope, { threadId: childThreadId });
assert.isTrue(waiting.timedOut);
assert.equal(waiting.next, ORCHESTRATOR_MCP_RUNNING_TASK_NEXT);

// A child waiting on a question or approval needs an answer, not a wake.
pendingRequest = true;
const asking = yield* service.waitForThread(scope, { threadId: childThreadId });
assert.isTrue(asking.timedOut);
assert.notProperty(asking, "next");
pendingRequest = false;

finished = true;
const completed = yield* service.taskStatus(scope, taskId);
assert.equal(completed.status, "completed");
assert.notProperty(completed, "next");
const waited = yield* service.waitForThread(scope, { threadId: childThreadId });
assert.equal(waited.status, "completed");
assert.notProperty(waited, "next");
}).pipe(Effect.provide(layerOrchestratorService.pipe(Layer.provide(layerDependencies))));
}),
);

it.effect("does not dispose delivery when a nonterminal task has no active child run", () =>
Effect.gen(function* () {
const parentThreadId = ThreadId.make("thread:mcp-cancel-parent");
Expand Down
33 changes: 30 additions & 3 deletions apps/server/src/mcp/OrchestratorMcpService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
MessageId,
type ModelSelection,
NodeId,
ORCHESTRATOR_MCP_RUNNING_TASK_NEXT,
TurnItemId,
type OrchestrationV2Run,
type OrchestrationV2Subagent,
Expand Down Expand Up @@ -1594,7 +1595,7 @@ const make = Effect.gen(function* () {
instanceId: task.providerInstanceId,
model: task.model,
};
const response = {
const result = {
taskId: task.id,
childThreadId: task.childThreadId,
childRunId: childRun?.id ?? null,
Expand Down Expand Up @@ -1629,6 +1630,10 @@ const make = Effect.gen(function* () {
: pendingTaskApproval(childControls, task.id, childRun),
waitTimedOut,
} satisfies OrchestratorMcpDelegateTaskResult;
const response: OrchestratorMcpDelegateTaskResult =
endsTaskWait(result) || task.completionDelivery?.state === "disposed"
? result
: { ...result, next: ORCHESTRATOR_MCP_RUNNING_TASK_NEXT };
if (
acknowledgeTerminal &&
isTerminalTaskStatus(status) &&
Expand Down Expand Up @@ -2847,7 +2852,7 @@ const make = Effect.gen(function* () {
} satisfies OrchestratorMcpThreadSendResult;
}),
waitForThread: Effect.fn("OrchestratorMcpService.waitForThread")(function* (scope, input) {
const { target } = yield* loadScopedThread(scope, input.threadId);
const { parent, target } = yield* loadScopedThread(scope, input.threadId);
const result = yield* threadManagement
.waitForThread({
projectId: target.thread.projectId,
Expand All @@ -2859,11 +2864,33 @@ const make = Effect.gen(function* () {
),
})
.pipe(Effect.mapError(threadManagementFailure));
const status = result.run === null ? "idle" : runOutcome(result.run);
const taskId = result.run?.delegatedTaskId;
const ownedTask =
result.timedOut &&
status !== "waiting" &&
parent?.subagents.some(
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
(task) =>
task.id === taskId &&
task.origin === "app_owned" &&
task.threadId === parent.thread.id &&
task.childThreadId === input.threadId &&
task.completionDelivery?.state !== "disposed",
) === true;
// A child waiting on a question or approval needs an answer, not a wake.
const childRequests = ownedTask
? (yield* threadManagement
.getThreadRecords(input.threadId, ["runtimeRequests"])
.pipe(Effect.mapError(threadManagementFailure))).runtimeRequests
: [];
const wakesCaller =
ownedTask && !childRequests.some((request) => request.status === "pending");
return {
threadId: input.threadId,
runId: result.run?.id ?? null,
status: result.run === null ? "idle" : runOutcome(result.run),
status,
timedOut: result.timedOut,
...(wakesCaller ? { next: ORCHESTRATOR_MCP_RUNNING_TASK_NEXT } : {}),
} satisfies OrchestratorMcpThreadWaitResult;
}),
interruptThread: Effect.fn("OrchestratorMcpService.interruptThread")(function* (scope, input) {
Expand Down
7 changes: 7 additions & 0 deletions packages/contracts/src/orchestratorMcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,11 @@ export const OrchestratorMcpDelegateTaskInput = Schema.Struct({
});
export type OrchestratorMcpDelegateTaskInput = typeof OrchestratorMcpDelegateTaskInput.Type;

// Set only on results for still-running tasks whose completion will wake the parent.
export const ORCHESTRATOR_MCP_RUNNING_TASK_NEXT =
"Completion wakes this thread. End your turn unless you need this result now.";
const OrchestratorMcpRunningTaskNext = Schema.Literal(ORCHESTRATOR_MCP_RUNNING_TASK_NEXT);

export const OrchestratorMcpDelegateTaskResult = Schema.Struct({
taskId: NodeId,
childThreadId: ThreadId,
Expand Down Expand Up @@ -223,6 +228,7 @@ export const OrchestratorMcpDelegateTaskResult = Schema.Struct({
description:
"True only on that mode=wait call when timeoutMs elapsed. The timeout does not cancel the child. Later task_status reads return false and use status for liveness.",
}),
next: Schema.optional(OrchestratorMcpRunningTaskNext),
});
export type OrchestratorMcpDelegateTaskResult = typeof OrchestratorMcpDelegateTaskResult.Type;

Expand Down Expand Up @@ -495,6 +501,7 @@ export const OrchestratorMcpThreadWaitResult = Schema.Struct({
runId: Schema.NullOr(RunId),
status: OrchestratorMcpThreadStatus,
timedOut: Schema.Boolean,
next: Schema.optional(OrchestratorMcpRunningTaskNext),
});
export type OrchestratorMcpThreadWaitResult = typeof OrchestratorMcpThreadWaitResult.Type;

Expand Down
Loading