talon-agent 5.24.0 → 5.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/package.json +2 -1
  2. package/src/app.ts +30 -0
  3. package/src/backend/agy/process/orphans.ts +5 -1
  4. package/src/backend/claude-sdk/one-shot.ts +8 -1
  5. package/src/backend/codex/factory.ts +5 -1
  6. package/src/backend/codex/plan-usage.ts +12 -0
  7. package/src/backend/runtime/turn/turn-phases.ts +7 -1
  8. package/src/bootstrap.ts +1 -0
  9. package/src/cli.ts +18 -12
  10. package/src/core/agent-runtime/capabilities.ts +7 -0
  11. package/src/core/agents/index.ts +1 -1
  12. package/src/core/agents/prompt.ts +29 -2
  13. package/src/core/agents/runner.ts +7 -1
  14. package/src/core/agents/types.ts +6 -0
  15. package/src/core/background/cron/job-oneshot.ts +7 -1
  16. package/src/core/background/cron/scheduler.ts +28 -14
  17. package/src/core/background/cron/spec.ts +23 -3
  18. package/src/core/background/heartbeat/agent.ts +6 -0
  19. package/src/core/background/triggers/resume.ts +19 -4
  20. package/src/core/background/triggers/spawn.ts +1 -1
  21. package/src/core/config/index.ts +82 -0
  22. package/src/core/daemon/discovery.ts +160 -0
  23. package/src/core/daemon/pidfile.ts +107 -0
  24. package/src/core/daemon/respawn.ts +4 -1
  25. package/src/core/engine/backend-router/breaker.ts +158 -0
  26. package/src/core/engine/backend-router/headroom.ts +90 -22
  27. package/src/core/engine/backend-router/index.ts +11 -0
  28. package/src/core/engine/gateway-actions/agents/control.ts +9 -0
  29. package/src/core/engine/gateway-actions/agents/index.ts +4 -0
  30. package/src/core/engine/gateway-actions/agents/preflight.ts +215 -0
  31. package/src/core/engine/gateway-actions/cron.ts +5 -0
  32. package/src/core/engine/gateway-actions/fetch-url/guard.ts +13 -44
  33. package/src/core/engine/gateway-actions/fetch-url/index.ts +23 -43
  34. package/src/core/engine/gateway-actions/mesh.ts +4 -0
  35. package/src/core/engine/gateway-actions/models.ts +19 -4
  36. package/src/core/engine/gateway-routes.ts +31 -2
  37. package/src/core/engine/gateway.ts +11 -1
  38. package/src/core/fetch/classify.ts +73 -0
  39. package/src/core/fetch/curl-impersonate.ts +447 -0
  40. package/src/core/fetch/errors.ts +14 -0
  41. package/src/core/fetch/index.ts +133 -0
  42. package/src/core/fetch/ladder.ts +401 -0
  43. package/src/core/fetch/rungs.ts +319 -0
  44. package/src/core/fetch/types.ts +105 -0
  45. package/src/core/frontend-runtime/admin-notify.ts +100 -12
  46. package/src/core/mcp-hub/child-guard.ts +215 -0
  47. package/src/core/mcp-hub/child-transport.ts +89 -39
  48. package/src/core/mcp-hub/children.ts +65 -9
  49. package/src/core/mcp-hub/guest-scope.ts +3 -2
  50. package/src/core/mcp-hub/index.ts +36 -16
  51. package/src/core/mcp-hub/launcher.ts +81 -37
  52. package/src/core/mcp-hub/proxy-server.ts +12 -8
  53. package/src/core/mcp-hub/reaper.ts +120 -0
  54. package/src/core/mesh/credentials/index.ts +1 -1
  55. package/src/core/mesh/credentials/store.ts +1 -1
  56. package/src/core/mesh/devices/service.ts +8 -0
  57. package/src/core/mesh/links/bridge-links.ts +37 -0
  58. package/src/core/mesh/links/companion-pairing.ts +2 -2
  59. package/src/core/plugin/mcp.ts +8 -8
  60. package/src/core/tools/bridge.ts +5 -0
  61. package/src/core/tools/content/web.ts +1 -1
  62. package/src/core/tools/index.ts +2 -1
  63. package/src/core/tools/ops/agents.ts +28 -0
  64. package/src/core/tools/ops/mesh.ts +21 -0
  65. package/src/core/tools/ops/scheduling.ts +15 -0
  66. package/src/frontend/native/bridge/credentials/claims.ts +11 -1
  67. package/src/frontend/telegram/actions/media.ts +164 -12
  68. package/src/index.ts +22 -19
  69. package/src/plugins/playwright/version-coupling.ts +172 -62
  70. package/src/storage/cron.ts +34 -2
  71. package/src/storage/db.ts +1 -0
  72. package/src/storage/repositories/cron-repo.ts +9 -0
  73. package/src/storage/sql/cron.sql +5 -5
  74. package/src/storage/sql/db.sql +5 -0
  75. package/src/storage/sql/schema.sql +2 -1
  76. package/src/storage/sql/statements.generated.ts +10 -6
  77. package/src/util/log.ts +2 -1
  78. package/src/util/paths.ts +2 -0
  79. package/src/core/background/triggers/pid.ts +0 -28
