agent-relay-orchestrator 0.129.20 → 0.129.22

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.20",
3
+ "version": "0.129.22",
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.132",
20
+ "agent-relay-sdk": "0.2.133",
21
21
  "callmux": "0.24.2"
22
22
  },
23
23
  "devDependencies": {
package/src/control.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { errMessage, isPathWithinBase, isRecord, normalizeAgentLifecycle, normalizeWorkspaceMode, Semaphore } from "agent-relay-sdk";
2
+ import { renderTmuxSessionTarget } from "agent-relay-sdk/tmux-utils";
2
3
  import { fireAndForget } from "./async-guard";
3
4
  import { getAllManifests, getManifest } from "agent-relay-providers";
4
5
  import { SCHEDULER_COMMAND_MAX_CONCURRENT, SCHEDULER_COMMAND_MAX_OUTPUT_BYTES, type OrchestratorConfig } from "./config";
@@ -327,7 +328,23 @@ export function createControlHandler(
327
328
 
328
329
  async function handleShutdown(ctrl: Record<string, any>, restart = false): Promise<Record<string, unknown>> {
329
330
  const current = managedAgentShutdownTarget(managedAgents, ctrl);
330
- const session = current?.sessionName ?? current?.tmuxSession;
331
+ // #1746 RC-1(c) when the in-memory managedAgents report has no match (an orchestrator that
332
+ // restarted and lost its report cache, or a report/record divergence), fall back to the durable
333
+ // on-disk session state before giving up. findSessionRecord (post-fix) tries every key, so a live
334
+ // session the report missed can still be resolved and force-killed rather than silently skipped.
335
+ const fallbackRecord = current
336
+ ? undefined
337
+ : findSessionRecord({
338
+ agentId: typeof ctrl.agentId === "string" && ctrl.agentId ? ctrl.agentId : undefined,
339
+ spawnRequestId: typeof ctrl.spawnRequestId === "string" && ctrl.spawnRequestId ? ctrl.spawnRequestId : undefined,
340
+ policyName: typeof ctrl.policyName === "string" && ctrl.policyName ? ctrl.policyName : undefined,
341
+ tmuxSession: typeof ctrl.sessionName === "string" && ctrl.sessionName
342
+ ? ctrl.sessionName
343
+ : typeof ctrl.tmuxSession === "string" && ctrl.tmuxSession
344
+ ? ctrl.tmuxSession
345
+ : undefined,
346
+ });
347
+ const session = current?.sessionName ?? current?.tmuxSession ?? fallbackRecord?.name;
331
348
  const restartSpawn = isRecord(ctrl.restartSpawn) ? ctrl.restartSpawn : undefined;
332
349
  if (!session) {
333
350
  let restarted: ManagedAgentReport | undefined;
@@ -339,12 +356,17 @@ export function createControlHandler(
339
356
  spawnSlots.release();
340
357
  }
341
358
  }
359
+ // #1746 RC-1(c) — the target resolved to NOTHING and (for a restart) no successor was spawned:
360
+ // dispatchStop never ran, so we CANNOT claim the process was stopped. Signal `targetNotFound` so
361
+ // the caller marks the command FAILED (a red terminal outcome the dashboard/watchdog can act on)
362
+ // instead of the old inert `succeeded`, which deregistered live agents and wrote a false
363
+ // `agent.exited`. A restart that DID respawn a successor is a legitimate success.
342
364
  return {
343
365
  stopped: false,
344
366
  wasRunning: false,
345
367
  restart,
346
368
  restarted: Boolean(restarted),
347
- ...(restarted ? { agent: restarted } : {}),
369
+ ...(restarted ? { agent: restarted } : { targetNotFound: true }),
348
370
  policyName: ctrl.policyName,
349
371
  spawnRequestId: ctrl.spawnRequestId,
350
372
  };
@@ -510,7 +532,25 @@ export function createControlHandler(
510
532
  try {
511
533
  if (command.type === "agent.shutdown" || command.type === "agent.restart") {
512
534
  const result = await handleShutdown(command.params, command.type === "agent.restart");
513
- await relay.updateCommand(command.id, "succeeded", result);
535
+ // #1746 RC-1(c) a shutdown is a KILL-SWITCH: never report success unless the target was
536
+ // resolved AND is confirmed gone. Two outcomes fail the command with a red terminal reason so
537
+ // the dashboard/watchdog can escalate (and no false `agent.exited` is written server-side):
538
+ // - target-not-found: nothing matched, dispatchStop never ran (the surviving-agent bug);
539
+ // - kill-failed: the session was resolved but its process survived graceful+forceful stop.
540
+ // Restart keeps its existing settlement — its successor spawn + predecessor teardown are
541
+ // reconciled by the restart path itself, not by this kill-switch gate.
542
+ const shutdownFailure = command.type === "agent.shutdown"
543
+ ? (result.targetNotFound === true
544
+ ? "target-not-found: no live session, agent, spawn, or policy matched the shutdown request"
545
+ : result.wasRunning === true && result.stopped !== true
546
+ ? "kill-failed: target process survived graceful and forceful stop"
547
+ : undefined)
548
+ : undefined;
549
+ if (shutdownFailure) {
550
+ await relay.updateCommand(command.id, "failed", result, shutdownFailure);
551
+ } else {
552
+ await relay.updateCommand(command.id, "succeeded", result);
553
+ }
514
554
  } else if (command.type === "workspace.cleanup") {
515
555
  const result = await cleanupWorkspace({
516
556
  id: typeof command.params.workspaceId === "string" ? command.params.workspaceId : undefined,
@@ -700,21 +740,47 @@ export function createControlHandler(
700
740
  }
701
741
 
702
742
  export function managedAgentShutdownTarget(agents: ManagedAgentReport[], ctrl: Record<string, any>): ManagedAgentReport | undefined {
743
+ // #1746 RC-1 — resolve the shutdown target by trying EVERY key present on the command in priority
744
+ // order, returning the first that matches an agent. The prior code early-returned on the
745
+ // first-SPECIFIED key (session) even when it did NOT match, so a session-name divergence stranded
746
+ // the whole lookup at `undefined` — the force-kill machinery was never reached and the inert
747
+ // shutdown was reported as `succeeded` while the process stayed alive (the silent zombie). A MISS on
748
+ // one key must fall through to the next, never short-circuit the resolution.
703
749
  const requestedSession = typeof ctrl.sessionName === "string" && ctrl.sessionName
704
750
  ? ctrl.sessionName
705
751
  : typeof ctrl.tmuxSession === "string" && ctrl.tmuxSession
706
752
  ? ctrl.tmuxSession
707
753
  : undefined;
708
- if (requestedSession) return agents.find((agent) => agent.sessionName === requestedSession || agent.tmuxSession === requestedSession);
754
+ if (requestedSession) {
755
+ // #1583/#1746 RC-1(b) — the runner publishes tmux's RENDERED session name (`.`/`:`→`_`) while the
756
+ // orchestrator record keeps the REQUESTED (dotted) name; normalize BOTH sides so a rendering
757
+ // divergence can't miss. Raw equality is tried first, then the rendered comparison.
758
+ const renderedRequest = renderTmuxSessionTarget(requestedSession);
759
+ const bySession = agents.find((agent) =>
760
+ agent.sessionName === requestedSession ||
761
+ agent.tmuxSession === requestedSession ||
762
+ (agent.sessionName != null && renderTmuxSessionTarget(agent.sessionName) === renderedRequest) ||
763
+ renderTmuxSessionTarget(agent.tmuxSession) === renderedRequest);
764
+ if (bySession) return bySession;
765
+ }
709
766
 
710
767
  const agentId = typeof ctrl.agentId === "string" && ctrl.agentId ? ctrl.agentId : undefined;
711
- if (agentId) return agents.find((agent) => agent.agentId === agentId);
768
+ if (agentId) {
769
+ const byAgentId = agents.find((agent) => agent.agentId === agentId);
770
+ if (byAgentId) return byAgentId;
771
+ }
712
772
 
713
773
  const spawnRequestId = typeof ctrl.spawnRequestId === "string" && ctrl.spawnRequestId ? ctrl.spawnRequestId : undefined;
714
- if (spawnRequestId) return agents.find((agent) => agent.spawnRequestId === spawnRequestId);
774
+ if (spawnRequestId) {
775
+ const bySpawnRequestId = agents.find((agent) => agent.spawnRequestId === spawnRequestId);
776
+ if (bySpawnRequestId) return bySpawnRequestId;
777
+ }
715
778
 
716
779
  const policyName = typeof ctrl.policyName === "string" && ctrl.policyName ? ctrl.policyName : undefined;
717
- if (policyName) return agents.find((agent) => agent.policyName === policyName);
780
+ if (policyName) {
781
+ const byPolicyName = agents.find((agent) => agent.policyName === policyName);
782
+ if (byPolicyName) return byPolicyName;
783
+ }
718
784
  return undefined;
719
785
  }
720
786
 
package/src/index.ts CHANGED
@@ -217,9 +217,13 @@ async function healthCheck(): Promise<void> {
217
217
  continue;
218
218
  }
219
219
  if (liveness === "dead") {
220
+ // #1746 fwd — resolve THIS specific dead agent by its UNIQUE keys only. Since #1746 fwd made
221
+ // selectSessionRecord "try every key" (a MISS falls through to the next, for the kill-switch),
222
+ // passing policyName here would let a full-key-set miss (this agent's own record already removed)
223
+ // fall through to the policyName branch and misattribute a live sibling policy session's exit —
224
+ // flipping the sibling offline while it's still running and dropping this agent with no exit record.
220
225
  const diagnostics = diagnoseSessionExit({
221
226
  agentId: refreshed.agentId,
222
- policyName: refreshed.policyName,
223
227
  spawnRequestId: refreshed.spawnRequestId,
224
228
  tmuxSession: sessionName,
225
229
  systemdOverride: capturedSystemd,
@@ -3,7 +3,7 @@ import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import type { ManagedAgentReport } from "../relay";
5
5
  import { classifyPidLiveness, isPidAlive, isValidPid, parseProcStateIsZombie, readProcessStartMs, type PidLiveness } from "agent-relay-sdk/process-utils";
6
- import { tmuxCommand, tmuxHasSession } from "agent-relay-sdk/tmux-utils";
6
+ import { renderTmuxSessionTarget, tmuxCommand, tmuxHasSession } from "agent-relay-sdk/tmux-utils";
7
7
  import { LOG_DIR, RUNNER_INFO_DIR, SESSION_DIR, STATE_FILE, TOMBSTONE_DIR } from "./constants";
8
8
  import { systemdMainPid, systemdUnitDiagnostics, systemdUnitLivenessFromDiagnostics, type SystemdUnitDiagnostics, type SystemdUnitLiveness } from "./systemd";
9
9
  import { sanitizeFsName } from "agent-relay-sdk/fs-name";
@@ -184,20 +184,31 @@ export function sessionReportFields(record: Pick<SessionRecord, "name" | "superv
184
184
  }
185
185
 
186
186
  export function selectSessionRecord(records: SessionRecord[], input: { agentId?: string; policyName?: string; spawnRequestId?: string; tmuxSession?: string }): SessionRecord | undefined {
187
- if (input.tmuxSession) return records.find((record) => record.name === input.tmuxSession);
188
-
189
- if (input.spawnRequestId) {
190
- return records.find((record) =>
191
- record.spawnRequestId === input.spawnRequestId &&
192
- (!input.policyName || record.policyName === input.policyName)
193
- );
187
+ // #1746 RC-1 try EVERY provided key, falling through on a MISS rather than early-returning
188
+ // `undefined` on the first-specified one. A session-name lookup that misses (e.g. the runner's
189
+ // rendered `.`/`:`→`_` name vs the record's requested name, #1583) must still resolve by
190
+ // spawnRequestId/agentId/policyName so the host-side shutdown fallback can find the live session.
191
+ if (input.tmuxSession) {
192
+ const rendered = renderTmuxSessionTarget(input.tmuxSession);
193
+ const bySession = records.find((record) => record.name === input.tmuxSession || renderTmuxSessionTarget(record.name) === rendered);
194
+ if (bySession) return bySession;
194
195
  }
195
196
 
197
+ // #1746 fwd BLOCKER — each key falls through on a MISS instead of early-returning `undefined`. The
198
+ // prior code `return`ed the `.find()` result for the first-specified key even when it was undefined,
199
+ // so a spawnRequestId that missed (or whose policyName conjunct rejected the match) stranded the whole
200
+ // lookup and never tried agentId/policyName — the exact surviving-agent path the kill-switch must
201
+ // resolve. This mirrors control.ts `managedAgentShutdownTarget`: match each UNIQUE key on its own
202
+ // (agentId → spawnRequestId → policyName, per e12b4c2a's stated canonical order) with no cross-key
203
+ // conjunct, so a divergent policyName can never veto an otherwise-exact agentId/spawnRequestId hit.
196
204
  if (input.agentId) {
197
- return records.find((record) =>
198
- record.agentId === input.agentId &&
199
- (!input.policyName || record.policyName === input.policyName)
200
- );
205
+ const byAgentId = records.find((record) => record.agentId === input.agentId);
206
+ if (byAgentId) return byAgentId;
207
+ }
208
+
209
+ if (input.spawnRequestId) {
210
+ const bySpawnRequestId = records.find((record) => record.spawnRequestId === input.spawnRequestId);
211
+ if (bySpawnRequestId) return bySpawnRequestId;
201
212
  }
202
213
 
203
214
  if (input.policyName) {
@@ -69,10 +69,15 @@ export function managedSessionLivenessDetailed(name: string): SessionRecordLiven
69
69
  }
70
70
 
71
71
  export function refreshManagedAgentReport(agent: ManagedAgentReport): ManagedAgentReport {
72
+ // #1746 fwd — refresh a SPECIFIC known agent by its UNIQUE keys only. Since #1746 fwd made
73
+ // selectSessionRecord "try every key" (a MISS falls through to the next, for the kill-switch),
74
+ // passing policyName here would let a full-key-set miss (this agent's own session gone from state)
75
+ // fall through to the policyName branch and refresh THIS agent's report with a live SIBLING policy
76
+ // session's fields. The unique keys fully identify the agent; a genuine miss must return the agent
77
+ // unchanged (as before), never a sibling.
72
78
  const record = findSessionRecord({
73
79
  tmuxSession: agent.sessionName ?? agent.tmuxSession,
74
80
  agentId: agent.agentId,
75
- policyName: agent.policyName,
76
81
  spawnRequestId: agent.spawnRequestId,
77
82
  });
78
83
  if (!record) return agent;
@@ -154,7 +154,12 @@ export async function spawnAgent(
154
154
 
155
155
  function existingSpawnSession(opts: SpawnOptions, deps: Pick<SpawnAgentDeps, "findSessionRecord" | "sessionRecordLiveness" | "currentSessionPid" | "sessionReportFields">): ManagedAgentReport | undefined {
156
156
  if (!opts.spawnRequestId) return undefined;
157
- const record = deps.findSessionRecord({ spawnRequestId: opts.spawnRequestId, policyName: opts.policyName });
157
+ // #1746 fwd this is an EXACT-spawnRequestId idempotency probe ("is THIS request already running?"),
158
+ // not a target resolution. Since #1746 fwd made selectSessionRecord "try every key" (a MISS on one key
159
+ // falls through to the next, for the kill-switch), passing policyName here would let a FRESH
160
+ // spawnRequestId that misses fall through to the policyName branch and wrongly dedupe a genuinely-new
161
+ // spawn onto an existing policy session. spawnRequestId is unique, so match it and it ALONE.
162
+ const record = deps.findSessionRecord({ spawnRequestId: opts.spawnRequestId });
158
163
  if (!record) return undefined;
159
164
  const liveness = deps.sessionRecordLiveness(record);
160
165
  if (liveness === "dead") return undefined;