agent-relay-orchestrator 0.11.4 → 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 +2 -2
- package/src/control.ts +6 -1
- package/src/workspace-probe.ts +181 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-orchestrator",
|
|
3
|
-
"version": "0.11.
|
|
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.
|
|
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,
|
package/src/workspace-probe.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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;
|