agent-relay-orchestrator 0.129.11 → 0.129.13

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 (47) hide show
  1. package/package.json +3 -3
  2. package/src/api.ts +20 -6
  3. package/src/async-guard.ts +42 -0
  4. package/src/command-poller.ts +2 -1
  5. package/src/command-result-report.ts +32 -0
  6. package/src/config.ts +47 -0
  7. package/src/control.ts +63 -13
  8. package/src/git.ts +6 -0
  9. package/src/host-admission.ts +426 -0
  10. package/src/index.ts +58 -29
  11. package/src/maintenance.ts +78 -24
  12. package/src/process.ts +83 -9
  13. package/src/provider-probe.ts +73 -6
  14. package/src/quota-poller.ts +2 -1
  15. package/src/registration.ts +144 -0
  16. package/src/relay.ts +322 -27
  17. package/src/repo-lock.ts +3 -2
  18. package/src/self-supervision.ts +8 -7
  19. package/src/self-upgrade-guard.ts +1717 -0
  20. package/src/self-upgrade.ts +451 -170
  21. package/src/shared-callmux.ts +47 -6
  22. package/src/spawn/command.ts +5 -0
  23. package/src/spawn/constants.ts +18 -1
  24. package/src/spawn/log-utils.ts +21 -0
  25. package/src/spawn/runtime.ts +440 -8
  26. package/src/spawn/sessions.ts +103 -22
  27. package/src/spawn/spawn-agent.ts +19 -1
  28. package/src/spawn/supervisor.ts +63 -8
  29. package/src/spawn/systemd.ts +15 -2
  30. package/src/spawn/terminal.ts +3 -4
  31. package/src/spawn/types.ts +6 -0
  32. package/src/terminal-stream.ts +12 -10
  33. package/src/wedged-session-reaper.ts +804 -0
  34. package/src/workspace-pr.ts +13 -0
  35. package/src/workspace-probe/base-sync.ts +408 -0
  36. package/src/workspace-probe/cleanup.ts +29 -29
  37. package/src/workspace-probe/content-landed.ts +108 -0
  38. package/src/workspace-probe/git-state.ts +90 -17
  39. package/src/workspace-probe/idle-refresh.ts +3 -1
  40. package/src/workspace-probe/index.ts +1 -0
  41. package/src/workspace-probe/integrated-land-gates.ts +758 -32
  42. package/src/workspace-probe/merge.ts +173 -243
  43. package/src/workspace-probe/names.ts +18 -0
  44. package/src/workspace-probe/plain-git.ts +96 -21
  45. package/src/workspace-probe/protected-tip.ts +57 -7
  46. package/src/workspace-probe/review-ref.ts +363 -0
  47. package/src/workspace-probe/types.ts +3 -0
