agent-relay-orchestrator 0.120.0 → 0.120.2

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.0",
3
+ "version": "0.120.2",
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.107",
20
+ "agent-relay-sdk": "0.2.108",
21
21
  "callmux": "0.23.0"
22
22
  },
23
23
  "devDependencies": {
package/src/control.ts CHANGED
@@ -373,6 +373,8 @@ export function createControlHandler(
373
373
  baseRef: typeof command.params.baseRef === "string" ? command.params.baseRef : undefined,
374
374
  baseSha: typeof command.params.baseSha === "string" ? command.params.baseSha : undefined,
375
375
  deleteBranch: command.params.deleteBranch !== false,
376
+ force: command.params.force === true,
377
+ reason: typeof command.params.reason === "string" ? command.params.reason : undefined,
376
378
  workspacesRoot: workspacesRoot(config.baseDir),
377
379
  });
378
380
  await relay.updateCommand(command.id, "succeeded", result);
package/src/process.ts CHANGED
@@ -271,9 +271,10 @@ export async function execProcess(cmd: string[], options: ExecOptions = {}): Pro
271
271
  const msg = abortMessage(cmd, options);
272
272
  stderr = stderr ? `${stderr}\n${msg}` : msg;
273
273
  }
274
+ const reportedExitCode = timedOut || aborted || outputLimitExceeded ? null : exitCode;
274
275
  return {
275
- ok: !timedOut && !aborted && !outputLimitExceeded && exitCode === 0,
276
- exitCode,
276
+ ok: !timedOut && !aborted && !outputLimitExceeded && reportedExitCode === 0,
277
+ exitCode: reportedExitCode,
277
278
  stdout: options.trimStdout === false ? stdout : stdout.trim(),
278
279
  stderr: options.trimStderr === false ? stderr : stderr.trim(),
279
280
  ...(timedOut ? { timedOut } : {}),
@@ -1,4 +1,4 @@
1
- import { chmodSync, closeSync, existsSync, openSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
1
+ import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import type { OrchestratorConfig } from "../config";
4
4
  import type { ManagedSessionExitDiagnostics } from "../relay";
@@ -15,26 +15,33 @@ import { systemdMainPidAsync, systemdUnitDiagnostics, systemdUnitName } from "./
15
15
  import type { SessionRecord, SessionSupervisor, SpawnedRunner } from "./types";
16
16
  import { execProcess } from "../process";
17
17
 
18
+ const FILE_BACKED_ENV_PAYLOADS = new Map<string, string>([
19
+ ["AGENT_RELAY_AGENT_PROFILE_JSON", "agent-profile.json"],
20
+ ["AGENT_RELAY_WORKSPACE_JSON", "workspace.json"],
21
+ ["AGENT_RELAY_INJECTION_EVENTS_JSON", "relay-injection-events.json"],
22
+ ]);
23
+
18
24
  export async function spawnRunner(name: string, command: string[], cwd: string, env: Record<string, string>, logFile: string): Promise<SpawnedRunner> {
25
+ const launchScript = launchScriptPath(name);
26
+ const launch = materializeLaunchPayload(launchScript, command, env);
19
27
  if (await shouldUseSystemdSupervisor()) {
20
28
  try {
21
- return await spawnSystemdRunner(name, command, cwd, env, logFile);
29
+ return await spawnSystemdRunner(name, launch.command, cwd, launch.env, logFile);
22
30
  } catch (error) {
23
31
  console.error(`[orchestrator] systemd runner supervisor unavailable for ${name}: ${errMessage(error)}`);
24
32
  console.error("[orchestrator] Falling back to process child; this agent will not survive orchestrator service restart.");
25
33
  }
26
34
  }
27
35
 
28
- const launchScript = launchScriptPath(name);
29
36
  ensureSessionDir();
30
- writeFileSync(launchScript, buildLaunchScript(command, cwd, env), { mode: 0o700 });
37
+ writeFileSync(launchScript, buildLaunchScript(launch.command, cwd, launch.env), { mode: 0o700 });
31
38
  chmodSync(launchScript, 0o700);
32
39
 
33
40
  const logFd = openSync(logFile, "a");
34
41
  try {
35
42
  const proc = Bun.spawn([launchScript], {
36
43
  cwd,
37
- env,
44
+ env: launch.env,
38
45
  stdin: "ignore",
39
46
  stdout: logFd,
40
47
  stderr: logFd,
@@ -86,6 +93,47 @@ function launchScriptPath(session: string): string {
86
93
  return join(SESSION_DIR, `${safe}.sh`);
87
94
  }
88
95
 
96
+ function launchPayloadDirPath(launchScript: string): string {
97
+ return `${launchScript}.d`;
98
+ }
99
+
100
+ function payloadFilePath(launchScript: string, filename: string): string {
101
+ return join(launchPayloadDirPath(launchScript), filename);
102
+ }
103
+
104
+ function materializeEnvPayload(env: Record<string, string>, launchScript: string): Record<string, string> {
105
+ const nextEnv = { ...env };
106
+ for (const [envKey, filename] of FILE_BACKED_ENV_PAYLOADS) {
107
+ const value = nextEnv[envKey];
108
+ if (typeof value !== "string" || value.length === 0) continue;
109
+ const filePath = payloadFilePath(launchScript, filename);
110
+ mkdirSync(launchPayloadDirPath(launchScript), { recursive: true });
111
+ writeFileSync(filePath, value);
112
+ delete nextEnv[envKey];
113
+ nextEnv[`${envKey}_FILE`] = filePath;
114
+ }
115
+ return nextEnv;
116
+ }
117
+
118
+ function materializeCommandArg(command: string[], env: Record<string, string>, launchScript: string, flag: string, envKey: string, filename: string): void {
119
+ const index = command.indexOf(flag);
120
+ if (index < 0 || !command[index + 1]) return;
121
+ const filePath = payloadFilePath(launchScript, filename);
122
+ mkdirSync(launchPayloadDirPath(launchScript), { recursive: true });
123
+ writeFileSync(filePath, command[index + 1]!);
124
+ command.splice(index, 2);
125
+ env[`${envKey}_FILE`] = filePath;
126
+ }
127
+
128
+ export function materializeLaunchPayload(launchScript: string, command: string[], env: Record<string, string>): { command: string[]; env: Record<string, string> } {
129
+ const nextCommand = [...command];
130
+ const nextEnv = materializeEnvPayload(env, launchScript);
131
+ materializeCommandArg(nextCommand, nextEnv, launchScript, "--prompt", "AGENT_RELAY_PROMPT", "prompt.txt");
132
+ materializeCommandArg(nextCommand, nextEnv, launchScript, "--system-prompt-append", "AGENT_RELAY_SYSTEM_PROMPT_APPEND", "system-prompt-append.txt");
133
+ materializeCommandArg(nextCommand, nextEnv, launchScript, "--append-system-prompt", "AGENT_RELAY_SYSTEM_PROMPT_APPEND", "system-prompt-append.txt");
134
+ return { command: nextCommand, env: nextEnv };
135
+ }
136
+
89
137
  export function buildLaunchScript(command: string[], cwd: string, env: Record<string, string>): string {
90
138
  const exports = Object.entries(env)
91
139
  .filter(([key, value]) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(key) && value !== undefined)
@@ -252,7 +300,10 @@ function killSystemdUnit(unit: string): void {
252
300
 
253
301
  function cleanupSupervisor(supervisor: SessionSupervisor): void {
254
302
  if (supervisor.type === "systemd" && supervisor.unit) stopSystemdUnit(supervisor.unit);
255
- if (supervisor.launchScript) rmSync(supervisor.launchScript, { force: true });
303
+ if (supervisor.launchScript) {
304
+ rmSync(supervisor.launchScript, { force: true });
305
+ rmSync(launchPayloadDirPath(supervisor.launchScript), { recursive: true, force: true });
306
+ }
256
307
  }
257
308
 
258
309
  export function cleanupSessionRecord(record: SessionRecord): void {
@@ -22,13 +22,20 @@ export async function owningRepoRoot(worktreePath: string, fallback: string): Pr
22
22
  return existsSync(root) ? root : fallback;
23
23
  }
24
24
 
25
- export async function cleanupWorkspace(workspace: { repoRoot?: string; worktreePath?: string; id?: string; branch?: string; baseRef?: string; baseSha?: string; deleteBranch?: boolean; workspacesRoot?: string }): Promise<{ workspaceId?: string; removed: boolean; worktreePath?: string; branchDeleted?: boolean; branchPreservedReason?: string; containerRemoved?: boolean }> {
25
+ export async function cleanupWorkspace(workspace: { repoRoot?: string; worktreePath?: string; id?: string; branch?: string; baseRef?: string; baseSha?: string; deleteBranch?: boolean; workspacesRoot?: string; force?: boolean; reason?: string }): Promise<{ workspaceId?: string; removed: boolean; worktreePath?: string; branchDeleted?: boolean; branchPreservedReason?: string; containerRemoved?: boolean; dirtySnapshotRef?: string }> {
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;
29
29
  // Remove via the REAL owning repo, not the recorded (chained/deleted) repoRoot which
30
30
  // would silently no-op and leak the dir while the relay marks it `cleaned` (#307).
31
31
  const repo = await owningRepoRoot(path, recordedRepo);
32
+ let dirtySnapshotRef: string | undefined;
33
+ if (existsSync(path)) {
34
+ const safety = await worktreeSafeToRemove(path, workspace.baseRef, workspace.baseSha);
35
+ const explicitForce = workspace.force === true && Boolean(workspace.reason?.trim());
36
+ if (!safety.safe && !explicitForce) throw new Error(`unsafe workspace cleanup refused: ${safety.reason}`);
37
+ if (!safety.safe) dirtySnapshotRef = await snapshotDirtyWorktree(path, workspace.id);
38
+ }
32
39
  const result = await git(["worktree", "remove", "--force", path], repo);
33
40
  // Trust the filesystem, not git's exit code (a remove against the wrong repo "succeeds"
34
41
  // touching nothing). If the dir survives, surface failure so the relay leaves the row
@@ -53,7 +60,39 @@ export async function cleanupWorkspace(workspace: { repoRoot?: string; worktreeP
53
60
  }
54
61
  }
55
62
  const containerRemoved = workspace.workspacesRoot ? removeEmptyContainer(dirname(path), resolve(workspace.workspacesRoot)) : false;
56
- return { workspaceId: workspace.id, removed: true, worktreePath: path, branchDeleted, ...(branchPreservedReason ? { branchPreservedReason } : {}), containerRemoved };
63
+ return { workspaceId: workspace.id, removed: true, worktreePath: path, branchDeleted, ...(branchPreservedReason ? { branchPreservedReason } : {}), containerRemoved, ...(dirtySnapshotRef ? { dirtySnapshotRef } : {}) };
64
+ }
65
+
66
+ async function worktreeSafeToRemove(worktreePath: string, baseRef?: string, baseSha?: string): Promise<{ safe: boolean; reason?: string }> {
67
+ const status = await git(["status", "--porcelain"], worktreePath);
68
+ if (!status.ok) return { safe: false, reason: status.stderr || "git status unavailable" };
69
+ const dirtyCount = status.stdout ? status.stdout.split("\n").filter(Boolean).length : 0;
70
+ if (dirtyCount > 0) return { safe: false, reason: `${dirtyCount} uncommitted change(s)` };
71
+
72
+ const base = await resolveCleanupBase(worktreePath, baseRef, baseSha);
73
+ if (!base) return { safe: false, reason: "base ref unavailable" };
74
+ const counts = await git(["rev-list", "--left-right", "--count", `${base}...HEAD`], worktreePath);
75
+ if (!counts.ok || !counts.stdout) return { safe: false, reason: counts.stderr || "ahead count unavailable" };
76
+ const ahead = Number(counts.stdout.split(/\s+/)[1]);
77
+ if (!Number.isFinite(ahead)) return { safe: false, reason: "ahead count unavailable" };
78
+ if (ahead === 0) return { safe: true };
79
+
80
+ const cherryBase = await upstreamRef(worktreePath, base) ?? base;
81
+ if ((await git(["diff", "--quiet", cherryBase, "HEAD"], worktreePath)).ok) return { safe: true };
82
+ const cherry = await git(["cherry", cherryBase, "HEAD"], worktreePath);
83
+ if (!cherry.ok) return { safe: false, reason: cherry.stderr || "unmerged commit check unavailable" };
84
+ const unmergedAhead = cherry.stdout ? cherry.stdout.split("\n").filter((line) => line.startsWith("+")).length : 0;
85
+ return unmergedAhead === 0 ? { safe: true } : { safe: false, reason: `${unmergedAhead} unlanded commit(s)` };
86
+ }
87
+
88
+ async function snapshotDirtyWorktree(worktreePath: string, workspaceId: string | undefined): Promise<string | undefined> {
89
+ const snapshot = await git(["stash", "create", `agent-relay cleanup snapshot ${workspaceId ?? "workspace"}`], worktreePath);
90
+ const commit = snapshot.ok ? snapshot.stdout.trim() : "";
91
+ if (!commit) return undefined;
92
+ const safe = (workspaceId ?? basename(worktreePath)).replace(/[^A-Za-z0-9._/-]+/g, "-").replace(/^\/+|\/+$/g, "").slice(0, 180) || "workspace";
93
+ const ref = `refs/agent-relay/workspace-tips/${safe}-dirty-${Date.now()}`;
94
+ const updated = await git(["update-ref", ref, commit], worktreePath);
95
+ return updated.ok ? ref : undefined;
57
96
  }
58
97
 
59
98
  export async function branchSafeToDelete(repo: string, branch: string, baseRef?: string, baseSha?: string): Promise<{ safe: boolean; reason?: string }> {
@@ -3,7 +3,6 @@ import { join, resolve } from "node:path";
3
3
  import { errMessage, type BaseWorktreeSyncResult, type WorkspaceMergePreview, type WorkspaceMergeResult } from "agent-relay-sdk";
4
4
  import { git, gitRaw } from "../git";
5
5
  import { execProcess } from "../process";
6
- import { prMergedState } from "../workspace-pr";
7
6
  import { deleteBranchIfSafe, owningRepoRoot } from "./cleanup";
8
7
  import { refreshWorkspaceDeps } from "./deps";
9
8
  import { type LandGatesResult } from "./land-gates-runner";
@@ -11,6 +10,8 @@ import { populateMergeState, resolveBranchRef, syncBaseFromOrigin, upstreamRef,
11
10
  import { type MergePhase, mergePhaseTimeoutMs, throwIfMergeAborted, withMergePhaseTimeout } from "./merge-timeouts";
12
11
  import { nextBranchName } from "./names";
13
12
  import { parseWorktrees, shortBranch } from "./parse";
13
+ import { applyPrMergeState } from "./pr-preview";
14
+ import { protectWorkspaceTip } from "./protected-tip";
14
15
  import { json, resolveRequestedPath } from "./request";
15
16
  import type { WorkspaceMergeInput } from "./types";
16
17
  import { workspacePushEnabled } from "../config";
@@ -210,24 +211,6 @@ function logBaseWorktreeStrand(base: string, worktreePath: string, sync: BaseWor
210
211
  );
211
212
  }
212
213
 
213
- // Populate a preview's PR fields from `gh` ground truth; returns whether the PR is merged
214
- // (#168/#304). One home for the PR-landing rule so the worktree- and repoRoot-based previews
215
- // can't drift. On a merge it stamps the merge SHA so the relay can finalize a parked pr land.
216
- async function applyPrMergeState(base: WorkspaceMergePreview, cwd: string, branch: string | undefined): Promise<boolean> {
217
- const pr = await prMergedState(cwd, branch);
218
- if (!pr) return false;
219
- base.prState = pr.state;
220
- if (pr.url) base.prUrl = pr.url;
221
- if (pr.number) base.prNumber = pr.number;
222
- if (pr.reviewDecision) base.reviewDecision = pr.reviewDecision;
223
- if (pr.statusCheckRollup) base.statusCheckRollup = pr.statusCheckRollup; if (pr.mergeable) base.mergeable = pr.mergeable; if (pr.mergeStateStatus) base.mergeStateStatus = pr.mergeStateStatus;
224
- if (pr.state !== "merged") return false;
225
- base.prMerged = true;
226
- if (pr.sha) base.prMergeSha = pr.sha;
227
- if (pr.title) base.prMergeSubject = pr.title;
228
- return true;
229
- }
230
-
231
214
  export async function previewBranchMerge(input: {
232
215
  repoRoot?: string;
233
216
  branch?: string;
@@ -283,6 +266,7 @@ export async function mergePreviewResponse(url: URL, baseDir: string): Promise<R
283
266
  baseSha: url.searchParams.get("baseSha") || undefined,
284
267
  strategy: validPreviewStrategy(strategy),
285
268
  checkPr: url.searchParams.get("checkPr") === "1",
269
+ workspaceId: url.searchParams.get("workspaceId") || undefined, protectTip: url.searchParams.get("protectTip") === "1",
286
270
  }));
287
271
  } catch (e) {
288
272
  return json({ error: (e as Error).message }, 400);
@@ -311,7 +295,7 @@ export async function branchMergePreviewResponse(url: URL, baseDir: string): Pro
311
295
  * `auto` would pick plus whether the merge is clean, would conflict, or is a
312
296
  * no-op — so the dashboard can warn before the user commits to it.
313
297
  */
314
- export async function previewWorkspaceMerge(input: { worktreePath?: string; baseRef?: string; baseSha?: string; strategy?: "pr" | "rebase-ff" | "auto"; checkPr?: boolean }): Promise<WorkspaceMergePreview> {
298
+ export async function previewWorkspaceMerge(input: { worktreePath?: string; baseRef?: string; baseSha?: string; strategy?: "pr" | "rebase-ff" | "auto"; checkPr?: boolean; workspaceId?: string; protectTip?: boolean }): Promise<WorkspaceMergePreview> {
315
299
  const gitState = await workspaceGitState(input);
316
300
  const remote = input.worktreePath ? await hasOriginRemote(resolve(input.worktreePath)) : false;
317
301
  const gh = ghAvailable();
@@ -328,6 +312,7 @@ export async function previewWorkspaceMerge(input: { worktreePath?: string; base
328
312
  base.unmergedAhead = gitState.unmergedAhead;
329
313
  base.landed = gitState.landed;
330
314
  base.behind = gitState.behind;
315
+ base.headSha = gitState.lastCommit?.sha;
331
316
  base.dirtyCount = gitState.dirtyCount;
332
317
  if ((gitState.dirtyCount ?? 0) > 0) return { ...base, reason: "worktree has uncommitted changes" };
333
318
  let effectiveAhead = gitState.landed ? 0 : (gitState.unmergedAhead ?? gitState.ahead ?? 0);
@@ -337,6 +322,17 @@ export async function previewWorkspaceMerge(input: { worktreePath?: string; base
337
322
  if (input.checkPr && strategy === "pr" && input.worktreePath) {
338
323
  if (await applyPrMergeState(base, resolve(input.worktreePath), gitState.branch)) effectiveAhead = 0;
339
324
  }
325
+ if (input.protectTip && input.worktreePath) {
326
+ const protection = await protectWorkspaceTip({
327
+ worktreePath: resolve(input.worktreePath), workspaceId: input.workspaceId, branch: gitState.branch,
328
+ baseRef: gitState.baseRef, headSha: gitState.lastCommit?.sha, dirtyCount: gitState.dirtyCount, effectiveAhead,
329
+ });
330
+ Object.assign(base, protection);
331
+ if (protection.protectedTipRestored) {
332
+ return { ...await previewWorkspaceMerge({ ...input, protectTip: false }), ...protection };
333
+ }
334
+ if (protection.protectedTipReachable === false) return base;
335
+ }
340
336
  if (effectiveAhead === 0) {
341
337
  const reason = base.prMerged
342
338
  ? "PR merged on remote"
@@ -0,0 +1,22 @@
1
+ import type { WorkspaceMergePreview } from "agent-relay-sdk";
2
+ import { prMergedState } from "../workspace-pr";
3
+
4
+ // Populate a preview's PR fields from `gh` ground truth; returns whether the PR is merged
5
+ // (#168/#304). One home for the PR-landing rule so the worktree- and repoRoot-based previews
6
+ // can't drift. On a merge it stamps the merge SHA so the relay can finalize a parked pr land.
7
+ export async function applyPrMergeState(base: WorkspaceMergePreview, cwd: string, branch: string | undefined): Promise<boolean> {
8
+ const pr = await prMergedState(cwd, branch);
9
+ if (!pr) return false;
10
+ base.prState = pr.state;
11
+ if (pr.url) base.prUrl = pr.url;
12
+ if (pr.number) base.prNumber = pr.number;
13
+ if (pr.reviewDecision) base.reviewDecision = pr.reviewDecision;
14
+ if (pr.statusCheckRollup) base.statusCheckRollup = pr.statusCheckRollup;
15
+ if (pr.mergeable) base.mergeable = pr.mergeable;
16
+ if (pr.mergeStateStatus) base.mergeStateStatus = pr.mergeStateStatus;
17
+ if (pr.state !== "merged") return false;
18
+ base.prMerged = true;
19
+ if (pr.sha) base.prMergeSha = pr.sha;
20
+ if (pr.title) base.prMergeSubject = pr.title;
21
+ return true;
22
+ }
@@ -91,7 +91,9 @@ export async function resolveSpawnWorkspace(input: WorkspaceResolutionInput): Pr
91
91
  if (input.resumeWorkspace?.mode === "branch-from") {
92
92
  const resume = input.resumeWorkspace;
93
93
  const repoRoot = probe.repoRoot;
94
- const baseSha = await requireGit(["rev-parse", resume.branch], repoRoot);
94
+ const startSha = await requireGit(["rev-parse", resume.branch], repoRoot);
95
+ const baseRef = resume.baseRef ?? await terminalBaseRef(repoRoot, resume.branch);
96
+ const baseSha = resume.baseSha ?? (baseRef ? await requireGit(["rev-parse", baseRef], repoRoot) : undefined);
95
97
  const id = workspaceId(input);
96
98
  const workspaceRoot = input.workspaceRoot ? resolve(input.workspaceRoot) : workspacesRoot(homedir());
97
99
  const worktreePath = join(workspaceRoot, repoSlug(repoRoot), id);
@@ -103,7 +105,7 @@ export async function resolveSpawnWorkspace(input: WorkspaceResolutionInput): Pr
103
105
  sourceCwd,
104
106
  worktreePath,
105
107
  branch: existing.branch,
106
- baseRef: resume.branch,
108
+ baseRef,
107
109
  baseSha,
108
110
  requestedMode,
109
111
  probe,
@@ -112,7 +114,7 @@ export async function resolveSpawnWorkspace(input: WorkspaceResolutionInput): Pr
112
114
 
113
115
  const branch = await availableBranch(repoRoot, branchName(input, id));
114
116
  mkdirSync(join(worktreePath, ".."), { recursive: true });
115
- await requireGit(["worktree", "add", "-b", branch, worktreePath, baseSha], repoRoot);
117
+ await requireGit(["worktree", "add", "-b", branch, worktreePath, startSha], repoRoot);
116
118
  const deps = await provisionWorkspaceDeps(repoRoot, worktreePath);
117
119
  const symlinks = provisionWorkspaceSymlinks(repoRoot, worktreePath, input.workspaceSymlinks ?? []);
118
120
  return {
@@ -125,8 +127,8 @@ export async function resolveSpawnWorkspace(input: WorkspaceResolutionInput): Pr
125
127
  sourceCwd,
126
128
  worktreePath,
127
129
  branch,
128
- baseRef: resume.branch,
129
- baseSha,
130
+ ...(baseRef ? { baseRef } : {}),
131
+ ...(baseSha ? { baseSha } : {}),
130
132
  status: "active",
131
133
  deps,
132
134
  ...(symlinks.linked.length || symlinks.errors ? { symlinks } : {}),
@@ -0,0 +1,108 @@
1
+ import { git } from "../git";
2
+ import { mergePhaseTimeoutMs, throwIfMergeAborted } from "./merge-timeouts";
3
+
4
+ async function previewGit(args: string[], cwd: string, timeoutLabel: string, signal?: AbortSignal): ReturnType<typeof git> {
5
+ throwIfMergeAborted(signal);
6
+ return git(args, cwd, {
7
+ timeoutMs: mergePhaseTimeoutMs("preview"),
8
+ timeoutLabel,
9
+ signal,
10
+ });
11
+ }
12
+
13
+ function gitError(result: Awaited<ReturnType<typeof git>>, fallback: string): string {
14
+ return result.stderr || result.stdout || fallback;
15
+ }
16
+
17
+ function protectedTipRef(workspaceId: string | undefined): string | undefined {
18
+ const safe = workspaceId?.replace(/[^A-Za-z0-9._/-]+/g, "-").replace(/^\/+|\/+$/g, "").slice(0, 180);
19
+ return safe ? `refs/agent-relay/workspace-tips/${safe}` : undefined;
20
+ }
21
+
22
+ function discardedTipRef(ref: string): string {
23
+ return `${ref}-discarded-${Date.now()}`;
24
+ }
25
+
26
+ async function readCommit(cwd: string, ref: string, signal?: AbortSignal): Promise<string | undefined> {
27
+ const result = await previewGit(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], cwd, `workspace preview read ${ref}`, signal);
28
+ return result.ok && result.stdout ? result.stdout.trim() : undefined;
29
+ }
30
+
31
+ async function isAncestor(cwd: string, ancestor: string, descendant: string, signal?: AbortSignal): Promise<boolean> {
32
+ return (await previewGit(["merge-base", "--is-ancestor", ancestor, descendant], cwd, "workspace preview ancestor check", signal)).ok;
33
+ }
34
+
35
+ async function patchEquivalentOrContained(cwd: string, oldTip: string, currentTip: string, signal?: AbortSignal): Promise<boolean> {
36
+ const cherry = await previewGit(["cherry", currentTip, oldTip], cwd, "workspace preview protected-tip patch-equivalence", signal);
37
+ if (!cherry.ok) return false;
38
+ return !cherry.stdout.split("\n").some((line) => line.startsWith("+"));
39
+ }
40
+
41
+ export interface ProtectedTipCheck {
42
+ protectedTipRef?: string;
43
+ protectedTip?: string;
44
+ protectedTipReachable?: boolean;
45
+ protectedTipRestored?: boolean;
46
+ protectedTipDiscardedRef?: string;
47
+ protectedTipDiscardedHead?: string;
48
+ protectedTipError?: string;
49
+ }
50
+
51
+ export async function protectWorkspaceTip(input: {
52
+ worktreePath: string;
53
+ workspaceId?: string;
54
+ branch?: string;
55
+ baseRef?: string;
56
+ headSha?: string;
57
+ dirtyCount?: number;
58
+ effectiveAhead: number;
59
+ signal?: AbortSignal;
60
+ }): Promise<ProtectedTipCheck> {
61
+ const ref = protectedTipRef(input.workspaceId);
62
+ if (!ref || !input.headSha) return {};
63
+
64
+ const existing = await readCommit(input.worktreePath, ref, input.signal);
65
+ if (!existing) {
66
+ if (input.effectiveAhead <= 0) return { protectedTipRef: ref };
67
+ const update = await previewGit(["update-ref", ref, input.headSha], input.worktreePath, "workspace preview protect worker tip", input.signal);
68
+ return update.ok
69
+ ? { protectedTipRef: ref, protectedTip: input.headSha, protectedTipReachable: true }
70
+ : { protectedTipRef: ref, protectedTipError: gitError(update, "failed to protect worker branch tip") };
71
+ }
72
+
73
+ if (await isAncestor(input.worktreePath, existing, input.headSha, input.signal)) {
74
+ if (input.effectiveAhead > 0 && existing !== input.headSha) {
75
+ const update = await previewGit(["update-ref", ref, input.headSha, existing], input.worktreePath, "workspace preview advance protected worker tip", input.signal);
76
+ if (!update.ok) return { protectedTipRef: ref, protectedTip: existing, protectedTipReachable: true, protectedTipError: gitError(update, "failed to advance protected worker tip") };
77
+ return { protectedTipRef: ref, protectedTip: input.headSha, protectedTipReachable: true };
78
+ }
79
+ return { protectedTipRef: ref, protectedTip: existing, protectedTipReachable: true };
80
+ }
81
+
82
+ if (input.baseRef && await isAncestor(input.worktreePath, existing, input.baseRef, input.signal)) {
83
+ return { protectedTipRef: ref, protectedTip: existing, protectedTipReachable: true };
84
+ }
85
+
86
+ if (await patchEquivalentOrContained(input.worktreePath, existing, input.headSha, input.signal)) {
87
+ const update = await previewGit(["update-ref", ref, input.headSha, existing], input.worktreePath, "workspace preview advance rebased protected worker tip", input.signal);
88
+ if (!update.ok) return { protectedTipRef: ref, protectedTip: existing, protectedTipReachable: true, protectedTipError: gitError(update, "failed to advance rebased protected worker tip") };
89
+ return { protectedTipRef: ref, protectedTip: input.headSha, protectedTipReachable: true };
90
+ }
91
+
92
+ if ((input.dirtyCount ?? 0) > 0) {
93
+ return { protectedTipRef: ref, protectedTip: existing, protectedTipReachable: false, protectedTipError: "protected worker tip is no longer reachable, and the worktree is dirty" };
94
+ }
95
+ if (!input.branch) {
96
+ return { protectedTipRef: ref, protectedTip: existing, protectedTipReachable: false, protectedTipError: "protected worker tip is no longer reachable, and the current branch is unknown" };
97
+ }
98
+
99
+ const discardedRef = discardedTipRef(ref);
100
+ const snapshot = await previewGit(["update-ref", discardedRef, input.headSha], input.worktreePath, "workspace preview snapshot discarded worker head", input.signal);
101
+ if (!snapshot.ok) {
102
+ return { protectedTipRef: ref, protectedTip: existing, protectedTipReachable: false, protectedTipError: gitError(snapshot, "failed to snapshot discarded worker head before protected-tip restore") };
103
+ }
104
+ const restore = await previewGit(["checkout", "-B", input.branch, existing], input.worktreePath, "workspace preview restore protected worker tip", input.signal);
105
+ return restore.ok
106
+ ? { protectedTipRef: ref, protectedTip: existing, protectedTipReachable: true, protectedTipRestored: true, protectedTipDiscardedRef: discardedRef, protectedTipDiscardedHead: input.headSha }
107
+ : { protectedTipRef: ref, protectedTip: existing, protectedTipReachable: false, protectedTipDiscardedRef: discardedRef, protectedTipDiscardedHead: input.headSha, protectedTipError: gitError(restore, "failed to restore protected worker tip") };
108
+ }