@staix/agent-hub 0.12.19 → 0.12.21

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 (87) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +7 -6
  3. package/docs/agent-notes/adapters.md +10 -2
  4. package/docs/agent-notes/bus.md +6 -2
  5. package/docs/agent-notes/daemon.md +10 -3
  6. package/docs/agent-notes/local-worker.md +3 -1
  7. package/docs/agent-notes/models.md +3 -0
  8. package/docs/agent-notes/tasks.md +6 -3
  9. package/docs/agent-notes/tests.md +3 -2
  10. package/docs/bench.md +143 -0
  11. package/docs/events.md +6 -2
  12. package/docs/operations.md +655 -34
  13. package/docs/quickstart.md +12 -10
  14. package/docs/research.md +92 -0
  15. package/docs/security.md +59 -17
  16. package/docs/smoke.md +137 -0
  17. package/docs/specs/2026-09-19-agent-hub-design.md +586 -16
  18. package/docs/specs/2026-09-20-pi-local-models-design.md +2 -0
  19. package/docs/specs/2026-09-20-upgrade-recovery-design.md +345 -0
  20. package/docs/specs/2026-10-04-switchyard-source-port-design.md +10 -0
  21. package/docs/verified.json +29 -18
  22. package/package.json +1 -1
  23. package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
  24. package/plugins/agent-hub/server.js +65 -26
  25. package/src/adapters/acp.ts +67 -6
  26. package/src/adapters/claude-channel.ts +61 -20
  27. package/src/adapters/codex-appserver.ts +70 -2
  28. package/src/adapters/local-worker.ts +8 -3
  29. package/src/adapters/pi.ts +58 -4
  30. package/src/cli/bench.ts +287 -0
  31. package/src/cli/console-state.ts +531 -110
  32. package/src/cli/console.ts +59 -26
  33. package/src/cli/facts-hook.ts +50 -8
  34. package/src/cli/help.ts +130 -0
  35. package/src/cli/identity.ts +11 -5
  36. package/src/cli/launch.ts +98 -17
  37. package/src/cli/main.ts +325 -88
  38. package/src/cli/preview.ts +14 -3
  39. package/src/cli/reset.ts +194 -0
  40. package/src/cli/status-lines.ts +10 -5
  41. package/src/cli/terminal-recovery.ts +196 -40
  42. package/src/cli/upgrade-runtime.ts +327 -60
  43. package/src/cli/upgrade.ts +442 -45
  44. package/src/hub/ask.ts +1 -1
  45. package/src/hub/bench.ts +211 -0
  46. package/src/hub/board.ts +15 -7
  47. package/src/hub/bus.ts +79 -8
  48. package/src/hub/child-process.ts +1 -0
  49. package/src/hub/conductor.ts +54 -21
  50. package/src/hub/config-trust.ts +1 -0
  51. package/src/hub/control-client.ts +9 -4
  52. package/src/hub/daemon.ts +401 -48
  53. package/src/hub/delivery-journal.ts +6 -6
  54. package/src/hub/events.ts +9 -2
  55. package/src/hub/execution-budget.ts +15 -6
  56. package/src/hub/hub-tools.ts +4 -4
  57. package/src/hub/inference.ts +136 -1
  58. package/src/hub/lifecycle.ts +13 -12
  59. package/src/hub/manager.ts +16 -14
  60. package/src/hub/peers.ts +2 -0
  61. package/src/hub/permission-mode.ts +44 -0
  62. package/src/hub/recovery-store.ts +68 -8
  63. package/src/hub/registry.ts +36 -22
  64. package/src/hub/report.ts +43 -1
  65. package/src/hub/research.ts +255 -0
  66. package/src/hub/restart.ts +25 -0
  67. package/src/hub/routing.ts +48 -16
  68. package/src/hub/snapshots.ts +3 -2
  69. package/src/hub/tasks.ts +295 -115
  70. package/src/hub/tool-refusal.ts +7 -0
  71. package/src/hub/ui.ts +4 -1
  72. package/src/local/deny.ts +2 -1
  73. package/src/local/tools.ts +14 -10
  74. package/src/models/mlx.ts +11 -8
  75. package/src/models/ollama.ts +2 -2
  76. package/src/models/relay.ts +190 -42
  77. package/src/models/route/relay-selector.ts +43 -31
  78. package/src/models/route/runtime.ts +17 -7
  79. package/src/models/route/signals.ts +12 -1
  80. package/src/models/route/stage.ts +57 -3
  81. package/src/omniroute/client.ts +1 -1
  82. package/src/pi/process-signature.ts +23 -0
  83. package/src/pi/tool-receipts.ts +7 -1
  84. package/src/ui/index.html +84 -6
  85. package/src/ui/task-progress.ts +33 -0
  86. package/templates/AGENTS.block.md +1 -1
  87. package/templates/routing.toml +8 -0
