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 +2 -2
- package/src/config.ts +10 -1
- package/src/relay.ts +20 -3
- package/src/self-upgrade.ts +114 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-orchestrator",
|
|
3
|
-
"version": "0.129.
|
|
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.
|
|
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
|
-
|
|
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:
|
|
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:
|
|
185
|
+
gitSha: reportedGitSha(),
|
|
169
186
|
providers: providerSnapshot.providers,
|
|
170
187
|
providerStatus: providerSnapshot.providerStatus,
|
|
171
188
|
providerCatalog: providerSnapshot.providerCatalog,
|
package/src/self-upgrade.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
?
|
|
101
|
-
|
|
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
|
-
|
|
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)
|