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.
|
|
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.
|
|
20
|
+
"agent-relay-sdk": "0.2.111",
|
|
21
21
|
"callmux": "0.23.0"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
package/src/shared-callmux.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|