@@ -318,6 +318,80 @@ const whatsappConfigSchema = z
318
318
  })
319
319
  .strict();
320
320
 
321
+ /** `fetch` — the fetch ladder behind `fetch_url` (docs/fetch-ladder.md). */
322
+ export const fetchConfigSchema = z
323
+ .object({
324
+ /** Browser-TLS impersonation via curl-impersonate (default true). */
325
+ impersonate: z.boolean().default(true),
326
+ /** curl-impersonate profiles for the direct rung, tried in order. */
327
+ impersonateTargets: z
328
+ .array(
329
+ z
330
+ .string()
331
+ .regex(
332
+ /^[a-z0-9_]+$/,
333
+ "a curl-impersonate target name, e.g. safari184",
334
+ ),
335
+ )
336
+ .min(1)
337
+ .optional(),
338
+ /** Use this curl-impersonate binary instead of the first-use download. */
339
+ curlImpersonatePath: z.string().trim().min(1).optional(),
340
+ /**
341
+ * SOCKS exits for the impersonation rung, e.g.
342
+ * "socks5h://socks-nl1.nordvpn.com:1080". Credentials are never
343
+ * accepted here: they come from TALON_FETCH_SOCKS_USER /
344
+ * TALON_FETCH_SOCKS_PASS or `socksCredentialsFile` (one line,
345
+ * "user:pass").
346
+ */
347
+ socksExits: z
348
+ .array(
349
+ z
350
+ .string()
351
+ .trim()
352
+ .refine((value) => {
353
+ try {
354
+ return /^socks(4a?|5h?):$/.test(new URL(value).protocol);
355
+ } catch {
356
+ return false;
357
+ }
358
+ }, "must be a socks5h://, socks5://, socks4a:// or socks4:// URL")
359
+ .refine((value) => {
360
+ try {
361
+ const u = new URL(value);
362
+ return !u.username && !u.password;
363
+ } catch {
364
+ return true;
365
+ }
366
+ }, "must not contain credentials — set TALON_FETCH_SOCKS_USER/TALON_FETCH_SOCKS_PASS or fetch.socksCredentialsFile"),
367
+ )
368
+ .default([]),
369
+ socksCredentialsFile: z.string().trim().min(1).optional(),
370
+ /**
371
+ * Anti-detect browser rung: connect to the Camoufox/Playwright server
372
+ * at `browserEndpoint` (default: the playwright plugin's endpoint).
373
+ */
374
+ camoufox: z.boolean().default(false),
375
+ browserEndpoint: z.string().trim().min(1).optional(),
376
+ /**
377
+ * Last resort: run curl on this mesh device (id or name). Off unless
378
+ * set — the device's owner pays for the traffic and the IP.
379
+ */
380
+ egressDevice: z.string().trim().min(1).optional(),
381
+ /**
382
+ * Daily cap (bytes) on traffic through relayed rungs (SOCKS exits and
383
+ * the egress device). 0 disables those rungs. Direct rungs are
384
+ * uncapped. The counter is in-memory and resets at UTC midnight and
385
+ * on restart.
386
+ */
387
+ dailyByteCap: z
388
+ .number()
389
+ .int()
390
+ .min(0)
391
+ .default(100 * 1024 * 1024),
392
+ })
393
+ .strict();
394
+
321
395
  const playwrightConfigSchema = z.object({
322
396
  enabled: z.boolean().default(false),
323
397
  /** Browser engine: chromium (default), chrome, firefox, webkit, msedge */
@@ -690,6 +764,14 @@ const configSchema = z.object({
690
764
  .object({ allowPrivateNetworks: z.boolean().default(true) })
691
765
  .strict()
692
766
  .optional(),
767
+ /**
768
+ * The fetch ladder behind `fetch_url` (docs/fetch-ladder.md): when a page
769
+ * answers with a bot wall it retries through browser-TLS impersonation,
770
+ * SOCKS exits, a plain fetch, an anti-detect browser and — only if set —
771
+ * a mesh device, in that order. Everything but impersonation is off
772
+ * until configured.
773
+ */
774
+ fetch: fetchConfigSchema.optional(),
693
775
  /**
694
776
  * Codex-specific OpenAI API key. Prefer this, CODEX_API_KEY, or
695
777
  * TALON_CODEX_KEY when the Codex backend should use API-key billing
@@ -17,6 +17,7 @@
17
17
  */
18
18
 
19
19
  import { readPidRecord, isProcessAlive } from "./pidfile.js";
20
+ import { log, logWarn } from "../../util/log.js";
20
21
  import {
21
22
  gatewayAuthHeaders,
22
23
  readGatewayToken,
@@ -174,3 +175,162 @@ export async function findRunningInstance(
174
175
 
175
176
  return scanForDaemon();
176
177
  }
178
+
179
+ // ── Single-instance guard ───────────────────────────────────────────────────
180
+
181
+ /**
182
+ * Single-instance guard — a daemon refuses to boot while another one runs.
183
+ *
184
+ * `talon start` already checked for a running instance, but only the CLI
185
+ * did. The daemon entry wrote its pidfile unconditionally. Anything that
186
+ * started the entry directly (systemd, a stray `bun src/index.ts`, the
187
+ * handoff watcher's fallback racing a slow successor) got a second daemon.
188
+ * On 2026-09-27 that ran for 13 minutes:
189
+ * - the newcomer overwrote the pidfile;
190
+ * - its gateway and bridge fell back to 19877/19881;
191
+ * - both polled Telegram, and every getUpdates answered 409;
192
+ * - the newcomer's trigger resume killed the running daemon's watchers
193
+ * as "orphans".
194
+ *
195
+ * This guard runs first thing in `app.ts`, before any side effect. It
196
+ * refuses when an identity-checked `/health` answers as a daemon (or a
197
+ * live pidfile pid turns into one while we wait for it to finish booting).
198
+ *
199
+ * The one daemon allowed to overlap is our own predecessor. A `/restart`
200
+ * successor is spawned in the last moments of the old process's graceful
201
+ * shutdown, so the guard waits for that pid to exit and then carries on.
202
+ */
203
+
204
+ /**
205
+ * Set by `spawnSuccessor()` on the child it spawns: the pid of the daemon
206
+ * handing over. The guard waits that process out instead of refusing.
207
+ */
208
+ export const PREDECESSOR_PID_ENV = "TALON_PREDECESSOR_PID";
209
+
210
+ /** How long a successor waits for its predecessor to exit. */
211
+ const PREDECESSOR_WAIT_MS = 20_000;
212
+ /** How long a live-but-silent pidfile pid gets to answer /health. */
213
+ const UNVERIFIED_WAIT_MS = 15_000;
214
+ const GUARD_POLL_MS = 250;
215
+
216
+ export type InstanceCheck =
217
+ { ok: true } | { ok: false; instance: RunningInstance };
218
+
219
+ export interface InstanceCheckDeps {
220
+ find?: (pidfilePath?: string) => Promise<RunningInstance | null>;
221
+ isAlive?: (pid: number) => boolean;
222
+ sleep?: (ms: number) => Promise<void>;
223
+ now?: () => number;
224
+ env?: NodeJS.ProcessEnv;
225
+ pidfilePath?: string;
226
+ predecessorWaitMs?: number;
227
+ unverifiedWaitMs?: number;
228
+ }
229
+
230
+ const defaultSleep = (ms: number): Promise<void> =>
231
+ new Promise((resolve) => setTimeout(resolve, ms));
232
+
233
+ function predecessorPid(env: NodeJS.ProcessEnv): number | undefined {
234
+ const pid = Number(env[PREDECESSOR_PID_ENV]);
235
+ return Number.isInteger(pid) && pid > 0 && pid !== process.pid
236
+ ? pid
237
+ : undefined;
238
+ }
239
+
240
+ /** Poll until `done()` or the deadline; resolves whether `done()` held. */
241
+ async function waitFor(
242
+ done: () => boolean | Promise<boolean>,
243
+ ms: number,
244
+ sleep: (ms: number) => Promise<void>,
245
+ now: () => number,
246
+ ): Promise<boolean> {
247
+ const deadline = now() + ms;
248
+ for (;;) {
249
+ if (await done()) return true;
250
+ if (now() >= deadline) return false;
251
+ await sleep(GUARD_POLL_MS);
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Decide whether this process may boot as the daemon. Never throws: a
257
+ * discovery failure answers "ok" (a guard that keeps the only daemon down
258
+ * is worse than the duplicate it prevents).
259
+ */
260
+ export async function checkSingleInstance(
261
+ deps: InstanceCheckDeps = {},
262
+ ): Promise<InstanceCheck> {
263
+ const find = deps.find ?? findRunningInstance;
264
+ const isAlive = deps.isAlive ?? isProcessAlive;
265
+ const sleep = deps.sleep ?? defaultSleep;
266
+ const now = deps.now ?? Date.now;
267
+ const env = deps.env ?? process.env;
268
+
269
+ try {
270
+ const predecessor = predecessorPid(env);
271
+ // Consumed: children of this daemon must not inherit it.
272
+ delete env[PREDECESSOR_PID_ENV];
273
+ if (predecessor !== undefined && isAlive(predecessor)) {
274
+ log("bot", `Waiting for predecessor daemon ${predecessor} to exit`);
275
+ const gone = await waitFor(
276
+ () => !isAlive(predecessor),
277
+ deps.predecessorWaitMs ?? PREDECESSOR_WAIT_MS,
278
+ sleep,
279
+ now,
280
+ );
281
+ if (!gone) {
282
+ // It is past its own 15s force-exit timer and has already let go of
283
+ // the frontends. Staying down would leave nothing running.
284
+ logWarn(
285
+ "bot",
286
+ `Predecessor daemon ${predecessor} is still alive — booting anyway`,
287
+ );
288
+ }
289
+ }
290
+
291
+ let instance = await find(deps.pidfilePath);
292
+ if (instance?.source === "pidfile-unverified") {
293
+ // A live pid with no /health: a daemon still booting, or a recycled
294
+ // pid. Give it time to answer before deciding.
295
+ const pid = instance.pid;
296
+ await waitFor(
297
+ async () => {
298
+ if (!isAlive(pid)) return true;
299
+ instance = await find(deps.pidfilePath);
300
+ return instance?.source !== "pidfile-unverified";
301
+ },
302
+ deps.unverifiedWaitMs ?? UNVERIFIED_WAIT_MS,
303
+ sleep,
304
+ now,
305
+ );
306
+ if (instance?.source === "pidfile-unverified") {
307
+ logWarn(
308
+ "bot",
309
+ `pidfile names live pid ${instance.pid} but no daemon answers — ` +
310
+ `treating it as a recycled pid`,
311
+ );
312
+ return { ok: true };
313
+ }
314
+ }
315
+ if (!instance) return { ok: true };
316
+ if (instance.pid === process.pid || instance.pid === predecessor) {
317
+ return { ok: true };
318
+ }
319
+ return { ok: false, instance };
320
+ } catch (err) {
321
+ logWarn(
322
+ "bot",
323
+ `single-instance check failed (${err instanceof Error ? err.message : String(err)}) — booting`,
324
+ );
325
+ return { ok: true };
326
+ }
327
+ }
328
+
329
+ /** One line for the log and stderr when the guard refuses. */
330
+ export function describeRefusal(instance: RunningInstance): string {
331
+ const where = instance.port ? ` on :${instance.port}` : "";
332
+ return (
333
+ `another Talon daemon is already running (pid ${instance.pid}${where}). ` +
334
+ "Refusing to start a second one — use `talon restart` to replace it."
335
+ );
336
+ }
@@ -96,3 +96,110 @@ export function isProcessAlive(pid: number): boolean {
96
96
  return (err as NodeJS.ErrnoException).code === "EPERM";
97
97
  }
98
98
  }
99
+
100
+ // ── Child ownership ─────────────────────────────────────────────────────────
101
+
102
+ /**
103
+ * Daemon ownership of child processes.
104
+ *
105
+ * Every process the daemon spawns (backend CLIs, MCP children, trigger
106
+ * scripts) inherits its environment. At boot the daemon stamps two
107
+ * variables into that environment: its own pid and its /proc start time.
108
+ * Any orphan sweep can then tell a child whose daemon is gone (safe to
109
+ * reap) from a child of a daemon that is still running.
110
+ *
111
+ * The distinction matters because "orphan" used to mean "alive and
112
+ * tagged with our chat or trigger id". On 2026-09-27 two daemons ran at
113
+ * once for 13 minutes. Each one's sweeps saw the other's live children as
114
+ * leftovers from a previous run, and the newcomer's trigger resume
115
+ * SIGKILLed the running daemon's watchers.
116
+ *
117
+ * Linux-only in practice: the reads go through /proc. Where /proc is
118
+ * absent, {@link childBelongsToLiveDaemon} answers false and the sweeps
119
+ * behave exactly as before.
120
+ */
121
+
122
+ /** Pid of the daemon that spawned this process (inherited env). */
123
+ export const DAEMON_PID_ENV = "TALON_DAEMON_PID";
124
+ /** That daemon's /proc start time, so a recycled pid cannot pass for it. */
125
+ export const DAEMON_STARTTIME_ENV = "TALON_DAEMON_STARTTIME";
126
+
127
+ /**
128
+ * Field 22 of /proc/<pid>/stat: start time in jiffies since boot.
129
+ * Monotonic per boot and unchanged by exec(), so it pins a pid to one
130
+ * process. `undefined` without /proc or when the read fails.
131
+ *
132
+ * The `comm` field (2nd) is wrapped in parens and may itself contain ')'
133
+ * — split after the LAST ')'; index 19 of the rest is field 22.
134
+ */
135
+ export function readPidStarttimeSync(pid: number): number | undefined {
136
+ try {
137
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf-8");
138
+ const lastParen = stat.lastIndexOf(")");
139
+ if (lastParen < 0) return undefined;
140
+ const tail = stat.slice(lastParen + 2).split(" ");
141
+ const starttime = Number(tail[19]);
142
+ return Number.isFinite(starttime) ? starttime : undefined;
143
+ } catch {
144
+ return undefined;
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Stamp this process as the daemon in `env` (default: our own), so every
150
+ * child spawned from here on names us. Overwrites any inherited stamp: a
151
+ * `/restart` successor inherits its predecessor's environment.
152
+ */
153
+ export function stampDaemonOwner(env: NodeJS.ProcessEnv = process.env): void {
154
+ env[DAEMON_PID_ENV] = String(process.pid);
155
+ const starttime = readPidStarttimeSync(process.pid);
156
+ if (starttime !== undefined) env[DAEMON_STARTTIME_ENV] = String(starttime);
157
+ else delete env[DAEMON_STARTTIME_ENV];
158
+ }
159
+
160
+ export interface DaemonOwner {
161
+ pid: number;
162
+ starttime?: number;
163
+ }
164
+
165
+ /** The owner stamp carried in a NUL-split `/proc/<pid>/environ`. */
166
+ export function ownerFromEnviron(entries: string[]): DaemonOwner | undefined {
167
+ const value = (key: string): string | undefined => {
168
+ const prefix = `${key}=`;
169
+ return entries.find((e) => e.startsWith(prefix))?.slice(prefix.length);
170
+ };
171
+ const pid = Number(value(DAEMON_PID_ENV));
172
+ if (!Number.isInteger(pid) || pid <= 0) return undefined;
173
+ const starttime = Number(value(DAEMON_STARTTIME_ENV));
174
+ return Number.isFinite(starttime) && value(DAEMON_STARTTIME_ENV)
175
+ ? { pid, starttime }
176
+ : { pid };
177
+ }
178
+
179
+ /**
180
+ * Whether `owner` is a daemon other than this one that is still running.
181
+ * A matching pid with a different start time is a recycled pid, so the
182
+ * owner is dead.
183
+ */
184
+ export function isOtherLiveDaemon(owner: DaemonOwner | undefined): boolean {
185
+ if (!owner || owner.pid === process.pid) return false;
186
+ if (!isProcessAlive(owner.pid)) return false;
187
+ if (owner.starttime === undefined) return true;
188
+ const current = readPidStarttimeSync(owner.pid);
189
+ return current === undefined || current === owner.starttime;
190
+ }
191
+
192
+ /**
193
+ * Whether process `pid` was spawned by a daemon other than this one that
194
+ * is still alive. Orphan sweeps must leave such a process alone. Reads
195
+ * `/proc/<pid>/environ`; false when it can't be read.
196
+ */
197
+ export function childBelongsToLiveDaemon(pid: number): boolean {
198
+ let raw: string;
199
+ try {
200
+ raw = readFileSync(`/proc/${pid}/environ`, "utf-8");
201
+ } catch {
202
+ return false;
203
+ }
204
+ return isOtherLiveDaemon(ownerFromEnviron(raw.split("\0")));
205
+ }
@@ -55,6 +55,7 @@
55
55
  import { spawn } from "node:child_process";
56
56
  import { log, logError, openRespawnLog } from "../../util/log.js";
57
57
  import { HANDOFF_WATCH_SUBCOMMAND } from "./handoff.js";
58
+ import { PREDECESSOR_PID_ENV } from "./discovery.js";
58
59
 
59
60
  let pendingReason: string | null = null;
60
61
  let shutdown: ((reason: string) => void) | null = null;
@@ -187,7 +188,9 @@ export function spawnSuccessor(spawnFn: SpawnFn = spawn): void {
187
188
  cwd: process.cwd(),
188
189
  detached: true,
189
190
  stdio: ["ignore", fd ?? "ignore", fd ?? "ignore"],
190
- env: { ...process.env },
191
+ // Tells the successor's single-instance guard that the daemon it
192
+ // can still see is us, on our way out — wait, don't refuse.
193
+ env: { ...process.env, [PREDECESSOR_PID_ENV]: String(process.pid) },
191
194
  });
192
195
  child.once("error", (err) => {
193
196
  logError("shutdown", `Respawn failed (${reason}) — see respawn.log`, err);
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Backend circuit breaker — "is this backend actually working right now?"
3
+ *
4
+ * Headroom answers "how much plan is left". It says nothing about whether
5
+ * runs on the backend succeed. In Sep 2026 Codex's login expired and its
6
+ * default model was retired, so every run on it failed. Codex still had no
7
+ * usage signal and read as 100% headroom, and the router kept sending
8
+ * background work there for 36 hours. This module holds the missing
9
+ * signal, fed by the outcome of every routed run:
10
+ *
11
+ * - an auth failure (401, expired login, "run `… login`") opens the
12
+ * breaker at once: the next run is not going to fix a credential;
13
+ * - {@link BREAKER_FAILURE_THRESHOLD} consecutive failures of any other
14
+ * kind open it too;
15
+ * - a success closes it and clears the count.
16
+ *
17
+ * An open breaker reads as zero headroom (see `headroom.ts`) until its
18
+ * cool-off ends. After that the backend is half-open: it can win again,
19
+ * and one more failure without a success in between re-opens it straight
20
+ * away with twice the cool-off, capped at {@link BREAKER_MAX_COOLOFF_MS}.
21
+ *
22
+ * State lives in memory only. A restart gives every backend a fresh
23
+ * chance, which is what an operator who just ran `codex login` and
24
+ * restarted expects.
25
+ */
26
+
27
+ import { logWarn } from "../../../util/log.js";
28
+
29
+ /** Consecutive non-auth failures that open the breaker. */
30
+ export const BREAKER_FAILURE_THRESHOLD = 3;
31
+ /** First cool-off. Each re-trip without a success in between doubles it. */
32
+ export const BREAKER_BASE_COOLOFF_MS = 15 * 60_000;
33
+ /** Ceiling on the cool-off. */
34
+ export const BREAKER_MAX_COOLOFF_MS = 4 * 60 * 60_000;
35
+
36
+ interface BreakerState {
37
+ /** Failures since the last success. */
38
+ consecutive: number;
39
+ /** Times the breaker has opened since the last success. */
40
+ trips: number;
41
+ /** Epoch ms the current cool-off ends; undefined when never opened. */
42
+ openUntil?: number;
43
+ /** Why it last opened. */
44
+ reason?: string;
45
+ }
46
+
47
+ /** An open breaker, as `headroom.ts` and the router log see it. */
48
+ export interface OpenBreaker {
49
+ readonly reason: string;
50
+ /** Epoch ms the cool-off ends. */
51
+ readonly until: number;
52
+ }
53
+
54
+ const breakers = new Map<string, BreakerState>();
55
+
56
+ /**
57
+ * Credential failures, not model or transport ones. Covers what the
58
+ * backends actually say: HTTP 401, Codex's "refresh token" / "login
59
+ * expired" wording, agy's "authentication required", and any "run
60
+ * `x login`" remedy.
61
+ */
62
+ const AUTH_FAILURE_RE =
63
+ /\b401\b|unauthori[sz]ed|authentication (?:required|failed)|not (?:logged|signed) in|log(?:in|ged in) (?:has )?expired|refresh[_ ]token|invalid[_ ](?:api[_ ])?key|run [`'"]?[\w-]+ login/i;
64
+
65
+ /** Whether an error message describes a credential problem. */
66
+ export function isAuthFailureMessage(message: string): boolean {
67
+ return AUTH_FAILURE_RE.test(message);
68
+ }
69
+
70
+ function messageOf(err: unknown): string {
71
+ return err instanceof Error ? err.message : String(err);
72
+ }
73
+
74
+ /**
75
+ * A run the caller cancelled says nothing about the backend. A timeout
76
+ * does count: a backend that hangs is as unusable as one that errors.
77
+ */
78
+ function isCallerAbort(err: unknown): boolean {
79
+ if (err instanceof Error && err.name === "AbortError") return true;
80
+ return /aborted before the run started/i.test(messageOf(err));
81
+ }
82
+
83
+ function stateFor(id: string): BreakerState {
84
+ let state = breakers.get(id);
85
+ if (!state) {
86
+ state = { consecutive: 0, trips: 0 };
87
+ breakers.set(id, state);
88
+ }
89
+ return state;
90
+ }
91
+
92
+ function open(id: string, state: BreakerState, reason: string, now: number) {
93
+ state.trips += 1;
94
+ const cooloff = Math.min(
95
+ BREAKER_BASE_COOLOFF_MS * 2 ** (state.trips - 1),
96
+ BREAKER_MAX_COOLOFF_MS,
97
+ );
98
+ state.openUntil = now + cooloff;
99
+ state.reason = reason;
100
+ logWarn(
101
+ "router",
102
+ `breaker open for ${id} (${reason}) — no routed work for ` +
103
+ `${Math.round(cooloff / 60_000)}m`,
104
+ );
105
+ }
106
+
107
+ /** Record a failed run on a backend. */
108
+ export function recordBackendRunFailure(
109
+ id: string,
110
+ err: unknown,
111
+ now = Date.now(),
112
+ ): void {
113
+ if (isCallerAbort(err)) return;
114
+ const state = stateFor(id);
115
+ state.consecutive += 1;
116
+ const message = messageOf(err).split("\n")[0]?.trim().slice(0, 160) ?? "";
117
+ // Already open: a failure in the cool-off (a pinned run, say) must not
118
+ // stretch it.
119
+ if (state.openUntil !== undefined && now < state.openUntil) return;
120
+ if (isAuthFailureMessage(message)) {
121
+ open(id, state, `auth failure: ${message}`, now);
122
+ return;
123
+ }
124
+ // Half-open (tripped before, no success since): the probe failed, so
125
+ // re-open at once rather than waiting for a fresh run of failures.
126
+ if (state.trips > 0) {
127
+ open(id, state, `still failing after cool-off: ${message}`, now);
128
+ return;
129
+ }
130
+ if (state.consecutive >= BREAKER_FAILURE_THRESHOLD) {
131
+ open(
132
+ id,
133
+ state,
134
+ `${state.consecutive} consecutive failures: ${message}`,
135
+ now,
136
+ );
137
+ }
138
+ }
139
+
140
+ /** Record a successful run: the backend works, forget its failures. */
141
+ export function recordBackendRunSuccess(id: string): void {
142
+ breakers.delete(id);
143
+ }
144
+
145
+ /** The breaker, when it is open at `now`; undefined when the backend may run. */
146
+ export function openBreaker(
147
+ id: string,
148
+ now = Date.now(),
149
+ ): OpenBreaker | undefined {
150
+ const state = breakers.get(id);
151
+ if (!state?.openUntil || now >= state.openUntil) return undefined;
152
+ return { reason: state.reason ?? "failing", until: state.openUntil };
153
+ }
154
+
155
+ /** Test seam — close every breaker. */
156
+ export function resetBackendBreakersForTest(): void {
157
+ breakers.clear();
158
+ }