diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index 8fb3517135a3..aa5257078a37 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -396,7 +396,14 @@ describe("OrchestratorMcpService provider resolution", () => { }), ); - const parentProjection = (subagents: ReadonlyArray): OrchestrationV2ThreadProjection => + const parentProjection = ( + subagents: ReadonlyArray, + modelSelection: { + readonly instanceId: ProviderInstanceId; + readonly model: string; + readonly options?: ReadonlyArray<{ readonly id: string; readonly value: unknown }>; + } = { instanceId: codexInstanceId, model: "gpt-5.4" }, + ): OrchestrationV2ThreadProjection => ({ thread: { id: parentThreadId, @@ -404,7 +411,7 @@ describe("OrchestratorMcpService provider resolution", () => { title: "MCP parent", createdBy: "user", creationSource: "web", - modelSelection: { instanceId: codexInstanceId, model: "gpt-5.4" }, + modelSelection, runtimeMode: "full-access", interactionMode: "default", }, @@ -415,7 +422,7 @@ describe("OrchestratorMcpService provider resolution", () => { status: "running", rootNodeId: parentNodeId, providerInstanceId: codexInstanceId, - modelSelection: { instanceId: codexInstanceId, model: "gpt-5.4" }, + modelSelection, }, ], contextTransfers: [], @@ -784,4 +791,180 @@ describe("OrchestratorMcpService provider resolution", () => { }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(dependencies)))); }), ); + + it.effect( + "inherits an available parent instance for driver-only targets and otherwise selects a healthy peer", + () => + Effect.gen(function* () { + const codexAltInstanceId = ProviderInstanceId.make("codex-alt"); + const driver = ProviderDriverKind.make("codex"); + const parentModelSelection = { + instanceId: codexInstanceId, + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "high" }], + } as const; + const task = { + id: taskId, + threadId: parentThreadId, + runId: parentRunId, + parentNodeId, + origin: "app_owned", + createdBy: "agent", + driver, + providerInstanceId: codexInstanceId, + providerThreadId: null, + childThreadId, + nativeTaskRef: null, + prompt: "Summarize the diff.", + title: null, + model: "gpt-5.4", + status: "running", + result: null, + startedAt: null, + completedAt: null, + }; + const cases = [ + { + name: "healthy-inherited", + inheritedEnabled: true, + peerEnabled: true, + explicit: false, + selectedInstanceId: codexInstanceId, + }, + { + name: "unavailable-inherited-falls-back-to-healthy-peer", + inheritedEnabled: false, + peerEnabled: true, + explicit: false, + selectedInstanceId: codexAltInstanceId, + }, + { + name: "no-available-peer", + inheritedEnabled: false, + peerEnabled: false, + explicit: false, + selectedInstanceId: null, + }, + { + name: "explicit-unavailable", + inheritedEnabled: false, + peerEnabled: true, + explicit: true, + selectedInstanceId: null, + }, + { + name: "explicit-healthy", + inheritedEnabled: true, + peerEnabled: true, + explicit: true, + selectedInstanceId: codexAltInstanceId, + }, + ] as const; + + for (const testCase of cases) { + const dispatched = yield* Ref.make>([]); + let delegated = false; + const dependencies = Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService)({ + getThreadProjection: (threadId) => + Effect.succeed( + threadId === parentThreadId + ? parentProjection(delegated ? [task] : [], parentModelSelection) + : childProjection, + ), + dispatch: (command) => + Ref.update(dispatched, (commands) => [...commands, command]).pipe( + Effect.andThen( + Effect.sync(() => { + delegated = true; + }), + ), + Effect.as({ + sequence: 1, + storedEvents: [ + { + sequence: 1, + commandId: null, + event: { type: "subagent.updated", payload: task }, + }, + ], + } as never), + ), + }), + Layer.mock(ProviderRegistry)({ + getProviders: Effect.succeed([ + providerSnapshot({ + instanceId: codexInstanceId, + driver, + model: "gpt-5.4", + enabled: testCase.inheritedEnabled, + }), + providerSnapshot({ + instanceId: codexAltInstanceId, + driver, + model: "codex-alt-model", + enabled: testCase.peerEnabled, + }), + ]), + }), + adapterRegistryLayer([codexInstanceId, codexAltInstanceId]), + Layer.mock(ScheduledTaskService)({}), + ); + + yield* Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + const target = testCase.explicit + ? ({ + providerInstanceId: + testCase.selectedInstanceId === null + ? codexInstanceId + : testCase.selectedInstanceId, + } as const) + : ({ driverKind: driver } as const); + if (testCase.selectedInstanceId === null) { + const error = yield* service + .delegateTask(scope, { + task: "Summarize the diff.", + target, + mode: "async", + clientRequestId: `delegate-select-${testCase.name}`, + }) + .pipe(Effect.flip); + assert.equal(error.code, "provider_unavailable", testCase.name); + assert.deepEqual(yield* Ref.get(dispatched), [], testCase.name); + return; + } + const result = yield* service.delegateTask(scope, { + task: "Summarize the diff.", + target, + mode: "async", + clientRequestId: `delegate-select-${testCase.name}`, + }); + assert.equal(result.status, "running", testCase.name); + const commands = yield* Ref.get(dispatched); + assert.equal(commands.length, 1, testCase.name); + const request = commands[0] as { + type: string; + modelSelection: { + instanceId: string; + model: string; + options?: ReadonlyArray<{ id: string; value: unknown }>; + }; + }; + assert.equal(request.type, "delegated_task.request", testCase.name); + assert.equal( + request.modelSelection.instanceId, + testCase.selectedInstanceId, + testCase.name, + ); + if (testCase.name === "healthy-inherited") { + assert.deepEqual(request.modelSelection, parentModelSelection, testCase.name); + } else { + assert.equal(request.modelSelection.model, "codex-alt-model", testCase.name); + } + }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(dependencies)))); + } + }), + ); }); diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 5140640f615f..aacfb9609648 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -815,8 +815,13 @@ const make = Effect.gen(function* () { `No V2 provider adapter is registered for driver ${requestedDriver}.`, ); } + // Inherit the parent's instance only when it can actually serve the + // child; an unavailable parent yields to a healthy instance of the + // requested driver rather than failing the delegation. const inheritedCandidate = candidates.find( - (candidate) => candidate.instanceId === input.parent.thread.modelSelection.instanceId, + (candidate) => + candidate.instanceId === input.parent.thread.modelSelection.instanceId && + providerConstraints(candidate, true).length === 0, ); const availableCandidate = candidates.find( (candidate) => providerConstraints(candidate, true).length === 0, diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index 7ac66217ae7a..88459a94465b 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -222,7 +222,10 @@ type DelegateTaskInput = { ``` Provider, model, runtime mode, and interaction mode inherit from the parent -when omitted. Selecting a different provider without a model uses that +when omitted. A driver-only target inherits the parent's provider instance +when it can run child tasks, and otherwise selects an available instance of +that driver; an explicit `providerInstanceId` is honored exactly and fails +when unavailable. Selecting a different provider without a model uses that provider's first advertised model. Delegation requires an active parent run owned by the MCP credential's