Skip to content

Commit 334aec2

Browse files
t3dotggclaude
andcommitted
fix(server): delegation re-checks an unavailable provider once
Provider snapshots only re-probe while a client is in the foreground. An unattended agent that fixed its provider CLI kept getting "not installed" from delegate_task until someone opened the app. On provider_unavailable, delegate_task and create_threads now re-probe the requested instance once and try again. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
1 parent 9e5229b commit 334aec2

2 files changed

Lines changed: 156 additions & 28 deletions

File tree

‎apps/server/src/mcp/OrchestratorMcpService.test.ts‎

Lines changed: 130 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -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‎

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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,

0 commit comments

Comments
 (0)