agent-relay-orchestrator 0.11.4 → 0.11.6

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.4",
3
+ "version": "0.11.6",
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.3"
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,
@@ -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;