@@ -0,0 +1,426 @@
1
+ import os from "node:os";
2
+ import { readFileSync } from "node:fs";
3
+ import { execFileSync } from "node:child_process";
4
+ import type { QueuedSpawn } from "agent-relay-sdk";
5
+
6
+ // #893 — memory/load-aware spawn admission. The #930/#880 spawnSlots cap bounds how many
7
+ // spawns can be MID-REGISTRATION at once (a port-collision fix); it does nothing about the
8
+ // STEADY-STATE count of already-registered running agents. The 2026-06-30 incident hung the
9
+ // relay by accumulating 24 running agents until the host swapped to 96% and load average hit
10
+ // 43 — no gate anywhere rejected or queued spawns past what the host could actually hold. This
11
+ // is the per-host resource check that closes that gap: a cheap, local memory/`loadavg()`
12
+ // read the orchestrator (which already has host access) can do before every spawn, no relay/
13
+ // protocol change required.
14
+ //
15
+ // #1516 — the gate must reflect *usable* memory, not raw `os.freemem()`. On darwin macOS keeps
16
+ // raw "free" low by holding pages as reclaimable file cache, so raw free under-reports true
17
+ // headroom. During the 2026-07-18 macmini incident raw free dipped to 146–217MB and the gate
18
+ // rejected a spawn at 185MB while GBs were idle-and-reclaimable and memory_pressure reported
19
+ // "normal". On linux we derive MemAvailable from /proc/meminfo and gate on that.
20
+ //
21
+ // #1516-r3 — on darwin, STOP deriving a usable-bytes number by summing `vm_stat` page classes.
22
+ // Two prior rounds (r1: "Pages inactive"; r2: "File-backed pages") both over-ADMITTED (OOM risk):
23
+ // every "reclaimable" page class secretly contains dirty content (dirty anonymous pages must swap;
24
+ // XNU counts the FULL external pageable population — including dirty/writeback external pages — in
25
+ // the file-backed figure). No page-class sum is a sound "immediately usable" number.
26
+ //
27
+ // Instead we gate darwin on the KERNEL'S OWN memory-pressure signal, which already accounts for
28
+ // dirty/reclaimable/compressed memory internally:
29
+ // * `sysctl -n kern.memorystatus_vm_pressure_level` → 1 = NORMAL, 2 = WARN, 4 = CRITICAL (the
30
+ // dispatch_source_memorypressure flag values; XNU raises WARN/CRITICAL precisely when it can no
31
+ // longer reclaim enough — including the dirty-writeback case both prior rounds mis-admitted).
32
+ // * NORMAL → the kernel reports headroom → report usable at physical RAM so the memory gate
33
+ // passes (this is what fixes macmini's false-low flicker: normal pressure despite low raw-free).
34
+ // * WARN / CRITICAL / any non-normal level → HOLD: no reclaimable boost, fall back to raw
35
+ // `os.freemem()` so a genuinely tight host still blocks. Never harder-closed than raw-free.
36
+ // * usable is sanity-capped at os.totalmem() and floored at a SINGLE os.freemem() sample threaded
37
+ // through the whole computation, so `freeMemMb <= usableMemMb <= totalMemMb` holds deterministically.
38
+ // * The sysctl probe uses a short timeout and a short-TTL cache (keyed on a MONOTONIC clock, so a
39
+ // wall-clock rollback can't keep a stale high probe alive) so a synchronous execFileSync can't
40
+ // serialize into multi-second event-loop stalls across admission polls.
41
+ // The probe stays best-effort: any failure (sysctl missing, timeout, unparseable, unknown platform)
42
+ // falls back to os.freemem() (fail-open) — the gate is never harder-closed than raw-free today.
43
+
44
+ const DEFAULT_MIN_FREE_MEM_MB = 512;
45
+ const DEFAULT_MAX_LOAD_PER_CPU = 4;
46
+ const DEFAULT_ADMISSION_MAX_WAIT_MS = 60_000;
47
+ const DEFAULT_ADMISSION_POLL_MS = 2_000;
48
+
49
+ // #1516-r2 — execFileSync(sysctl) synchronously blocks the event loop. Keep the per-probe timeout
50
+ // short and cache the result briefly so admission polling (every ~2s) doesn't spawn the probe on
51
+ // every iteration and can't serialize failing probes into multi-second stalls.
52
+ const PROBE_TIMEOUT_MS = 400;
53
+ const PROBE_CACHE_TTL_MS = 3_000;
54
+
55
+ // #1516-r3 — darwin `kern.memorystatus_vm_pressure_level` values. These are the XNU
56
+ // dispatch_source_memorypressure flag values (NOTE_MEMORYSTATUS_PRESSURE_{NORMAL,WARN,CRITICAL}).
57
+ export const VM_PRESSURE_NORMAL = 1;
58
+ export const VM_PRESSURE_WARN = 2;
59
+ export const VM_PRESSURE_CRITICAL = 4;
60
+
61
+ // #1516-r3 — the byte value the darwin NORMAL-pressure branch reports: "the kernel says there is
62
+ // headroom, impose no memory-class ceiling." readUsableMemMb clamps this down to the SINGLE
63
+ // os.totalmem() sample threaded through checkHostHeadroom, so it resolves to exactly physical RAM
64
+ // without the probe taking a second totalmem sample (preserves the free<=usable<=total invariant).
65
+ const PRESSURE_HEADROOM_SENTINEL_BYTES = Number.MAX_SAFE_INTEGER;
66
+
67
+ // #1513 — stable marker prefixed onto the rejection reason when a spawn is held back for the full
68
+ // admission wait ceiling and then fails. Lets the spawner (and the failed-command notification)
69
+ // distinguish a genuinely-can't-be-admitted queue timeout from other spawn failures.
70
+ export const HOST_HEADROOM_TIMEOUT_REASON = "host-headroom-timeout";
71
+
72
+ export interface HostHeadroomResult {
73
+ ok: boolean;
74
+ /** Present when ok is false — a human-readable reason suitable for a failed-command message. */
75
+ reason?: string;
76
+ /**
77
+ * #1516 — usable memory the gate actually decides on. On darwin (#1516-r3) this is driven by the
78
+ * kernel's own VM pressure level: NORMAL reports physical RAM (headroom), WARN/CRITICAL falls back
79
+ * to raw free (hold); on linux it is MemAvailable. Floored at raw `os.freemem()` and capped at
80
+ * `os.totalmem()`, so `freeMemMb <= usableMemMb <= totalMemMb` always holds.
81
+ */
82
+ usableMemMb: number;
83
+ /** Raw `os.freemem()`, retained for observability alongside the usable figure the gate uses. */
84
+ freeMemMb: number;
85
+ totalMemMb: number;
86
+ loadAvgPerCpu: number;
87
+ }
88
+
89
+ export function resolveMinFreeMemMb(env: NodeJS.ProcessEnv = process.env): number {
90
+ const raw = env.AGENT_RELAY_SPAWN_MIN_FREE_MEM_MB;
91
+ if (raw === undefined || raw.trim() === "") return DEFAULT_MIN_FREE_MEM_MB;
92
+ const parsed = Number(raw);
93
+ if (!Number.isFinite(parsed) || parsed < 0) return DEFAULT_MIN_FREE_MEM_MB;
94
+ return parsed;
95
+ }
96
+
97
+ export function resolveMaxLoadPerCpu(env: NodeJS.ProcessEnv = process.env): number {
98
+ const raw = env.AGENT_RELAY_SPAWN_MAX_LOAD_PER_CPU;
99
+ if (raw === undefined || raw.trim() === "") return DEFAULT_MAX_LOAD_PER_CPU;
100
+ const parsed = Number(raw);
101
+ if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_MAX_LOAD_PER_CPU;
102
+ return parsed;
103
+ }
104
+
105
+ export function resolveAdmissionMaxWaitMs(env: NodeJS.ProcessEnv = process.env): number {
106
+ const raw = env.AGENT_RELAY_SPAWN_ADMISSION_MAX_WAIT_MS;
107
+ if (raw === undefined || raw.trim() === "") return DEFAULT_ADMISSION_MAX_WAIT_MS;
108
+ const parsed = Number(raw);
109
+ if (!Number.isFinite(parsed) || parsed < 0) return DEFAULT_ADMISSION_MAX_WAIT_MS;
110
+ return parsed;
111
+ }
112
+
113
+ export function resolveAdmissionPollMs(env: NodeJS.ProcessEnv = process.env): number {
114
+ const raw = env.AGENT_RELAY_SPAWN_ADMISSION_POLL_MS;
115
+ if (raw === undefined || raw.trim() === "") return DEFAULT_ADMISSION_POLL_MS;
116
+ const parsed = Number(raw);
117
+ if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_ADMISSION_POLL_MS;
118
+ return parsed;
119
+ }
120
+
121
+ export function isHostAdmissionDisabled(env: NodeJS.ProcessEnv = process.env): boolean {
122
+ const raw = env.AGENT_RELAY_SPAWN_ADMISSION_DISABLED;
123
+ return raw === "1" || raw?.toLowerCase() === "true";
124
+ }
125
+
126
+ export interface WaitForHostHeadroomOptions {
127
+ disabled: boolean;
128
+ maxWaitMs: number;
129
+ pollMs: number;
130
+ check: () => HostHeadroomResult;
131
+ /** Called once per failed poll with a human-readable "still queued" message. */
132
+ log?: (message: string) => void;
133
+ sleep?: (ms: number) => Promise<void>;
134
+ // #1513 — called on each poll while the spawn is held back for headroom, with the current headroom
135
+ // snapshot (its reason/freeMem update as the host recovers). Lets the caller surface a "queued"
136
+ // dashboard row and notify the spawner that the spawn is queued rather than lost.
137
+ onQueued?: (result: HostHeadroomResult) => void;
138
+ // #1513 — called exactly once when a wait that ever queued ends (admitted OR timed out), so the
139
+ // caller can clear the queued row it published via onQueued. Not called for an immediate admit.
140
+ onSettled?: () => void;
141
+ }
142
+
143
+ /**
144
+ * Poll host headroom until it's admissible or `maxWaitMs` elapses. A spawn racing an agent
145
+ * that's about to exit gets admitted once headroom frees; only past the deadline does this
146
+ * throw — a clear, actionable rejection rather than silently oversubscribing the host.
147
+ *
148
+ * #1513 — while it holds a spawn back it drives onQueued/onSettled so the queued state is
149
+ * observable (dashboard row + spawner notification) instead of a silent wait, and the timeout
150
+ * rejection is tagged with HOST_HEADROOM_TIMEOUT_REASON so a can't-ever-admit queue is unambiguous.
151
+ */
152
+ export async function waitForHostHeadroom(opts: WaitForHostHeadroomOptions): Promise<void> {
153
+ if (opts.disabled) return;
154
+ const sleep = opts.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
155
+ const deadline = Date.now() + opts.maxWaitMs;
156
+ let queued = false;
157
+ try {
158
+ for (;;) {
159
+ const result = opts.check();
160
+ if (result.ok) return;
161
+ if (Date.now() >= deadline) {
162
+ throw new Error(`spawn rejected: ${HOST_HEADROOM_TIMEOUT_REASON} — insufficient host headroom after waiting ${opts.maxWaitMs}ms (${result.reason})`);
163
+ }
164
+ queued = true;
165
+ opts.onQueued?.(result);
166
+ opts.log?.(`spawn queued: waiting for host headroom (${result.reason})`);
167
+ await sleep(Math.min(opts.pollMs, Math.max(0, deadline - Date.now())));
168
+ }
169
+ } finally {
170
+ // Only fire onSettled when we actually published a queued row (queued===true); an immediate
171
+ // admit never surfaced one. Runs on both the admit (return) and timeout (throw) paths.
172
+ if (queued) opts.onSettled?.();
173
+ }
174
+ }
175
+
176
+ /**
177
+ * #1516-r3/r4/r5 — parse `sysctl -n kern.memorystatus_vm_pressure_level` into the kernel's VM
178
+ * pressure level (1 = NORMAL, 2 = WARN, 4 = CRITICAL). `-n` prints just the integer, e.g. "1\n";
179
+ * this also tolerates the `name: value` form (`sysctl` without `-n`), but ONLY when `name` is the
180
+ * exact literal key `kern.memorystatus_vm_pressure_level` — e.g. "kern.memorystatus_vm_pressure_level: 1".
181
+ * Returns null on anything else — including empty/whitespace-only output, a leading sign or decimal,
182
+ * extra tokens/words, embedded diagnostics, multiple numbers, a `name: value` form with any other
183
+ * name, or an integer that isn't a known pressure level — so the caller falls back to
184
+ * os.freemem() (HOLD, never a false NORMAL).
185
+ *
186
+ * #1516-r4 — this MUST be strict. The r3 parser took the LAST `/\d+/` token in the output, which
187
+ * stripped signs and picked digits out of garbage: `"-1\n"` -> 1, `"warning: -1\n"` -> 1,
188
+ * `"2 warning 1\n"` -> 1. Any of those misread as NORMAL inflates usable memory to physical RAM
189
+ * under real memory pressure (over-open/OOM) — ambiguous/unexpected probe output must always HOLD.
190
+ *
191
+ * #1516-r5 — the r4 `name: value` branch still accepted ANY identifier before the colon
192
+ * (`/^[A-Za-z0-9_.]+:\s*(\d+)$/`), so diagnostic/garbage output like `"warning: 1"` or `"foo: 1"`
193
+ * parsed as pressure level 1 (NORMAL) even though no real sysctl emits those keys. The name must
194
+ * match the literal sysctl key exactly — anything else is unrecognized output and must HOLD.
195
+ *
196
+ * We gate on this signal — not a sum of `vm_stat` page classes — because every "reclaimable" page
197
+ * class (inactive, file-backed) secretly holds dirty content that isn't immediately usable, so any
198
+ * such sum over-admits (the r1 and r2 OOM bugs). The kernel's pressure level already accounts for
199
+ * dirty/reclaimable/compressed memory internally and raises WARN/CRITICAL when it genuinely can't
200
+ * reclaim — including the dirty-writeback-to-slow-storage case both prior rounds mis-admitted.
201
+ */
202
+ export function parseVmPressureLevel(output: string): number | null {
203
+ if (!output) return null;
204
+ const trimmed = output.trim();
205
+ if (trimmed === "") return null;
206
+
207
+ let valueStr: string;
208
+ if (/^\d+$/.test(trimmed)) {
209
+ valueStr = trimmed;
210
+ } else {
211
+ const match = trimmed.match(/^kern\.memorystatus_vm_pressure_level:\s*(\d+)$/);
212
+ if (!match) return null;
213
+ valueStr = match[1]!;
214
+ }
215
+
216
+ const level = Number(valueStr);
217
+ if (!Number.isSafeInteger(level)) return null;
218
+ if (level !== VM_PRESSURE_NORMAL && level !== VM_PRESSURE_WARN && level !== VM_PRESSURE_CRITICAL) {
219
+ return null;
220
+ }
221
+ return level;
222
+ }
223
+
224
+ /**
225
+ * #1516 — parse `/proc/meminfo` on linux for `MemAvailable` (kernel's own estimate of memory
226
+ * available for new work without swapping, which already discounts page-cache that can't be
227
+ * reclaimed). Returns bytes, or null if the field is absent (caller falls back to os.freemem()).
228
+ */
229
+ export function parseMemAvailableBytes(meminfo: string): number | null {
230
+ if (!meminfo) return null;
231
+ const m = meminfo.match(/^MemAvailable:\s*(\d+)\s*kB/im);
232
+ if (!m) return null;
233
+ const kb = Number(m[1]);
234
+ if (!Number.isSafeInteger(kb) || kb < 0) return null;
235
+ const bytes = kb * 1024;
236
+ if (!Number.isSafeInteger(bytes) || bytes < 0) return null;
237
+ return bytes;
238
+ }
239
+
240
+ /**
241
+ * #1516-r5 — the darwin raw-sysctl-output → usable-bytes mapping, factored out of
242
+ * {@link computeUsableMemBytes} so tests can drive this EXACT production mapping (parse, then
243
+ * NORMAL→sentinel/else→hold) over injected sysctl text, instead of hand-reimplementing it.
244
+ */
245
+ export function darwinUsableMemBytesFromSysctlOutput(out: string): number | null {
246
+ const level = parseVmPressureLevel(out);
247
+ if (level === null) return null; // unparseable → fail OPEN (raw free)
248
+ if (level === VM_PRESSURE_NORMAL) {
249
+ // Kernel reports headroom → no memory-class ceiling; readUsableMemMb clamps this to the
250
+ // threaded os.totalmem() sample and floors at raw free, so the memory gate passes. This is
251
+ // what fixes macmini's false-low flicker (normal pressure despite a low raw-free reading).
252
+ return PRESSURE_HEADROOM_SENTINEL_BYTES;
253
+ }
254
+ // WARN / CRITICAL / any unexpected non-normal level → HOLD: no reclaimable boost. Falling back
255
+ // to raw free keeps a genuinely tight host blocked while never being harder-closed than
256
+ // raw-free, and is safe against the dirty-writeback OOM path (the kernel is why we're here).
257
+ return null;
258
+ }
259
+
260
+ /**
261
+ * The raw, un-cached probe: run the platform-specific headroom read (darwin: kernel VM pressure
262
+ * level → headroom-sentinel/hold; linux: /proc/meminfo MemAvailable). Never throws — any failure
263
+ * (binary missing, timeout, permissions, unexpected format, unknown platform) → null (fail-open).
264
+ */
265
+ function computeUsableMemBytes(platform: NodeJS.Platform): number | null {
266
+ try {
267
+ if (platform === "darwin") {
268
+ // #1516-r3 — gate on the kernel's own memory-pressure signal, not a page-class sum.
269
+ const out = execFileSync("sysctl", ["-n", "kern.memorystatus_vm_pressure_level"], {
270
+ encoding: "utf8",
271
+ timeout: PROBE_TIMEOUT_MS,
272
+ // Capture stdout; discard stderr so a failing/absent sysctl fails silently (fail-open) rather
273
+ // than spamming the orchestrator's stderr on every re-probe.
274
+ stdio: ["ignore", "pipe", "ignore"],
275
+ });
276
+ return darwinUsableMemBytesFromSysctlOutput(out);
277
+ }
278
+ if (platform === "linux") {
279
+ const meminfo = readFileSync("/proc/meminfo", "utf8");
280
+ return parseMemAvailableBytes(meminfo);
281
+ }
282
+ } catch {
283
+ // Probe failed — fail OPEN (caller floors at os.freemem()).
284
+ return null;
285
+ }
286
+ return null;
287
+ }
288
+
289
+ interface ProbeCacheEntry {
290
+ atMs: number;
291
+ value: number | null;
292
+ }
293
+ const probeCache = new Map<string, ProbeCacheEntry>();
294
+
295
+ export interface ProbeUsableMemOptions {
296
+ /**
297
+ * Injectable clock for tests. Defaults to a MONOTONIC source (`performance.now`) — #1516-r2/r3:
298
+ * the cache TTL must not be measured on the wall clock, or a backwards clock step (NTP/DST) makes
299
+ * the age negative and keeps a stale high probe alive past the TTL.
300
+ */
301
+ now?: () => number;
302
+ /** Injectable raw probe for tests (defaults to the real platform read). */
303
+ compute?: (platform: NodeJS.Platform) => number | null;
304
+ }
305
+
306
+ // #1516-r3 — monotonic default clock for the probe cache TTL. `performance.now()` never runs
307
+ // backwards, so a wall-clock rollback can't extend the cache the way Date.now() could.
308
+ const monotonicNowMs = (): number => performance.now();
309
+
310
+ /** Clear the probe cache — for tests, so cached results don't leak across cases. */
311
+ export function resetUsableMemProbeCache(): void {
312
+ probeCache.clear();
313
+ }
314
+
315
+ /**
316
+ * #1516 — best-effort probe of *usable* host memory in bytes. Prefers genuinely-reclaimable
317
+ * platform signals (darwin vm_stat, linux MemAvailable); on any probe failure, or an unrecognized
318
+ * platform, returns null so the caller falls back to raw `os.freemem()`. Never throws.
319
+ *
320
+ * #1516-r2 — the result is cached per-platform for {@link PROBE_CACHE_TTL_MS} so a synchronous
321
+ * execFileSync(sysctl) isn't spawned on every admission poll and can't serialize into long
322
+ * event-loop stalls. #1516-r3 — the TTL is measured on a MONOTONIC clock, and a negative age (a
323
+ * clock that ran backwards) is treated as expired, so a stale high probe can't survive a rollback.
324
+ */
325
+ export function probeUsableMemBytes(
326
+ platform: NodeJS.Platform = process.platform,
327
+ opts: ProbeUsableMemOptions = {},
328
+ ): number | null {
329
+ const now = opts.now ?? monotonicNowMs;
330
+ const compute = opts.compute ?? computeUsableMemBytes;
331
+ const t = now();
332
+ const cached = probeCache.get(platform);
333
+ if (cached) {
334
+ const age = t - cached.atMs;
335
+ if (age >= 0 && age < PROBE_CACHE_TTL_MS) {
336
+ return cached.value;
337
+ }
338
+ }
339
+ const value = compute(platform);
340
+ probeCache.set(platform, { atMs: t, value });
341
+ return value;
342
+ }
343
+
344
+ /**
345
+ * #1516 — usable memory in MB the admission gate decides on. Takes the platform probe when
346
+ * available (darwin: pressure-level → physical RAM on NORMAL / null on WARN+CRITICAL; linux:
347
+ * MemAvailable), floors it at raw `os.freemem()` so the gate is NEVER stricter than the historical
348
+ * raw-free behavior even if the probe under-reports or holds, and caps it at physical RAM so a
349
+ * headroom signal can't inflate the figure past `os.totalmem()`.
350
+ *
351
+ * #1516-r2 — `rawFreeBytes` and `totalBytes` are threaded in from a SINGLE sample by the caller,
352
+ * so `usableMemMb >= freeMemMb` holds deterministically even while free memory churns.
353
+ */
354
+ export function readUsableMemMb(
355
+ platform: NodeJS.Platform = process.platform,
356
+ rawFreeBytes: number = os.freemem(),
357
+ totalBytes: number = os.totalmem(),
358
+ ): number {
359
+ const probed = probeUsableMemBytes(platform);
360
+ let usableBytes = probed === null ? rawFreeBytes : Math.max(probed, rawFreeBytes);
361
+ // Sanity cap: usable can never exceed physical RAM (guards a plausible-but-wrong probe).
362
+ if (Number.isSafeInteger(totalBytes) && totalBytes > 0) {
363
+ usableBytes = Math.min(usableBytes, totalBytes);
364
+ }
365
+ return usableBytes / (1024 * 1024);
366
+ }
367
+
368
+ // #1513 — build the dashboard/notification descriptor for a spawn the headroom gate is holding back.
369
+ // `spawnedBy` (the routing target) and `spawnRequestId` (idempotency) come off the spawn command
370
+ // params; the reason/freeMem come from the live headroom poll so the row updates as the host recovers.
371
+ export function queuedSpawnFromCommand(
372
+ params: Record<string, any>,
373
+ result: HostHeadroomResult,
374
+ queuedAt: number,
375
+ minFreeMemMb: number,
376
+ maxWaitMs: number,
377
+ ): QueuedSpawn {
378
+ return {
379
+ provider: typeof params.provider === "string" ? params.provider : "unknown",
380
+ ...(typeof params.spawnRequestId === "string" ? { spawnRequestId: params.spawnRequestId } : {}),
381
+ ...(typeof params.label === "string" ? { label: params.label } : {}),
382
+ ...(typeof params.cwd === "string" ? { cwd: params.cwd } : {}),
383
+ ...(typeof params.spawnedBy === "string" ? { spawnedBy: params.spawnedBy } : {}),
384
+ reason: result.reason ?? "waiting for host memory headroom",
385
+ freeMemMb: Math.round(result.freeMemMb),
386
+ minFreeMemMb,
387
+ queuedAt,
388
+ maxWaitMs,
389
+ };
390
+ }
391
+
392
+ /** Read current host memory/load headroom and decide whether it can admit another spawn. */
393
+ export function checkHostHeadroom(opts: { minFreeMemMb?: number; maxLoadPerCpu?: number } = {}): HostHeadroomResult {
394
+ // #1516-r2 — sample raw free / total ONCE and thread them through so the usable figure is
395
+ // computed against the same free reading it is floored at (usableMemMb >= freeMemMb always).
396
+ const rawFreeBytes = os.freemem();
397
+ const totalBytes = os.totalmem();
398
+ const freeMemMb = rawFreeBytes / (1024 * 1024);
399
+ const usableMemMb = readUsableMemMb(process.platform, rawFreeBytes, totalBytes);
400
+ const totalMemMb = totalBytes / (1024 * 1024);
401
+ const cpuCount = Math.max(1, os.cpus().length);
402
+ const loadAvgPerCpu = os.loadavg()[0]! / cpuCount;
403
+ const minFreeMemMb = opts.minFreeMemMb ?? resolveMinFreeMemMb();
404
+ const maxLoadPerCpu = opts.maxLoadPerCpu ?? resolveMaxLoadPerCpu();
405
+ if (usableMemMb < minFreeMemMb) {
406
+ return {
407
+ ok: false,
408
+ reason: `usable memory ${usableMemMb.toFixed(0)}MB is below the ${minFreeMemMb}MB minimum required to admit another agent`,
409
+ usableMemMb,
410
+ freeMemMb,
411
+ totalMemMb,
412
+ loadAvgPerCpu,
413
+ };
414
+ }
415
+ if (loadAvgPerCpu > maxLoadPerCpu) {
416
+ return {
417
+ ok: false,
418
+ reason: `load average ${loadAvgPerCpu.toFixed(2)} per CPU exceeds the ${maxLoadPerCpu} max allowed to admit another agent`,
419
+ usableMemMb,
420
+ freeMemMb,
421
+ totalMemMb,
422
+ loadAvgPerCpu,
423
+ };
424
+ }
425
+ return { ok: true, usableMemMb, freeMemMb, totalMemMb, loadAvgPerCpu };
426
+ }
package/src/index.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  #!/usr/bin/env bun
2
2
  import { errMessage } from "agent-relay-sdk";
