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.
- package/package.json +2 -1
- package/src/app.ts +30 -0
- package/src/backend/agy/process/orphans.ts +5 -1
- package/src/backend/claude-sdk/one-shot.ts +8 -1
- package/src/backend/codex/factory.ts +5 -1
- package/src/backend/codex/plan-usage.ts +12 -0
- package/src/backend/runtime/turn/turn-phases.ts +7 -1
- package/src/bootstrap.ts +1 -0
- package/src/cli.ts +18 -12
- package/src/core/agent-runtime/capabilities.ts +7 -0
- package/src/core/agents/index.ts +1 -1
- package/src/core/agents/prompt.ts +29 -2
- package/src/core/agents/runner.ts +7 -1
- package/src/core/agents/types.ts +6 -0
- package/src/core/background/cron/job-oneshot.ts +7 -1
- package/src/core/background/cron/scheduler.ts +28 -14
- package/src/core/background/cron/spec.ts +23 -3
- package/src/core/background/heartbeat/agent.ts +6 -0
- package/src/core/background/triggers/resume.ts +19 -4
- package/src/core/background/triggers/spawn.ts +1 -1
- package/src/core/config/index.ts +82 -0
- package/src/core/daemon/discovery.ts +160 -0
- package/src/core/daemon/pidfile.ts +107 -0
- package/src/core/daemon/respawn.ts +4 -1
- package/src/core/engine/backend-router/breaker.ts +158 -0
- package/src/core/engine/backend-router/headroom.ts +90 -22
- package/src/core/engine/backend-router/index.ts +11 -0
- package/src/core/engine/gateway-actions/agents/control.ts +9 -0
- package/src/core/engine/gateway-actions/agents/index.ts +4 -0
- package/src/core/engine/gateway-actions/agents/preflight.ts +215 -0
- package/src/core/engine/gateway-actions/cron.ts +5 -0
- package/src/core/engine/gateway-actions/fetch-url/guard.ts +13 -44
- package/src/core/engine/gateway-actions/fetch-url/index.ts +23 -43
- package/src/core/engine/gateway-actions/mesh.ts +4 -0
- package/src/core/engine/gateway-actions/models.ts +19 -4
- package/src/core/engine/gateway-routes.ts +31 -2
- package/src/core/engine/gateway.ts +11 -1
- package/src/core/fetch/classify.ts +73 -0
- package/src/core/fetch/curl-impersonate.ts +447 -0
- package/src/core/fetch/errors.ts +14 -0
- package/src/core/fetch/index.ts +133 -0
- package/src/core/fetch/ladder.ts +401 -0
- package/src/core/fetch/rungs.ts +319 -0
- package/src/core/fetch/types.ts +105 -0
- package/src/core/frontend-runtime/admin-notify.ts +100 -12
- package/src/core/mcp-hub/child-guard.ts +215 -0
- package/src/core/mcp-hub/child-transport.ts +89 -39
- package/src/core/mcp-hub/children.ts +65 -9
- package/src/core/mcp-hub/guest-scope.ts +3 -2
- package/src/core/mcp-hub/index.ts +36 -16
- package/src/core/mcp-hub/launcher.ts +81 -37
- package/src/core/mcp-hub/proxy-server.ts +12 -8
- package/src/core/mcp-hub/reaper.ts +120 -0
- package/src/core/mesh/credentials/index.ts +1 -1
- package/src/core/mesh/credentials/store.ts +1 -1
- package/src/core/mesh/devices/service.ts +8 -0
- package/src/core/mesh/links/bridge-links.ts +37 -0
- package/src/core/mesh/links/companion-pairing.ts +2 -2
- package/src/core/plugin/mcp.ts +8 -8
- package/src/core/tools/bridge.ts +5 -0
- package/src/core/tools/content/web.ts +1 -1
- package/src/core/tools/index.ts +2 -1
- package/src/core/tools/ops/agents.ts +28 -0
- package/src/core/tools/ops/mesh.ts +21 -0
- package/src/core/tools/ops/scheduling.ts +15 -0
- package/src/frontend/native/bridge/credentials/claims.ts +11 -1
- package/src/frontend/telegram/actions/media.ts +164 -12
- package/src/index.ts +22 -19
- package/src/plugins/playwright/version-coupling.ts +172 -62
- package/src/storage/cron.ts +34 -2
- package/src/storage/db.ts +1 -0
- package/src/storage/repositories/cron-repo.ts +9 -0
- package/src/storage/sql/cron.sql +5 -5
- package/src/storage/sql/db.sql +5 -0
- package/src/storage/sql/schema.sql +2 -1
- package/src/storage/sql/statements.generated.ts +10 -6
- package/src/util/log.ts +2 -1
- package/src/util/paths.ts +2 -0
- package/src/core/background/triggers/pid.ts +0 -28
package/src/core/config/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|