agent-relay-server 0.117.1 → 0.118.1

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.
@@ -19,6 +19,7 @@ import type {
19
19
  DispatchModelTarget,
20
20
  DispatchPolicy,
21
21
  DispatchRule,
22
+ HostUtilizationSignal,
22
23
  SpawnProvider,
23
24
  TaskDispatch,
24
25
  } from "agent-relay-sdk";
@@ -42,12 +43,31 @@ export interface DispatchContext {
42
43
  policy?: DispatchPolicy;
43
44
  bands?: ProviderQuotaBand[];
44
45
  catalog?: ProviderCatalogEntry[];
46
+ /**
47
+ * #901 degrade-never-refuse: when true, the dispatcher ALWAYS returns a usable provider —
48
+ * widening the search through caution → avoid-large → least-utilized blockedNow tiers before
49
+ * giving up. A loud warning is emitted for each degraded tier used. Non-critical paths (flag
50
+ * unset or false) keep the existing stricter behaviour (prefer|normal only).
51
+ */
52
+ mustResolveAvailable?: boolean;
53
+ /**
54
+ * #901 host-placement seam. Shape-only in v1 — no selection logic reads this field yet.
55
+ * A future host-placement phase will inject live host load here so the dispatcher can
56
+ * prefer the least-loaded machine when multiple orchestrators are viable.
57
+ */
58
+ hosts?: HostUtilizationSignal[];
45
59
  }
46
60
 
47
61
  export interface DispatchTarget {
48
62
  provider: SpawnProvider;
49
63
  model?: string;
50
64
  effort?: string;
65
+ /**
66
+ * #901 host-placement seam. Stays undefined in v1 — a future host-placement phase will
67
+ * populate this with the chosen orchestrator id when multiple hosts are viable.
68
+ * No selection logic writes or reads this field in v1.
69
+ */
70
+ orchestratorId?: string;
51
71
  }
52
72
 
53
73
  /** Structured trace of the inputs that produced the decision (the machine-readable rationale). */
@@ -131,7 +151,12 @@ export function dispatchTask(shape: TaskDispatch | undefined, pin: DispatchPin =
131
151
  // Band-aware rerouting (skip when the provider is explicitly pinned). Protects scarce pools
132
152
  // and load-shifts large/blocked work onto an abundant one. Re-validates footprint + a pinned
133
153
  // effort on the rerouted provider so neither is silently dropped.
134
- if (!providerPinned) rerouteForBand(target, work, policy, bands, catalog, factors, warnings, { effortPinned });
154
+ if (!providerPinned) rerouteForBand(target, work, policy, bands, catalog, factors, warnings, { effortPinned, mustResolveAvailable: ctx.mustResolveAvailable });
155
+
156
+ // Guarantee the resolved model is enabled. If the routing policy or reroute picked a model
157
+ // that has been disabled/unavailable in the effective catalog, swap to the first enabled model
158
+ // on the chosen provider. Skip when the model was explicitly pinned — caller's choice wins.
159
+ if (!modelPinned) ensureEnabledModel(target, catalog, warnings);
135
160
 
136
161
  // Record the FINAL provider's band + the chosen model's window for transparency.
137
162
  factors.band = bandFor(target.provider, bands);
@@ -248,6 +273,33 @@ function escalateForFootprint(
248
273
  );
249
274
  }
250
275
 
