diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index 44899e872e83763d3facf75c339ab7074859ff60..a10730200da368e5d4908310d9b3160532a6927f 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -520,6 +520,13 @@ describe("OrchestratorMcpService provider resolution", () => { skills: [], }); + /** A registry whose probes report the same snapshots it already holds. */ + const providerRegistryLayer = (providers: ReadonlyArray) => + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed(providers), + refreshInstance: () => Effect.succeed(providers), + }); + const adapterRegistryLayer = (instanceIds: ReadonlyArray) => Layer.succeed( ProviderAdapterRegistry.ProviderAdapterRegistryV2, @@ -920,16 +927,14 @@ describe("OrchestratorMcpService provider resolution", () => { Layer.mock(ThreadManagementService.ThreadManagementService)({ getThreadRecords: () => Effect.succeed(parentProjection([])), }), - Layer.mock(ProviderRegistry.ProviderRegistry)({ - getProviders: Effect.succeed([ - providerSnapshot({ - instanceId: codexInstanceId, - driver: ProviderDriverKind.make("codex"), - model: "gpt-5.4", - }), - forkShadow, - ]), - }), + providerRegistryLayer([ + providerSnapshot({ + instanceId: codexInstanceId, + driver: ProviderDriverKind.make("codex"), + model: "gpt-5.4", + }), + forkShadow, + ]), adapterRegistryLayer([codexInstanceId]), Layer.mock(ProjectService.ProjectService)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), @@ -964,6 +969,107 @@ describe("OrchestratorMcpService provider resolution", () => { }), ); + it.effect("re-probes an unavailable target once before refusing it", () => + Effect.gen(function* () { + const claudeInstanceId = ProviderInstanceId.make("claudeAgent"); + const claudeDriver = ProviderDriverKind.make("claudeAgent"); + const task = { + id: taskId, + threadId: parentThreadId, + runId: parentRunId, + parentNodeId, + origin: "app_owned", + createdBy: "agent", + driver: claudeDriver, + providerInstanceId: claudeInstanceId, + providerThreadId: null, + childThreadId, + nativeTaskRef: null, + prompt: "Review the diff.", + title: null, + model: "claude-opus-5-5", + status: "running", + result: null, + startedAt: null, + completedAt: null, + }; + const healthy = providerSnapshot({ + instanceId: claudeInstanceId, + driver: claudeDriver, + model: "claude-opus-5-5", + }); + const missingCli: ServerProvider = { + ...healthy, + installed: false, + status: "error", + message: "Claude Agent CLI (`claude`) was not found on PATH.", + }; + const codex = providerSnapshot({ + instanceId: codexInstanceId, + driver: ProviderDriverKind.make("codex"), + model: "gpt-5.4", + }); + // The cache still says the CLI is missing; a probe reports `cliInstalled`. + let cliInstalled = false; + const probes = yield* Ref.make(0); + const dispatched = yield* Ref.make(0); + const dependencies = Layer.mergeAll( + NodeServices.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + getThreadRecords: (threadId) => + Effect.succeed( + threadId === parentThreadId ? parentProjection([task]) : childProjection, + ), + dispatch: () => + Ref.update(dispatched, (count) => count + 1).pipe( + Effect.as({ + sequence: 1, + storedEvents: [ + { + sequence: 1, + commandId: null, + event: { type: "subagent.updated", payload: task }, + }, + ], + } as never), + ), + }), + Layer.mock(ProviderRegistry.ProviderRegistry)({ + getProviders: Effect.succeed([codex, missingCli]), + refreshInstance: () => + Ref.update(probes, (count) => count + 1).pipe( + Effect.as([codex, cliInstalled ? healthy : missingCli]), + ), + }), + adapterRegistryLayer([codexInstanceId, claudeInstanceId]), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + Layer.mock(ProjectService.ProjectService)({}), + ); + + yield* Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + const delegate = (clientRequestId: string) => + service.delegateTask(scope, { + task: "Review the diff.", + target: { providerInstanceId: claudeInstanceId, model: "claude-opus-5-5" }, + mode: "async", + clientRequestId, + }); + + const error = yield* delegate("delegate-recheck-1").pipe(Effect.flip); + assert.equal(error.code, "provider_unavailable"); + assert.equal(yield* Ref.get(probes), 1); + assert.equal(yield* Ref.get(dispatched), 0); + + cliInstalled = true; + const result = yield* delegate("delegate-recheck-2"); + assert.equal(result.providerInstanceId, claudeInstanceId); + assert.equal(yield* Ref.get(probes), 2); + assert.equal(yield* Ref.get(dispatched), 1); + }).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", () => @@ -1078,22 +1184,20 @@ describe("OrchestratorMcpService provider resolution", () => { } as never), ), }), - Layer.mock(ProviderRegistry.ProviderRegistry)({ - getProviders: Effect.succeed([ - providerSnapshot({ - instanceId: codexInstanceId, - driver, - model: "gpt-5.4", - enabled: testCase.inheritedEnabled, - }), - providerSnapshot({ - instanceId: codexAltInstanceId, - driver: testCase.candidateDriver, - model: "codex-alt-model", - enabled: testCase.peerEnabled, - }), - ]), - }), + providerRegistryLayer([ + providerSnapshot({ + instanceId: codexInstanceId, + driver, + model: "gpt-5.4", + enabled: testCase.inheritedEnabled, + }), + providerSnapshot({ + instanceId: codexAltInstanceId, + driver: testCase.candidateDriver, + model: "codex-alt-model", + enabled: testCase.peerEnabled, + }), + ]), adapterRegistryLayer([codexInstanceId, codexAltInstanceId]), Layer.mock(ProjectService.ProjectService)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index d8175f18d62d99935b7097ca8f07a02c4a3a090e..cc34256b97da131e5c260c7341cec787d3f88b06 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -996,6 +996,30 @@ const make = Effect.gen(function* () { const loadOrchestrationCapableInstanceIds = () => providerAdapters.list().pipe(Effect.map((instanceIds) => new Set(instanceIds))); + /** + * Provider snapshots only re-probe while a client is in the foreground, so an + * unattended agent can see a provider as unavailable after it was fixed. + * Re-probe the requested instance once before refusing it. + */ + const resolveTargetRechecking = (input: Parameters[0]) => { + const instanceId = + input.target?.providerInstanceId ?? + (input.target?.driverKind === undefined + ? input.parent.thread.modelSelection.instanceId + : undefined); + const resolved = resolveTarget(input); + if (instanceId === undefined) return resolved; + return resolved.pipe( + Effect.catchIf( + (error) => error.code === "provider_unavailable", + () => + providerRegistry + .refreshInstance(instanceId) + .pipe(Effect.flatMap((providers) => resolveTarget({ ...input, providers }))), + ), + ); + }; + const resolveTarget = (input: { readonly parent: Pick; readonly target: OrchestratorMcpTarget | undefined; @@ -1519,7 +1543,7 @@ const make = Effect.gen(function* () { ); } const providers = yield* loadProviders; - const target = yield* resolveTarget({ + const target = yield* resolveTargetRechecking({ parent, target: input.target, providers, @@ -1751,7 +1775,7 @@ const make = Effect.gen(function* () { input.threads, (request, index) => Effect.gen(function* () { - const target = yield* resolveTarget({ + const target = yield* resolveTargetRechecking({ parent, target: request.target, providers,