agent-relay-orchestrator 0.120.4 → 0.120.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.120.4",
3
+ "version": "0.120.6",
4
4
  "description": "Agent Relay orchestrator — manages agent lifecycle across hosts",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,7 +17,7 @@
17
17
  },
18
18
  "dependencies": {
19
19
  "agent-relay-providers": "0.104.3",
20
- "agent-relay-sdk": "0.2.109",
20
+ "agent-relay-sdk": "0.2.111",
21
21
  "callmux": "0.23.0"
22
22
  },
23
23
  "devDependencies": {
@@ -347,10 +347,12 @@ function callmuxServerFromMcpServer(server: Record<string, unknown>): Record<str
347
347
  out.command = server.command;
348
348
  if (Array.isArray(server.args)) out.args = server.args.filter((arg): arg is string => typeof arg === "string");
349
349
  if (isRecord(server.env)) out.env = server.env;
350
+ if (isRecord(server.configEnv)) out.configEnv = server.configEnv;
350
351
  } else if (typeof server.url === "string") {
351
352
  out.url = server.url;
352
353
  if (server.type === "sse") out.transport = "sse";
353
354
  if (isRecord(server.headers)) out.headers = server.headers;
355
+ if (isRecord(server.configEnv)) out.configEnv = server.configEnv;
354
356
  }
355
357
  return out;
356
358
  }
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readdirSync, rmdirSync } from "node:fs";
2
2
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
3
3
  import { git } from "../git";
4
+ import { protectedTipRef } from "./protected-tip";
4
5
 
