agent-relay-orchestrator 0.129.6 → 0.129.7

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.6",
3
+ "version": "0.129.7",
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.4",
20
- "agent-relay-sdk": "0.2.125",
20
+ "agent-relay-sdk": "0.2.126",
21
21
  "callmux": "0.23.0"
22
22
  },
23
23
  "devDependencies": {
package/src/config.ts CHANGED
@@ -53,8 +53,17 @@ export function providerHomeRootFromEnv(): string {
53
53
  return process.env.AGENT_RELAY_PROVIDER_HOME_ROOT || join(homedir(), ".agent-relay", "provider-homes");
54
54
  }
55
55
 
56
+ // An ephemeral/test instance advertises AGENT_RELAY_EPHEMERAL=1 (see
57
+ // src/execution-mode.ts). Mirrored here so the orchestrator package stays
58
+ // dependency-free of the server package.
59
+ export function isEphemeralMode(): boolean {
60
+ return process.env.AGENT_RELAY_EPHEMERAL === "1";
61
+ }
62
+
56
63
  export function disableSystemdSupervisor(): boolean {
57
- return process.env.AGENT_RELAY_DISABLE_SYSTEMD_SUPERVISOR === "1";
64
+ // Force process (never systemd) supervision in ephemeral mode so an ephemeral
65
+ // boot can never create/adopt host `agent-relay-*` systemd units (#1280).
66
+ return process.env.AGENT_RELAY_DISABLE_SYSTEMD_SUPERVISOR === "1" || isEphemeralMode();
58
67
  }
59
68
 
60
69
  export function forceSystemdSupervisor(): boolean {
package/src/relay.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import type { OrchestratorConfig } from "./config";
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
2
4
  import type { ProviderProbeCache } from "./provider-probe";
3
5
  import { detectSelfSupervision } from "./self-supervision";
4
6
  import { GIT_SHA, ORCHESTRATOR_PROTOCOL_VERSION, VERSION, runtimeMetadata } from "./version";
@@ -83,6 +85,7 @@ export function buildRegistrationMeta(
83
85
  pid = process.pid,
84
86
  ): Record<string, unknown> {
85
87
  const supervision = detectSelfSupervision();
88
+ const artifactSha = supervision.runtimePrefix ? readArtifactSha(supervision.runtimePrefix) : undefined;
86
89
  return {
87
90
  ...runtime,
88
91
  pid,
@@ -90,13 +93,27 @@ export function buildRegistrationMeta(
90
93
  startedAt: now(),
91
94
  version: VERSION,
92
95
  protocolVersion: ORCHESTRATOR_PROTOCOL_VERSION,
93
- gitSha: GIT_SHA,
96
+ gitSha: artifactSha ?? GIT_SHA,
94
97
  supervisor: supervision.supervisor,
95
98
  ...(supervision.selfUnit ? { selfUnit: supervision.selfUnit } : {}),
96
99
  ...(supervision.runtimePrefix ? { runtimePrefix: supervision.runtimePrefix } : {}),
97
100
  };
98
101
  }
99
102
 
103
+ function readArtifactSha(runtimePrefix: string): string | undefined {
104
+ try {
105
+ const sha = readFileSync(join(runtimePrefix, ".agent-relay-artifact-sha"), "utf8").trim();
106
+ return /^[0-9a-f]{40}$/i.test(sha) ? sha : undefined;
107
+ } catch {
108
+ return undefined;
109
+ }
110
+ }
111
+
112
+ function reportedGitSha(): string | undefined {
113
+ const runtimePrefix = detectSelfSupervision().runtimePrefix;
114
+ return (runtimePrefix ? readArtifactSha(runtimePrefix) : undefined) ?? GIT_SHA;
115
+ }
116
+
100
117
  export function createRelayClient(config: OrchestratorConfig, probeCache: ProviderProbeCache): RelayClient {
101
118
  const agentId = `orchestrator-${config.id}`;
102
119
  let heartbeatTimer: Timer | null = null;
@@ -132,7 +149,7 @@ export function createRelayClient(config: OrchestratorConfig, probeCache: Provid
132
149
  capabilities: runtime.capabilities,
133
150
  version: VERSION,
134
151
  protocolVersion: ORCHESTRATOR_PROTOCOL_VERSION,
135
- gitSha: GIT_SHA,
152
+ gitSha: reportedGitSha(),
136
153
  meta: buildRegistrationMeta(config, runtime),
137
154
  });
138
155
  if (!res.ok) {
@@ -165,7 +182,7 @@ export function createRelayClient(config: OrchestratorConfig, probeCache: Provid
165
182
  capabilities: runtime.capabilities,
166
183
  version: VERSION,
167
184
  protocolVersion: ORCHESTRATOR_PROTOCOL_VERSION,
168
- gitSha: GIT_SHA,
185
+ gitSha: reportedGitSha(),
169
186
  providers: providerSnapshot.providers,
170
187
  providerStatus: providerSnapshot.providerStatus,
171
188
  providerCatalog: providerSnapshot.providerCatalog,
@@ -2,7 +2,9 @@ import { join } from "node:path";
2
2
  import { existsSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { getAllManifests } from "agent-relay-providers";
5
+ import { gitCheckoutArtifactPaths, prepareGitCheckoutArtifact, rollbackRuntimeSymlink, swapRuntimeSymlink, type GitCheckoutArtifact } from "agent-relay-sdk/git-checkout-artifact";
5
6
  import { runInstallWithRetry } from "agent-relay-sdk";
7
+ import { shellQuote } from "agent-relay-sdk/shell-utils";
6
8
  import type { OrchestratorConfig } from "./config";
7
9
  import type { RelayClient, RelayCommand } from "./relay";
8
10
  import { detectSelfSupervision, type SelfSupervision } from "./self-supervision";
@@ -22,14 +24,19 @@ interface SelfUpgradeOptions {
22
24
  }
23
25
 
24
26
  export interface SelfUpgradeRunner {
25
- run(cmd: string[]): Promise<{ exitCode: number; stdout: string; stderr: string }>;
27
+ run(cmd: string[], cwd?: string): Promise<{ exitCode: number; stdout: string; stderr: string }>;
26
28
  commandExists(name: string): boolean;
27
29
  fileExists?(path: string): boolean;
28
30
  }
29
31
 
32
+ function localReadinessUrl(apiPort: number | undefined = 4860): string {
33
+ const port = Number.isInteger(apiPort) && apiPort! > 0 && apiPort! <= 65_535 ? apiPort : 4860;
34
+ return `http://127.0.0.1:${port}`;
35
+ }
36
+
30
37
  const defaultRunner: SelfUpgradeRunner = {
31
- async run(cmd) {
32
- const proc = Bun.spawn({ cmd, stdout: "pipe", stderr: "pipe" });
38
+ async run(cmd, cwd) {
39
+ const proc = Bun.spawn({ cmd, cwd, stdout: "pipe", stderr: "pipe" });
33
40
  const [stdout, stderr] = await Promise.all([
34
41
  new Response(proc.stdout).text(),
35
42
  new Response(proc.stderr).text(),
@@ -58,6 +65,7 @@ interface SelfUpgradePlan {
58
65
  restartCmd: string[];
59
66
  /** restart runs decoupled from this process's cgroup (transient unit) */
60
67
  restartDetached: boolean;
68
+ artifact?: GitCheckoutArtifact;
61
69
  }
62
70
 
63
71
  /**
@@ -69,18 +77,29 @@ export function planSelfUpgrade(
69
77
  params: Record<string, unknown>,
70
78
  supervision = detectSelfSupervision(),
71
79
  runner: SelfUpgradeRunner = defaultRunner,
80
+ readinessUrl = localReadinessUrl(),
72
81
  ): SelfUpgradePlan {
73
82
  const targetVersion = typeof params.targetVersion === "string" ? params.targetVersion.trim() : "";
74
83
  if (!SEMVER_RE.test(targetVersion)) {
75
84
  throw new Error(`invalid targetVersion "${targetVersion}" (expected x.y.z)`);
76
85
  }
77
86
  const providers = normalizeProviders(params.providers);
87
+ const mode = params.mode === "git-checkout" ? "git-checkout" : "npm-reuse";
78
88
 
79
89
  if ((supervision.supervisor !== "systemd" && supervision.supervisor !== "launchd") || !supervision.selfUnit) {
80
90
  throw new Error("orchestrator is not under systemd or launchd; remote self-upgrade requires a managed service");
81
91
  }
82
92
  const unit = supervision.selfUnit;
83
- const installCmd = buildInstallCommand(targetVersion, providers, supervision, runner);
93
+ const installCmd = mode === "git-checkout"
94
+ ? []
95
+ : buildInstallCommand(targetVersion, providers, supervision, runner);
96
+ const artifact = mode === "git-checkout"
97
+ ? gitCheckoutArtifactPaths(
98
+ homedir(),
99
+ requiredCommitSha(params.commitSha),
100
+ requiredRepoUrl(params.repoUrl),
101
+ )
102
+ : undefined;
84
103
 
85
104
  let restartDetached: boolean;
86
105
  let restartCmd: string[];
@@ -89,19 +108,29 @@ export function planSelfUpgrade(
89
108
  const uid = process.getuid?.() ?? 501;
90
109
  // Shell-background the kickstart so it survives our process teardown when
91
110
  // launchd kills us. The shell exits immediately; launchd handles the restart.
92
- restartCmd = ["/bin/sh", "-c", `launchctl kickstart -kp gui/${uid}/${unit} &`];
111
+ const artifactGuard = artifact
112
+ ? launchdArtifactRestartGuard(uid, unit, supervision.runtimePrefix ?? join(homedir(), ".agent-relay", "runtime"), readinessUrl)
113
+ : `launchctl kickstart -kp gui/${uid}/${unit}`;
114
+ restartCmd = ["/bin/sh", "-c", `${artifactGuard} &`];
93
115
  restartDetached = true;
94
116
  } else {
95
117
  // Decouple the restart from this orchestrator's own cgroup: restarting our unit
96
118
  // SIGTERMs us, and a child in our cgroup would be killed mid-restart. systemd-run
97
119
  // schedules it as an independent transient unit that survives our teardown.
98
120
  restartDetached = runner.commandExists("systemd-run");
121
+ const artifactGuard = artifact
122
+ ? artifactRestartGuard(unit, supervision.runtimePrefix ?? join(homedir(), ".agent-relay", "runtime"), readinessUrl)
123
+ : undefined;
99
124
  restartCmd = restartDetached
100
- ? ["systemd-run", "--user", "--collect", "--description", "agent-relay orchestrator self-upgrade restart", "systemctl", "--user", "restart", unit]
101
- : ["setsid", "systemctl", "--user", "restart", unit];
125
+ ? artifactGuard
126
+ ? ["systemd-run", "--user", "--collect", "--description", "agent-relay git-checkout self-upgrade restart", "/bin/sh", "-c", artifactGuard]
127
+ : ["systemd-run", "--user", "--collect", "--description", "agent-relay orchestrator self-upgrade restart", "systemctl", "--user", "restart", unit]
128
+ : artifactGuard
129
+ ? ["setsid", "/bin/sh", "-c", artifactGuard]
130
+ : ["setsid", "systemctl", "--user", "restart", unit];
102
131
  }
103
132
 
104
- return { targetVersion, providers, unit, runtimePrefix: supervision.runtimePrefix, installCmd, restartCmd, restartDetached };
133
+ return { targetVersion, providers, unit, runtimePrefix: supervision.runtimePrefix, installCmd, restartCmd, restartDetached, artifact };
105
134
  }
106
135
 
107
136
  /**
@@ -112,19 +141,43 @@ export function planSelfUpgrade(
112
141
  */
113
142
  export async function handleSelfUpgrade(
114
143
  command: RelayCommand,
115
- _config: OrchestratorConfig,
144
+ config: OrchestratorConfig,
116
145
  relay: RelayClient,
117
146
  runner: SelfUpgradeRunner = defaultRunner,
118
147
  opts: SelfUpgradeOptions = {},
119
148
  ): Promise<void> {
120
- const plan = planSelfUpgrade(command.params, opts.supervision ?? detectSelfSupervision(), runner);
149
+ const plan = planSelfUpgrade(command.params, opts.supervision ?? detectSelfSupervision(), runner, localReadinessUrl(config.apiPort));
121
150
  await relay.updateCommand(command.id, "running", {
122
151
  phase: "installing",
123
152
  targetVersion: plan.targetVersion,
124
153
  providers: plan.providers,
125
154
  unit: plan.unit,
155
+ ...(plan.artifact ? { mode: "git-checkout", commitSha: plan.artifact.sha } : {}),
126
156
  });
127
157
 
158
+ if (plan.artifact) {
159
+ await prepareGitCheckoutArtifact(plan.artifact, runner.run.bind(runner));
160
+ const runtimePath = plan.runtimePrefix ?? join(homedir(), ".agent-relay", "runtime");
161
+ const swap = swapRuntimeSymlink(runtimePath, plan.artifact.checkoutDir);
162
+ try {
163
+ const restart = await runner.run(plan.restartCmd);
164
+ if (restart.exitCode !== 0) throw new Error(`restart failed (exit ${restart.exitCode}): ${(restart.stderr || restart.stdout).trim().slice(-500)}`);
165
+ } catch (error) {
166
+ rollbackRuntimeSymlink(swap);
167
+ throw error;
168
+ }
169
+ await relay.updateCommand(command.id, "running", {
170
+ phase: "restart-pending",
171
+ targetVersion: plan.targetVersion,
172
+ unit: plan.unit,
173
+ restartDetached: plan.restartDetached,
174
+ mode: "git-checkout",
175
+ commitSha: plan.artifact.sha,
176
+ });
177
+ console.error(`[orchestrator] git-checkout self-upgrade to ${plan.artifact.sha} prepared; restart dispatched for ${plan.unit}`);
178
+ return;
179
+ }
180
+
128
181
  const install = await runInstallWithRetry(plan.installCmd, runner.run.bind(runner), {
129
182
  sleep: opts.sleep,
130
183
  retries: opts.installRetries,
@@ -160,6 +213,57 @@ export async function handleSelfUpgrade(
160
213
  console.error(`[orchestrator] self-upgrade to ${plan.targetVersion} installed; restart dispatched for ${plan.unit}`);
161
214
  }
162
215
 
216
+ function requiredCommitSha(value: unknown): string {
217
+ if (typeof value !== "string" || !/^[0-9a-f]{40}$/i.test(value.trim())) throw new Error("git-checkout mode requires a 40-character commitSha");
218
+ return value.trim();
219
+ }
220
+
221
+ function requiredRepoUrl(value: unknown): string {
222
+ if (typeof value !== "string" || !value.trim()) throw new Error("git-checkout mode requires repoUrl");
223
+ return value.trim();
224
+ }
225
+
226
+ /** Detached supervisor-side guard: active service keeps the new pointer; a failed restart restores it. */
227
+ function readinessGuard(baseUrl: string): string {
228
+ const pattern = '"status"[[:space:]]*:[[:space:]]*"ok"';
229
+ return [
230
+ "ready=0",
231
+ "for attempt in $(seq 1 30); do",
232
+ `if curl -fsS --max-time 5 ${shellQuote(`${baseUrl}/api/health`)} | grep -Eq ${shellQuote(pattern)}; then ready=$((ready + 1)); else ready=0; fi`,
233
+ '[ "$ready" -ge 2 ] && break',
234
+ "sleep 2",
235
+ "done",
236
+ '[ "$ready" -ge 2 ]',
237
+ ].join("; ");
238
+ }
239
+
240
+ function artifactRestartGuard(unit: string, runtimePath: string, readinessUrl: string): string {
241
+ const previous = `${runtimePath}.previous`;
242
+ const legacy = `${runtimePath}.legacy`;
243
+ return [
244
+ `systemctl --user restart ${shellQuote(unit)}`,
245
+ // On success drop the rollback pointer AND the relocated original runtime (.legacy),
246
+ // which swapRuntimeSymlink parks aside only until the new runtime proves healthy.
247
+ // On failure mv -T is a single rename over the live symlink — .previous is always a
248
+ // symlink now, so a prior real DIRECTORY rolls back without ENOTDIR (#1287 Bug B) —
249
+ // and a rm-then-mv rollback would have a fatal no-runtime-pointer window.
250
+ `if ${readinessGuard(readinessUrl)}; then rm -rf ${shellQuote(previous)} ${shellQuote(legacy)}; else mv -fT ${shellQuote(previous)} ${shellQuote(runtimePath)}; systemctl --user restart ${shellQuote(unit)}; exit 1; fi`,
251
+ ].join("; ");
252
+ }
253
+
254
+ function launchdArtifactRestartGuard(uid: number, unit: string, runtimePath: string, readinessUrl: string): string {
255
+ const previous = `${runtimePath}.previous`;
256
+ const legacy = `${runtimePath}.legacy`;
257
+ const service = `gui/${uid}/${unit}`;
258
+ return [
259
+ `launchctl kickstart -kp ${shellQuote(service)}`,
260
+ // BSD mv follows a destination symlink to a directory unless -h is present. .previous
261
+ // is always a symlink, so -fh replaces runtimePath itself and preserves the atomic
262
+ // rollback. Do not unlink runtimePath first or a guard death can strand launchd.
263
+ `if ${readinessGuard(readinessUrl)}; then rm -rf ${shellQuote(previous)} ${shellQuote(legacy)}; else mv -fh ${shellQuote(previous)} ${shellQuote(runtimePath)}; launchctl kickstart -kp ${shellQuote(service)}; exit 1; fi`,
264
+ ].join("; ");
265
+ }
266
+
163
267
  function normalizeProviders(value: unknown): string[] {
164
268
  const list = Array.isArray(value)
165
269
  ? value.filter((v): v is string => typeof v === "string").map((v) => v.trim()).filter(Boolean)