3
+ import { fireAndForget, guardedInterval, installUnhandledRejectionGuard } from "./async-guard";
3
4
  import { loadConfig, initConfigFile } from "./config";
4
5
  import { createRelayClient } from "./relay";
5
6
  import type { ManagedSessionExitDiagnostics } from "./relay";
7
+ import { createRegistrationDriver } from "./registration";
6
8
  import { createControlHandler } from "./control";
7
9
  import { diagnoseSessionExit, hydrateTerminalGuests, managedSessionLivenessDetailed, reapTerminalGuests, refreshManagedAgentReport } from "./spawn";
8
10
  import { startApiServer } from "./api";
@@ -13,6 +15,8 @@ import { startOrchestratorMaintenanceScheduler } from "./maintenance";
13
15
  import { OrchestratorQuotaPoller, resolveLiveCodexResetConsumeTransport, type CodexResetFiredEvent } from "./quota-poller";
14
16
  import { SharedCallmuxSupervisor } from "./shared-callmux";
15
17
  import { createCommandPoller } from "./command-poller";
18
+ import { defaultLocalHealthProbe, localReadinessUrl, resolveRuntimePath, selfHealLingeringUpgradeGuard } from "./self-upgrade";
19
+ import { detectSelfSupervision } from "./self-supervision";
16
20
 
