agent-relay-orchestrator 0.78.6 → 0.78.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-orchestrator",
3
- "version": "0.78.6",
3
+ "version": "0.78.7",
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.56"
19
+ "agent-relay-sdk": "0.2.57"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@types/bun": "latest",
package/src/control.ts CHANGED
@@ -95,6 +95,8 @@ export function createControlHandler(
95
95
  repoRoot: typeof command.params.repoRoot === "string" ? command.params.repoRoot : undefined,
96
96
  worktreePath: typeof command.params.worktreePath === "string" ? command.params.worktreePath : undefined,
97
97
  branch: typeof command.params.branch === "string" ? command.params.branch : undefined,
98
+ baseRef: typeof command.params.baseRef === "string" ? command.params.baseRef : undefined,
99
+ baseSha: typeof command.params.baseSha === "string" ? command.params.baseSha : undefined,
98
100
  deleteBranch: command.params.deleteBranch !== false,
99
101
  workspacesRoot: workspacesRoot(config.baseDir),
100
102
  });
@@ -22,7 +22,7 @@ function owningRepoRoot(worktreePath: string, fallback: string): string {
22
22
  return existsSync(root) ? root : fallback;
23
23
  }
24
24
 
25
- 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 } {
25
+ export function cleanupWorkspace(workspace: { repoRoot?: string; worktreePath?: string; id?: string; branch?: string; baseRef?: string; baseSha?: string; deleteBranch?: boolean; workspacesRoot?: string }): { workspaceId?: string; removed: boolean; worktreePath?: string; branchDeleted?: boolean; branchPreservedReason?: string; containerRemoved?: boolean } {
26
26
  if (!workspace.worktreePath) throw new Error("worktreePath required");
27
27
  const path = resolve(workspace.worktreePath);
28
28
  const recordedRepo = workspace.repoRoot ? resolve(workspace.repoRoot) : path;
@@ -38,14 +38,61 @@ export function cleanupWorkspace(workspace: { repoRoot?: string; worktreePath?:
38
38
  throw new Error(result.stderr || `worktree ${path} still present after \`git worktree remove\` (repo ${repo})`);
39
39
  }
40
40
  // Once the worktree is gone the agent/... branch is litter — delete it so
41
- // branches don't accumulate. Best-effort: don't fail cleanup if it can't
42
- // (e.g. branch already gone, or checked out elsewhere).
41
+ // branches don't accumulate. First prove it has no unlanded commits, because
42
+ // deleting the branch ref is what can make committed-but-unlanded work
43
+ // unreachable after a crashed worker (#614).
43
44
  let branchDeleted = false;
45
+ let branchPreservedReason: string | undefined;
44
46
  if (workspace.branch && workspace.deleteBranch !== false) {
45
- branchDeleted = git(["branch", "-D", workspace.branch], repo).ok;
47
+ const safety = branchSafeToDelete(repo, workspace.branch, workspace.baseRef, workspace.baseSha);
48
+ if (safety.safe) {
49
+ branchDeleted = git(["branch", "-D", workspace.branch], repo).ok;
50
+ if (!branchDeleted) branchPreservedReason = "branch delete failed";
51
+ } else {
52
+ branchPreservedReason = safety.reason;
53
+ }
46
54
  }
47
55
  const containerRemoved = workspace.workspacesRoot ? removeEmptyContainer(dirname(path), resolve(workspace.workspacesRoot)) : false;
48
- return { workspaceId: workspace.id, removed: true, worktreePath: path, branchDeleted, containerRemoved };
56
+ return { workspaceId: workspace.id, removed: true, worktreePath: path, branchDeleted, ...(branchPreservedReason ? { branchPreservedReason } : {}), containerRemoved };
57
+ }
58
+
59
+ function branchSafeToDelete(repo: string, branch: string, baseRef?: string, baseSha?: string): { safe: boolean; reason?: string } {
60
+ const branchRef = resolveCommit(repo, branch) ? branch : resolveCommit(repo, `refs/heads/${branch}`) ? `refs/heads/${branch}` : undefined;
61
+ if (!branchRef) return { safe: true };
62
+
63
+ const base = resolveCleanupBase(repo, baseRef, baseSha);
64
+ if (!base) return { safe: false, reason: "base ref unavailable" };
65
+
66
+ const counts = git(["rev-list", "--left-right", "--count", `${base}...${branchRef}`], repo);
67
+ if (!counts.ok || !counts.stdout) return { safe: false, reason: counts.stderr || "ahead count unavailable" };
68
+ const ahead = Number(counts.stdout.split(/\s+/)[1]);
69
+ if (!Number.isFinite(ahead)) return { safe: false, reason: "ahead count unavailable" };
70
+ if (ahead === 0) return { safe: true };
71
+
72
+ const cherryBase = upstreamRef(repo, base) ?? base;
73
+ if (git(["diff", "--quiet", cherryBase, branchRef], repo).ok) return { safe: true };
74
+ const cherry = git(["cherry", cherryBase, branchRef], repo);
75
+ if (!cherry.ok) return { safe: false, reason: cherry.stderr || "unmerged commit check unavailable" };
76
+ const unmergedAhead = cherry.stdout ? cherry.stdout.split("\n").filter((line) => line.startsWith("+")).length : 0;
77
+ return unmergedAhead === 0
78
+ ? { safe: true }
79
+ : { safe: false, reason: `${unmergedAhead} unlanded commit(s)` };
80
+ }
81
+
82
+ function resolveCleanupBase(repo: string, baseRef?: string, baseSha?: string): string | undefined {
83
+ for (const candidate of [baseRef, baseSha, "main", "master"]) {
84
+ if (candidate && resolveCommit(repo, candidate)) return candidate;
85
+ }
86
+ return undefined;
87
+ }
88
+
89
+ function resolveCommit(repo: string, ref: string): boolean {
90
+ return git(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], repo).ok;
91
+ }
92
+
93
+ function upstreamRef(repo: string, base: string): string | undefined {
94
+ const res = git(["rev-parse", "--abbrev-ref", `${base}@{upstream}`], repo);
95
+ return res.ok && res.stdout ? res.stdout : undefined;
49
96
  }
50
97
 
51
98
  export function sweepEmptyWorkspaceContainers(wsRoot: string): string[] {
@@ -138,7 +138,7 @@ export function reconcileWorkspace(workspace: { id?: string; repoRoot?: string;
138
138
  // detection can only under-report, so this never deletes unmerged work.
139
139
  const empty = gitState.error === undefined && gitState.dirtyCount === 0 && ((gitState.ahead ?? 0) === 0 || gitState.landed === true);
140
140
  if (empty) {
141
- cleanupWorkspace({ id: workspace.id, repoRoot: workspace.repoRoot, worktreePath: workspace.worktreePath, branch: workspace.branch });
141
+ cleanupWorkspace({ id: workspace.id, repoRoot: workspace.repoRoot, worktreePath: workspace.worktreePath, branch: workspace.branch, baseRef: workspace.baseRef, baseSha: workspace.baseSha });
142
142
  return { workspaceId: workspace.id, removed: true, status: "cleaned", gitState };
143
143
  }
144
144
  return { workspaceId: workspace.id, removed: false, status: "review_requested", gitState };