@@ -0,0 +1,7 @@
1
+ /** A hub guard proved this request was refused before any task or board effect (screen telemetry is allowed). Never wrap arbitrary failures in this type. */
2
+ export class PreEffectToolRefusal extends Error {
3
+ constructor(message: string) {
4
+ super(message);
5
+ this.name = "PreEffectToolRefusal";
6
+ }
7
+ }
package/src/hub/ui.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { taskProgress } from "../ui/task-progress.ts";
1
2
  import { createHash, randomBytes } from "node:crypto";
2
3
  import { readFileSync } from "node:fs";
3
4
 
@@ -20,7 +21,9 @@ export function startDashboard(options: DashboardOptions) {
20
21
  const now = options.now ?? Date.now;
21
22
  const tickets = new Map<string, number>();
22
23
  const sessions = new Map<string, number>();
23
- const html = readFileSync(new URL("../ui/index.html", import.meta.url), "utf8");
24
+ // Inject the same pure public-board model used by the console before computing CSP hashes.
25
+ const html = readFileSync(new URL("../ui/index.html", import.meta.url), "utf8")
26
+ .replace("/* TASK_PROGRESS_MODEL */", () => `const taskProgress = ${taskProgress.toString()};`);
24
27
  const hashes = (tag: string) => [...html.matchAll(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`, "g"))]
25
28
  .map((m) => `'sha256-${createHash("sha256").update(m[1]!).digest("base64")}'`).join(" ");
26
29
  const headers = {
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
 
@@ -1,7 +1,8 @@
1
1
  import { lstatSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join, relative, resolve } from "node:path";
3
3
  import { realPath } from "../hub/project.ts";
4
- import { hasSegment, isDenied } from "./deny.ts";
4
+ import { isHubWritePath } from "../hub/permission-mode.ts";
5
+ import { isDenied } from "./deny.ts";
5
6
  import { OUTPUT_CAP, sandboxedExec } from "./sandbox.ts";
6
7
 
7
8
  export { isDenied };
@@ -17,14 +18,11 @@ export interface ToolContext {
17
18
  /** Turn cancellation signal, including the execution-budget wall cap. */
18
19
  signal?: AbortSignal;
19
20
  /** Ask the console. Resolves false on deny or timeout. */
20
- permit: (title: string) => Promise<boolean>;
21
+ permit: (title: string, tool?: string, signal?: AbortSignal, path?: string) => Promise<boolean>;
21
22
  /** Publish a message to other peers mid-turn. Returns a one-line receipt. */
22
23
  send: (text: string, to?: string[]) => string;
23
24
  }
24
25
 
25
- /** Not secret, but writing them changes what runs outside the worker's control. */
26
- const WRITE_DENY_SEGMENTS = [".git", ".agenthub"];
27
-
28
26
  const lexists = (p: string) => {
29
27
  try {
30
28
  lstatSync(p);
@@ -52,7 +50,9 @@ export function guardPath(ctx: Pick<ToolContext, "cwd" | "deny">, path: string,
52
50
  const rel = relative(root, real);
53
51
  if (rel.startsWith("..") || resolve(root, rel) !== real) throw new Error(`${path} is outside the project directory`);
54
52
  if (isDenied(rel, ctx.deny)) throw new Error(`${path} is on the secrets denylist`);
55
- if (mode === "write" && WRITE_DENY_SEGMENTS.some((s) => hasSegment(rel, s))) throw new Error(`${path} is not writable by the local worker`);
53
+ // Not secret, but writing .git or .agenthub changes what runs outside the worker's control. Names are compared
54
+ // folded: a new `.GIT/config` is `.git/config` to a case-insensitive disk, and git then runs what it names.
55
+ if (mode === "write" && isHubWritePath(rel)) throw new Error(`${path} is not writable by the local worker`);
56
56
  return real;
57
57
  }
58
58
 
@@ -129,23 +129,27 @@ export async function runTool(name: string, rawArgs: string, ctx: ToolContext):
129
129
  }
130
130
  case "write": {
131
131
  let file = guardPath(ctx, String(a.path), "write");
132
+ const approvedTarget = file;
132
133
  const content = String(a.content ?? "");
133
- if (!(await ctx.permit(`write ${a.path} (${content.length} chars):\n${preview(content)}`))) return "error: the user did not approve this write";
134
+ if (!(await ctx.permit(`write ${a.path} (${content.length} chars):\n${preview(content)}`, "write", ctx.signal, file))) return "error: the user did not approve this write";
134
135
  if (ctx.signal?.aborted) return "error: turn cancelled before write";
135
136
  file = guardPath(ctx, String(a.path), "write");
137
+ if (file !== approvedTarget) return "error: path target changed during approval; inspect before retrying";
136
138
  mkdirSync(dirname(file), { recursive: true });
137
139
  writeFileSync(file, content);
138
140
  return `wrote ${a.path}`;
139
141
  }
140
142
  case "edit": {
141
143
  let file = guardPath(ctx, String(a.path), "write");
144
+ const approvedTarget = file;
142
145
  let text = readFileSync(file, "utf8");
143
146
  let count = text.split(String(a.old)).length - 1;
144
147
  if (!a.old || count !== 1) return `error: \`old\` must match exactly once, it matched ${count} times`;
145
- if (!(await ctx.permit(`edit ${a.path}:\n- ${preview(String(a.old), 600)}\n+ ${preview(String(a.new ?? ""), 600)}`))) return "error: the user did not approve this edit";
148
+ if (!(await ctx.permit(`edit ${a.path}:\n- ${preview(String(a.old), 600)}\n+ ${preview(String(a.new ?? ""), 600)}`, "edit", ctx.signal, file))) return "error: the user did not approve this edit";
146
149
  if (ctx.signal?.aborted) return "error: turn cancelled before edit";
147
150
  // Approval can outlive another peer's edit or a path change. Apply only the approved fragment to current bytes.
148
151
  file = guardPath(ctx, String(a.path), "write");
152
+ if (file !== approvedTarget) return "error: path target changed during approval; inspect before retrying";
149
153
  text = readFileSync(file, "utf8");
150
154
  count = text.split(String(a.old)).length - 1;
151
155
  if (count !== 1) return `error: file changed during approval; \`old\` must match exactly once, it matched ${count} times`;
@@ -157,7 +161,7 @@ export async function runTool(name: string, rawArgs: string, ctx: ToolContext):
157
161
  if (!command.trim()) return "error: empty command";
158
162
  // The approver sees the whole command, not a prefix: what is hidden cannot be approved.
159
163
  if (command.length > 4000) return "error: command longer than 4000 characters; put it in a script file with write, then run that";
160
- if (!(await ctx.permit(`bash: ${command}`))) return "error: the user did not approve this command";
164
+ if (!(await ctx.permit(`bash: ${command}`, "bash", ctx.signal))) return "error: the user did not approve this command";
161
165
  const res = await sandboxedExec(["/bin/bash", "-c", command], { cwd: ctx.cwd, profile: ctx.sandboxProfile, timeoutMs: (Number(a.timeout_s) || 120) * 1000, ...(ctx.sandboxEnv ? { env: ctx.sandboxEnv } : {}), ...(ctx.signal ? { signal: ctx.signal } : {}) });
162
166
  return `${res.output}\n(exit ${res.code})`;
163
167
  }
@@ -167,7 +171,7 @@ export async function runTool(name: string, rawArgs: string, ctx: ToolContext):
167
171
  if (!GIT_READ.has(sub) && !GIT_WRITE.has(sub)) return `error: git ${sub} is not available to the local worker`;
168
172
  const problem = gitArgsProblem(args, ctx.deny);
169
173
  if (problem) return `error: git ${sub}: ${problem}`;
170
- if (GIT_WRITE.has(sub) && !(await ctx.permit(`git ${args.join(" ")}`))) return "error: the user did not approve this git command";
174
+ if (GIT_WRITE.has(sub) && !(await ctx.permit(`git ${args.join(" ")}`, "git", ctx.signal))) return "error: the user did not approve this git command";
171
175
  // Sandboxed like bash: flags such as --output or an editor cannot write outside the project or reach the network.
172
176
  const res = await sandboxedExec(["git", "--no-pager", ...args], { cwd: ctx.cwd, profile: ctx.sandboxProfile, ...(ctx.sandboxEnv ? { env: ctx.sandboxEnv } : {}), ...(ctx.signal ? { signal: ctx.signal } : {}) });
173
177
  return `${res.output}\n(exit ${res.code})`;
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) => {