5
6
  export async function pruneWorktrees(input: { repoRoot?: string }): Promise<{ repoRoot: string; pruned: boolean; output?: string; error?: string }> {
6
7
  const repo = resolve(input.repoRoot ?? ".");
@@ -51,7 +52,7 @@ export async function cleanupWorkspace(workspace: { repoRoot?: string; worktreeP
51
52
  let branchDeleted = false;
52
53
  let branchPreservedReason: string | undefined;
53
54
  if (workspace.branch && workspace.deleteBranch !== false) {
54
- const safety = await branchSafeToDelete(repo, workspace.branch, workspace.baseRef, workspace.baseSha);
55
+ const safety = await branchSafeToDelete(repo, workspace.branch, workspace.baseRef, workspace.baseSha, workspace.id);
55
56
  if (safety.safe) {
56
57
  branchDeleted = (await git(["branch", "-D", workspace.branch], repo)).ok;
57
58
  if (!branchDeleted) branchPreservedReason = "branch delete failed";
@@ -95,13 +96,19 @@ async function snapshotDirtyWorktree(worktreePath: string, workspaceId: string |
95
96
  return updated.ok ? ref : undefined;
96
97
  }
97
98
 
98
- export async function branchSafeToDelete(repo: string, branch: string, baseRef?: string, baseSha?: string): Promise<{ safe: boolean; reason?: string }> {
99
+ export async function branchSafeToDelete(repo: string, branch: string, baseRef?: string, baseSha?: string, workspaceId?: string): Promise<{ safe: boolean; reason?: string }> {
99
100
  const branchRef = await resolveCommit(repo, branch) ? branch : await resolveCommit(repo, `refs/heads/${branch}`) ? `refs/heads/${branch}` : undefined;
100
- if (!branchRef) return { safe: true };
101
+ const rescueRef = protectedTipRef(workspaceId);
102
+ const rescueTip = rescueRef ? await resolveCommitSha(repo, rescueRef) : undefined;
103
+ if (!branchRef && !rescueTip) return { safe: true };
101
104
 
102
105
  const base = await resolveCleanupBase(repo, baseRef, baseSha);
103
106
  if (!base) return { safe: false, reason: "base ref unavailable" };
104
107
 
108
+ const rescue = await rescueTipSafety(repo, base, rescueRef, rescueTip);
109
+ if (!rescue.safe) return rescue;
110
+ if (!branchRef) return { safe: true };
111
+
105
112
  const counts = await git(["rev-list", "--left-right", "--count", `${base}...${branchRef}`], repo);
106
113
  if (!counts.ok || !counts.stdout) return { safe: false, reason: counts.stderr || "ahead count unavailable" };
107
114
  const ahead = Number(counts.stdout.split(/\s+/)[1]);
@@ -118,9 +125,9 @@ export async function branchSafeToDelete(repo: string, branch: string, baseRef?:
118
125
  : { safe: false, reason: `${unmergedAhead} unlanded commit(s)` };
119
126
  }
120
127
 
121
- export async function deleteBranchIfSafe(repo: string, branch: string, baseRef?: string, baseSha?: string, signal?: AbortSignal): Promise<{ branchDeleted: boolean; branchPreservedReason?: string }> {
128
+ export async function deleteBranchIfSafe(repo: string, branch: string, baseRef?: string, baseSha?: string, signal?: AbortSignal, workspaceId?: string): Promise<{ branchDeleted: boolean; branchPreservedReason?: string }> {
122
129
  if (signal?.aborted) throw signal.reason instanceof Error ? signal.reason : new Error("workspace merge aborted");
123
- const safety = await branchSafeToDelete(repo, branch, baseRef, baseSha);
130
+ const safety = await branchSafeToDelete(repo, branch, baseRef, baseSha, workspaceId);
124
131
  if (signal?.aborted) throw signal.reason instanceof Error ? signal.reason : new Error("workspace merge aborted");
125
132
  if (!safety.safe) return { branchDeleted: false, ...(safety.reason ? { branchPreservedReason: safety.reason } : {}) };
126
133
  const branchDeleted = (await git(["branch", "-D", branch], repo, { signal })).ok;
@@ -138,6 +145,25 @@ async function resolveCommit(repo: string, ref: string): Promise<boolean> {
138
145
  return (await git(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], repo)).ok;
139
146
  }
140
147
 
148
+ async function resolveCommitSha(repo: string, ref: string): Promise<string | undefined> {
149
+ const result = await git(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], repo);
150
+ return result.ok && result.stdout ? result.stdout.trim() : undefined;
151
+ }
152
+
153
+ async function rescueTipSafety(repo: string, base: string, ref: string | undefined, tip: string | undefined): Promise<{ safe: boolean; reason?: string }> {
154
+ if (!ref || !tip) return { safe: true };
155
+ if ((await git(["merge-base", "--is-ancestor", tip, base], repo)).ok) return { safe: true };
156
+
157
+ const cherryBase = await upstreamRef(repo, base) ?? base;
158
+ if ((await git(["diff", "--quiet", cherryBase, tip], repo)).ok) return { safe: true };
159
+ const cherry = await git(["cherry", cherryBase, tip], repo);
160
+ if (!cherry.ok) return { safe: false, reason: cherry.stderr || "recoverable workspace tip check unavailable" };
161
+ const unmergedAhead = cherry.stdout ? cherry.stdout.split("\n").filter((line) => line.startsWith("+")).length : 0;
162
+ return unmergedAhead === 0
163
+ ? { safe: true }
164
+ : { safe: false, reason: `recoverable tip ${tip.slice(0, 12)} at ${ref} has ${unmergedAhead} unlanded commit(s)` };
165
+ }
166
+
141
167
  async function upstreamRef(repo: string, base: string): Promise<string | undefined> {
142
168
  const res = await git(["rev-parse", "--abbrev-ref", `${base}@{upstream}`], repo);
143
169
  return res.ok && res.stdout ? res.stdout : undefined;
@@ -11,7 +11,7 @@ import { type MergePhase, mergePhaseTimeoutMs, throwIfMergeAborted, withMergePha
11
11
  import { nextBranchName } from "./names";
12
12
  import { parseWorktrees, shortBranch } from "./parse";
13
13
  import { applyPrMergeState } from "./pr-preview";
14
- import { protectWorkspaceTip } from "./protected-tip";
14
+ import { protectWorkspaceTip, recoverableProtectedTip } from "./protected-tip";
15
15
  import { json, resolveRequestedPath } from "./request";
16
16
  import type { WorkspaceMergeInput } from "./types";
17
17
  import { workspacePushEnabled } from "../config";
@@ -213,9 +213,8 @@ function logBaseWorktreeStrand(base: string, worktreePath: string, sync: BaseWor
213
213
 
214
214
  export async function previewBranchMerge(input: {
215
215
  repoRoot?: string;
216
- branch?: string;
217
- baseRef?: string;
218
- baseSha?: string;
216
+ branch?: string; workspaceId?: string;
217
+ baseRef?: string; baseSha?: string;
219
218
  strategy?: "pr" | "rebase-ff" | "auto";
220
219
  checkPr?: boolean;
221
220
  }): Promise<WorkspaceMergePreview | null> {
@@ -231,10 +230,11 @@ export async function previewBranchMerge(input: {
231
230
  ? input.strategy
232
231
  : remote && gh && baseBranch ? "pr" : "rebase-ff";
233
232
  const branchRef = await resolveBranchRef(repoRoot, input.branch);
234
- if (!branchRef) return null;
233
+ const recoverable = branchRef ? undefined : await recoverableProtectedTip(repoRoot, input.workspaceId);
234
+ if (!branchRef && !recoverable) return null;
235
235
 
236
- const gitState = await populateMergeState(repoRoot, branchRef, { dirty: false, dirtyCount: 0, branch: input.branch }, input.baseRef, input.baseSha);
237
- const base: WorkspaceMergePreview = { strategy, hasRemote: remote, ghAvailable: gh, baseRef: baseBranch ?? gitState.baseRef };
236
+ const gitState = await populateMergeState(repoRoot, branchRef ?? recoverable!.recoverableTip, { dirty: false, dirtyCount: 0, branch: input.branch }, input.baseRef, input.baseSha);
237
+ const base: WorkspaceMergePreview = { strategy, hasRemote: remote, ghAvailable: gh, baseRef: baseBranch ?? gitState.baseRef, ...recoverable };
238
238
  if (!gitState.baseRef) return { ...base, error: "base ref unavailable" };
239
239
  base.ahead = gitState.ahead;
240
240
  base.unmergedAhead = gitState.unmergedAhead;
@@ -278,7 +278,7 @@ export async function branchMergePreviewResponse(url: URL, baseDir: string): Pro
278
278
  const strategy = url.searchParams.get("strategy");
279
279
  const preview = await previewBranchMerge({
280
280
  repoRoot: resolveRequestedPath(url.searchParams.get("repoRoot") || undefined, baseDir),
281
- branch: url.searchParams.get("branch") || undefined,
281
+ branch: url.searchParams.get("branch") || undefined, workspaceId: url.searchParams.get("workspaceId") || undefined,
282
282
  baseRef: url.searchParams.get("baseRef") || undefined,
283
283
  baseSha: url.searchParams.get("baseSha") || undefined,
284
284
  strategy: validPreviewStrategy(strategy),
@@ -533,7 +533,7 @@ async function resolveNoopMerge(
533
533
  throwIfMergeAborted(signal);
534
534
  if ((await mergeGit(["checkout", "-B", fresh, start], worktreePath, "rebase", `workspace merge noop recycle worktree to ${fresh}`, signal)).ok) {
535
535
  const baseSha = (await mergeGit(["rev-parse", start], worktreePath, "rebase", `workspace merge resolve noop recycle start ${start}`, signal)).stdout || undefined;
536
- const deleteResult = await deleteBranchIfSafe(ownerRepo, branch, undefined, baseSha ?? start, signal);
536
+ const deleteResult = await deleteBranchIfSafe(ownerRepo, branch, undefined, baseSha ?? start, signal, input.id);
537
537
  // Recycled onto base, which may declare deps the symlinked node_modules lacks (#51).
538
538
  const depsRefresh = await refreshWorkspaceDeps(ownerRepo, worktreePath);
539
539
  throwIfMergeAborted(signal);
@@ -551,7 +551,7 @@ async function resolveNoopMerge(
551
551
  throwIfMergeAborted(signal);
552
552
  const removed = await mergeGit(["worktree", "remove", "--force", worktreePath], ownerRepo, "cleanup", "workspace merge remove noop worktree", signal);
553
553
  const worktreeRemoved = removed.ok;
554
- const deleteResult = worktreeRemoved ? await deleteBranchIfSafe(ownerRepo, branch, preview.baseRef, undefined, signal) : { branchDeleted: false };
554
+ const deleteResult = worktreeRemoved ? await deleteBranchIfSafe(ownerRepo, branch, preview.baseRef, undefined, signal, input.id) : { branchDeleted: false };
555
555
  return head({ status: "merged", noop: true, worktreeRemoved, ...deleteResult, ...pushedStranded, error: undefined });
556
556
  }
557
557
  return head({ status: "merged", noop: true, ...pushedStranded, error: undefined });
@@ -1103,7 +1103,7 @@ async function mergeRebaseFf(
1103
1103
  throwIfMergeAborted(signal);
1104
1104
  const switched = await mergeGit(["checkout", "-B", fresh, base], worktreePath, "rebase", `workspace merge recycle worktree to ${fresh}`, signal);
1105
1105
  if (switched.ok) {
1106
- const deleteResult = await deleteBranchIfSafe(ownerRepo, branch, undefined, baseTip, signal);
1106
+ const deleteResult = await deleteBranchIfSafe(ownerRepo, branch, undefined, baseTip, signal, input.id);
1107
1107
  // The worktree just moved onto the advanced base, which may declare deps the
1108
1108
  // shared (symlinked) node_modules lacks. Re-provision so the recycled session
1109
1109
  // continues from a buildable state (issue #51). No-op when nothing is stale.
@@ -1118,6 +1118,6 @@ async function mergeRebaseFf(
1118
1118
  throwIfMergeAborted(signal);
1119
1119
  const removed = await mergeGit(["worktree", "remove", "--force", worktreePath], ownerRepo, "cleanup", "workspace merge remove landed worktree", signal);
1120
1120
  const worktreeRemoved = removed.ok;
1121
- const deleteResult = worktreeRemoved ? await deleteBranchIfSafe(ownerRepo, branch, undefined, baseTip, signal) : { branchDeleted: false };
1121
+ const deleteResult = worktreeRemoved ? await deleteBranchIfSafe(ownerRepo, branch, undefined, baseTip, signal, input.id) : { branchDeleted: false };
1122
1122
  return head({ merged: true, status: "merged", mergedSha: headSha, baseSha: baseTip, subject: landedSubject, worktreeRemoved, ...deleteResult, pushed, ...baseWorktreeSyncField, ...gateWarningsField, error: undefined });
1123
1123
  }
@@ -14,11 +14,18 @@ function gitError(result: Awaited<ReturnType<typeof git>>, fallback: string): st
14
14
  return result.stderr || result.stdout || fallback;
15
15
  }
16
16
 
17
- function protectedTipRef(workspaceId: string | undefined): string | undefined {
17
+ export function protectedTipRef(workspaceId: string | undefined): string | undefined {
18
18
  const safe = workspaceId?.replace(/[^A-Za-z0-9._/-]+/g, "-").replace(/^\/+|\/+$/g, "").slice(0, 180);
19
19
  return safe ? `refs/agent-relay/workspace-tips/${safe}` : undefined;
20
20
  }
21
21
 
22
+ export async function recoverableProtectedTip(cwd: string, workspaceId: string | undefined): Promise<{ recoverableTip: string; recoverableTipRef: string; reason: string } | undefined> {
23
+ const ref = protectedTipRef(workspaceId);
24
+ if (!ref) return undefined;
25
+ const tip = await readCommit(cwd, ref);
26
+ return tip ? { recoverableTip: tip, recoverableTipRef: ref, reason: "branch ref is gone but protected workspace tip is recoverable" } : undefined;
27
+ }
28
+
22
29
  function discardedTipRef(ref: string): string {
23
30
  return `${ref}-discarded-${Date.now()}`;
24
31
  }