agent-relay-orchestrator 0.129.16 → 0.129.18

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-orchestrator",
3
- "version": "0.129.16",
3
+ "version": "0.129.18",
4
4
  "description": "Agent Relay orchestrator — manages agent lifecycle across hosts",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,7 +17,7 @@
17
17
  },
18
18
  "dependencies": {
19
19
  "agent-relay-providers": "0.104.6",
20
- "agent-relay-sdk": "0.2.131",
20
+ "agent-relay-sdk": "0.2.132",
21
21
  "callmux": "0.24.2"
22
22
  },
23
23
  "devDependencies": {
package/src/api.ts CHANGED
@@ -5,6 +5,7 @@ import type { ServerWebSocket } from "bun";
5
5
  import { proxyArtifactRequest } from "./artifact-proxy";
6
6
  import { fireAndForget } from "./async-guard";
7
7
  import type { OrchestratorConfig } from "./config";
8
+ import type { CommandLoopHealth } from "./command-poller";
8
9
  import type { ProviderProbeCache } from "./provider-probe";
9
10
  import type { RegistrationDriver } from "./registration";
10
11
  import type { RelayClient } from "./relay";
@@ -392,7 +393,7 @@ export interface CodexResetConsumer {
392
393
  }>;
393
394
  }
394
395
 
