agent-relay-server 0.118.4 → 0.119.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/openapi.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Agent Relay API",
5
- "version": "0.118.4",
5
+ "version": "0.119.0",
6
6
  "description": "Real-time message bus for inter-agent communication. Agent-first: this spec is designed for machine consumption — agents can self-discover the full API surface via GET /api/spec.",
7
7
  "license": {
8
8
  "name": "MIT",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-server",
3
- "version": "0.118.4",
3
+ "version": "0.119.0",
4
4
  "description": "Lightweight HTTP message relay for inter-agent communication across machines",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
@@ -36,9 +36,9 @@
36
36
  "CONTRIBUTING.md"
37
37
  ],
38
38
  "dependencies": {
39
- "agent-relay-channels-host": "0.118.4",
39
+ "agent-relay-channels-host": "0.119.0",
40
40
  "agent-relay-providers": "0.104.1",
41
- "agent-relay-sdk": "0.2.101",
41
+ "agent-relay-sdk": "0.2.102",
42
42
  "ajv": "^8.20.0"
43
43
  },
44
44
  "scripts": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-relay-runner",
3
3
  "description": "Thin Agent Relay runner bridge for Claude Code",
4
- "version": "0.118.4",
4
+ "version": "0.119.0",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
@@ -848,6 +848,61 @@ import { promisify } from "util";
848
848
  var execFileAsync = promisify(execFile);
849
849
  // sdk/src/teams-config.ts
850
850
  var TEAM_IDLE_THRESHOLD_MINUTES_MAX = 24 * 60;
