@staix/agent-hub 0.12.18 → 0.12.20

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.
Files changed (66) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +3 -3
  3. package/docs/agent-notes/adapters.md +2 -0
  4. package/docs/agent-notes/bus.md +1 -0
  5. package/docs/agent-notes/daemon.md +2 -1
  6. package/docs/agent-notes/models.md +3 -0
  7. package/docs/agent-notes/tasks.md +3 -2
  8. package/docs/events.md +4 -1
  9. package/docs/operations.md +419 -17
  10. package/docs/quickstart.md +1 -1
  11. package/docs/security.md +18 -11
  12. package/docs/smoke.md +91 -0
  13. package/docs/specs/2026-09-19-agent-hub-design.md +412 -3
  14. package/docs/specs/2026-09-20-pi-local-models-design.md +2 -0
  15. package/docs/specs/2026-09-20-upgrade-recovery-design.md +287 -0
  16. package/docs/specs/2026-10-04-switchyard-source-port-design.md +10 -0
  17. package/docs/verified.json +13 -13
  18. package/package.json +1 -1
  19. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  20. package/plugins/agent-hub/server.js +63 -25
  21. package/src/adapters/claude-channel.ts +61 -20
  22. package/src/adapters/codex-appserver.ts +3 -0
  23. package/src/adapters/local-worker.ts +4 -0
  24. package/src/cli/console-state.ts +325 -102
  25. package/src/cli/console.ts +44 -23
  26. package/src/cli/help.ts +120 -0
  27. package/src/cli/identity.ts +3 -3
  28. package/src/cli/launch.ts +2 -0
  29. package/src/cli/main.ts +171 -75
  30. package/src/cli/preview.ts +1 -0
  31. package/src/cli/reset.ts +194 -0
  32. package/src/cli/status-lines.ts +9 -5
  33. package/src/cli/terminal-recovery.ts +140 -36
  34. package/src/cli/upgrade-runtime.ts +274 -56
  35. package/src/cli/upgrade.ts +366 -41
  36. package/src/hub/ask.ts +1 -1
  37. package/src/hub/board.ts +15 -7
  38. package/src/hub/bus.ts +60 -4
  39. package/src/hub/child-process.ts +1 -0
  40. package/src/hub/conductor.ts +38 -6
  41. package/src/hub/control-client.ts +5 -3
  42. package/src/hub/daemon.ts +70 -12
  43. package/src/hub/events.ts +7 -1
  44. package/src/hub/execution-budget.ts +15 -6
  45. package/src/hub/hub-tools.ts +4 -4
  46. package/src/hub/inference.ts +136 -1
  47. package/src/hub/lifecycle.ts +7 -1
  48. package/src/hub/peers.ts +2 -0
  49. package/src/hub/recovery-store.ts +51 -3
  50. package/src/hub/report.ts +43 -1
  51. package/src/hub/restart.ts +25 -0
  52. package/src/hub/routing.ts +48 -16
  53. package/src/hub/snapshots.ts +3 -2
  54. package/src/hub/tasks.ts +236 -83
  55. package/src/local/deny.ts +2 -1
  56. package/src/models/mlx.ts +11 -8
  57. package/src/models/ollama.ts +2 -2
  58. package/src/models/relay.ts +190 -42
  59. package/src/models/route/relay-selector.ts +43 -31
  60. package/src/models/route/runtime.ts +17 -7
  61. package/src/models/route/signals.ts +12 -1
  62. package/src/models/route/stage.ts +57 -3
  63. package/src/omniroute/client.ts +1 -1
  64. package/src/ui/index.html +1 -0
  65. package/templates/AGENTS.block.md +1 -1
  66. package/templates/routing.toml +8 -0
package/src/local/deny.ts CHANGED
@@ -4,7 +4,8 @@ import { basename, sep } from "node:path";
4
4
  * The one denylist. tools.ts checks paths against it, sandbox.ts turns it into seatbelt rules, capture.ts keeps
5
5
  * matching calls out of memory. Two lists would drift, and the gap is where a secret leaks.
6
6
  */
7
- export const DENY_SEGMENTS = [".maru/secrets", ".agenthub/state"];
7
+ // `.agenthub/archive` holds the state directories `ahub reset --all` moved there: hub.db with task text, PII included.
8
+ export const DENY_SEGMENTS = [".maru/secrets", ".agenthub/state", ".agenthub/archive"];
8
9
  /** Regex sources, matched against a file's basename. */
9
10
  export const DENY_NAMES = ["^\\.env", "\\.pem$", "\\.key$", "^id_rsa", "^id_ed25519", "^credentials(\\.|$)", "^auth\\.json$", "^\\.netrc$", "^\\.npmrc$", "^\\.pypirc$"];
10
11
 
package/src/models/mlx.ts CHANGED
@@ -48,7 +48,8 @@ export interface MlxHandle {
48
48
  readonly status: () => MlxStatus;
49
49
  readonly url: string;
50
50
  readonly model: string;
51
- readonly acquire: (signal?: AbortSignal) => Promise<() => void>;
51
+ /** `waitMs` bounds the wait for a free generation slot (default 120 s); past it, `MlxBusyError`. */
52
+ readonly acquire: (signal?: AbortSignal, waitMs?: number) => Promise<() => void>;
52
53
  readonly close: () => Promise<void>;
53
54
  }
54
55
 
@@ -177,6 +178,8 @@ interface GenerationOwner {
177
178
  }
178
179
 
179
180
  class GenerationSlotBusy extends Error {}
181
+ /** Every generation slot stayed taken for the whole wait. */
182
+ export class MlxBusyError extends Error {}
180
183
 
181
184
  function generationDatabase(runtimeDir: string): Database {
182
185
  mkdirSync(runtimeDir, { recursive: true, mode: 0o700 });
@@ -239,10 +242,10 @@ function claimGenerationSlot(db: Database, slot: number, readInfo: (pid: number)
239
242
  };
240
243
  }
