agent-relay-orchestrator 0.129.20 → 0.129.21
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 -2
- package/src/control.ts +73 -7
- package/src/index.ts +5 -1
- package/src/spawn/runtime.ts +23 -12
- package/src/spawn/sessions.ts +6 -1
- package/src/spawn/spawn-agent.ts +6 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-orchestrator",
|
|
3
|
-
"version": "0.129.
|
|
3
|
+
"version": "0.129.21",
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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)
|
|
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)
|
|
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)
|
|
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,
|
package/src/spawn/runtime.ts
CHANGED
|
@@ -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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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) {
|
package/src/spawn/sessions.ts
CHANGED
|
@@ -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;
|
package/src/spawn/spawn-agent.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|