851
+ // sdk/src/admission/semaphore.ts
852
+ class Semaphore {
853
+ #limit;
854
+ #inFlight = 0;
855
+ #waiters = [];
856
+ constructor(limit) {
857
+ if (!Number.isInteger(limit) || limit < 1) {
858
+ throw new RangeError(`Semaphore limit must be an integer >= 1, got ${limit}`);
859
+ }
860
+ this.#limit = limit;
861
+ }
862
+ get limit() {
863
+ return this.#limit;
864
+ }
865
+ get inFlight() {
866
+ return this.#inFlight;
867
+ }
868
+ get waiting() {
869
+ return this.#waiters.length;
870
+ }
871
+ get available() {
872
+ return this.#limit - this.#inFlight;
873
+ }
874
+ tryAcquire() {
875
+ if (this.#inFlight < this.#limit) {
876
+ this.#inFlight++;
877
+ return true;
878
+ }
879
+ return false;
880
+ }
881
+ acquire() {
882
+ if (this.tryAcquire())
883
+ return Promise.resolve();
884
+ return new Promise((resolve) => {
885
+ this.#waiters.push(resolve);
886
+ });
887
+ }
888
+ release() {
889
+ const next = this.#waiters.shift();
890
+ if (next) {
891
+ next();
892
+ return;
893
+ }
894
+ if (this.#inFlight > 0)
895
+ this.#inFlight--;
896
+ }
897
+ async run(fn) {
898
+ await this.acquire();
899
+ try {
900
+ return await fn();
901
+ } finally {
902
+ this.release();
903
+ }
904
+ }
905
+ }
851
906
  // runner/src/config.ts
852
907
  function messageBodyMaxCharsFromEnv() {
853
908
  return process.env.AGENT_RELAY_MESSAGE_BODY_MAX_CHARS;
@@ -27,6 +27,36 @@ interface CommandFilters {
27
27
  }
28
28
 
29
29
  const ACTIVE_STATUSES: CommandStatus[] = ["pending", "accepted", "running"];
30
+ const TERMINAL_STATUSES: CommandStatus[] = ["succeeded", "failed", "canceled", "timed_out"];
31
+
32
+ // #930 — an `agent.spawn` command carries a hard TTL from CREATION (see
33
+ // defaultExpiresAt). The #880 concurrency cap introduces REAL queuing, so a
34
+ // legitimately-queued spawn can sit `accepted`/`running` in the orchestrator past
35
+ // that create-time TTL and be swept to `timed_out` while the orchestrator still
36
+ // intends to run it. Refresh the TTL from dispatch-intent (claim → accepted, or
37
+ // slot-acquire → running) so the window measures from when the host committed to
38
+ // the spawn, not from enqueue. Scoped to `agent.spawn`; every other command type
39
+ // keeps create-time TTL semantics.
40
+ const SPAWN_TTL_MS = 180_000;
41
+ const SPAWN_DISPATCH_STATUSES: CommandStatus[] = ["accepted", "running"];
42
+
43
+ function isTerminalStatus(status: CommandStatus): boolean {
44
+ return TERMINAL_STATUSES.includes(status);
45
+ }
46
+
47
+ // #930 — the two terminal→different transitions that ARE legitimate (verified by
48
+ // checking every updateCommand caller). Everything else out of a terminal state is
49
+ // state churn to be refused (esp. a backgrounded spawn's late `succeeded` racing the
50
+ // TTL sweep: `timed_out`→`succeeded`).
51
+ // 1. The spawn-registration watchdog corrects a premature spawn `succeeded` to
52
+ // `failed` when the agent never registered (src/services/spawn-registration-watchdog.ts).
53
+ // 2. The upgrade reconcile flips a timed-out upgrade back to `succeeded` when the
54
+ // orchestrator eventually reports the desired version (src/routes/orchestrator.ts).
55
+ function isLegitimateTerminalCorrection(type: string, from: CommandStatus, to: CommandStatus): boolean {
56
+ if (type === "agent.spawn" && from === "succeeded" && to === "failed") return true;
57
+ if (type === "orchestrator.upgrade" && from === "failed" && to === "succeeded") return true;
58
+ return false;
59
+ }
30
60
 
31
61
  export function createCommand(input: CreateCommandInput): Command {
32
62
  const now = Date.now();
@@ -97,16 +127,41 @@ export function updateCommand(id: string, input: UpdateCommandInput): Command |
97
127
  const existing = getCommand(id);
98
128
  if (!existing) return null;
99
129
  const nextStatus = input.status ?? existing.status;
130
+ // #930 — terminal-state guard. A command already in a terminal state must not be
131
+ // silently transitioned to a DIFFERENT status. The concrete hazard: the backgrounded
132
+ // spawn dispatch (#880 cap) can call updateCommand("succeeded") AFTER expireCommands
133
+ // has already swept the row to `timed_out`, silently flipping timed_out→succeeded —
134
+ // state churn plus double-spawn risk if the requestor already reacted to the timeout.
135
+ // Idempotent terminal→same-terminal is fine; the only terminal→different transitions
136
+ // allowed are the checked-caller corrections in isLegitimateTerminalCorrection. Any
137
+ // other is an observable no-op (returns the row unchanged, logs) rather than a write.
138
+ if (
139
+ input.status &&
140
+ input.status !== existing.status &&
141
+ isTerminalStatus(existing.status) &&
142
+ !isLegitimateTerminalCorrection(existing.type, existing.status, input.status)
143
+ ) {
144
+ console.error(`[commands-db] refused terminal transition ${existing.status}→${input.status} for ${existing.type} command ${id}`);
145
+ return existing;
146
+ }
100
147
  const now = Date.now();
148
+ // #930 — refresh the spawn TTL from dispatch-intent when the command is claimed or
149
+ // dispatched (see SPAWN_DISPATCH_STATUSES). Other command types keep their existing
150
+ // expiry untouched.
151
+ const nextExpiresAt =
152
+ existing.type === "agent.spawn" && input.status && SPAWN_DISPATCH_STATUSES.includes(input.status)
153
+ ? now + SPAWN_TTL_MS
154
+ : existing.expiresAt ?? null;
101
155
  getDb().query(`
102
156
  UPDATE commands
103
- SET status = ?, result = ?, error = ?, updated_at = ?
157
+ SET status = ?, result = ?, error = ?, updated_at = ?, expires_at = ?
104
158
  WHERE id = ?
105
159
  `).run(
106
160
  nextStatus,
107
161
  input.result !== undefined ? JSON.stringify(input.result) : existing.result ? JSON.stringify(existing.result) : null,
108
162
  input.error ?? existing.error ?? null,
109
163
  now,
164
+ nextExpiresAt,
110
165
  id,
111
166
  );
112
167
  const updated = getCommand(id);
@@ -131,7 +186,7 @@ export function expireCommands(now: number = Date.now()): Command[] {
131
186
  }
132
187
 
133
188
  function defaultExpiresAt(type: string, now: number): number | undefined {
134
- if (type === "agent.spawn") return now + 180_000;
189
+ if (type === "agent.spawn") return now + SPAWN_TTL_MS;
135
190
  if (type === "agent.shutdown" || type === "agent.restart" || type === "agent.reconnect" || type === "agent.kill" || type === "agent.compact" || type === "agent.clearContext" || type === "agent.injectContext") return now + 30_000;
136
191
  return undefined;
137
192
  }
@@ -255,6 +255,7 @@ const SPAWN_LIFECYCLE_AUDIENCE_TYPES = [
255
255
  "agent.exited",
256
256
  "agent.spawn_failed",
257
257
  "agent.spawn_recovered",
258
+ "agent.transient_empty_reap",
258
259
  ] as const satisfies readonly NotificationType[];
259
260
 
260
261
  for (const type of SPAWN_LIFECYCLE_AUDIENCE_TYPES) {
@@ -129,7 +129,7 @@ export function notificationTypeEnabled(config: NotificationsConfig, type: Notif
129
129
  function notificationToggleForType(type: NotificationType): NotificationToggleKey | null {
130
130
  if (type === "branch.landed" || type === "landed-failure") return "branchLanded";
131
131
  if (type === "agent.ready") return "agentReady";
132
- if (type === "agent.exited") return "agentExited";
132
+ if (type === "agent.exited" || type === "agent.transient_empty_reap") return "agentExited";
133
133
  if (type === "agent.spawn_failed") return "agentSpawnFailed";
134
134
  return null;
135
135
  }
@@ -22,6 +22,7 @@ export type NotificationType =
22
22
  | "agent.exited"
23
23
  | "agent.spawn_failed"
24
24
  | "agent.spawn_recovered"
25
+ | "agent.transient_empty_reap"
25
26
  | "agent.context_threshold"
26
27
  | "agent.stuck_busy"
27
28
  | "agent.quota_threshold"
@@ -120,6 +121,7 @@ export const DEFAULT_TTL_MS: Record<NotificationType, number | null> = {
120
121
  "agent.exited": null,
121
122
  "agent.spawn_failed": null,
122
123
  "agent.spawn_recovered": null,
124
+ "agent.transient_empty_reap": null,
123
125
  "agent.context_threshold": 15 * MINUTES,
124
126
  "agent.stuck_busy": null,
125
127
  "agent.quota_threshold": 15 * MINUTES,
@@ -338,7 +338,9 @@ function joinBlocks(...parts: Array<string | undefined>): string | undefined {
338
338
  * single instruction source. Caller system append is preserved unless it already embeds the
339
339
  * hydrated brief, which avoids delivering the same brief through both provider channels.
340
340
  * The steer used as a first-message kick is sentinel-stripped so it cannot forge the data fence
341
- * in the (instruction-bearing) systemPromptAppend brief.
341
+ * in the (instruction-bearing) systemPromptAppend brief. #927 — for the append-system-prompt
342
+ * channel a spawn with no steer falls back to submitting the hydrated brief as turn 1, so the
343
+ * worker is never left with an empty prompt (which left task-bound Claude spawns idling).
342
344
  */
343
345
  export function projectTaskHydration(
344
346
  provider: SpawnProvider | string,
@@ -352,7 +354,10 @@ export function projectTaskHydration(
352
354
  if (taskHydrationChannel(provider) === "append-system-prompt") {
353
355
  return {
354
356
  systemPromptAppend: joinBlocks(context, callerAppend),
355
- prompt: joinBlocks(steer ? stripSentinels(steer) : undefined),
357
+ // #927 always submit a turn-1 message. Steer drives it when present (the brief lives in
358
+ // systemPromptAppend as durable context); with NO steer, fall back to submitting the brief
359
+ // itself so the spawn isn't left with an empty prompt and idling forever.
360
+ prompt: joinBlocks(steer ? stripSentinels(steer) : context),
356
361
  };
357
362
  }
358
363
  return {
@@ -9,6 +9,7 @@ import {
9
9
  createDeliverableTask,
10
10
  deliverableTaskIdsByOwnerAgent,
11
11
  deliverableTaskIdsBySpawnRequestId,
12
+ getAgent,
12
13
  getTaskDetail,
13
14
  listDeliverableTaskSummaries,
14
15
  listDeliverableTasks,
@@ -34,6 +35,8 @@ import {
34
35
  import { emitTaskChanged } from "../sse";
35
36
  import type { Task, TaskDetail, TaskEvent, TaskGateState, TaskListItem } from "../types";
36
37
  import { removeSubscriptionsForTask } from "../db/notification-subscriptions";
38
+ import { emitRelayEvent } from "../events";
39
+ import { routeNotification } from "../notification-router";
37
40
  import { isAgentExitDoneWhen, stageSatisfiesDoneWhen } from "./task-done-when";
38
41
  import { autoSubscribeTaskCoordinator, emitTaskSemanticNotification, taskTransitionForStage, taskTransitionForStatus } from "./task-semantic-events";
39
42
 
@@ -187,11 +190,11 @@ export function taskEntityHistory(id: number): TaskEvent[] {
187
190
  return listTaskEvents(id);
188
191
  }
189
192
 
190
- // #889 — finalize bound tasks for an reaped agent whose doneWhen is "agent-exit" / "reaped".
193
+ // #889 — finalize bound tasks for a reaped agent whose doneWhen is "agent-exit" / "reaped".
191
194
  // Called by the transient reaper after requesting shutdown so ephemeral run-and-exit workers
192
195
  // don't leave their auto-scaffolded Task stuck in_progress forever.
193
196
  // Never overrides an already-terminal status. Idempotent (reapedAt guard inside markTaskAgentReaped).
194
- export function finalizeEphemeralTasksOnReap(agentId: string, opts: { spawnRequestId?: string; reason?: string } = {}): number[] {
197
+ export function finalizeEphemeralTasksOnReap(agentId: string, opts: { spawnRequestId?: string; reason?: string; producedWork?: boolean; evidence?: string } = {}): number[] {
195
198
  const ids = new Set<number>();
196
199
  for (const id of deliverableTaskIdsByOwnerAgent(agentId)) ids.add(id);
197
200
  if (opts.spawnRequestId) {
@@ -203,6 +206,15 @@ export function finalizeEphemeralTasksOnReap(agentId: string, opts: { spawnReque
203
206
  if (!task) continue;
204
207
  if (task.status === "done" || task.status === "failed" || task.status === "canceled") continue;
205
208
  if (!isAgentExitDoneWhen(task.doneWhen)) continue;
209
+ if (opts.producedWork === false) {
210
+ try {
211
+ setTaskEntityStatus(taskId, { status: "open", note: "worker reaped without producing work; task reopened for retry", actor: "task-lifecycle" });
212
+ notifyEmptyTransientReap(agentId, task, opts);
213
+ } catch {
214
+ // best-effort; failures must not surface into the reaper scan
215
+ }
216
+ continue;
217
+ }
206
218
  try {
207
219
  if (!task.reapedAt) markTaskAgentReaped(taskId, { agentId, reason: opts.reason ?? "agent-exit" });
208
220
  setTaskEntityStatus(taskId, { status: "done", note: "doneWhen=agent-exit satisfied by worker reap", actor: "task-lifecycle" });
@@ -213,3 +225,32 @@ export function finalizeEphemeralTasksOnReap(agentId: string, opts: { spawnReque
213
225
  }
214
226
  return finalized;
215
227
  }
228
+
229
+ function notifyEmptyTransientReap(agentId: string, task: TaskDetail, opts: { spawnRequestId?: string; reason?: string; evidence?: string }): void {
230
+ const agent = getAgent(agentId);
231
+ const parent = agent?.spawnedBy;
232
+ const recipients = [...new Set([parent, task.createdBy].filter((value): value is string => Boolean(value && value.trim())))];
233
+ const payload = {
234
+ kind: "agent.transient_empty_reap",
235
+ agentId,
236
+ parent,
237
+ taskId: task.id,
238
+ taskTitle: task.title,
239
+ spawnRequestId: opts.spawnRequestId,
240
+ reason: opts.reason ?? "agent-exit",
241
+ evidence: opts.evidence ?? "no-produced-work",
242
+ };
243
+ emitRelayEvent({ type: "agent.transient_empty_reap", source: "task-lifecycle", subject: agentId, data: payload });
244
+ routeNotification({
245
+ type: "agent.transient_empty_reap",
246
+ scope: { agentId, parent, taskId: String(task.id) },
247
+ recipients,
248
+ message: {
249
+ subject: "Worker reaped without producing work",
250
+ body: `Worker \`${agentId}\` was reaped without producing work for Task #${task.id} (${task.title}). The task was reopened for retry instead of being marked done.`,
251
+ payload,
252
+ idempotencyKey: `task:${task.id}:empty-reap:${agentId}:${opts.reason ?? "agent-exit"}`,
253
+ replyExpected: false,
254
+ },
255
+ });
256
+ }
@@ -105,24 +105,39 @@ export function resetCommitsAheadForTests(): void {
105
105
  _commitsAheadFn = defaultCommitsAhead;
106
106
  }
107
107
 
108
- function transientLandComplete(agentId: string): { complete: boolean; workspaceId?: string; status?: string; reason?: string } {
108
+ function completedProviderTurns(agent: AgentCard): number {
109
+ const value = agent.meta?.completedProviderTurns;
110
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
111
+ }
112
+
113
+ function workspaceProducedWork(ws: WorkspaceRecord | undefined): boolean {
114
+ if (!ws) return false;
115
+ if (ws.status === "merged") return true;
116
+ return typeof ws.metadata.mergedAt === "number";
117
+ }
118
+
119
+ function transientLandComplete(agent: AgentCard): { complete: boolean; producedWork: boolean; workspaceId?: string; status?: string; reason?: string } {
120
+ const agentId = agent.id;
109
121
  const isolated = listWorkspaces({ ownerAgentId: agentId }).filter((ws) => ws.mode === "isolated");
110
- if (!isolated.length) return { complete: true, reason: "no-isolated-workspace" };
122
+ if (!isolated.length) return { complete: true, producedWork: completedProviderTurns(agent) > 0, reason: "no-isolated-workspace" };
111
123
  const live = isolated.find((ws) => !TERMINAL_WORKSPACE_STATUSES.has(ws.status));
112
- if (!live) return { complete: true, workspaceId: isolated[0]?.id, status: isolated[0]?.status, reason: "terminal-workspace" };
124
+ if (!live) {
125
+ const latest = isolated[0];
126
+ return { complete: true, producedWork: workspaceProducedWork(latest), workspaceId: latest?.id, status: latest?.status, reason: "terminal-workspace" };
127
+ }
113
128
  const mergedAt = typeof live.metadata.mergedAt === "number" ? live.metadata.mergedAt : undefined;
114
129
  const landReportedAt = typeof live.metadata.landReportedAt === "number" ? live.metadata.landReportedAt : undefined;
115
130
  if (live.status === "active" && mergedAt !== undefined && mergedAt > (landReportedAt ?? 0)) {
116
- return { complete: true, workspaceId: live.id, status: live.status, reason: "recycled-after-land" };
131
+ return { complete: true, producedWork: true, workspaceId: live.id, status: live.status, reason: "recycled-after-land" };
117
132
  }
118
133
  // Fast-path (#629): a zero-commit branch has nothing to preserve — reap immediately
119
134
  // instead of holding it pending a land that will never happen. Only fires when the
120
135
  // branch is provably empty (count === 0); null (unknown/git error) falls through to
121
136
  // the safe #614 hold-pending bias below.
122
137
  if (live.status === "active" && _commitsAheadFn(live) === 0) {
123
- return { complete: true, workspaceId: live.id, status: live.status, reason: "empty-branch" };
138
+ return { complete: true, producedWork: false, workspaceId: live.id, status: live.status, reason: "empty-branch" };
124
139
  }
125
- return { complete: false, workspaceId: live.id, status: live.status, reason: "land-pending" };
140
+ return { complete: false, producedWork: false, workspaceId: live.id, status: live.status, reason: "land-pending" };
126
141
  }
127
142
 
128
143
  function notifyTransientHeld(agentId: string, workspaceId: string | undefined, status: string | undefined, waitMs: number): void {
@@ -171,11 +186,14 @@ export function reapTransientAgents(): Record<string, unknown> {
171
186
  // #800 — `reapProtected` is the single home for that decision: it honors only
172
187
  // evidence-based block states, never a high quota-utilization number.
173
188
  if (reapProtected(agent)) { transientTracker.delete(agent.id); continue; }
189
+ // A just-registered transient worker can briefly report idle/ready before its first provider
190
+ // turn starts. Do not start the idle-reap clock until the runner has observed a completed turn.
191
+ if (completedProviderTurns(agent) === 0) { transientTracker.delete(agent.id); waiting.push(agent.id); continue; }
174
192
  const entry = transientTracker.get(agent.id) ?? { firstIdleAt: now };
175
193
  transientTracker.set(agent.id, entry);
176
194
  const idleForMs = now - entry.firstIdleAt;
177
195
  if (idleForMs < grace) { waiting.push(agent.id); continue; }
178
- const land = transientLandComplete(agent.id);
196
+ const land = transientLandComplete(agent);
179
197
  if (!land.complete) {
180
198
  if (idleForMs >= grace + maxLandWait) {
181
199
  held.push(agent.id);
@@ -201,7 +219,7 @@ export function reapTransientAgents(): Record<string, unknown> {
201
219
  // #889 — finalize any bound task whose doneWhen is "agent-exit" (auto-scaffolded ephemeral
202
220
  // tasks that produce no deliverable and would otherwise hang in_progress forever).
203
221
  const spawnRequestId = typeof agent.meta?.spawnRequestId === "string" ? agent.meta.spawnRequestId : undefined;
204
- finalizeEphemeralTasksOnReap(agent.id, { spawnRequestId, reason: "transient-complete" });
222
+ finalizeEphemeralTasksOnReap(agent.id, { spawnRequestId, reason: "transient-complete", producedWork: land.producedWork, evidence: land.reason });
205
223
  reaped.push(agent.id);
206
224
  }
207
225
  for (const key of transientTracker.keys()) if (!seen.has(key)) transientTracker.delete(key);
@@ -113,6 +113,26 @@ const DEFAULT_QUOTA_ADVISORY: SpawnQuotaAdvisory = {
113
113
  recommendedAction: "No quota burn projection available; spawn normally.",
114
114
  };
115
115
 
116
+ // #926 — the orchestrator's REPORTED catalog is built from the static SDK manifest only
117
+ // (orchestrator/src/provider-probe.ts uses providerCatalogList(), which has no DB access), while
118
+ // spawn VALIDATION resolves against effectiveProviderCatalogList() (static catalog + DB
119
+ // provider_model_overrides — see resolveSpawnModelParams in spawn-command.ts). A model enabled
120
+ // only via a DB override (e.g. a newly added `sonnet-5`) is therefore spawnable but invisible to
121
+ // this matrix even after an orchestrator restart, since the orchestrator's snapshot never reads
122
+ // the DB. Merge by alias, with the DB-matrix (global) entry winning on collision, so every model
123
+ // spawn validation would accept is discoverable here too.
124
+ function mergeCatalogModels(reportedCat: SpawnCatalogish | undefined, globalCat: SpawnCatalogish | undefined): SpawnCatalogish | undefined {
125
+ if (!reportedCat && !globalCat) return undefined;
126
+ const byAlias = new Map<string, SpawnCatalogish["models"][number]>();
127
+ for (const m of reportedCat?.models ?? []) byAlias.set(m.alias, m);
128
+ for (const m of globalCat?.models ?? []) byAlias.set(m.alias, m);
129
+ return {
130
+ provider: (reportedCat ?? globalCat)!.provider,
131
+ defaultModel: reportedCat?.defaultModel ?? globalCat?.defaultModel,
132
+ models: [...byAlias.values()],
133
+ };
134
+ }
135
+
116
136
  function providerMatrixFor(o: Orchestrator, quotaAdvisories: Map<SpawnProvider, SpawnQuotaAdvisory>): Array<Record<string, unknown>> {
117
137
  const reported: ProviderCatalogSummary[] = o.providerCatalog ?? [];
118
138
  const global = effectiveProviderCatalogList();
@@ -129,8 +149,7 @@ function providerMatrixFor(o: Orchestrator, quotaAdvisories: Map<SpawnProvider,
129
149
  const unavailableAliasesFor = (provider: SpawnProvider): Set<string> =>
130
150
  new Set(global.find((e) => e.provider === provider)?.models.filter((m) => m.unavailable).map((m) => m.alias) ?? []);
131
151
  return o.providers.map((provider) => {
132
- const cat: SpawnCatalogish | undefined =
133
- reported.find((s) => s.provider === provider) ?? global.find((e) => e.provider === provider);
152
+ const cat = mergeCatalogModels(reported.find((s) => s.provider === provider), global.find((e) => e.provider === provider));
134
153
  const disabled = disabledAliasesFor(provider);
135
154
  const unavailable = unavailableAliasesFor(provider);
136
155
  const quotaAdvisory = quotaAdvisories.get(provider) ?? DEFAULT_QUOTA_ADVISORY;