241
244
 
242
- export async function acquireGeneration(runtimeDir: string, maxConcurrency: number, signal?: AbortSignal, readInfo: (pid: number) => ProcessSignature | undefined = processInfo): Promise<() => void> {
245
+ export async function acquireGeneration(runtimeDir: string, maxConcurrency: number, signal?: AbortSignal, readInfo: (pid: number) => ProcessSignature | undefined = processInfo, waitMs = 120_000): Promise<() => void> {
243
246
  if (!Number.isInteger(maxConcurrency) || maxConcurrency < 1) throw new Error("MLX maxConcurrency must be a positive integer");
244
247
  if (signal?.aborted) throw new Error("MLX generation acquisition cancelled");
245
- const deadline = Date.now() + 120_000;
248
+ const deadline = Date.now() + waitMs;
246
249
  const db = generationDatabase(runtimeDir);
247
250
  try {
248
251
  for (;;) {
@@ -251,7 +254,7 @@ export async function acquireGeneration(runtimeDir: string, maxConcurrency: numb
251
254
  if (release) return release;
252
255
  }
253
256
  if (signal?.aborted) throw new Error("MLX generation acquisition cancelled");
254
- if (Date.now() >= deadline) throw new Error("MLX generation is busy");
257
+ if (Date.now() >= deadline) throw new MlxBusyError("MLX generation is busy");
255
258
  await new Promise<void>((resolve, reject) => {
256
259
  let settled = false;
257
260
  const finish = (error?: Error) => {
@@ -359,8 +362,8 @@ export async function ensureMlx(options: MlxOptions = {}): Promise<MlxHandle> {
359
362
  }
360
363
  const sharedHandle = (status: MlxStatus): MlxHandle => {
361
364
  let active = 0;
362
- return { url: status.url!, model: modelPath, status: () => ({ ...status, active }), acquire: async (signal) => {
363
- const releaseSlot = await acquireGeneration(runtimeDir, maxConcurrency, signal, readInfo);
365
+ return { url: status.url!, model: modelPath, status: () => ({ ...status, active }), acquire: async (signal, waitMs) => {
366
+ const releaseSlot = await acquireGeneration(runtimeDir, maxConcurrency, signal, readInfo, waitMs);
364
367
  active++;
365
368
  return () => { active = Math.max(0, active - 1); releaseSlot(); };
366
369
  }, close: async () => {} };
@@ -408,8 +411,8 @@ export async function ensureMlx(options: MlxOptions = {}): Promise<MlxHandle> {
408
411
  // The server is machine-shared. Relay shutdown releases this process's handle;
409
412
  // only the explicit stopMlx operation may stop the owned runtime.
410
413
  const close = async () => {};
411
- return { url, model: modelPath, status: () => ({ state: "ready", url, model: modelPath, pid: child?.pid, maxInputTokens, maxConcurrency, active }), acquire: async (signal) => {
412
- const releaseSlot = await acquireGeneration(runtimeDir, maxConcurrency, signal, readInfo);
414
+ return { url, model: modelPath, status: () => ({ state: "ready", url, model: modelPath, pid: child?.pid, maxInputTokens, maxConcurrency, active }), acquire: async (signal, waitMs) => {
415
+ const releaseSlot = await acquireGeneration(runtimeDir, maxConcurrency, signal, readInfo, waitMs);
413
416
  active++;
414
417
  return () => { active = Math.max(0, active - 1); releaseSlot(); };
415
418
  }, close };
@@ -142,8 +142,8 @@ export async function ensureOllama(options: MlxOptions = {}): Promise<MlxHandle>
142
142
  url: `${baseUrl(config)}/v1`,
143
143
  model: config.model,
144
144
  status,
145
- acquire: async (signal) => {
146
- const releaseSlot = await acquireGeneration(config.runtimeDir, config.maxConcurrency, signal);
145
+ acquire: async (signal, waitMs) => {
146
+ const releaseSlot = await acquireGeneration(config.runtimeDir, config.maxConcurrency, signal, undefined, waitMs);
147
147
  try {
148
148
  const current = await inspectOllama(options);
149
149
  if (signal?.aborted) throw new Error("Ollama generation was cancelled");
@@ -1,8 +1,9 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { isIP } from "node:net";
3
3
  import type { OmniRoute, ChatMessage } from "../omniroute/client.ts";
4
- import { ensureMlx, type MlxHandle, type MlxOptions, type MlxStatus } from "./mlx.ts";
5
- import { AutoRouteSelector } from "./route/relay-selector.ts";
4
+ import { ensureMlx, MlxBusyError, type MlxHandle, type MlxOptions, type MlxStatus } from "./mlx.ts";
5
+ import { AutoRouteSelector, type AutoChoice, type RelayRouteEvent } from "./route/relay-selector.ts";
6
+ import { estimateInputTokens, type StaySwitchPolicy } from "./route/stage.ts";
6
7
 
7
8
  export type ModelBackend = { kind: "mlx"; alias?: string } | { kind: "dgx"; alias: string };
8
9
 
@@ -23,6 +24,11 @@ export interface RelayBackendStatus {
23
24
  provider?: string;
24
25
  active: number;
25
26
  lastError?: string;
27
+ /** While the alias cools down after consecutive transport or startup failures (#199). */
28
+ coolingUntil?: string;
29
+ failures?: number;
30
+ /** While its last dispatch counts as failed: no load move goes to it. */
31
+ failingUntil?: string;
26
32
  }
27
33
 
28
34
  export interface ModelRelayStatus {
@@ -153,7 +159,22 @@ export interface ModelRelayOptions {
153
159
  enableHubAuto?: boolean;
154
160
  /** Trusted host callback. Requests without a stable session key get stateless stage selection. */
155
161
  routeSessionKey?: (request: RelayRequest) => string | undefined;
156
- onRoute?: (event: { route: "hub/auto"; tier: string; source: "override" | "dimensions" | "hold" | "classifier" | "default"; score: number; ms: number }) => void;
162
+ onRoute?: (event: RelayRouteEvent) => void;
163
+ /** Read on every `hub/auto` call (#197); absent is the shadow default. */
164
+ staySwitch?: () => StaySwitchPolicy | undefined;
165
+ /** Opt-in (#199): how long a `hub/auto` request waits for a busy MLX slot before it moves to its fallback. Absent, no
166
+ * load move or enforced-pin move happens; cooldown reorders still do. */
167
+ efficientWaitMs?: number;
168
+ /** A backend alias's cooldown starts or ends. */
169
+ onCooldown?: (event: RelayCooldownEvent) => void;
170
+ /** Cooldown clock; tests inject one. */
171
+ now?: () => number;
172
+ /** How long a dispatch waits for a busy MLX slot before it gives up and falls back (default 120 s); tests shorten it. */
173
+ slotWaitMs?: number;
174
+ /** A moved attempt's deadline for its response headers (default 15 s); tests shorten it. */
175
+ moveFirstByteMs?: number;
176
+ /** Whether the request runs under an execution budget, read without admitting anything; then nothing moves (#199). */
177
+ underBudget?: () => boolean;
157
178
  allowedDGXmodels: Record<string, string>;
158
179
  /** Trusted physical model expectations by backend alias. Gateway identifiers can include a provider
159
180
  * prefix or route name that differs from the model reported by generation. Omission preserves the
@@ -184,11 +205,85 @@ export interface ModelRelay {
184
205
  readonly close: () => Promise<void>;
185
206
  }
186
207
 
208
+ export interface RelayCooldownEvent { alias: string; event: "start" | "end"; failures: number; ms?: number }
209
+
210
+ // ponytail: fixed policy, 3 failures and 30 s doubling up to 5 min; routing.toml keys if a backend needs other values.
211
+ const COOLDOWN_FAILURES = 3, COOLDOWN_MS = 30_000, COOLDOWN_CAP_MS = 300_000;
212
+ /** The longest one upstream request may take. */
213
+ const REQUEST_DEADLINE_MS = 180_000;
214
+ // ponytail: a moved attempt (a load move or an enforced pin, both only optimizations) gets 15 s to its response headers
215
+ // and is then abandoned for MLX; tune it from the #199 AC5 measurement.
216
+ const MOVE_FIRST_BYTE_MS = 15_000;
217
+
218
+ /**
219
+ * Per-alias health in the relay (#199). A cooldown follows consecutive transport or startup failures, and any HTTP
220
+ * answer, a success included, ends it. Separately, a backend whose last dispatch failed in any way (an error status too)
221
+ * gets no load move for COOLDOWN_MS or until it succeeds.
222
+ */
223
+ export class BackendCooldowns {
224
+ private readonly entries = new Map<string, { failures: number; until?: number; failedAt?: number; countedAt?: number }>();
225
+ constructor(private readonly now: () => number = Date.now, private readonly notify: (event: RelayCooldownEvent) => void = () => {}) {}
226
+
227
+ /** The alias's cooldown while it lasts. Its end is recorded when it is first seen to have passed, or at an answer. */
228
+ cooling(alias: string): { until: number; failures: number } | undefined {
229
+ const entry = this.entries.get(alias);
230
+ if (entry?.until === undefined) return undefined;
231
+ if (this.now() < entry.until) return { until: entry.until, failures: entry.failures };
232
+ entry.until = undefined;
233
+ this.notify({ alias, event: "end", failures: entry.failures });
234
+ return undefined;
235
+ }
236
+
237
+ /**
238
+ * A failed dispatch. `streak`: a transport or startup failure, which counts toward a cooldown unless one is running (a
239
+ * request already in flight, or a cooling alias tried as a fallback); otherwise it only marks the alias `failing`. A
240
+ * streak whose last counted failure is more than twice the cap old starts over, so an old cooldown does not double.
241
+ */
242
+ failed(alias: string, streak = true): void {
243
+ const cooling = this.cooling(alias);
244
+ const entry = this.entry(alias);
245
+ entry.failedAt = this.now();
246
+ if (cooling || !streak) return;
247
+ if (entry.countedAt !== undefined && this.now() - entry.countedAt > 2 * COOLDOWN_CAP_MS) entry.failures = 0;
248
+ entry.countedAt = this.now();
249
+ entry.failures++;
250
+ if (entry.failures < COOLDOWN_FAILURES) return;
251
+ const ms = Math.min(COOLDOWN_CAP_MS, COOLDOWN_MS * 2 ** (entry.failures - COOLDOWN_FAILURES));
252
+ entry.until = this.now() + ms;
253
+ this.notify({ alias, event: "start", failures: entry.failures, ms });
254
+ }
255
+
256
+ /** The backend answered: its transport works, so the failure streak and any cooldown end. Only a success clears `failing`. */
257
+ answered(alias: string, ok: boolean): void {
258
+ const entry = this.entry(alias);
259
+ if (entry.until !== undefined) this.notify({ alias, event: "end", failures: entry.failures });
260
+ entry.failures = 0;
261
+ entry.until = undefined;
262
+ entry.failedAt = ok ? undefined : this.now();
263
+ }
264
+
265
+ /** Until when the alias's last dispatch counts as failed for a load move, while it does. */
266
+ failing(alias: string): number | undefined {
267
+ const at = this.entries.get(alias)?.failedAt;
268
+ return at !== undefined && this.now() < at + COOLDOWN_MS ? at + COOLDOWN_MS : undefined;
269
+ }
270
+
271
+ private entry(alias: string) {
272
+ let entry = this.entries.get(alias);
273
+ if (!entry) this.entries.set(alias, entry = { failures: 0 });
274
+ return entry;
275
+ }
276
+ }
277
+
278
+ type Probe = { release: () => void } | { error: unknown };
279
+
187
280
  interface RequestJournalEntry {
188
281
  readonly record: RelayRequestRecord;
189
282
  identify(model: string, source: "header" | "stream" | "configured"): void;
190
283
  usage(value: unknown): void;
191
284
  close(outcome: RelayRequestRecord["outcome"]): void;
285
+ /** The relay's own deadline cut the attempt: the execution budget's, or a moved attempt's first-byte one. No cooldown count. */
286
+ cut?: "budget" | "move";
192
287
  }
193
288
 
194
289
  interface ActiveRequest {
@@ -225,10 +320,6 @@ function assertLoopback(host: string): void {
225
320
  if (value !== "localhost" && value !== "::1" && !(isIP(value) === 4 && value.startsWith("127."))) throw new Error("model relay host must be loopback");
226
321
  }
227
322
 
228
- function estimateInputTokens(messages: ChatMessage[], tools?: unknown[]): number {
229
- return Math.ceil(JSON.stringify({ messages, tools }).length / 4);
230
- }
231
-
232
323
  function bearer(request: Request): string | undefined {
233
324
  const value = request.headers.get("authorization");
234
325
  return value?.startsWith("Bearer ") ? value.slice("Bearer ".length) : undefined;
@@ -337,7 +428,10 @@ export async function startModelRelay(options: ModelRelayOptions): Promise<Model
337
428
  const dgxMaxInputTokens = options.dgxMaxInputTokens ?? 262_144;
338
429
  const defaultBackend = options.defaultBackend ?? (options.mlx ? { kind: "mlx", alias: mlxAlias } : { kind: "dgx", alias: "dgx/coding" });
339
430
  const models = relayModelIds(options);
340
- const autoRoute = options.enableHubAuto ? new AutoRouteSelector(options, defaultBackend, mlxAlias, estimateInputTokens) : undefined;
431
+ const efficientWaitMs = options.efficientWaitMs;
432
+ const moveFirstByteMs = options.moveFirstByteMs ?? MOVE_FIRST_BYTE_MS;
433
+ const cooldowns = new BackendCooldowns(options.now, (event) => { try { options.onCooldown?.(event); } catch { /* observation cannot fail routing */ } });
434
+ const autoRoute = options.enableHubAuto ? new AutoRouteSelector({ ...options, dgxMaxInputTokens }, defaultBackend, mlxAlias, estimateInputTokens) : undefined;
341
435
  let mlx: MlxHandle | undefined;
342
436
  let mlxStarting: Promise<MlxHandle> | undefined;
343
437
  const states = new Map<string, RelayBackendStatus>();
@@ -403,20 +497,22 @@ export async function startModelRelay(options: ModelRelayOptions): Promise<Model
403
497
  states.set(alias, { ...state(backend), ...patch, alias, kind: backend.kind });
404
498
  };
405
499
 
406
- const resolve = async (body: RelayRequest): Promise<ModelBackend> => {
500
+ const resolve = async (body: RelayRequest): Promise<{ backend: ModelBackend; auto?: AutoChoice }> => {
407
501
  const requested = typeof body.model === "string" ? body.model : undefined;
408
502
  const automatic = options.enableHubAuto === true && requested === "hub/auto";
409
503
  if (requested && !models.includes(requested)) throw new Error("model alias is not allowed");
410
- if (requested === mlxAlias && options.mlx) return { kind: "mlx", alias: mlxAlias };
411
- if (requested && requested in options.allowedDGXmodels) return { kind: "dgx", alias: requested };
412
- if (automatic) return autoRoute!.select(body);
504
+ if (requested === mlxAlias && options.mlx) return { backend: { kind: "mlx", alias: mlxAlias } };
505
+ if (requested && requested in options.allowedDGXmodels) return { backend: { kind: "dgx", alias: requested } };
506
+ if (automatic) { const auto = await autoRoute!.select(body); return { backend: auto.backend, auto }; }
413
507
  const selected = options.selectBackend ? await options.selectBackend(body) : defaultBackend;
414
508
  if (selected.kind === "mlx" && !options.mlx) throw new Error("MLX backend is not configured");
415
509
  if (selected.kind === "dgx" && !(selected.alias in options.allowedDGXmodels)) throw new Error("DGX model alias is not allowed");
416
- return selected;
510
+ return { backend: selected };
417
511
  };
418
512
 
419
- const upstream = async (request: RelayRequest, backend: ModelBackend, signal: AbortSignal, journalEntry: RequestJournalEntry): Promise<{ response: Response; release: () => void; onModel?: (model: string) => void }> => {
513
+ /** `pre`: the load probe's outcome for MLX, a held generation slot (released exactly once by whoever ends the request)
514
+ * or the error it met, reported here without asking MLX again. */
515
+ const upstream = async (request: RelayRequest, backend: ModelBackend, signal: AbortSignal, journalEntry: RequestJournalEntry, pre?: Probe, firstByteMs?: number): Promise<{ response: Response; release: () => void; onModel?: (model: string) => void }> => {
420
516
  const alias = aliasOf(backend, mlxAlias);
421
517
  let base: string;
422
518
  let model: string;
@@ -436,7 +532,8 @@ export async function startModelRelay(options: ModelRelayOptions): Promise<Model
436
532
  if (options.mlx?.provider === "ollama" && options.mlxModel && options.mlxModel !== handle.model) throw new Error("Ollama model override does not match the validated model");
437
533
  model = options.mlxModel ?? handle.model;
438
534
  if (signal.aborted) throw new Error("request was cancelled before MLX generation started");
439
- release = await handle.acquire(signal);
535
+ if (pre && "error" in pre) throw pre.error;
536
+ release = pre?.release ?? await handle.acquire(signal, options.slotWaitMs);
440
537
  } else {
441
538
  base = (await options.omni.base()) ?? (() => { throw new Error("DGX gateway is unavailable"); })();
442
539
  model = options.allowedDGXmodels[backend.alias]!;
@@ -455,6 +552,9 @@ export async function startModelRelay(options: ModelRelayOptions): Promise<Model
455
552
  const headers: Record<string, string> = { "content-type": "application/json" };
456
553
  if (key) Object.assign(headers, { authorization: `Bearer ${key}` }, options.omni.accessHeaders(base));
457
554
  let response: Response;
555
+ let budgetDeadline: AbortSignal | undefined;
556
+ const firstByte = firstByteMs === undefined ? undefined : new AbortController();
557
+ let firstByteTimer: ReturnType<typeof setTimeout> | undefined;
458
558
  try {
459
559
  const isOllama = backend.kind === "mlx" && options.mlx?.provider === "ollama";
460
560
  const boundedRequest = isOllama
@@ -462,14 +562,19 @@ export async function startModelRelay(options: ModelRelayOptions): Promise<Model
462
562
  : request;
463
563
  const decision = await options.admitRequest?.();
464
564
  if (decision && !decision.allowed) throw new ExecutionAdmissionError(decision.reason ?? "execution budget exhausted");
465
- const deadline = decision?.remainingMs === undefined ? 180_000 : Math.max(1, Math.min(180_000, decision.remainingMs));
466
- response = await fetch(`${base.replace(/\/$/, "")}/chat/completions`, { method: "POST", ...(isOllama ? { redirect: "error" as const } : {}), headers, body: JSON.stringify(bodyForUpstream(boundedRequest, model)), signal: AbortSignal.any([signal, AbortSignal.timeout(deadline)]) });
565
+ const deadline = decision?.remainingMs === undefined ? REQUEST_DEADLINE_MS : Math.max(1, Math.min(REQUEST_DEADLINE_MS, decision.remainingMs));
566
+ const timeout = AbortSignal.timeout(deadline);
567
+ if (deadline < REQUEST_DEADLINE_MS) budgetDeadline = timeout;
568
+ if (firstByte) firstByteTimer = setTimeout(() => firstByte.abort(new Error(`moved attempt had no answer within ${firstByteMs} ms`)), firstByteMs);
569
+ response = await fetch(`${base.replace(/\/$/, "")}/chat/completions`, { method: "POST", ...(isOllama ? { redirect: "error" as const } : {}), headers, body: JSON.stringify(bodyForUpstream(boundedRequest, model)), signal: AbortSignal.any([signal, timeout, ...(firstByte ? [firstByte.signal] : [])]) });
467
570
  } catch (error) {
468
571
  releaseOnce();
469
572
  journalEntry.record.failureClass = "transport";
573
+ if (firstByte?.signal.aborted) journalEntry.cut = "move";
574
+ else if (budgetDeadline?.aborted) journalEntry.cut = "budget";
470
575
  setState(backend, { state: "error", lastError: error instanceof Error ? error.message.slice(0, 160) : "upstream request failed" });
471
576
  throw error;
472
- }
577
+ } finally { clearTimeout(firstByteTimer); }
473
578
  const provider = safeHeader(response.headers.get("x-omniroute-provider"));
474
579
  if (provider) {
475
580
  journalEntry.record.provider = provider;
@@ -524,32 +629,65 @@ export async function startModelRelay(options: ModelRelayOptions): Promise<Model
524
629
  activeRequests.delete(record);
525
630
  return Response.json({ error: error instanceof Error ? error.message : "invalid request" }, { status: 400 });
526
631
  }
527
- let backend: ModelBackend;
528
- try { backend = await resolve(body); } catch (error) {
632
+ let backend: ModelBackend, auto: AutoChoice | undefined;
633
+ try { ({ backend, auto } = await resolve(body)); } catch (error) {
529
634
  record.cleanup();
530
635
  activeRequests.delete(record);
531
636
  return Response.json({ error: error instanceof Error ? error.message : "backend unavailable" }, { status: 400 });
532
637
  }
533
- const inputBudget = backend.kind === "mlx" ? (options.mlx?.maxInputTokens ?? (options.mlx?.provider === "ollama" ? 6000 : 16_000)) : dgxMaxInputTokens;
638
+ const own = backend;
639
+ // A refused request still records the decision, unmoved; a dispatched one records it after the ordering step.
640
+ const emitRoute = () => { if (auto) try { options.onRoute?.(auto.route); } catch { /* observation cannot fail routing */ } };
534
641
  const inputTokens = estimateInputTokens(body.messages, body.tools);
535
- const ollama = backend.kind === "mlx" && options.mlx?.provider === "ollama";
642
+ const inputBudget = own.kind === "mlx" ? (options.mlx?.maxInputTokens ?? (options.mlx?.provider === "ollama" ? 6000 : 16_000)) : dgxMaxInputTokens;
643
+ const ollama = own.kind === "mlx" && options.mlx?.provider === "ollama";
536
644
  const contextWindow = ollama ? (options.mlx?.contextWindow ?? 8192) : undefined;
537
645
  const configuredMaxTokens = ollama ? (options.mlx?.maxTokens ?? 2048) : undefined;
538
646
  const requestedMaxTokens = body.max_tokens === undefined ? configuredMaxTokens : body.max_tokens;
539
647
  if (ollama && (!Number.isInteger(requestedMaxTokens) || (requestedMaxTokens as number) < 1 || (requestedMaxTokens as number) > configuredMaxTokens! || inputTokens + (requestedMaxTokens as number) > contextWindow!)) {
648
+ emitRoute();
540
649
  record.cleanup();
541
650
  activeRequests.delete(record);
542
651
  return Response.json({ error: "input and max_tokens exceed the Ollama context budget" }, { status: 400 });
543
652
  }
544
653
  if (inputTokens > inputBudget) {
654
+ emitRoute();
545
655
  record.cleanup();
546
656
  activeRequests.delete(record);
547
657
  return Response.json({ error: "input exceeds the model context budget" }, { status: 400 });
548
658
  }
549
- const fallback = backend.kind === "mlx" && options.fallbackDGXAlias ? { kind: "dgx", alias: options.fallbackDGXAlias } as ModelBackend : undefined;
659
+ // #199: one ordering step, for hub/auto only (a fixed alias keeps its backend). The candidates are the route's own
660
+ // backend and, for MLX, its DGX fallback, exactly as before; a cooldown, an enforced pin or a busy MLX slot only
661
+ // reorders them. Nothing goes ahead to a fallback that is failing or cooling down.
662
+ const candidates: ModelBackend[] = [own, ...(own.kind === "mlx" && options.fallbackDGXAlias ? [{ kind: "dgx", alias: options.fallbackDGXAlias } as ModelBackend] : [])];
663
+ const second = candidates[1];
664
+ let moved: "optional" | "cooldown" | undefined;
665
+ const swap = (source: "load" | "cooldown" | "pin") => { candidates.reverse(); moved = source === "cooldown" ? "cooldown" : "optional"; auto?.moved(aliasOf(candidates[0]!, mlxAlias), source); };
666
+ // A load move or pin is only an optimization: opt-in (`efficientWaitMs`) until #199 AC5 measures it, bounded by its
667
+ // first-byte deadline, and never under an execution budget, where it could spend the model call or the time MLX
668
+ // needs after it (a throw counts as a budget).
669
+ // ponytail: no moves at all under a budget; budget-aware moves from admitPiRequest's remaining units are the upgrade.
670
+ let room = efficientWaitMs !== undefined;
671
+ try { room &&= !options.underBudget?.(); } catch { room = false; }
672
+ let pre: Probe | undefined;
673
+ if (auto && second && !cooldowns.failing(aliasOf(second, mlxAlias)) && !cooldowns.cooling(aliasOf(second, mlxAlias))) {
674
+ if (cooldowns.cooling(aliasOf(own, mlxAlias))) swap("cooldown");
675
+ else if (auto.prefer === aliasOf(second, mlxAlias) && room) swap("pin");
676
+ else if (auto.movable && mlx && room) {
677
+ try {
678
+ const held = await mlx.acquire(controller.signal, efficientWaitMs);
679
+ let holding = true;
680
+ pre = { release: () => { if (holding) { holding = false; held(); } } };
681
+ } catch (error) {
682
+ if (error instanceof MlxBusyError) swap("load");
683
+ else pre = { error }; // the MLX dispatch reports it, without inspecting Ollama a second time
684
+ }
685
+ }
686
+ }
687
+ emitRoute();
550
688
  const dispatchGroupId = randomUUID();
551
689
  let primaryDispatchId: string | undefined;
552
- const dispatch = async (selected: ModelBackend, body: RelayRequest) => {
690
+ const dispatch = async (selected: ModelBackend, body: RelayRequest, pre?: Probe, firstByteMs?: number) => {
553
691
  const journalEntry = openRequestRecord(aliasOf(selected, mlxAlias), dispatchGroupId);
554
692
  // The surface is a property of the request as admitted: even a failed dispatch keeps what the
555
693
  // native published. A request without a tools array carries no observation, never an empty one.
@@ -565,11 +703,21 @@ export async function startModelRelay(options: ModelRelayOptions): Promise<Model
565
703
  record.closeRecord = journalEntry.close;
566
704
  let result: Awaited<ReturnType<typeof upstream>>;
567
705
  try {
568
- result = await upstream(body, selected, controller.signal, journalEntry);
706
+ result = await upstream(body, selected, controller.signal, journalEntry, pre, firstByteMs);
569
707
  } catch (error) {
708
+ if (pre && "release" in pre) pre.release();
570
709
  journalEntry.record.failureClass ??= controller.signal.aborted ? "cancelled" : error instanceof ExecutionAdmissionError ? "admission" : "startup";
710
+ const failure = journalEntry.record.failureClass;
711
+ // A busy MLX slot is load, never a failure; neither is a cancelled request, an admission refusal or a budget cut.
712
+ if (!controller.signal.aborted && !journalEntry.cut && !(error instanceof ExecutionAdmissionError) && !(error instanceof MlxBusyError) && (failure === "transport" || failure === "startup")) {
713
+ // A DGX alias can fail before it has a status row (gateway or key unavailable); a cooldown must still show.
714
+ if (!states.has(journalEntry.record.alias)) setState(selected, { state: "error", lastError: error instanceof Error ? error.message.slice(0, 160) : "backend unavailable" });
715
+ cooldowns.failed(journalEntry.record.alias);
716
+ } else if (journalEntry.cut === "move") cooldowns.failed(journalEntry.record.alias, false); // slow, not unreachable
717
+ else if (failure === "http") cooldowns.answered(journalEntry.record.alias, false);
571
718
  throw error;
572
719
  }
720
+ cooldowns.answered(journalEntry.record.alias, true);
573
721
  let released = false;
574
722
  const release = () => {
575
723
  if (released) return;
@@ -582,30 +730,30 @@ export async function startModelRelay(options: ModelRelayOptions): Promise<Model
582
730
  const onUsage = journalEntry.usage;
583
731
  return { result, release, journalEntry, onUsage };
584
732
  };
585
- try {
586
- const { result, release, journalEntry, onUsage } = await dispatch(backend, body);
587
- return sseResponse(result.response, release, result.onModel, (cancel) => { record.cancel = cancel; }, journalEntry.close, onUsage);
588
- } catch (error) {
589
- record.closeRecord?.(controller.signal.aborted ? "cancelled" : "failed");
590
- if (!fallback || controller.signal.aborted || error instanceof ExecutionAdmissionError) {
591
- record.cleanup();
592
- activeRequests.delete(record);
593
- return Response.json({ error: error instanceof Error ? error.message : "backend unavailable" }, { status: 502 });
594
- }
733
+ let lastError: unknown;
734
+ for (const [i, candidate] of candidates.entries()) {
595
735
  try {
596
- const { result, release, journalEntry, onUsage } = await dispatch(fallback, { ...body, model: fallback.alias });
597
- return sseResponse(result.response, release, result.onModel, (cancel) => { record.cancel = cancel; }, journalEntry.close, onUsage);
598
- } catch (fallbackError) {
736
+ const { result, release, journalEntry, onUsage } = await dispatch(candidate, i ? { ...body, model: aliasOf(candidate, mlxAlias) } : body, i ? undefined : pre, i === 0 && moved === "optional" ? moveFirstByteMs : undefined);
737
+ // A stream that fails after its headers cannot fall back; it still marks the alias failing, so no move follows. A
738
+ // client that went away can fail the pending read before the cancel arrives: that says nothing about the backend.
739
+ const close = (outcome: RelayRequestRecord["outcome"]) => { journalEntry.close(outcome); if (outcome === "failed" && !controller.signal.aborted) cooldowns.failed(journalEntry.record.alias, false); };
740
+ return sseResponse(result.response, release, result.onModel, (cancel) => { record.cancel = cancel; }, close, onUsage);
741
+ } catch (error) {
599
742
  record.closeRecord?.(controller.signal.aborted ? "cancelled" : "failed");
600
- record.cleanup();
601
- activeRequests.delete(record);
602
- return Response.json({ error: fallbackError instanceof Error ? fallbackError.message : "fallback unavailable" }, { status: 502 });
743
+ lastError = error;
744
+ if (controller.signal.aborted || error instanceof ExecutionAdmissionError) break;
603
745
  }
604
746
  }
747
+ record.cleanup();
748
+ activeRequests.delete(record);
749
+ return Response.json({ error: lastError instanceof Error ? lastError.message : "backend unavailable" }, { status: 502 });
605
750
  },
606
751
  });
607
752
  const url = `http://${host}:${server.port}/v1`;
608
- const status = (): ModelRelayStatus => ({ url, models, backends: [...states.values()].map((value) => ({ ...value })) });
753
+ const status = (): ModelRelayStatus => ({ url, models, backends: [...states.values()].map((value) => {
754
+ const cooling = cooldowns.cooling(value.alias), failing = cooldowns.failing(value.alias);
755
+ return { ...value, ...(cooling ? { coolingUntil: new Date(cooling.until).toISOString(), failures: cooling.failures } : {}), ...(failing ? { failingUntil: new Date(failing).toISOString() } : {}) };
756
+ }) });
609
757
  const requests = (): RelayRequestRecord[] => journal.map(copyRecord);
610
758
  return { url, token, models, status, requests, close: async () => {
611
759
  const closing = [...activeRequests].map(async (request) => {
@@ -1,17 +1,24 @@
1
1
  import type { ModelBackend, ModelRelayOptions, RelayRequest } from "../relay.ts";
2
2
  import { normalizeConversation } from "./normalize.ts";
3
- import { extractToolSignals } from "./signals.ts";
4
- import { selectStage, type StageState } from "./stage.ts";
3
+ import { extractToolSignals, turnKind } from "./signals.ts";
4
+ import { selectStage, stayOrSwitch, type StageState, type SwitchTrace, type Tier } from "./stage.ts";
5
5
  import { SessionState } from "./state.ts";
6
6
 
7
- type RelaySelectorOptions = Pick<ModelRelayOptions, "mlx" | "selectBackend" | "routeSessionKey" | "onRoute" | "allowedDGXmodels">;
8
- type RouteSource = "override" | "dimensions" | "hold" | "classifier" | "default";
9
- type RelayRouteEvent = { route: "hub/auto"; tier: string; source: RouteSource; score: number; ms: number };
7
+ type RelaySelectorOptions = Pick<ModelRelayOptions, "mlx" | "selectBackend" | "routeSessionKey" | "allowedDGXmodels" | "staySwitch"> & { dgxMaxInputTokens: number };
8
+ type RouteSource = "override" | "dimensions" | "hold" | "classifier" | "default" | "load" | "cooldown" | "pin";
9
+ /** `stateless`: the request carried no session key, so the planner saw a new session on every call. */
10
+ export type RelayRouteEvent = { route: "hub/auto"; tier: string; source: RouteSource; score: number; ms: number; stateless?: boolean } & Partial<SwitchTrace>;
10
11
  type EstimateInputTokens = (messages: RelayRequest["messages"], tools?: unknown[]) => number;
12
+ type AutoState = { stage: StageState; pin?: { tier: Tier; alias: string } };
13
+ /**
14
+ * A `hub/auto` decision (#199). `backend` is the stage backend; the relay only reorders it with its fallback, trying
15
+ * `prefer` (an enforced tool loop's pinned backend) first when that is the fallback, and reports a reorder through `moved`.
16
+ */
17
+ export interface AutoChoice { backend: ModelBackend; route: RelayRouteEvent; movable: boolean; prefer?: string; moved: (alias: string, source: "load" | "cooldown" | "pin") => void }
11
18
 
12
19
  /** Stage selection for the relay's virtual `hub/auto` model. */
13
20
  export class AutoRouteSelector {
14
- private readonly states = new SessionState<StageState>(512, 60 * 60_000);
21
+ private readonly states = new SessionState<AutoState>(512, 60 * 60_000);
15
22
 
16
23
  constructor(
17
24
  private readonly options: RelaySelectorOptions,
@@ -20,33 +27,38 @@ export class AutoRouteSelector {
20
27
  private readonly estimateInputTokens: EstimateInputTokens,
21
28
  ) {}
22
29
 
23
- async select(body: RelayRequest): Promise<ModelBackend> {
30
+ async select(body: RelayRequest): Promise<AutoChoice> {
24
31
  const started = performance.now();
25
32
  let sessionKey: string | undefined;
26
33
  try {
27
34
  const key = this.options.routeSessionKey?.(body);
28
35
  if (typeof key === "string" && key.length > 0 && key.length <= 512) sessionKey = key;
29
36
  } catch { /* host identity lookup is fail-open */ }
30
- const priorState = sessionKey ? this.states.get(sessionKey) ?? { capableHoldTurnsRemaining: 0 } : { capableHoldTurnsRemaining: 0 };
37
+ const prior = sessionKey ? this.states.get(sessionKey) : undefined;
31
38
 
32
39
  let backend = this.defaultBackend;
33
- let tier = this.aliasOf(backend);
34
40
  let source: RouteSource = "default";
35
41
  let score = 0;
42
+ let trace: SwitchTrace | undefined;
43
+ let next: AutoState | undefined;
44
+ let movable = true;
45
+ let prefer: string | undefined;
36
46
  try {
37
- const signals = extractToolSignals(normalizeConversation(body));
38
- const decision = selectStage(signals, { mode: "efficient_first", confidenceThreshold: 0.5, capableHoldTurns: 2 }, priorState);
39
- if (sessionKey) this.states.set(sessionKey, decision.state);
47
+ const conversation = normalizeConversation(body);
48
+ const decision = selectStage(extractToolSignals(conversation), { mode: "efficient_first", confidenceThreshold: 0.5, capableHoldTurns: 2 }, prior?.stage ?? { capableHoldTurnsRemaining: 0 });
49
+ const inputTokens = this.estimateInputTokens(body.messages, body.tools);
50
+ const fits = (candidate: Tier) => { const staged = this.stageBackend(candidate, body, inputTokens); return !!staged && (staged.kind === "mlx" || inputTokens <= this.options.dgxMaxInputTokens); };
51
+ const staged = stayOrSwitch(this.options.staySwitch?.(), prior?.pin?.tier, decision, turnKind(conversation), { inputTokens, fits });
52
+ next = { stage: decision.state, ...(staged.pin ? { pin: { tier: staged.pin, alias: "" } } : {}) };
53
+ trace = staged.trace;
40
54
  score = decision.score;
41
55
  source = this.sourceOf(decision.source);
42
- const selectedTier = decision.tier ?? decision.defaultTier;
43
- const stagedBackend = this.stageBackend(selectedTier, body);
44
- if (stagedBackend) {
45
- backend = stagedBackend;
46
- tier = this.aliasOf(stagedBackend);
47
- } else {
48
- source = "default";
49
- }
56
+ // Enforced, a tool loop that keeps its tier keeps its backend too: a move inside the tier costs the same prefill (#199).
57
+ movable = !(trace.staySwitch === "enforce" && trace.turnType === "tool_result" && trace.plan === "stay" && trace.reason !== "new_pin");
58
+ if (!movable) prefer = prior?.pin?.alias;
59
+ const stagedBackend = this.stageBackend(staged.tier, body, inputTokens);
60
+ if (stagedBackend) backend = stagedBackend;
61
+ else source = "default";
50
62
  } catch {
51
63
  source = "default";
52
64
  }
@@ -56,27 +68,31 @@ export class AutoRouteSelector {
56
68
  const override = await this.options.selectBackend(body);
57
69
  if (this.isAllowed(override)) {
58
70
  backend = override;
59
- tier = this.aliasOf(override);
60
71
  source = "override";
61
72
  }
62
73
  } catch { /* deterministic stage choice is the fail-open route */ }
63
74
  }
64
75
 
65
- this.emit({ route: "hub/auto", tier, source, score, ms: Math.max(0, performance.now() - started) });
66
- return backend;
76
+ if (next?.pin) next.pin.alias = this.aliasOf(backend);
77
+ if (sessionKey && next) this.states.set(sessionKey, next);
78
+ const route: RelayRouteEvent = { route: "hub/auto", tier: this.aliasOf(backend), source, score, ms: Math.max(0, performance.now() - started), ...trace, ...(sessionKey ? {} : { stateless: true }) };
79
+ return { backend, route, movable, ...(prefer ? { prefer } : {}), moved: (alias, moveSource) => {
80
+ route.tier = alias;
81
+ route.source = moveSource;
82
+ if (next?.pin) next.pin.alias = alias;
83
+ } };
67
84
  }
68
85
 
69
- private stageBackend(tier: "capable" | "efficient", body: RelayRequest): ModelBackend | undefined {
86
+ private stageBackend(tier: "capable" | "efficient", body: RelayRequest, input: number): ModelBackend | undefined {
70
87
  if (tier === "capable") return "dgx/coding" in this.options.allowedDGXmodels ? { kind: "dgx", alias: "dgx/coding" } : undefined;
71
- if (this.options.mlx && this.mlxFitsBudget(body)) return { kind: "mlx", alias: this.mlxAlias };
88
+ if (this.options.mlx && this.mlxFitsBudget(body, input)) return { kind: "mlx", alias: this.mlxAlias };
72
89
  return "dgx/fast" in this.options.allowedDGXmodels ? { kind: "dgx", alias: "dgx/fast" } : undefined;
73
90
  }
74
91
 
75
- private mlxFitsBudget(body: RelayRequest): boolean {
92
+ private mlxFitsBudget(body: RelayRequest, input: number): boolean {
76
93
  const context = Math.min(8192, this.options.mlx?.contextWindow ?? 8192);
77
94
  const maxOutput = this.options.mlx?.maxTokens ?? 2048;
78
95
  const output = body.max_tokens ?? maxOutput;
79
- const input = this.estimateInputTokens(body.messages, body.tools);
80
96
  const inputLimit = this.options.mlx?.maxInputTokens ?? (this.options.mlx?.provider === "ollama" ? 6000 : 16_000);
81
97
  return Number.isInteger(output) && output > 0 && output <= maxOutput && input <= inputLimit && input + output <= context;
82
98
  }
@@ -95,8 +111,4 @@ export class AutoRouteSelector {
95
111
  if (source === "llm-classifier") return "classifier";
96
112
  return "default";
97
113
  }
98
-
99
- private emit(event: RelayRouteEvent): void {
100
- try { this.options.onRoute?.(event); } catch { /* observation cannot fail routing */ }
101
- }
102
114
  }