276
+ /** True when the provider has at least one model that is not disabled or unavailable. */
277
+ function hasEnabledModel(catalog: ProviderCatalogEntry[], provider: SpawnProvider): boolean {
278
+ const entry = catalogEntry(catalog, provider);
279
+ return !!entry && entry.models.some((m) => !m.disabled && !m.unavailable);
280
+ }
281
+
282
+ /**
283
+ * Validate that `target.model` is enabled on the chosen provider. When it is disabled or
284
+ * unavailable, replace it with the first enabled model and emit a warning. Callers must skip
285
+ * this when the model was explicitly pinned (pin takes precedence, even if broken).
286
+ */
287
+ function ensureEnabledModel(target: DispatchTarget, catalog: ProviderCatalogEntry[], warnings: string[]): void {
288
+ const entry = catalogEntry(catalog, target.provider);
289
+ if (!entry) return;
290
+ const alias = target.model ?? entry.defaultModel;
291
+ if (!alias) return;
292
+ const model = entry.models.find((m) => m.alias === alias || m.providerModel === alias);
293
+ if (model && !model.disabled && !model.unavailable) return;
294
+ const enabled = entry.models.find((m) => !m.disabled && !m.unavailable);
295
+ if (enabled) {
296
+ warnings.push(`Model ${alias} is disabled/unavailable on ${target.provider}; resolved to ${enabled.alias} instead.`);
297
+ target.model = enabled.alias;
298
+ } else {
299
+ warnings.push(`No enabled models on ${target.provider}; spawn may fail.`);
300
+ }
301
+ }
302
+
251
303
  // The smallest-window model on this provider whose window meets the requirement. Prefers the
252
304
  // same model family as `alias` (e.g. opus-4.8 → opus-4.8[1m]) when one qualifies, else any family.
