agent-relay-orchestrator 0.32.2 → 0.32.4

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.32.2",
3
+ "version": "0.32.4",
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"
19
+ "agent-relay-sdk": "0.2.20"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@types/bun": "latest",
package/src/git.ts ADDED
@@ -0,0 +1,30 @@
1
+ // Low-level git primitives shared by the workspace probe/merge/cleanup helpers.
2
+ // Extracted from workspace-probe.ts so that giant keeps shrinking (epic #291) and the
3
+ // `git -C` invocation lives in one place.
4
+
5
+ interface GitResult {
6
+ ok: boolean;
7
+ stdout: string;
8
+ stderr: string;
9
+ }
10
+
11
+ /** Run `git -C cwd <args>` and capture trimmed stdout/stderr; never throws. */
12
+ export function git(args: string[], cwd: string): GitResult {
13
+ const proc = Bun.spawnSync(["git", "-C", cwd, ...args], {
14
+ stdin: "ignore",
15
+ stdout: "pipe",
16
+ stderr: "pipe",
17
+ });
18
+ return {
19
+ ok: proc.exitCode === 0,
20
+ stdout: proc.stdout.toString().trim(),
21
+ stderr: proc.stderr.toString().trim(),
22
+ };
23
+ }
24
+
25
+ /** Like {@link git} but throws on a non-zero exit, returning stdout on success. */
26
+ export function requireGit(args: string[], cwd: string): string {
27
+ const result = git(args, cwd);
28
+ if (!result.ok) throw new Error(result.stderr || `git ${args.join(" ")} failed`);
29
+ return result.stdout;
30
+ }
@@ -5,6 +5,7 @@ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:pat
5
5
  import type { WorkspaceDepsProvision, WorkspaceDepsRefreshDir, WorkspaceDepsRefreshResult, WorkspaceDiff, WorkspaceDiffFile, WorkspaceGitState, WorkspaceMergePreview, WorkspaceMergeResult, WorkspaceMetadata, WorkspaceMode, WorkspaceProbe, WorkspaceProbeWorktree, WorkspaceStatus, WorkspaceSymlinkProvision } from "agent-relay-sdk";
6
6
  import { errMessage } from "agent-relay-sdk";
7
7
  import { sanitizeFsName } from "agent-relay-sdk/fs-name";
8
+ import { git, requireGit } from "./git";
8
9
 
9
10
  const MAX_DIFF_PATCH_BYTES = 200_000;
10
11
 
@@ -25,31 +26,6 @@ interface WorkspaceResolution {
25
26
  workspace: WorkspaceMetadata;
26
27
  }
27
28
 
