authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-05 15:34:21-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-05 15:34:21-07:00
logcbb731beb36db2330c9a2632b11354315658ea21
treee7fe3de660f72f544c922d5845348b4cf8acaffd
parent9e5229b47f7af66e2f88447a49f8ea7f00c734c6
signature Signed by PGP key B5690EEEBB952194

fix(server): delegation sees a fixed provider without the app open (#16219)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

2 files changed, 156 insertions(+), 28 deletions(-)

apps/server/src/mcp/OrchestratorMcpService.test.ts+130-26
......@@ -520,6 +520,13 @@ describe("OrchestratorMcpService provider resolution", () => {
520520 skills: [],
521521 });
522522
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
523530 const adapterRegistryLayer = (instanceIds: ReadonlyArray<ProviderInstanceId>) =>
524531 Layer.succeed(
525532 ProviderAdapterRegistry.ProviderAdapterRegistryV2,
......@@ -920,16 +927,14 @@ describe("OrchestratorMcpService provider resolution", () => {
920927 Layer.mock(ThreadManagementService.ThreadManagementService)({
921928 getThreadRecords: () => Effect.succeed(parentProjection([])),
922929 }),
923 Layer.mock(ProviderRegistry.ProviderRegistry)({
924 getProviders: Effect.succeed([
925 providerSnapshot({
926 instanceId: codexInstanceId,
927 driver: ProviderDriverKind.make("codex"),
928 model: "gpt-5.4",
929 }),
930 forkShadow,
931 ]),
932 }),
930 providerRegistryLayer([
931 providerSnapshot({
932 instanceId: codexInstanceId,
933 driver: ProviderDriverKind.make("codex"),
934 model: "gpt-5.4",
935 }),
936 forkShadow,
937 ]),
933938 adapterRegistryLayer([codexInstanceId]),
934939 Layer.mock(ProjectService.ProjectService)({}),
935940 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
......@@ -964,6 +969,107 @@ describe("OrchestratorMcpService provider resolution", () => {
964969 }),
965970 );
966971
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
9671073 it.effect(
9681074 "inherits an available parent instance for driver-only targets and otherwise selects a healthy peer",
9691075 () =>
......@@ -1078,22 +1184,20 @@ describe("OrchestratorMcpService provider resolution", () => {
10781184 } as never),
10791185 ),
10801186 }),
1081 Layer.mock(ProviderRegistry.ProviderRegistry)({
1082 getProviders: Effect.succeed([
1083 providerSnapshot({
1084 instanceId: codexInstanceId,
1085 driver,
1086 model: "gpt-5.4",
1087 enabled: testCase.inheritedEnabled,
1088 }),
1089 providerSnapshot({
1090 instanceId: codexAltInstanceId,
1091 driver: testCase.candidateDriver,
1092 model: "codex-alt-model",
1093 enabled: testCase.peerEnabled,
1094 }),
1095 ]),
1096 }),
1187 providerRegistryLayer([
1188 providerSnapshot({
1189 instanceId: codexInstanceId,
1190 driver,
1191 model: "gpt-5.4",
1192 enabled: testCase.inheritedEnabled,
1193 }),
1194 providerSnapshot({
1195 instanceId: codexAltInstanceId,
1196 driver: testCase.candidateDriver,
1197 model: "codex-alt-model",
1198 enabled: testCase.peerEnabled,
1199 }),
1200 ]),
10971201 adapterRegistryLayer([codexInstanceId, codexAltInstanceId]),
10981202 Layer.mock(ProjectService.ProjectService)({}),
10991203 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
apps/server/src/mcp/OrchestratorMcpService.ts+26-2
......@@ -996,6 +996,30 @@ const make = Effect.gen(function* () {
996996 const loadOrchestrationCapableInstanceIds = () =>
997997 providerAdapters.list().pipe(Effect.map((instanceIds) => new Set(instanceIds)));
998998
999 /**
1000 * Provider snapshots only re-probe while a client is in the foreground, so an
1001 * unattended agent can see a provider as unavailable after it was fixed.
1002 * Re-probe the requested instance once before refusing it.
1003 */
1004 const resolveTargetRechecking = (input: Parameters<typeof resolveTarget>[0]) => {
1005 const instanceId =
1006 input.target?.providerInstanceId ??
1007 (input.target?.driverKind === undefined
1008 ? input.parent.thread.modelSelection.instanceId
1009 : undefined);
1010 const resolved = resolveTarget(input);
1011 if (instanceId === undefined) return resolved;
1012 return resolved.pipe(
1013 Effect.catchIf(
1014 (error) => error.code === "provider_unavailable",
1015 () =>
1016 providerRegistry
1017 .refreshInstance(instanceId)
1018 .pipe(Effect.flatMap((providers) => resolveTarget({ ...input, providers }))),
1019 ),
1020 );
1021 };
1022
9991023 const resolveTarget = (input: {
10001024 readonly parent: Pick<OrchestrationV2ThreadProjection, "thread">;
10011025 readonly target: OrchestratorMcpTarget | undefined;
......@@ -1519,7 +1543,7 @@ const make = Effect.gen(function* () {
15191543 );
15201544 }
15211545 const providers = yield* loadProviders;
1522 const target = yield* resolveTarget({
1546 const target = yield* resolveTargetRechecking({
15231547 parent,
15241548 target: input.target,
15251549 providers,
......@@ -1751,7 +1775,7 @@ const make = Effect.gen(function* () {
17511775 input.threads,
17521776 (request, index) =>
17531777 Effect.gen(function* () {
1754 const target = yield* resolveTarget({
1778 const target = yield* resolveTargetRechecking({
17551779 parent,
17561780 target: request.target,
17571781 providers,