253
305
  function biggestSufficient(
@@ -281,7 +333,7 @@ function rerouteForBand(
281
333
  catalog: ProviderCatalogEntry[],
282
334
  factors: DispatchFactors,
283
335
  warnings: string[],
284
- opts: { effortPinned: boolean },
336
+ opts: { effortPinned: boolean; mustResolveAvailable?: boolean },
285
337
  ): void {
286
338
  const band = bandFor(target.provider, bands);
287
339
  const blocked = isBlocked(target.provider, bands);
@@ -290,11 +342,16 @@ function rerouteForBand(
290
342
  const mustMove = blocked || band === "hard-avoid" || (isLarge && band === "avoid-large");
291
343
  if (!mustMove) return;
292
344
 
293
- const alt = healthiestAlternative(target.provider, bands, catalog);
345
+ const alt = healthiestAlternative(target.provider, bands, catalog, opts.mustResolveAvailable);
294
346
  if (!alt) {
295
347
  warnings.push(`${target.provider} band=${band}${blocked ? " (at capacity)" : ""} and no healthier provider is available — keeping it; expect contention.`);
296
348
  return;
297
349
  }
350
+ if (alt.degraded) {
351
+ warnings.push(
352
+ `[CRITICAL] mustResolveAvailable: all prefer/normal providers unavailable; degraded to ${alt.provider} (${alt.degradedReason ?? "degraded tier"}). Contention likely.`,
353
+ );
354
+ }
298
355
  const altEntry = catalogEntry(catalog, alt.provider)!;
299
356
  factors.reroutedFromProvider = target.provider;
300
357
  warnings.push(`Rerouted ${target.provider} (band=${band}${blocked ? ", at capacity" : ""}) → ${alt.provider} (band=${alt.band}) to ${alt.band === "prefer" ? "use abundant quota" : "avoid the scarce pool"}.`);
@@ -323,18 +380,48 @@ function rerouteForBand(
323
380
  // catalog — not only those with a quota advisory. A provider with no quota record defaults to the
324
381
  // "normal" band (via bandFor), so a healthy catalog provider is never skipped just because it has
325
382
  // no burn data yet. Prefer "prefer" band, then "normal"; never a blocked one.
383
+ //
384
+ // When mustResolveAvailable is set (#901 degrade-never-refuse), widens search in tiers when
385
+ // no prefer/normal provider is available: caution → avoid-large → least-utilized blockedNow.
386
+ // A `degraded` flag + `degradedReason` signals to the caller to emit a louder warning.
326
387
  function healthiestAlternative(
327
388
  avoid: SpawnProvider,
328
389
  bands: ProviderQuotaBand[],
329
390
  catalog: ProviderCatalogEntry[],
330
- ): { provider: SpawnProvider; band: QuotaBand } | undefined {
331
- const ranked = catalog
391
+ mustResolveAvailable?: boolean,
392
+ ): { provider: SpawnProvider; band: QuotaBand; degraded?: boolean; degradedReason?: string } | undefined {
393
+ const all = catalog
332
394
  .map((c) => c.provider)
333
395
  .filter((provider) => provider !== avoid)
334
- .map((provider) => ({ provider, band: bandFor(provider, bands), blockedNow: isBlocked(provider, bands) }))
396
+ .filter((provider) => hasEnabledModel(catalog, provider)) // skip providers with all models disabled
397
+ .map((provider) => ({ provider, band: bandFor(provider, bands), blockedNow: isBlocked(provider, bands) }));
398
+
399
+ const standard = all
335
400
  .filter((c) => !c.blockedNow && (c.band === "prefer" || c.band === "normal"))
336
401
  .sort((a, b) => bandRank(a.band) - bandRank(b.band));
337
- return ranked[0];
402
+ if (standard[0]) return standard[0];
403
+
404
+ if (!mustResolveAvailable) return undefined;
405
+
406
+ // Tier 1 degradation: caution (over quota but not avoid/blocked)
407
+ const cautionAlt = all
408
+ .filter((c) => !c.blockedNow && c.band === "caution")
409
+ .sort((a, b) => bandRank(a.band) - bandRank(b.band));
410
+ if (cautionAlt[0]) return { ...cautionAlt[0], degraded: true, degradedReason: "caution band (tier 1 degraded)" };
411
+
412
+ // Tier 2 degradation: avoid-large
413
+ const avoidLargeAlt = all
414
+ .filter((c) => !c.blockedNow && c.band === "avoid-large")
415
+ .sort((a, b) => bandRank(a.band) - bandRank(b.band));
416
+ if (avoidLargeAlt[0]) return { ...avoidLargeAlt[0], degraded: true, degradedReason: "avoid-large band (tier 2 degraded)" };
417
+
418
+ // Tier 3 degradation: least-utilized blockedNow (last resort — emit the loudest warning).
419
+ // Sort by bandRank so the healthiest (lowest penalty) blocked provider is picked first,
420
+ // not whichever happens to be earliest in catalog order.
421
+ const blockedAlts = all.filter((c) => c.blockedNow).sort((a, b) => bandRank(a.band) - bandRank(b.band));
422
+ if (blockedAlts[0]) return { ...blockedAlts[0], degraded: true, degradedReason: "blockedNow provider (tier 3 last-resort — ALL providers at capacity)" };
423
+
424
+ return undefined;
338
425
  }
339
426
 
340
427
  function collectBandWarnings(provider: SpawnProvider, work: TaskDispatch, bands: ProviderQuotaBand[], providerPinned: boolean, warnings: string[]): void {
package/src/utils.ts CHANGED
@@ -1,6 +1,12 @@
1
- import { isAbsolute, join, relative, resolve } from "node:path";
1
+ import { join } from "node:path";
2
2
  import { homedir } from "node:os";
3
3
 
4
+ // `isPathWithinBase` and `parseJson` now live in the SDK (agent-relay-sdk) as the
5
+ // single canonical home shared with the orchestrator package; re-exported here so
6
+ // the server's existing importers stay byte-unchanged. Never re-declare them — the
7
+ // path-containment helper in particular has a history of duplicated-copy bugs.
8
+ export { isPathWithinBase, parseJson } from "agent-relay-sdk";
9
+
4
10
  /**
5
11
  * Expand a leading `~` / `~/` to the current user's home directory. Leaves any
6
12
  * other value (absolute paths, `./rel`, embedded `~` mid-string) untouched.
@@ -14,40 +20,3 @@ export function expandTilde(value: string): string {
14
20
  if (value.startsWith("~/")) return join(homedir(), value.slice(2));
15
21
  return value;
16
22
  }
17
-
18
- /**
19
- * Path containment check — is `path` inside (or equal to) `baseDir`?
20
- *
21
- * Both inputs are `resolve()`d first for defensive clarity. (Note: `path.relative`
22
- * already `resolve()`s both args against cwd internally, so this is belt-and-
23
- * suspenders, not a behavior change — verified: the `security.ts` copy that
24
- * omitted the explicit `resolve()` produced identical results. The audit's
25
- * "bug #4" was a false alarm.) Returns `false` when either input is missing.
26
- *
27
- * Was hand-copied in 6 server modules (security, workspace-merge, mcp,
28
- * lifecycle-manager, steward, routes) under three different names. Import this;
29
- * never re-declare it.
30
- */
31
- export function isPathWithinBase(path: string | undefined, baseDir: string | undefined): boolean {
32
- if (!path || !baseDir) return false;
33
- const rel = relative(resolve(baseDir), resolve(path));
34
- return rel === "" || (!!rel && !rel.startsWith("..") && !isAbsolute(rel));
35
- }
36
-
37
- /**
38
- * Parse JSON, returning `fallback` on any failure (non-string input, empty
39
- * string, or malformed JSON). Never throws.
40
- *
41
- * Folds the four fallback-style `parseJson` copies (db, memory-sqlite-broker,
42
- * provider-catalog-store, automations). The throwing / Buffer / null-returning
43
- * variants (memory-*-broker operation-context, control-server, insights-db) are
44
- * deliberately separate — different contracts.
45
- */
46
- export function parseJson<T>(raw: unknown, fallback: T): T {
47
- if (typeof raw !== "string" || !raw) return fallback;
48
- try {
49
- return JSON.parse(raw) as T;
50
- } catch {
51
- return fallback;
52
- }
53
- }
package/src/validation.ts CHANGED
@@ -1,33 +1,14 @@
1
1
  import { ValidationError } from "./db/connection.ts";
2
2
  import { type TokenConstraints, type WorkspaceAutoMergePolicy, type WorkspaceLandingPolicy, type WorkspaceMetadata, type WorkspaceMode, type WorkspaceProbe, type WorkspaceStatus } from "./types";
3
- import { AUTO_MERGE_POLICIES, VALID_WORKSPACE_MODES, isRecord } from "agent-relay-sdk";
3
+ import { AUTO_MERGE_POLICIES, VALID_WORKSPACE_MODES, cleanString, isRecord } from "agent-relay-sdk";
4
4
 
5
5
  const WORKSPACE_LAND_STRATEGIES = ["direct", "pr"] as const;
6
6
 
7
- /**
8
- * Trim + validate an optional string input. Throws `ValidationError` when the
9
- * value is present but not a string, exceeds `max`, or is required-but-empty.
10
- * Returns `undefined` for absent/blank non-required values.
11
- *
12
- * Single home — was byte-identical in config-store, automations, and routes.
13
- */
14
- export function cleanString(
15
- value: unknown,
16
- field: string,
17
- opts: { required?: boolean; max?: number } = {},
18
- ): string | undefined {
19
- if (value === undefined || value === null) {
20
- if (opts.required) throw new ValidationError(`${field} required`);
21
- return undefined;
22
- }
23
- if (typeof value !== "string") throw new ValidationError(`${field} must be a string`);
24
- const trimmed = value.trim();
25
- if (opts.required && !trimmed) throw new ValidationError(`${field} required`);
26
- if (opts.max && trimmed.length > opts.max) {
27
- throw new ValidationError(`${field} must be ${opts.max} characters or fewer`);
28
- }
29
- return trimmed || undefined;
30
- }
7
+ // `cleanString` now lives in the SDK (agent-relay-sdk) as the single canonical home
8
+ // shared with the orchestrator package; imported above for the local validators here and
9
+ // re-exported so existing importers stay byte-unchanged. The other validators keep their
10
+ // server home.
11
+ export { cleanString };
31
12
 
32
13
  /**
33
14
  * Validate a REQUIRED enum value: throws `ValidationError` if missing or not one