| ... | @@ -520,6 +520,13 @@ describe("OrchestratorMcpService provider resolution", () => { | ... | @@ -520,6 +520,13 @@ describe("OrchestratorMcpService provider resolution", () => { |
| 520 | skills: [], | 520 | skills: [], |
| 521 | }); | 521 | }); |
| 522 | | 522 | |
| | 523 | /** A registry whose probes report the same snapshots it already holds. */ |
| | 524 | const providerRegistryLayer = (providers: ReadonlyArray<ServerProvider>) => |
| | 525 | Layer.mock(ProviderRegistry.ProviderRegistry)({ |
| | 526 | getProviders: Effect.succeed(providers), |
| | 527 | refreshInstance: () => Effect.succeed(providers), |
| | 528 | }); |
| | 529 | |
| 523 | const adapterRegistryLayer = (instanceIds: ReadonlyArray<ProviderInstanceId>) => | 530 | const adapterRegistryLayer = (instanceIds: ReadonlyArray<ProviderInstanceId>) => |
| 524 | Layer.succeed( | 531 | Layer.succeed( |
| 525 | ProviderAdapterRegistry.ProviderAdapterRegistryV2, | 532 | ProviderAdapterRegistry.ProviderAdapterRegistryV2, |
| ... | @@ -920,16 +927,14 @@ describe("OrchestratorMcpService provider resolution", () => { | ... | @@ -920,16 +927,14 @@ describe("OrchestratorMcpService provider resolution", () => { |
| 920 | Layer.mock(ThreadManagementService.ThreadManagementService)({ | 927 | Layer.mock(ThreadManagementService.ThreadManagementService)({ |
| 921 | getThreadRecords: () => Effect.succeed(parentProjection([])), | 928 | getThreadRecords: () => Effect.succeed(parentProjection([])), |
| 922 | }), | 929 | }), |
| 923 | Layer.mock(ProviderRegistry.ProviderRegistry)({ | 930 | providerRegistryLayer([ |
| 924 | getProviders: Effect.succeed([ | 931 | providerSnapshot({ |
| 925 | providerSnapshot({ | 932 | instanceId: codexInstanceId, |
| 926 | instanceId: codexInstanceId, | 933 | driver: ProviderDriverKind.make("codex"), |
| 927 | driver: ProviderDriverKind.make("codex"), | 934 | model: "gpt-5.4", |
| 928 | model: "gpt-5.4", | 935 | }), |
| 929 | }), | 936 | forkShadow, |
| 930 | forkShadow, | 937 | ]), |
| 931 | ]), | | |
| 932 | }), | | |
| 933 | adapterRegistryLayer([codexInstanceId]), | 938 | adapterRegistryLayer([codexInstanceId]), |
| 934 | Layer.mock(ProjectService.ProjectService)({}), | 939 | Layer.mock(ProjectService.ProjectService)({}), |
| 935 | Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), | 940 | Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), |
| ... | @@ -964,6 +969,107 @@ describe("OrchestratorMcpService provider resolution", () => { | ... | @@ -964,6 +969,107 @@ describe("OrchestratorMcpService provider resolution", () => { |
| 964 | }), | 969 | }), |
| 965 | ); | 970 | ); |
| 966 | | 971 | |
| | 972 | it.effect("re-probes an unavailable target once before refusing it", () => |
| | 973 | Effect.gen(function* () { |
| | 974 | const claudeInstanceId = ProviderInstanceId.make("claudeAgent"); |
| | 975 | const claudeDriver = ProviderDriverKind.make("claudeAgent"); |
| | 976 | const task = { |
| | 977 | id: taskId, |
| | 978 | threadId: parentThreadId, |
| | 979 | runId: parentRunId, |
| | 980 | parentNodeId, |
| | 981 | origin: "app_owned", |
| | 982 | createdBy: "agent", |
| | 983 | driver: claudeDriver, |
| | 984 | providerInstanceId: claudeInstanceId, |
| | 985 | providerThreadId: null, |
| | 986 | childThreadId, |
| | 987 | nativeTaskRef: null, |
| | 988 | prompt: "Review the diff.", |
| | 989 | title: null, |
| | 990 | model: "claude-opus-5-5", |
| | 991 | status: "running", |
| | 992 | result: null, |
| | 993 | startedAt: null, |
| | 994 | completedAt: null, |
| | 995 | }; |
| | 996 | const healthy = providerSnapshot({ |
| | 997 | instanceId: claudeInstanceId, |
| | 998 | driver: claudeDriver, |
| | 999 | model: "claude-opus-5-5", |
| | 1000 | }); |
| | 1001 | const missingCli: ServerProvider = { |
| | 1002 | ...healthy, |
| | 1003 | installed: false, |
| | 1004 | status: "error", |
| | 1005 | message: "Claude Agent CLI (`claude`) was not found on PATH.", |
| | 1006 | }; |
| | 1007 | const codex = providerSnapshot({ |
| | 1008 | instanceId: codexInstanceId, |
| | 1009 | driver: ProviderDriverKind.make("codex"), |
| | 1010 | model: "gpt-5.4", |
| | 1011 | }); |
| | 1012 | // The cache still says the CLI is missing; a probe reports `cliInstalled`. |
| | 1013 | let cliInstalled = false; |
| | 1014 | const probes = yield* Ref.make(0); |
| | 1015 | const dispatched = yield* Ref.make(0); |
| | 1016 | const dependencies = Layer.mergeAll( |
| | 1017 | NodeServices.layer, |
| | 1018 | Layer.mock(ThreadManagementService.ThreadManagementService)({ |
| | 1019 | getThreadRecords: (threadId) => |
| | 1020 | Effect.succeed( |
| | 1021 | threadId === parentThreadId ? parentProjection([task]) : childProjection, |
| | 1022 | ), |
| | 1023 | dispatch: () => |
| | 1024 | Ref.update(dispatched, (count) => count + 1).pipe( |
| | 1025 | Effect.as({ |
| | 1026 | sequence: 1, |
| | 1027 | storedEvents: [ |
| | 1028 | { |
| | 1029 | sequence: 1, |
| | 1030 | commandId: null, |
| | 1031 | event: { type: "subagent.updated", payload: task }, |
| | 1032 | }, |
| | 1033 | ], |
| | 1034 | } as never), |
| | 1035 | ), |
| | 1036 | }), |
| | 1037 | Layer.mock(ProviderRegistry.ProviderRegistry)({ |
| | 1038 | getProviders: Effect.succeed([codex, missingCli]), |
| | 1039 | refreshInstance: () => |
| | 1040 | Ref.update(probes, (count) => count + 1).pipe( |
| | 1041 | Effect.as([codex, cliInstalled ? healthy : missingCli]), |
| | 1042 | ), |
| | 1043 | }), |
| | 1044 | adapterRegistryLayer([codexInstanceId, claudeInstanceId]), |
| | 1045 | Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), |
| | 1046 | Layer.mock(ProjectService.ProjectService)({}), |
| | 1047 | ); |
| | 1048 | |
| | 1049 | yield* Effect.gen(function* () { |
| | 1050 | const service = yield* OrchestratorMcpService.OrchestratorMcpService; |
| | 1051 | const delegate = (clientRequestId: string) => |
| | 1052 | service.delegateTask(scope, { |
| | 1053 | task: "Review the diff.", |
| | 1054 | target: { providerInstanceId: claudeInstanceId, model: "claude-opus-5-5" }, |
| | 1055 | mode: "async", |
| | 1056 | clientRequestId, |
| | 1057 | }); |
| | 1058 | |
| | 1059 | const error = yield* delegate("delegate-recheck-1").pipe(Effect.flip); |
| | 1060 | assert.equal(error.code, "provider_unavailable"); |
| | 1061 | assert.equal(yield* Ref.get(probes), 1); |
| | 1062 | assert.equal(yield* Ref.get(dispatched), 0); |
| | 1063 | |
| | 1064 | cliInstalled = true; |
| | 1065 | const result = yield* delegate("delegate-recheck-2"); |
| | 1066 | assert.equal(result.providerInstanceId, claudeInstanceId); |
| | 1067 | assert.equal(yield* Ref.get(probes), 2); |
| | 1068 | assert.equal(yield* Ref.get(dispatched), 1); |
| | 1069 | }).pipe(Effect.provide(OrchestratorMcpService.layer.pipe(Layer.provide(dependencies)))); |
| | 1070 | }), |
| | 1071 | ); |
| | 1072 | |
| 967 | it.effect( | 1073 | it.effect( |
| 968 | "inherits an available parent instance for driver-only targets and otherwise selects a healthy peer", | 1074 | "inherits an available parent instance for driver-only targets and otherwise selects a healthy peer", |
| 969 | () => | 1075 | () => |
| ... | @@ -1078,22 +1184,20 @@ describe("OrchestratorMcpService provider resolution", () => { | ... | @@ -1078,22 +1184,20 @@ describe("OrchestratorMcpService provider resolution", () => { |
| 1078 | } as never), | 1184 | } as never), |
| 1079 | ), | 1185 | ), |
| 1080 | }), | 1186 | }), |
| 1081 | Layer.mock(ProviderRegistry.ProviderRegistry)({ | 1187 | providerRegistryLayer([ |
| 1082 | getProviders: Effect.succeed([ | 1188 | providerSnapshot({ |
| 1083 | providerSnapshot({ | 1189 | instanceId: codexInstanceId, |
| 1084 | instanceId: codexInstanceId, | 1190 | driver, |
| 1085 | driver, | 1191 | model: "gpt-5.4", |
| 1086 | model: "gpt-5.4", | 1192 | enabled: testCase.inheritedEnabled, |
| 1087 | enabled: testCase.inheritedEnabled, | 1193 | }), |
| 1088 | }), | 1194 | providerSnapshot({ |
| 1089 | providerSnapshot({ | 1195 | instanceId: codexAltInstanceId, |
| 1090 | instanceId: codexAltInstanceId, | 1196 | driver: testCase.candidateDriver, |
| 1091 | driver: testCase.candidateDriver, | 1197 | model: "codex-alt-model", |
| 1092 | model: "codex-alt-model", | 1198 | enabled: testCase.peerEnabled, |
| 1093 | enabled: testCase.peerEnabled, | 1199 | }), |
| 1094 | }), | 1200 | ]), |
| 1095 | ]), | | |
| 1096 | }), | | |
| 1097 | adapterRegistryLayer([codexInstanceId, codexAltInstanceId]), | 1201 | adapterRegistryLayer([codexInstanceId, codexAltInstanceId]), |
| 1098 | Layer.mock(ProjectService.ProjectService)({}), | 1202 | Layer.mock(ProjectService.ProjectService)({}), |
| 1099 | Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), | 1203 | Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), |