395
- export function startApiServer(config: OrchestratorConfig, probeCache: ProviderProbeCache, relay?: RelayClient, codexResetConsumer?: CodexResetConsumer, registration?: Pick<RegistrationDriver, "getHealth">): { stop(): void; url: string } {
396
+ export function startApiServer(config: OrchestratorConfig, probeCache: ProviderProbeCache, relay?: RelayClient, codexResetConsumer?: CodexResetConsumer, registration?: Pick<RegistrationDriver, "getHealth">, commandLoop?: { getHealth(): CommandLoopHealth }): { stop(): void; url: string } {
396
397
  const server = Bun.serve<TerminalSocketData>({
397
398
  port: config.apiPort,
398
399
  hostname: "0.0.0.0",
@@ -679,10 +680,16 @@ export function startApiServer(config: OrchestratorConfig, probeCache: ProviderP
679
680
  // degraded peer must not make this process look un-upgradable. Read `degraded`.
680
681
  const peer = relay?.getHealth();
681
682
  const reg = registration?.getHealth();
683
+ // #1760 — surface the command loop's own health so a CRAWLING loop (a stuck inline handler
684
+ // head-of-line-blocking the queue) is distinguishable from a healthy one. Kept OUT of the
685
+ // top-level `degraded` flag on purpose: `degraded`/`ok` gate the self-upgrade guard's
686
+ // Relay-independent readiness probe, and a transiently-stalled command loop must not make
687
+ // this process look un-upgradable. Consumers read `commandLoop.stalled` directly.
682
688
  return json({
683
689
  status: "ok", ok: true, id: config.id, hostname: config.hostname,
684
690
  degraded: Boolean(peer?.degraded || reg?.degraded),
685
691
  ...(peer ? { relay: peer } : {}), ...(reg ? { registration: reg } : {}),
692
+ ...(commandLoop ? { commandLoop: commandLoop.getHealth() } : {}),
686
693
  });
687
694
  }
688
695
 
@@ -5,6 +5,23 @@ interface CommandPollerControl {
5
5
  handleCommand(command: RelayCommand): Promise<boolean>;
6
6
  }
7
7
 
8
+ // #1760 — a health snapshot of the command loop, surfaced on /api/health so a CRAWLING loop (a
9
+ // healthy heartbeat while commands sit unhandled for minutes) is detectable without log archaeology.
10
+ export interface CommandLoopHealth {
11
+ /** True while a poll+handle cycle is running. */
12
+ inFlight: boolean;
13
+ /** Where the in-flight cycle is: waiting on the network poll, or running a command handler. */
14
+ phase: "idle" | "poll" | "handler";
15
+ /** How long the in-flight cycle has been running (0 when idle). */
16
+ activeForMs: number;
17
+ /** The command currently being handled inline, when phase === "handler". */
18
+ handlingType?: string;
19
+ handlingId?: string;
20
+ /** True once the in-flight cycle has exceeded the watchdog threshold — the crawl signal. */
21
+ stalled: boolean;
22
+ stalledForMs?: number;
23
+ }
24
+
8
25
  interface CommandPollerOptions {
9
26
  relay: Pick<RelayClient, "connected" | "pollCommands">;
10
27
  control: CommandPollerControl;
@@ -35,10 +52,21 @@ export function createCommandPoller({
35
52
  let activePollController: AbortController | undefined;
36
53
  let activePollStartedAt = 0;
37
54
  let activePhase: "idle" | "poll" | "handler" = "idle";
55
+ // #1760 — the command currently being handled inline, and the timestamp at which the loop was first
56
+ // observed stalled in the current cycle (0 when not stalled). Only used for detection/surfacing.
57
+ let activeCommandType: string | undefined;
58
+ let activeCommandId: string | undefined;
59
+ let stalledSince = 0;
38
60
  let lastTickErrored = false;
39
61
  let lastCycleCompletedAt = now();
40
62
  let generation = 0;
41
63
 
64
+ function clearActiveCommand(): void {
65
+ activeCommandType = undefined;
66
+ activeCommandId = undefined;
67
+ stalledSince = 0;
68
+ }
69
+
42
70
  async function tick(): Promise<boolean> {
43
71
  if (!relay.connected || inFlight) return false;
44
72
  const tickGeneration = generation;
@@ -59,7 +87,14 @@ export function createCommandPoller({
59
87
  for (const command of commands) {
60
88
  if (tickGeneration !== generation) return false;
61
89
  log(`[orchestrator] Handling command: ${command.type} ${command.id}`);
62
- await control.handleCommand(command);
90
+ activeCommandType = command.type;
91
+ activeCommandId = command.id;
92
+ stalledSince = 0;
93
+ try {
94
+ await control.handleCommand(command);
95
+ } finally {
96
+ if (tickGeneration === generation) clearActiveCommand();
97
+ }
63
98
  }
64
99
  return true;
65
100
  } catch (err) {
@@ -72,6 +107,7 @@ export function createCommandPoller({
72
107
  inFlight = false;
73
108
  activePhase = "idle";
74
109
  activePollStartedAt = 0;
110
+ clearActiveCommand();
75
111
  }
76
112
  }
77
113
  }
@@ -103,18 +139,43 @@ export function createCommandPoller({
103
139
  if (watchdogTimer || watchdogMs <= 0 || watchdogCheckMs <= 0) return;
104
140
  watchdogTimer = setInterval(() => {
105
141
  if (stopped) return;
106
- if (activePhase !== "poll" || !activePollController) return;
142
+ if (!inFlight || activePhase === "idle" || activePollStartedAt === 0) return;
107
143
  const staleForMs = now() - activePollStartedAt;
108
144
  if (staleForMs < watchdogMs) return;
109
- generation += 1;
110
- activePollController.abort();
111
- activePollController = undefined;
112
- activePollStartedAt = 0;
113
- activePhase = "idle";
114
- inFlight = false;
115
- log(`[orchestrator] Poll loop watchdog: command poll stuck for ${staleForMs}ms; aborting stuck poll and restarting`);
116
- if (!timer) schedule(0);
117
- lastCycleCompletedAt = now();
145
+ if (activePhase === "poll" && activePollController) {
146
+ // A stuck network poll is safely abortable (its AbortController cancels the in-flight
147
+ // request); bump the generation, abort, and restart the cycle. Unchanged from pre-#1760.
148
+ generation += 1;
149
+ activePollController.abort();
150
+ activePollController = undefined;
151
+ activePollStartedAt = 0;
152
+ activePhase = "idle";
153
+ inFlight = false;
154
+ clearActiveCommand();
155
+ log(`[orchestrator] Poll loop watchdog: command poll stuck for ${staleForMs}ms; aborting stuck poll and restarting`);
156
+ if (!timer) schedule(0);
157
+ lastCycleCompletedAt = now();
158
+ return;
159
+ }
160
+ if (activePhase === "handler") {
161
+ // #1760 — a command handler (agent.restart / agent.shutdown / workspace.merge / …) is running
162
+ // INLINE in the poll tick and has exceeded the threshold, head-of-line-blocking every command
163
+ // queued behind it. We DETECT and SURFACE it (log once + a health flag) but deliberately do
164
+ // NOT abort: unlike a network poll, a handler is mid-mutation (a git land, a session teardown)
165
+ // and cancelling it by bumping the generation would free the single-flight guard while the
166
+ // work runs on, risking a concurrent re-dispatch of the same command. Spawn handling is
167
+ // already backgrounded off the tick (dispatchSpawnCommand), so it cannot land here; the
168
+ // inline handlers that can are bounded by their own timeouts. Surfacing turns a silent crawl
169
+ // into an observable one.
170
+ if (stalledSince === 0) {
171
+ stalledSince = activePollStartedAt;
172
+ log(
173
+ `[orchestrator] Command loop STALLED: handling ${activeCommandType ?? "command"} ${activeCommandId ?? ""} for ${staleForMs}ms ` +
174
+ `(heartbeat stays healthy; commands queued behind it are blocked). Not aborting an in-flight mutating handler.`,
175
+ );
176
+ }
177
+ return;
178
+ }
118
179
  }, watchdogCheckMs);
119
180
  watchdogTimer.unref?.();
120
181
  }
@@ -134,16 +195,33 @@ export function createCommandPoller({
134
195
  activePollController = undefined;
135
196
  activePollStartedAt = 0;
136
197
  activePhase = "idle";
198
+ clearActiveCommand();
137
199
  if (timer) clearTimeout(timer);
138
200
  timer = undefined;
139
201
  if (watchdogTimer) clearInterval(watchdogTimer);
140
202
  watchdogTimer = undefined;
141
203
  }
142
204
 
205
+ function getHealth(): CommandLoopHealth {
206
+ const active = inFlight && activePollStartedAt > 0;
207
+ const activeForMs = active ? Math.max(0, now() - activePollStartedAt) : 0;
208
+ const stalled = active && stalledSince > 0;
209
+ return {
210
+ inFlight,
211
+ phase: activePhase,
212
+ activeForMs,
213
+ stalled,
214
+ ...(stalled ? { stalledForMs: Math.max(0, now() - stalledSince) } : {}),
215
+ ...(activePhase === "handler" && activeCommandType ? { handlingType: activeCommandType } : {}),
216
+ ...(activePhase === "handler" && activeCommandId ? { handlingId: activeCommandId } : {}),
217
+ };
218
+ }
219
+
143
220
  return {
144
221
  tick,
145
222
  start,
146
223
  stop,
224
+ getHealth,
147
225
  get inFlight() {
148
226
  return inFlight;
149
227
  },
package/src/config.ts CHANGED
@@ -118,6 +118,18 @@ export function tmuxSocketSweepIntervalMs(): number {
118
118
  return envNonNegativeMax("AGENT_RELAY_TMUX_SOCKET_SWEEP_INTERVAL_MS", 10 * 60 * 1000, 60_000);
119
119
  }
120
120
 
121
+ // #1760 — a HARD per-socket bound on the sweep's `tmux list-sessions` liveness probe. The probe
122
+ // runs via Bun.spawnSync ON the event loop; a WEDGED tmux server (process alive, socket
123
+ // unresponsive) makes `list-sessions` block until the server answers — which, unbounded, freezes
124
+ // the whole orchestrator, the command poll loop included (the wbox stall in #1760: healthy
125
+ // heartbeat, commands crawling ~11–15 min behind). With the bound a single wedged socket costs at
126
+ // most this long and its socket is KEPT (a wedged-but-present server must never have its socket
127
+ // yanked). 2s is generous for a healthy `list-sessions` even under load; clamped so it can never be
128
+ // set so low that a slow-but-live probe is misread.
129
+ export function tmuxSocketSweepProbeTimeoutMs(): number {
130
+ return envNonNegativeMax("AGENT_RELAY_TMUX_SOCKET_SWEEP_PROBE_TIMEOUT_MS", 2_000, 250);
131
+ }
132
+
121
133
  // #1514 — reaper for orphaned, idle managed tmux sessions (e.g. a headless Claude
122
134
  // session wedged at the "Exit anyway / Move to background / Stay" dialog). On by
123
135
  // default; set AGENT_RELAY_WEDGED_SESSION_REAP=0 to disable.
package/src/index.ts CHANGED
@@ -99,6 +99,8 @@ const registration = createRegistrationDriver({ register: () => relay.register()
99
99
  const POLL_INTERVAL_MS = 3_000;
100
100
  const GUEST_REAP_INTERVAL_MS = 60_000;
101
101
  let commandPoller: ReturnType<typeof createCommandPoller> | null = null;
102
+ // #1760 — health reported before the poll loop is up (or after shutdown): an idle, non-stalled loop.
103
+ const nullCommandLoopHealth = { getHealth: () => ({ inFlight: false, phase: "idle" as const, activeForMs: 0, stalled: false }) };
102
104
  let healthCheckTimer: Timer | null = null;
103
105
  let guestReaperTimer: Timer | null = null;
104
106
  let apiServer: { stop(): void; url: string } | null = null;
@@ -111,7 +113,11 @@ async function startup(): Promise<void> {
111
113
  console.error(`[orchestrator] env keys: ${Object.keys(config.env).length}`);
112
114
 
113
115
  // Start API server before registration so we can advertise the URL
114
- apiServer = startApiServer(config, probeCache, relay, quotaPoller, registration);
116
+ // #1760 the poller is created later in startPolling(); pass a lazy accessor so /api/health reads
117
+ // whichever poller is live at request time (null before startPolling, giving no commandLoop field).
118
+ apiServer = startApiServer(config, probeCache, relay, quotaPoller, registration, {
119
+ getHealth: () => (commandPoller ?? nullCommandLoopHealth).getHealth(),
120
+ });
115
121
  console.error(`[orchestrator] apiUrl: ${apiServer.url}`);
116
122
 
117
123
  relay.setApiUrl(apiServer.url);
@@ -3,6 +3,7 @@ import type { OrchestratorConfig } from "./config";
3
3
  import {
4
4
  tmuxSocketSweepEnabled,
5
5
  tmuxSocketSweepIntervalMs,
6
+ tmuxSocketSweepProbeTimeoutMs,
6
7
  wedgedSessionIdleThresholdMs,
7
8
  wedgedSessionReapEnabled,
8
9
  wedgedSessionReapIntervalMs,
@@ -31,10 +32,18 @@ export function maintenanceJobDefinitions(config: OrchestratorConfig): Orchestra
31
32
  intervalMs: tmuxSocketSweepIntervalMs(),
32
33
  runOnStart: true,
33
34
  handler() {
34
- const result = sweepStaleTmuxSockets();
35
+ const result = sweepStaleTmuxSockets({ probeTimeoutMs: tmuxSocketSweepProbeTimeoutMs() });
35
36
  if (result.scanned > 0 || result.failed.length > 0) {
36
37
  console.error(
37
- `[orchestrator] Tmux socket sweep: removed=${result.removed} kept=${result.kept} scanned=${result.scanned} dir=${result.dir}`,
38
+ `[orchestrator] Tmux socket sweep: removed=${result.removed} kept=${result.kept} timedOut=${result.timedOut} scanned=${result.scanned} dir=${result.dir}`,
39
+ );
40
+ }
41
+ // #1760 — a socket whose liveness probe hit the timeout is a wedged/unresponsive tmux server.
42
+ // Surface it distinctly: this is the host-level state that stalls the command loop, and a
43
+ // plain restart won't clear it (the tmux server outlives the orchestrator).
44
+ if (result.timedOut > 0) {
45
+ console.error(
46
+ `[orchestrator] Tmux socket sweep: ${result.timedOut} socket(s) unresponsive (list-sessions timed out at ${tmuxSocketSweepProbeTimeoutMs()}ms) — kept, not removed; a wedged tmux server may need manual intervention (#1760)`,
38
47
  );
39
48
  }
40
49
  for (const failure of result.failed) {
@@ -4,11 +4,29 @@ import { tmuxCommand } from "agent-relay-sdk/tmux-utils";
4
4
 
5
5
  const SOCKET_PREFIX = "agent-relay-";
6
6
 
7
+ // #1760 — default hard bound for the per-socket liveness probe when a caller passes none. Mirrors
8
+ // tmuxSocketSweepProbeTimeoutMs()'s default; kept as a module constant so the sweeper stays
9
+ // dependency-free of config.ts (it is imported directly by tests and by the SDK-adjacent code).
10
+ export const DEFAULT_TMUX_SOCKET_PROBE_TIMEOUT_MS = 2_000;
11
+
12
+ // "live" — a tmux server answered `list-sessions` (exit 0). Keep the socket.
13
+ // "dead" — tmux exited non-zero ("no server running …"): a stale socket. Remove it.
14
+ // "unknown" — the probe did NOT exit on its own (killed at the timeout / by a signal): the socket is
15
+ // present but unresponsive — a WEDGED server. Keep it (never yank a possibly-live
16
+ // server's socket) and, crucially, never block the event loop past the bound. (#1760)
17
+ export type TmuxSocketLiveness = "live" | "dead" | "unknown";
18
+
19
+ export type TmuxSocketProbe = (socketName: string, timeoutMs: number, env?: NodeJS.ProcessEnv) => TmuxSocketLiveness;
20
+
7
21
  interface TmuxSocketSweepResult {
8
22
  dir: string;
9
23
  scanned: number;
10
24
  removed: number;
11
25
  kept: number;
26
+ // #1760 — sockets whose liveness probe hit the timeout (a wedged/unresponsive tmux server). These
27
+ // are counted within `kept` and surfaced separately so a hung socket is visible in the sweep log
28
+ // instead of silently inflating `kept`.
29
+ timedOut: number;
12
30
  failed: Array<{ socket: string; error: string }>;
13
31
  }
14
32
 
@@ -19,9 +37,13 @@ export function tmuxSocketDir(env: NodeJS.ProcessEnv = process.env): string {
19
37
  return basename(tmuxTmp) === `tmux-${uid}` ? tmuxTmp : join(tmuxTmp, `tmux-${uid}`);
20
38
  }
21
39
 
22
- export function sweepStaleTmuxSockets(input: { dir?: string; env?: NodeJS.ProcessEnv } = {}): TmuxSocketSweepResult {
40
+ export function sweepStaleTmuxSockets(
41
+ input: { dir?: string; env?: NodeJS.ProcessEnv; probeTimeoutMs?: number; probe?: TmuxSocketProbe } = {},
42
+ ): TmuxSocketSweepResult {
23
43
  const dir = input.dir ?? tmuxSocketDir(input.env);
24
- const result: TmuxSocketSweepResult = { dir, scanned: 0, removed: 0, kept: 0, failed: [] };
44
+ const probeTimeoutMs = input.probeTimeoutMs ?? DEFAULT_TMUX_SOCKET_PROBE_TIMEOUT_MS;
45
+ const probe = input.probe ?? tmuxSocketServerState;
46
+ const result: TmuxSocketSweepResult = { dir, scanned: 0, removed: 0, kept: 0, timedOut: 0, failed: [] };
25
47
  if (!existsSync(dir)) return result;
26
48
 
27
49
  for (const entry of readdirSync(dir)) {
@@ -38,8 +60,13 @@ export function sweepStaleTmuxSockets(input: { dir?: string; env?: NodeJS.Proces
38
60
  if (!removable) continue;
39
61
 
40
62
  result.scanned += 1;
41
- if (tmuxSocketHasLiveServer(entry, input.env)) {
63
+ const liveness = probe(entry, probeTimeoutMs, input.env);
64
+ if (liveness !== "dead") {
65
+ // "live" (a server answered) OR "unknown" (the probe hit the timeout against a wedged socket) —
66
+ // either way KEEP the socket. Removing a wedged-but-present server's socket is destructive, and
67
+ // we must never block the event loop waiting to be certain it is dead. (#1760)
42
68
  result.kept += 1;
69
+ if (liveness === "unknown") result.timedOut += 1;
43
70
  continue;
44
71
  }
45
72
 
@@ -54,12 +81,25 @@ export function sweepStaleTmuxSockets(input: { dir?: string; env?: NodeJS.Proces
54
81
  return result;
55
82
  }
56
83
 
57
- function tmuxSocketHasLiveServer(socketName: string, env?: NodeJS.ProcessEnv): boolean {
84
+ export function tmuxSocketServerState(socketName: string, timeoutMs: number, env?: NodeJS.ProcessEnv): TmuxSocketLiveness {
58
85
  const result = Bun.spawnSync(tmuxCommand(socketName, "list-sessions"), {
59
86
  stdin: "ignore",
60
87
  stdout: "ignore",
61
88
  stderr: "ignore",
89
+ // #1760 — the load-bearing bound: without it a wedged server's socket blocks this synchronous
90
+ // call (and thus the whole event loop) indefinitely.
91
+ ...(timeoutMs > 0 ? { timeout: timeoutMs } : {}),
92
+ // Force-kill on timeout with SIGKILL, NOT the default SIGTERM: the `tmux list-sessions` CLIENT
93
+ // traps SIGTERM and exits cleanly (observed exitCode 0 — or, on some builds, 1), which would
94
+ // misread a wedged server as "live" or, worse, "dead" (→ its socket removed). SIGKILL is
95
+ // uncatchable, so a timed-out probe reliably reports exitCode === null → "unknown". This only
96
+ // kills the short-lived probe client; the tmux SERVER is never signalled.
97
+ killSignal: "SIGKILL",
62
98
  ...(env ? { env } : {}),
63
99
  });
64
- return result.exitCode === 0;
100
+ if (result.exitCode === 0) return "live";
101
+ // Killed at the timeout (SIGKILL) → null exit code: the socket is present but unresponsive. Treat
102
+ // as unknown (keep it), never as dead — a wedged-but-live server must not lose its socket.
103
+ if (result.exitCode === null) return "unknown";
104
+ return "dead";
65
105
  }