17
21
  const args = process.argv.slice(2);
18
22
 
@@ -50,10 +54,24 @@ Config file: ~/.agent-relay/orchestrator.json
50
54
  process.exit(0);
51
55
  }
52
56
 
57
+ // #1676 — last-resort backstop, installed before anything can schedule work. An unhandled
58
+ // rejection in the orchestrator is NOT fatal: every realistic source is a recoverable I/O
59
+ // transient against the relay or a peer host, and the process that supervises other hosts'
60
+ // agents makes the fleet less recoverable by dying. `uncaughtException` deliberately stays
61
+ // at the runtime default (fatal) — see the SDK function for the full reasoning. If this
62
+ // handler ever fires, a guarded site was missed; it logs at the volume a crash would have.
63
+ installUnhandledRejectionGuard();
64
+
53
65
  const config = loadConfig();
54
66
  const probeCache = new ProviderProbeCache(config);
55
67
  const relay = createRelayClient(config, probeCache);
56
68
  const control = createControlHandler(config, relay);
69
+ // #1478 — on a relay RECONNECT (e.g. after a relay restart), re-report the live managed-agent roster
70
+ // so the relay's orchestrator↔agent control association is rebuilt for still-running agents. Without
71
+ // this, register() re-establishes messaging but never re-sends the roster, and the relay never
72
+ // reconstructs it — so relay_shutdown_agent stays -32004 and the dashboard web-terminal button stays
73
+ // missing for reconnecting agents until the next spawn/exit change happens to trigger a re-report.
74
+ relay.setOnReconnected(() => relay.updateManagedAgents(control.getManagedAgents()));
57
75
  // #1425 finding 1 — the live banked-reset consume transport is wired ONLY for a genuine production