28
- interface GitResult {
29
- ok: boolean;
30
- stdout: string;
31
- stderr: string;
32
- }
33
-
34
- function git(args: string[], cwd: string): GitResult {
35
- const proc = Bun.spawnSync(["git", "-C", cwd, ...args], {
36
- stdin: "ignore",
37
- stdout: "pipe",
38
- stderr: "pipe",
39
- });
40
- return {
41
- ok: proc.exitCode === 0,
42
- stdout: proc.stdout.toString().trim(),
43
- stderr: proc.stderr.toString().trim(),
44
- };
45
- }
46
-
47
- function requireGit(args: string[], cwd: string): string {
48
- const result = git(args, cwd);
49
- if (!result.ok) throw new Error(result.stderr || `git ${args.join(" ")} failed`);
50
- return result.stdout;
51
- }
52
-
53
29
  function shortBranch(ref: string | undefined): string | undefined {
54
30
  if (!ref || ref === "HEAD") return undefined;
55
31
  return ref.startsWith("refs/heads/") ? ref.slice("refs/heads/".length) : ref;
@@ -472,12 +448,34 @@ export function pruneWorktrees(input: { repoRoot?: string }): { repoRoot: string
472
448
  return { repoRoot: repo, pruned: true, output: result.stdout.trim() || undefined };
473
449
  }
474
450
 
451
+ // Repo that actually owns `worktreePath`'s git admin. A CHAINED workspace's recorded
452
+ // repoRoot points at the PARENT worktree (maybe deleted), not the real checkout (#278/#307);
453
+ // every linked worktree shares the main `.git` (--git-common-dir), so derive the owner from
454
+ // the worktree itself, falling back to `fallback` only when it can't be interrogated.
455
+ function owningRepoRoot(worktreePath: string, fallback: string): string {
456
+ const res = git(["rev-parse", "--git-common-dir"], worktreePath);
457
+ if (!res.ok || !res.stdout) return fallback;
458
+ let commonDir = res.stdout;
459
+ if (!isAbsolute(commonDir)) commonDir = resolve(worktreePath, commonDir);
460
+ const root = basename(commonDir) === ".git" ? dirname(commonDir) : commonDir; // .git's parent = repo
461
+ return existsSync(root) ? root : fallback;
462
+ }
463
+
475
464
  export function cleanupWorkspace(workspace: { repoRoot?: string; worktreePath?: string; id?: string; branch?: string; deleteBranch?: boolean; workspacesRoot?: string }): { workspaceId?: string; removed: boolean; worktreePath?: string; branchDeleted?: boolean; containerRemoved?: boolean } {
476
465
  if (!workspace.worktreePath) throw new Error("worktreePath required");
477
466
  const path = resolve(workspace.worktreePath);
478
- const repo = workspace.repoRoot ? resolve(workspace.repoRoot) : path;
467
+ const recordedRepo = workspace.repoRoot ? resolve(workspace.repoRoot) : path;
468
+ // Remove via the REAL owning repo, not the recorded (chained/deleted) repoRoot which
469
+ // would silently no-op and leak the dir while the relay marks it `cleaned` (#307).
470
+ const repo = owningRepoRoot(path, recordedRepo);
479
471
  const result = git(["worktree", "remove", "--force", path], repo);
480
- if (!result.ok) throw new Error(result.stderr || `failed to remove workspace ${path}`);
472
+ // Trust the filesystem, not git's exit code (a remove against the wrong repo "succeeds"
473
+ // touching nothing). If the dir survives, surface failure so the relay leaves the row
474
+ // un-cleaned instead of recording a phantom removal (#307).
475
+ if (existsSync(path)) {
476
+ git(["worktree", "prune"], repo);
477
+ throw new Error(result.stderr || `worktree ${path} still present after \`git worktree remove\` (repo ${repo})`);
478
+ }
481
479
  // Once the worktree is gone the agent/... branch is litter — delete it so
482
480
  // branches don't accumulate. Best-effort: don't fail cleanup if it can't
483
481
  // (e.g. branch already gone, or checked out elsewhere).
@@ -736,9 +734,9 @@ function ghAvailable(): boolean {
736
734
  * Returns undefined when there's no PR, no branch, or gh fails —
737
735
  * we never invent a merge.
738
736
  */
739
- function prMergedState(cwd: string, branch: string | undefined): { state: "merged" | "open"; url?: string } | undefined {
737
+ function prMergedState(cwd: string, branch: string | undefined): { state: "merged" | "open"; url?: string; sha?: string } | undefined {
740
738
  if (!branch) return undefined;
741
- const proc = Bun.spawnSync(["gh", "pr", "view", branch, "--json", "state,url"], {
739
+ const proc = Bun.spawnSync(["gh", "pr", "view", branch, "--json", "state,url,mergeCommit"], {
742
740
  cwd,
743
741
  stdin: "ignore",
744
742
  stdout: "pipe",
@@ -746,9 +744,10 @@ function prMergedState(cwd: string, branch: string | undefined): { state: "merge
746
744
  });
747
745
  if (proc.exitCode !== 0) return undefined; // no PR for the branch, or gh unavailable/unauthed
748
746
  try {
749
- const data = JSON.parse(proc.stdout.toString()) as { state?: string; url?: string };
747
+ const data = JSON.parse(proc.stdout.toString()) as { state?: string; url?: string; mergeCommit?: { oid?: string } | null };
750
748
  if (!data.state) return undefined;
751
- return { state: data.state === "MERGED" ? "merged" : "open", url: data.url };
749
+ const sha = data.mergeCommit?.oid;
750
+ return { state: data.state === "MERGED" ? "merged" : "open", url: data.url, ...(sha ? { sha } : {}) };
752
751
  } catch {
753
752
  return undefined;
754
753
  }
@@ -783,6 +782,20 @@ function worktreeForBranch(repoRoot: string, branch: string): { path: string; di
783
782
  return { path: match.path, dirty: status.ok ? status.stdout.length > 0 : true };
784
783
  }
785
784
 
785
+ // Populate a preview's PR fields from `gh` ground truth; returns whether the PR is merged
786
+ // (#168/#304). One home for the PR-landing rule so the worktree- and repoRoot-based previews
787
+ // can't drift. On a merge it stamps the merge SHA so the relay can finalize a parked pr land.
788
+ function applyPrMergeState(base: WorkspaceMergePreview, cwd: string, branch: string | undefined): boolean {
789
+ const pr = prMergedState(cwd, branch);
790
+ if (!pr) return false;
791
+ base.prState = pr.state;
792
+ if (pr.url) base.prUrl = pr.url;
793
+ if (pr.state !== "merged") return false;
794
+ base.prMerged = true;
795
+ if (pr.sha) base.prMergeSha = pr.sha;
796
+ return true;
797
+ }
798
+
786
799
  export function previewBranchMerge(input: {
787
800
  repoRoot?: string;
788
801
  branch?: string;
@@ -815,16 +828,8 @@ export function previewBranchMerge(input: {
815
828
  base.dirtyCount = 0;
816
829
 
817
830
  let effectiveAhead = gitState.landed ? 0 : (gitState.unmergedAhead ?? gitState.ahead ?? 0);
818
- if (effectiveAhead > 0 && input.checkPr && strategy === "pr") {
819
- const pr = prMergedState(repoRoot, input.branch);
820
- if (pr) {
821
- base.prState = pr.state;
822
- if (pr.url) base.prUrl = pr.url;
823
- if (pr.state === "merged") {
824
- base.prMerged = true;
825
- effectiveAhead = 0;
826
- }
827
- }
831
+ if (input.checkPr && strategy === "pr") {
832
+ if (applyPrMergeState(base, repoRoot, input.branch)) effectiveAhead = 0;
828
833
  }
829
834
  if (effectiveAhead === 0) {
830
835
  const reason = base.prMerged
@@ -862,19 +867,11 @@ export function previewWorkspaceMerge(input: { worktreePath?: string; baseRef?:
862
867
  base.dirtyCount = gitState.dirtyCount;
863
868
  if ((gitState.dirtyCount ?? 0) > 0) return { ...base, reason: "worktree has uncommitted changes" };
864
869
  let effectiveAhead = gitState.landed ? 0 : (gitState.unmergedAhead ?? gitState.ahead ?? 0);
865
- // Local git still thinks there's unmerged work, but a squash-merged PR whose
866
- // base has moved on is invisible to it. When the caller opts in (checkPr) and
867
- // the strategy targets a PR, ask gh for the truth a MERGED PR means landed.
868
- if (effectiveAhead > 0 && input.checkPr && strategy === "pr" && input.worktreePath) {
869
- const pr = prMergedState(resolve(input.worktreePath), gitState.branch);
870
- if (pr) {
871
- base.prState = pr.state;
872
- if (pr.url) base.prUrl = pr.url;
873
- if (pr.state === "merged") {
874
- base.prMerged = true;
875
- effectiveAhead = 0;
876
- }
877
- }
870
+ // Ask gh in BOTH cases local git can't see a PR landing: a squash-merge (looks ahead)
871
+ // and a regular merge-commit (branch becomes an ancestor ahead=0 no-op). A merged PR
872
+ // means landed; its SHA lets the relay finalize a parked pr land instead of stalling (#304).
873
+ if (input.checkPr && strategy === "pr" && input.worktreePath) {
874
+ if (applyPrMergeState(base, resolve(input.worktreePath), gitState.branch)) effectiveAhead = 0;
878
875
  }
879
876
  if (effectiveAhead === 0) {
880
877
  const reason = base.prMerged