agent-relay-orchestrator 0.11.3 → 0.11.5

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.11.3",
3
+ "version": "0.11.5",
4
4
  "description": "Agent Relay orchestrator — manages agent lifecycle across hosts",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,7 +16,7 @@
16
16
  "test": "bun test"
17
17
  },
18
18
  "dependencies": {
19
- "agent-relay-sdk": "0.2.2"
19
+ "agent-relay-sdk": "0.2.4"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@types/bun": "latest",
package/src/control.ts CHANGED
@@ -2,7 +2,7 @@ import type { OrchestratorConfig } from "./config";
2
2
  import type { ManagedAgentReport, RelayClient, RelayCommand } from "./relay";
3
3
  import { handleSelfUpgrade } from "./self-upgrade";
4
4
  import { spawnAgent, stopSession, type SpawnOptions } from "./spawn";
5
- import { cleanupWorkspace, mergeWorkspace, reconcileWorkspace } from "./workspace-probe";
5
+ import { cleanupWorkspace, mergeWorkspace, pruneWorktrees, reconcileWorkspace } from "./workspace-probe";
6
6
 
7
7
  interface ControlHandler {
8
8
  handleCommand(command: RelayCommand): Promise<boolean>;
@@ -103,6 +103,11 @@ export function createControlHandler(
103
103
  prBody: typeof command.params.prBody === "string" ? command.params.prBody : undefined,
104
104
  });
105
105
  await relay.updateCommand(command.id, "succeeded", result as unknown as Record<string, unknown>);
106
+ } else if (command.type === "workspace.prune") {
107
+ const result = pruneWorktrees({
108
+ repoRoot: typeof command.params.repoRoot === "string" ? command.params.repoRoot : undefined,
109
+ });
110
+ await relay.updateCommand(command.id, "succeeded", result);
106
111
  } else if (command.type === "orchestrator.upgrade") {
107
112
  // Install + restart ourselves. Intentionally NOT marked "succeeded": the
108
113
  // relay settles it by reconciling the version we report after we restart,
@@ -163,10 +168,10 @@ export function spawnOptionsFromControl(ctrl: Record<string, any>, config: Orche
163
168
  profile: typeof ctrl.profile === "string" ? ctrl.profile : undefined,
164
169
  workspaceMode: workspaceMode(ctrl.workspaceMode),
165
170
  agentProfile: isRecord(ctrl.agentProfile) ? ctrl.agentProfile : undefined,
166
- label: ctrl.label,
171
+ label: typeof ctrl.label === "string" ? ctrl.label : undefined,
167
172
  agentId: typeof ctrl.agentId === "string" ? ctrl.agentId : undefined,
168
- approvalMode: ctrl.approvalMode || "guarded",
169
- prompt: ctrl.prompt,
173
+ approvalMode: typeof ctrl.approvalMode === "string" ? ctrl.approvalMode : "guarded",
174
+ prompt: typeof ctrl.prompt === "string" ? ctrl.prompt : undefined,
170
175
  systemPromptAppend: typeof ctrl.systemPromptAppend === "string" ? ctrl.systemPromptAppend : undefined,
171
176
  tags: stringArray(ctrl.tags),
172
177
  capabilities: stringArray(ctrl.capabilities),
@@ -183,6 +188,7 @@ export function spawnOptionsFromRestartSource(restartSource: Record<string, any>
183
188
  return {
184
189
  provider: restartSource.provider === "codex" ? "codex" : "claude",
185
190
  cwd: typeof restartSource.cwd === "string" ? restartSource.cwd : config.baseDir,
191
+ rig: typeof restartSource.rig === "string" ? restartSource.rig : undefined,
186
192
  model: modelFromControl(restartSource),
187
193
  effort: typeof restartSource.effort === "string" ? restartSource.effort : undefined,
188
194
  profile: typeof restartSource.profile === "string" ? restartSource.profile : undefined,
package/src/index.ts CHANGED
@@ -17,12 +17,19 @@ if (args[0] === "init") {
17
17
  process.exit(0);
18
18
  }
19
19
 
20
+ if (args[0] === "--version" || args[0] === "-v") {
21
+ const { VERSION } = await import("./version");
22
+ console.log(VERSION);
23
+ process.exit(0);
24
+ }
25
+
20
26
  if (args[0] === "--help" || args[0] === "-h") {
21
27
  console.log(`agent-relay-orchestrator — manage agent lifecycle across hosts
22
28
 
23
29
  Usage:
24
- agent-relay-orchestrator Start the orchestrator daemon
25
- agent-relay-orchestrator init Create default config file
30
+ agent-relay-orchestrator Start the orchestrator daemon
31
+ agent-relay-orchestrator init Create default config file
32
+ agent-relay-orchestrator --version Print version and exit
26
33
 
27
34
  Environment:
28
35
  AGENT_RELAY_URL Relay server URL (default: http://localhost:4850)
@@ -1,8 +1,7 @@
1
- import { existsSync } from "node:fs";
2
1
  import { join } from "node:path";
3
2
  import type { OrchestratorConfig } from "./config";
4
3
  import type { RelayClient, RelayCommand } from "./relay";
5
- import { detectSelfSupervision } from "./self-supervision";
4
+ import { detectSelfSupervision, type SelfSupervision } from "./self-supervision";
6
5
 
7
6
  const VALID_PROVIDERS = new Set(["auto", "all", "codex", "claude", "orchestrator"]);
8
7
  const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
@@ -36,7 +35,6 @@ export interface SelfUpgradePlan {
36
35
  providers: string[];
37
36
  unit: string;
38
37
  runtimePrefix?: string;
39
- binary: string;
40
38
  installCmd: string[];
41
39
  restartCmd: string[];
42
40
  /** restart runs decoupled from this process's cgroup (transient unit) */
@@ -63,16 +61,7 @@ export function planSelfUpgrade(
63
61
  throw new Error("orchestrator is not under systemd or launchd; remote self-upgrade requires a managed service");
64
62
  }
65
63
  const unit = supervision.selfUnit;
66
- const binary = resolveBinary(supervision.runtimePrefix);
67
-
68
- const installCmd = [
69
- binary, "upgrade",
70
- "--version", targetVersion,
71
- "--providers", providers.join(","),
72
- "--no-restart",
73
- "--yes",
74
- ];
75
- if (supervision.runtimePrefix) installCmd.push("--runtime-prefix", supervision.runtimePrefix);
64
+ const installCmd = buildInstallCommand(targetVersion, providers, supervision, runner);
76
65
 
77
66
  let restartDetached: boolean;
78
67
  let restartCmd: string[];
@@ -93,7 +82,7 @@ export function planSelfUpgrade(
93
82
  : ["setsid", "systemctl", "--user", "restart", unit];
94
83
  }
95
84
 
96
- return { targetVersion, providers, unit, runtimePrefix: supervision.runtimePrefix, binary, installCmd, restartCmd, restartDetached };
85
+ return { targetVersion, providers, unit, runtimePrefix: supervision.runtimePrefix, installCmd, restartCmd, restartDetached };
97
86
  }
98
87
 
99
88
  /**
@@ -145,10 +134,56 @@ function normalizeProviders(value: unknown): string[] {
145
134
  return [...new Set(providers)];
146
135
  }
147
136
 
148
- function resolveBinary(runtimePrefix?: string): string {
149
- if (runtimePrefix) {
150
- const local = join(runtimePrefix, "node_modules", ".bin", "agent-relay");
151
- if (existsSync(local)) return local;
137
+ function buildInstallCommand(
138
+ targetVersion: string,
139
+ providers: string[],
140
+ supervision: SelfSupervision,
141
+ runner: SelfUpgradeRunner,
142
+ ): string[] {
143
+ if (supervision.runtimePrefix) {
144
+ if (!runner.commandExists("npm")) {
145
+ throw new Error("npm is required for runtime-prefix self-upgrade");
146
+ }
147
+ return [
148
+ "npm",
149
+ "install",
150
+ "--prefix",
151
+ supervision.runtimePrefix,
152
+ ...packagesForProviders(targetVersion, providers),
153
+ ];
154
+ }
155
+
156
+ if (runner.commandExists("agent-relay")) {
157
+ return [
158
+ "agent-relay", "upgrade",
159
+ "--version", targetVersion,
160
+ "--providers", providers.join(","),
161
+ "--no-restart",
162
+ "--yes",
163
+ ];
164
+ }
165
+
166
+ throw new Error("agent-relay CLI is not available and no runtime prefix was detected; self-upgrade cannot install packages");
167
+ }
168
+
169
+ function packagesForProviders(targetVersion: string, providers: string[]): string[] {
170
+ const selected = new Set(providers);
171
+ const packages = new Set<string>();
172
+ const includeAll = selected.has("all");
173
+ const includeAuto = selected.has("auto");
174
+
175
+ if (includeAll || includeAuto || selected.has("orchestrator")) {
176
+ packages.add("agent-relay-orchestrator");
152
177
  }
153
- return "agent-relay";
178
+ if (includeAll || selected.has("codex")) {
179
+ packages.add("agent-relay-runner");
180
+ packages.add("agent-relay-codex");
181
+ }
182
+ if (includeAll || selected.has("claude")) {
183
+ packages.add("agent-relay-runner");
184
+ packages.add("agent-relay-plugin");
185
+ }
186
+
187
+ if (packages.size === 0) packages.add("agent-relay-orchestrator");
188
+ return [...packages].map((pkg) => `${pkg}@${targetVersion}`);
154
189
  }
package/src/version.ts CHANGED
@@ -21,6 +21,7 @@ export const CONTRACTS = {
21
21
  export const RUNTIME_CAPABILITIES = {
22
22
  directoryBrowse: true,
23
23
  relayCommandBus: true,
24
+ selfUpgrade: true,
24
25
  authenticatedLogProxy: true,
25
26
  artifactProxy: true,
26
27
  managedAgentReports: true,
@@ -1,8 +1,8 @@
1
- import { existsSync, mkdirSync, statSync } from "node:fs";
1
+ import { existsSync, lstatSync, mkdirSync, readdirSync, statSync, symlinkSync, type Dirent } from "node:fs";
2
2
  import { createHash } from "node:crypto";
3
3
  import { homedir } from "node:os";
4
4
  import { basename, join, resolve } from "node:path";
5
- import type { WorkspaceDiff, WorkspaceDiffFile, WorkspaceGitState, WorkspaceMergePreview, WorkspaceMergeResult, WorkspaceMetadata, WorkspaceMode, WorkspaceProbe, WorkspaceProbeWorktree, WorkspaceStatus } from "agent-relay-sdk";
5
+ import type { WorkspaceDepsProvision, WorkspaceDiff, WorkspaceDiffFile, WorkspaceGitState, WorkspaceMergePreview, WorkspaceMergeResult, WorkspaceMetadata, WorkspaceMode, WorkspaceProbe, WorkspaceProbeWorktree, WorkspaceStatus } from "agent-relay-sdk";
6
6
 
7
7
  const MAX_DIFF_PATCH_BYTES = 200_000;
8
8
 
@@ -129,6 +129,11 @@ export async function resolveSpawnWorkspace(input: WorkspaceResolutionInput): Pr
129
129
  mkdirSync(join(worktreePath, ".."), { recursive: true });
130
130
  requireGit(["worktree", "add", "-b", branch, worktreePath, baseSha], repoRoot);
131
131
 
132
+ // A fresh worktree has no node_modules (git worktrees don't share
133
+ // gitignored/untracked files). Provision deps before handing cwd to the
134
+ // runner so the agent can typecheck/test/build without manual setup (#159).
135
+ const deps = provisionWorkspaceDeps(repoRoot, worktreePath);
136
+
132
137
  return {
133
138
  cwd: worktreePath,
134
139
  workspace: {
@@ -142,11 +147,147 @@ export async function resolveSpawnWorkspace(input: WorkspaceResolutionInput): Pr
142
147
  baseRef: probe.branch,
143
148
  baseSha,
144
149
  status: "active",
150
+ deps,
145
151
  probe,
146
152
  },
147
153
  };
148
154
  }
149
155
 
156
+ const NODE_MODULES_SCAN_DEPTH = 2;
157
+
158
+ const LOCKFILES: Array<{ file: string; pm: string; install: string[] }> = [
159
+ { file: "bun.lockb", pm: "bun", install: ["bun", "install"] },
160
+ { file: "bun.lock", pm: "bun", install: ["bun", "install"] },
161
+ { file: "pnpm-lock.yaml", pm: "pnpm", install: ["pnpm", "install"] },
162
+ { file: "package-lock.json", pm: "npm", install: ["npm", "install"] },
163
+ { file: "yarn.lock", pm: "yarn", install: ["yarn", "install"] },
164
+ ];
165
+
166
+ function pathExists(p: string): boolean {
167
+ try {
168
+ lstatSync(p);
169
+ return true;
170
+ } catch {
171
+ return false;
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Depth-limited walk collecting relative dirs (from root) matching `match`.
177
+ * Never descends into node_modules or dot-dirs (no point, and avoids walking
178
+ * into sibling worktrees under .agent-relay/).
179
+ */
180
+ function scanProjectDirs(root: string, depth: number, match: (dir: string) => boolean): string[] {
181
+ const found: string[] = [];
182
+ const walk = (dir: string, rel: string, remaining: number): void => {
183
+ if (match(dir)) found.push(rel);
184
+ if (remaining <= 0) return;
185
+ let entries: Dirent[];
186
+ try {
187
+ entries = readdirSync(dir, { withFileTypes: true });
188
+ } catch {
189
+ return;
190
+ }
191
+ for (const entry of entries) {
192
+ if (!entry.isDirectory()) continue;
193
+ if (entry.name === "node_modules" || entry.name.startsWith(".")) continue;
194
+ walk(join(dir, entry.name), rel ? join(rel, entry.name) : entry.name, remaining - 1);
195
+ }
196
+ };
197
+ walk(root, "", depth);
198
+ return found;
199
+ }
200
+
201
+ function detectPackageManager(dir: string): { pm: string; install: string[] } | undefined {
202
+ for (const lock of LOCKFILES) {
203
+ if (existsSync(join(dir, lock.file))) return { pm: lock.pm, install: lock.install };
204
+ }
205
+ return undefined;
206
+ }
207
+
208
+ /** Symlink the source checkout's node_modules dirs into the worktree. Fast and
209
+ * exact (matches the deps the source actually runs with). Best-effort per dir. */
210
+ function symlinkNodeModules(repoRoot: string, worktreePath: string): string[] {
211
+ const dirs = scanProjectDirs(repoRoot, NODE_MODULES_SCAN_DEPTH, (dir) => existsSync(join(dir, "node_modules")));
212
+ const linked: string[] = [];
213
+ for (const rel of dirs) {
214
+ const source = join(repoRoot, rel, "node_modules");
215
+ const targetParent = join(worktreePath, rel);
216
+ const target = join(targetParent, "node_modules");
217
+ if (pathExists(target)) continue;
218
+ try {
219
+ mkdirSync(targetParent, { recursive: true });
220
+ symlinkSync(source, target, "dir");
221
+ linked.push(rel || ".");
222
+ } catch {
223
+ // best-effort: a single failed link shouldn't block the spawn
224
+ }
225
+ }
226
+ return linked;
227
+ }
228
+
229
+ /** Run a package install in each dir that owns a lockfile. Root install covers
230
+ * the package manager's workspace members; standalone sub-projects with their
231
+ * own lockfile (e.g. dashboard) get a separate install. */
232
+ function installNodeModules(worktreePath: string): { installed: string[]; packageManager?: string; error?: string } {
233
+ const dirs = scanProjectDirs(worktreePath, NODE_MODULES_SCAN_DEPTH, (dir) => detectPackageManager(dir) !== undefined);
234
+ const installed: string[] = [];
235
+ let packageManager: string | undefined;
236
+ let error: string | undefined;
237
+ for (const rel of dirs) {
238
+ const dir = join(worktreePath, rel);
239
+ const pm = detectPackageManager(dir);
240
+ if (!pm) continue;
241
+ packageManager = pm.pm;
242
+ const proc = Bun.spawnSync(pm.install, { cwd: dir, stdin: "ignore", stdout: "ignore", stderr: "pipe", env: process.env });
243
+ if (proc.exitCode === 0) {
244
+ installed.push(rel || ".");
245
+ } else {
246
+ const detail = `${rel || "."}: ${proc.stderr.toString().trim().slice(0, 200)}`;
247
+ error = error ? `${error}; ${detail}` : detail;
248
+ }
249
+ }
250
+ return { installed, packageManager, error };
251
+ }
252
+
253
+ /**
254
+ * Provision node_modules into a freshly created isolated worktree (#159).
255
+ * Default: symlink the source checkout's node_modules (instant, matches what
256
+ * the source runs with). Falls back to a fresh install when the source has no
257
+ * node_modules to borrow. Set AGENT_RELAY_WORKSPACE_DEPS=install to always
258
+ * install (full isolation, no shared cache), or =none to skip entirely.
259
+ * Never throws — provisioning failure must not block the spawn.
260
+ */
261
+ export function provisionWorkspaceDeps(repoRoot: string, worktreePath: string): WorkspaceDepsProvision {
262
+ const requested = (process.env.AGENT_RELAY_WORKSPACE_DEPS || "symlink").toLowerCase();
263
+ if (requested === "none") return { mode: "none" };
264
+
265
+ try {
266
+ if (requested !== "install") {
267
+ const linked = symlinkNodeModules(repoRoot, worktreePath);
268
+ if (linked.length > 0) return { mode: "symlink", linked };
269
+ // Source has no installed deps to borrow — install fresh instead.
270
+ }
271
+ const result = installNodeModules(worktreePath);
272
+ if (result.installed.length === 0 && !result.error) return { mode: "none" };
273
+ return {
274
+ mode: "install",
275
+ installed: result.installed,
276
+ ...(result.packageManager ? { packageManager: result.packageManager } : {}),
277
+ ...(result.error ? { error: result.error } : {}),
278
+ };
279
+ } catch (error) {
280
+ return { mode: "none", error: error instanceof Error ? error.message : String(error) };
281
+ }
282
+ }
283
+
284
+ export function pruneWorktrees(input: { repoRoot?: string }): { repoRoot: string; pruned: boolean; output?: string; error?: string } {
285
+ const repo = resolve(input.repoRoot ?? ".");
286
+ const result = git(["worktree", "prune"], repo);
287
+ if (!result.ok) return { repoRoot: repo, pruned: false, error: result.stderr || "git worktree prune failed" };
288
+ return { repoRoot: repo, pruned: true, output: result.stdout.trim() || undefined };
289
+ }
290
+
150
291
  export function cleanupWorkspace(workspace: { repoRoot?: string; worktreePath?: string; id?: string; branch?: string; deleteBranch?: boolean }): { workspaceId?: string; removed: boolean; worktreePath?: string; branchDeleted?: boolean } {
151
292
  if (!workspace.worktreePath) throw new Error("worktreePath required");
152
293
  const path = resolve(workspace.worktreePath);
@@ -201,11 +342,39 @@ export function workspaceGitState(input: { worktreePath?: string; baseRef?: stri
201
342
  if (Number.isFinite(behind)) state.behind = behind;
202
343
  if (Number.isFinite(ahead)) state.ahead = ahead;
203
344
  }
345
+ if ((state.ahead ?? 0) > 0) {
346
+ // A squash or cherry-pick merge re-creates the work as a NEW commit on
347
+ // base, so the branch tip is never an ancestor and raw `ahead` stays
348
+ // positive even though the content has already landed. Discount that:
349
+ // compare against the base branch's upstream when it has one (squash PRs
350
+ // land on the remote), treat an identical tree as fully landed (covers a
351
+ // multi-commit squash), else count only commits whose patch isn't already
352
+ // present in base (git cherry '+'). Staleness of the compare ref can only
353
+ // under-count landings, never invent one — so `landed` is safe to act on.
354
+ const cherryBase = upstreamRef(path, base) ?? base;
355
+ if (git(["diff", "--quiet", cherryBase, "HEAD"], path).ok) {
356
+ state.unmergedAhead = 0;
357
+ } else {
358
+ const cherry = git(["cherry", cherryBase, "HEAD"], path);
359
+ if (cherry.ok) {
360
+ state.unmergedAhead = cherry.stdout ? cherry.stdout.split("\n").filter((line) => line.startsWith("+")).length : 0;
361
+ }
362
+ }
363
+ if (state.unmergedAhead === 0) state.landed = true;
364
+ }
204
365
  }
205
366
 
206
367
  return state;
207
368
  }
208
369
 
370
+ /** Remote-tracking branch a local base tracks (e.g. `main` -> `origin/main`),
371
+ * or undefined when base has no upstream or is a bare sha. Squash PRs land on
372
+ * the remote, so the upstream is the truthful "has this merged?" reference. */
373
+ function upstreamRef(worktreePath: string, base: string): string | undefined {
374
+ const res = git(["rev-parse", "--abbrev-ref", `${base}@{upstream}`], worktreePath);
375
+ return res.ok && res.stdout ? res.stdout : undefined;
376
+ }
377
+
209
378
  function resolveBaseRef(worktreePath: string, baseRef?: string, baseSha?: string): string | undefined {
210
379
  for (const candidate of [baseRef, baseSha]) {
211
380
  if (candidate && git(["rev-parse", "--verify", "--quiet", `${candidate}^{commit}`], worktreePath).ok) {
@@ -232,7 +401,10 @@ export function reconcileWorkspace(workspace: { id?: string; repoRoot?: string;
232
401
  if (gitState.missing) {
233
402
  return { workspaceId: workspace.id, removed: false, status: "cleaned", gitState };
234
403
  }
235
- const empty = gitState.error === undefined && gitState.dirtyCount === 0 && gitState.ahead === 0;
404
+ // Empty = nothing left to preserve: clean tree and either no commits ahead or
405
+ // the work already landed in base via squash/cherry-pick (`landed`). Landing
406
+ // detection can only under-report, so this never deletes unmerged work.
407
+ const empty = gitState.error === undefined && gitState.dirtyCount === 0 && ((gitState.ahead ?? 0) === 0 || gitState.landed === true);
236
408
  if (empty) {
237
409
  cleanupWorkspace({ id: workspace.id, repoRoot: workspace.repoRoot, worktreePath: workspace.worktreePath, branch: workspace.branch });
238
410
  return { workspaceId: workspace.id, removed: true, status: "cleaned", gitState };
@@ -366,10 +538,15 @@ export function previewWorkspaceMerge(input: { worktreePath?: string; baseRef?:
366
538
  if (gitState.missing) return { ...base, missing: true, reason: "worktree no longer exists" };
367
539
  if (gitState.error) return { ...base, error: gitState.error };
368
540
  base.ahead = gitState.ahead;
541
+ base.unmergedAhead = gitState.unmergedAhead;
542
+ base.landed = gitState.landed;
369
543
  base.behind = gitState.behind;
370
544
  base.dirtyCount = gitState.dirtyCount;
371
545
  if ((gitState.dirtyCount ?? 0) > 0) return { ...base, reason: "worktree has uncommitted changes" };
372
- if ((gitState.ahead ?? 0) === 0) return { ...base, reason: "no commits to merge" };
546
+ const effectiveAhead = gitState.landed ? 0 : (gitState.unmergedAhead ?? gitState.ahead ?? 0);
547
+ if (effectiveAhead === 0) {
548
+ return { ...base, reason: gitState.landed ? "already merged into base (squash/cherry-pick)" : "no commits to merge" };
549
+ }
373
550
  if (gitState.baseRef && input.worktreePath) {
374
551
  const conflict = predictConflict(resolve(input.worktreePath), gitState.baseRef);
375
552
  base.conflict = conflict;