58
76
  // run (explicit AGENT_RELAY_CODEX_RESET_CONSUME_LIVE=1 opt-in) and NEVER for the dev-service profile
59
77
  // (AGENT_RELAY_DEV_PROFILE=1) / a test / CI. In every other case the resolver returns undefined, so
@@ -69,14 +87,16 @@ const quotaPoller = new OrchestratorQuotaPoller(config, relay, {
69
87
  ...(liveCodexResetConsume ? { codexResetCreditConsume: liveCodexResetConsume } : {}),
70
88
  onResetFired: (event: CodexResetFiredEvent) => {
71
89
  if (event.trigger !== "auto") return;
72
- void relay.reportCodexResetFired(event).catch((err) =>
73
- console.error(`[orchestrator] codex reset-fired event report failed: ${errMessage(err)}`));
90
+ void fireAndForget("codex reset-fired event report", relay.reportCodexResetFired(event));
74
91
  },
75
92
  });
76
93
  const sharedCallmux = new SharedCallmuxSupervisor(config);
77
94
 
95
+ // #1676 — registration retry: exponential backoff + a surfaced degraded state, replacing
96
+ // the fixed 5s `for(;;)` spin that was the second half of the crash loop.
97
+ const registration = createRegistrationDriver({ register: () => relay.register() });
98
+
78
99
  const POLL_INTERVAL_MS = 3_000;
79
- const REGISTER_RETRY_MS = 5_000;
80
100
  const GUEST_REAP_INTERVAL_MS = 60_000;
81
101
  let commandPoller: ReturnType<typeof createCommandPoller> | null = null;
82
102
  let healthCheckTimer: Timer | null = null;
@@ -91,20 +111,42 @@ async function startup(): Promise<void> {
91
111
  console.error(`[orchestrator] env keys: ${Object.keys(config.env).length}`);
92
112
 
93
113
  // Start API server before registration so we can advertise the URL
94
- apiServer = startApiServer(config, probeCache, relay, quotaPoller);
114
+ apiServer = startApiServer(config, probeCache, relay, quotaPoller, registration);
95
115
  console.error(`[orchestrator] apiUrl: ${apiServer.url}`);
96
116
 
117
+ relay.setApiUrl(apiServer.url);
118
+
119
+ // #1509 r7 §13 — COSMETIC self-heal against a lingering OWN-unit self-upgrade restart guard,
120
+ // kicked off BEFORE registration and NOT awaited. Under the r7 one-shot model (RunAtLoad,
121
+ // KeepAlive=false) a guard that fails to boot itself out sits loaded-but-idle at PID `-`: it can
122
+ // NEITHER loop (KeepAlive=false ⇒ never relaunches) NOR wedge a later dispatch (the dispatch
123
+ // precondition blocks only on a RUNNING guard, and this reap is not on the critical path), so
124
+ // this once-at-startup unawaited call can no longer leave a wedge — it merely tidies the launchd
125
+ // listing. It also cleans up any PRE-r7 KeepAlive guard left over across the upgrade transition;
126
+ // for those it gates on a LOCAL /api/health signal (Relay-independent) and reaps only when proven
127
+ // healthy. It removes each stale generation by its exact unique `.s<seq>.<uuid>` label; a NEW
128
+ // upgrade's guard is RUNNING under a different uuid-unique label, so it is never mistaken for a
129
+ // stale one nor removed by a concurrent reap. Scoped to `${selfUnit}.upgrade-guard`.
130
+ const selfHealSupervision = detectSelfSupervision();
131
+ void fireAndForget("Upgrade-guard self-heal", selfHealLingeringUpgradeGuard(selfHealSupervision.selfUnit, {
132
+ probe: defaultLocalHealthProbe(localReadinessUrl(config.apiPort)),
133
+ runtimePath: resolveRuntimePath(selfHealSupervision.runtimePrefix),
134
+ }).then((reapedGuards) => {
135
+ if (reapedGuards.length > 0) {
136
+ console.error(`[orchestrator] Reaped ${reapedGuards.length} lingering own-unit self-upgrade guard job(s): ${reapedGuards.join(", ")}`);
137
+ }
138
+ }));
139
+
97
140
  // Register with relay. The server and orchestrator are often restarted
98
141
  // together, so startup must tolerate the server not listening yet.
99
- relay.setApiUrl(apiServer.url);
100
- await registerUntilConnected();
142
+ await registration.registerUntilConnected();
101
143
  relay.startHeartbeatLoop();
102
144
 
103
145
  // Recover existing tmux sessions
104
146
  await recoverManagedAgents(config, control, relay);
105
147
 
106
148
  // Host-local maintenance must run where the agent processes and tmux sockets live.
107
- startOrchestratorMaintenanceScheduler();
149
+ startOrchestratorMaintenanceScheduler(config);
108
150
  quotaPoller.start();
109
151
  try {
110
152
  sharedCallmux.start();
@@ -125,18 +167,18 @@ async function startup(): Promise<void> {
125
167
  // Start polling for command requests
126
168
  startPolling();
127
169
 
128
- // Periodic health check — remove dead sessions
129
- healthCheckTimer = setInterval(healthCheck, 60_000);
170
+ // Periodic health check — remove dead sessions.
171
+ // #1676 — this was `setInterval(healthCheck, 60_000)`, and it is the most likely site
172
+ // of the incident's UNCAUGHT AbortError: healthCheck awaits relay.updateManagedAgents(),
173
+ // whose fetch is timeout-aborted, and an async timer callback's rejection has no handler
174
+ // — in Bun that exits the process. guardedInterval consumes it and logs.
175
+ healthCheckTimer = guardedInterval("Managed-agent health check", 60_000, healthCheck);
130
176
 
131
177
  // Periodic guest-terminal reaper — enforces guest TTL without requiring a new
132
178
  // guest creation to trigger cleanup (#144).
133
- guestReaperTimer = setInterval(() => {
134
- try {
135
- reapTerminalGuests(config);
136
- } catch (err) {
137
- console.error(`[orchestrator] Guest reap error: ${err}`);
138
- }
139
- }, GUEST_REAP_INTERVAL_MS);
179
+ guestReaperTimer = guardedInterval("Guest terminal reap", GUEST_REAP_INTERVAL_MS, () => {
180
+ reapTerminalGuests(config);
181
+ });
140
182
 
141
183
  console.error("[orchestrator] Ready. Polling for command requests...");
142
184
  }
@@ -146,19 +188,6 @@ function startPolling(): void {
146
188
  commandPoller.start();
147
189
  }
148
190
 
149
- async function registerUntilConnected(): Promise<void> {
150
- for (;;) {
151
- try {
152
- await relay.register();
153
- return;
154
- } catch (err) {
155
- console.error(`[orchestrator] Register failed: ${err}`);
156
- console.error(`[orchestrator] Retrying registration in ${Math.round(REGISTER_RETRY_MS / 1000)}s...`);
157
- await new Promise((resolve) => setTimeout(resolve, REGISTER_RETRY_MS));
158
- }
159
- }
160
- }
161
-
162
191
  async function healthCheck(): Promise<void> {
163
192
  const agents = control.getManagedAgents();
164
193
  let changed = false;