agent-relay-orchestrator 0.119.3 → 0.119.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.119.3",
3
+ "version": "0.119.7",
4
4
  "description": "Agent Relay orchestrator — manages agent lifecycle across hosts",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,8 +16,8 @@
16
16
  "test": "bun test"
17
17
  },
18
18
  "dependencies": {
19
- "agent-relay-providers": "0.104.2",
20
- "agent-relay-sdk": "0.2.104",
19
+ "agent-relay-providers": "0.104.3",
20
+ "agent-relay-sdk": "0.2.105",
21
21
  "callmux": "0.23.0"
22
22
  },
23
23
  "devDependencies": {
package/src/control.ts CHANGED
@@ -32,6 +32,7 @@ interface ControlHandler {
32
32
  }
33
33
 
34
34
  const DEFAULT_MAX_CONCURRENT_SPAWNS = 2;
35
+ const TERMINAL_COMMAND_STATUSES = new Set(["succeeded", "failed", "timed_out", "rejected", "canceled"]);
35
36
 
36
37
  // #930/#880 — per-host spawn concurrency cap. Bursts of spawns racing worktree
37
38
  // materialization + port churn during the registration window are what surface
@@ -95,6 +96,8 @@ interface ControlHandlerDeps {
95
96
  waitForRegistration?: WaitForRegistration;
96
97
  // Override the fail-safe registration timeout, bypassing the env parse (tests).
97
98
  registrationTimeoutMs?: number;
99
+ // Injectable stop dispatch — production uses tmux/system process teardown.
100
+ stopSession?: typeof stopSession;
98
101
  }
99
102
 
100
103
  export function createControlHandler(
@@ -104,6 +107,7 @@ export function createControlHandler(
104
107
  ): ControlHandler {
105
108
  let managedAgents: ManagedAgentReport[] = [];
106
109
  const dispatchSpawn = deps.spawnAgent ?? spawnAgent;
110
+ const dispatchStop = deps.stopSession ?? stopSession;
107
111
  // In-memory counter is correct here: a slot only needs to live within this
108
112
  // orchestrator process, and a crash resets in-flight spawns anyway (#881 Q1).
109
113
  const spawnSlots = new Semaphore(deps.maxConcurrentSpawns ?? resolveMaxConcurrentSpawns());
@@ -114,14 +118,38 @@ export function createControlHandler(
114
118
  // registration window. Tracked so tests/shutdown can observe and settle them.
115
119
  const backgroundSpawns = new Set<Promise<void>>();
116
120
 
117
- async function handleSpawn(ctrl: Record<string, any>): Promise<ManagedAgentReport> {
118
- const opts = spawnOptionsFromControl(ctrl, config);
121
+ async function spawnManagedAgent(opts: SpawnOptions, action = "Spawned"): Promise<ManagedAgentReport> {
119
122
  const agent = await dispatchSpawn(opts, config);
120
123
  managedAgents.push(agent);
121
- console.error(`[orchestrator] Spawned ${opts.provider} agent: ${agent.tmuxSession}`);
124
+ console.error(`[orchestrator] ${action} ${opts.provider} agent: ${agent.tmuxSession}`);
122
125
  return agent;
123
126
  }
124
127
 
128
+ async function spawnAndWaitForRegistration(opts: SpawnOptions, action = "Spawned"): Promise<ManagedAgentReport> {
129
+ const agent = await spawnManagedAgent(opts, action);
130
+ const registered = await waitForRegistration(agent, registrationTimeoutMs);
131
+ if (!registered) {
132
+ // Not a spawn failure — the process launched; it just didn't register in
133
+ // time. Release the slot (fail-safe) so a slow/never-registering child
134
+ // can't wedge capacity, but still treat the launch as successful.
135
+ console.error(`[orchestrator] spawn ${agent.tmuxSession} did not register within ${registrationTimeoutMs}ms; releasing slot (fail-safe)`);
136
+ }
137
+ return agent;
138
+ }
139
+
140
+ async function markSpawnCommandFailed(command: RelayCommand, error: unknown): Promise<void> {
141
+ console.error(`[orchestrator] spawn dispatch failed: ${errMessage(error)}`);
142
+ // #930 — the `failed` status write can itself reject during a relay blip. Wrap it so
143
+ // a failed status write can't re-throw out of the backgrounded task (belt-and-suspenders
144
+ // with the `.catch` in dispatchSpawnCommand). The command may strand non-terminal until
145
+ // the TTL sweep / watchdog reconciles it, but the task settles cleanly regardless.
146
+ try {
147
+ await relay.updateCommand(command.id, "failed", undefined, errMessage(error));
148
+ } catch (statusError) {
149
+ console.error(`[orchestrator] failed to mark spawn command failed: ${errMessage(statusError)}`);
150
+ }
151
+ }
152
+
125
153
  // #930/#880 — dispatch a spawn command concurrently, up to the per-host cap.
126
154
  //
127
155
  // The command is CLAIMED (status → accepted) synchronously here, BEFORE we
@@ -151,28 +179,31 @@ export function createControlHandler(
151
179
  async function runSpawnToRegistration(command: RelayCommand): Promise<void> {
152
180
  // Blocks FIFO until a slot frees — this is what bounds concurrent spawns to N.
153
181
  await spawnSlots.acquire();
182
+ let launched = false;
154
183
  try {
184
+ const current = await relay.getCommand(command.id);
185
+ if (!current || TERMINAL_COMMAND_STATUSES.has(current.status)) {
186
+ console.error(`[orchestrator] skipping spawn command ${command.id}: ${current ? `status is ${current.status}` : "command not found"}`);
187
+ return;
188
+ }
155
189
  await relay.updateCommand(command.id, "running");
156
- const agent = await handleSpawn(command.params);
157
- const registered = await waitForRegistration(agent, registrationTimeoutMs);
158
- if (!registered) {
159
- // Not a spawn failure — the process launched; it just didn't register in
160
- // time. Release the slot (fail-safe) so a slow/never-registering child
161
- // can't wedge capacity, but still settle the command as succeeded.
162
- console.error(`[orchestrator] spawn ${agent.tmuxSession} did not register within ${registrationTimeoutMs}ms; releasing slot (fail-safe)`);
190
+ try {
191
+ await spawnAndWaitForRegistration(spawnOptionsFromControl(command.params, config));
192
+ launched = true;
193
+ } catch (error) {
194
+ await markSpawnCommandFailed(command, error);
195
+ return;
163
196
  }
197
+ // #956 — from this point the child process launched. A relay blip while
198
+ // recording success is a settle failure, not a spawn failure; do not flip
199
+ // the command to failed and notify the parent while the child may register.
164
200
  await relay.updateCommand(command.id, "succeeded", { managedAgents });
165
201
  await relay.updateManagedAgents(managedAgents);
166
202
  } catch (error) {
167
- console.error(`[orchestrator] spawn dispatch failed: ${errMessage(error)}`);
168
- // #930 the `failed` status write can itself reject during a relay blip. Wrap it so
169
- // a failed status write can't re-throw out of the backgrounded task (belt-and-suspenders
170
- // with the `.catch` in dispatchSpawnCommand). The command may strand non-terminal until
171
- // the TTL sweep / watchdog reconciles it, but the task settles cleanly regardless.
172
- try {
173
- await relay.updateCommand(command.id, "failed", undefined, errMessage(error));
174
- } catch (statusError) {
175
- console.error(`[orchestrator] failed to mark spawn command failed: ${errMessage(statusError)}`);
203
+ if (launched) {
204
+ console.error(`[orchestrator] spawn command settle failed after launch: ${errMessage(error)}`);
205
+ } else {
206
+ await markSpawnCommandFailed(command, error);
176
207
  }
177
208
  } finally {
178
209
  spawnSlots.release();
@@ -192,9 +223,12 @@ export function createControlHandler(
192
223
  if (!session) {
193
224
  let restarted: ManagedAgentReport | undefined;
194
225
  if (restart && restartSpawn) {
195
- restarted = await spawnAgent(spawnOptionsFromRestartSource(restartSpawn, config), config);
196
- managedAgents.push(restarted);
197
- console.error(`[orchestrator] Restarted ${restarted.provider} agent without prior live session: ${restarted.tmuxSession}`);
226
+ await spawnSlots.acquire();
227
+ try {
228
+ restarted = await spawnAndWaitForRegistration(spawnOptionsFromRestartSource(restartSpawn, config), "Restarted");
229
+ } finally {
230
+ spawnSlots.release();
231
+ }
198
232
  }
199
233
  return {
200
234
  stopped: false,
@@ -206,7 +240,7 @@ export function createControlHandler(
206
240
  spawnRequestId: ctrl.spawnRequestId,
207
241
  };
208
242
  }
209
- const result = await stopSession(session, config, typeof ctrl.reason === "string" ? ctrl.reason : restart ? "restart" : "shutdown", ctrl.graceful !== false, shutdownTimeoutMs(ctrl));
243
+ const result = await dispatchStop(session, config, typeof ctrl.reason === "string" ? ctrl.reason : restart ? "restart" : "shutdown", ctrl.graceful !== false, shutdownTimeoutMs(ctrl));
210
244
  managedAgents = managedAgents.filter((agent) => (agent.sessionName ?? agent.tmuxSession) !== session);
211
245
  // A managed restart carries a fresh spawnRequestId in restartSpawn — keep it.
212
246
  // Falling back to the live agent's params would reuse the stale id and break
@@ -214,9 +248,12 @@ export function createControlHandler(
214
248
  const restartSource = (restartSpawn ?? (current ? { ...current, spawnRequestId: undefined } : undefined)) as Record<string, any> | undefined;
215
249
  let restarted: ManagedAgentReport | undefined;
216
250
  if (restart && restartSource) {
217
- restarted = await spawnAgent(spawnOptionsFromRestartSource(restartSource, config), config);
218
- managedAgents.push(restarted);
219
- console.error(`[orchestrator] Restarted ${restarted.provider} agent: ${restarted.tmuxSession}`);
251
+ await spawnSlots.acquire();
252
+ try {
253
+ restarted = await spawnAndWaitForRegistration(spawnOptionsFromRestartSource(restartSource, config), "Restarted");
254
+ } finally {
255
+ spawnSlots.release();
256
+ }
220
257
  }
221
258
  return {
222
259
  ...result,
@@ -270,7 +307,7 @@ export function createControlHandler(
270
307
  } else if (command.type === "workspace.merge") {
271
308
  let result: WorkspaceMergeResult;
272
309
  try {
273
- result = await withMergePhaseTimeout("total", () => mergeWorkspace({
310
+ result = await withMergePhaseTimeout("total", (signal) => mergeWorkspace({
274
311
  id: typeof command.params.workspaceId === "string" ? command.params.workspaceId : undefined,
275
312
  repoRoot: typeof command.params.repoRoot === "string" ? command.params.repoRoot : undefined,
276
313
  worktreePath: typeof command.params.worktreePath === "string" ? command.params.worktreePath : undefined,
@@ -289,6 +326,7 @@ export function createControlHandler(
289
326
  subject: typeof command.params.prLanded.subject === "string" ? command.params.prLanded.subject : undefined,
290
327
  }
291
328
  : undefined,
329
+ signal,
292
330
  }));
293
331
  } catch (err) {
294
332
  const branch = typeof command.params.branch === "string" ? command.params.branch : undefined;
package/src/git.ts CHANGED
@@ -10,6 +10,7 @@ type GitResult = Pick<ExecResult, "ok" | "stdout" | "stderr" | "exitCode" | "tim
10
10
  interface GitOptions {
11
11
  timeoutMs?: number;
12
12
  timeoutLabel?: string;
13
+ signal?: AbortSignal;
13
14
  }
14
15
 
15
16
  /** Run `git -C cwd <args>` and capture trimmed stdout/stderr; never throws. */
package/src/process.ts CHANGED
@@ -16,6 +16,7 @@ interface ExecOptions {
16
16
  timeoutMs?: number;
17
17
  timeoutLabel?: string;
18
18
  streamDrainGraceMs?: number;
19
+ signal?: AbortSignal;
19
20
  }
20
21
 
21
22
  const DEFAULT_STREAM_DRAIN_GRACE_MS = 1_000;
@@ -65,7 +66,18 @@ function timeoutMessage(cmd: string[], options: ExecOptions): string {
65
66
  return `${label} timed out after ${options.timeoutMs}ms`;
66
67
  }
67
68
 
69
+ function abortMessage(cmd: string[], options: ExecOptions): string {
70
+ const reason = options.signal?.reason;
71
+ if (reason instanceof Error && reason.message) return reason.message;
72
+ if (typeof reason === "string" && reason) return reason;
73
+ const label = options.timeoutLabel ?? cmd.join(" ");
74
+ return `${label} aborted`;
75
+ }
76
+
68
77
  export async function execProcess(cmd: string[], options: ExecOptions = {}): Promise<ExecResult> {
78
+ if (options.signal?.aborted) {
79
+ return { ok: false, exitCode: null, stdout: "", stderr: abortMessage(cmd, options) };
80
+ }
69
81
  const proc = Bun.spawn(cmd, {
70
82
  cwd: options.cwd,
71
83
  env: options.env,
@@ -76,27 +88,34 @@ export async function execProcess(cmd: string[], options: ExecOptions = {}): Pro
76
88
  const stdoutCapture = options.stdout === "ignore" ? captureStream(undefined) : captureStream(proc.stdout);
77
89
  const stderrCapture = options.stderr === "ignore" ? captureStream(undefined) : captureStream(proc.stderr);
78
90
  let timedOut = false;
91
+ let aborted = false;
79
92
  let timeout: ReturnType<typeof setTimeout> | undefined;
80
93
  let killTimeout: ReturnType<typeof setTimeout> | undefined;
81
94
  let timeoutResolve: ((value: null) => void) | undefined;
82
95
  const timeoutPromise = new Promise<null>((resolve) => { timeoutResolve = resolve; });
96
+ const abortProcess = () => {
97
+ aborted = true;
98
+ try { proc.kill("SIGTERM"); } catch {}
99
+ killTimeout = setTimeout(() => {
100
+ try { proc.kill("SIGKILL"); } catch {}
101
+ }, 1_000);
102
+ killTimeout.unref?.();
103
+ timeoutResolve?.(null);
104
+ };
105
+ options.signal?.addEventListener("abort", abortProcess, { once: true });
83
106
  if (options.timeoutMs && options.timeoutMs > 0) {
84
107
  timeout = setTimeout(() => {
85
108
  timedOut = true;
86
- try { proc.kill("SIGTERM"); } catch {}
87
- killTimeout = setTimeout(() => {
88
- try { proc.kill("SIGKILL"); } catch {}
89
- }, 1_000);
90
- killTimeout.unref?.();
91
- timeoutResolve?.(null);
109
+ abortProcess();
92
110
  }, options.timeoutMs);
93
111
  timeout.unref?.();
94
112
  }
95
113
 
96
- const exitCode = await (timeout ? Promise.race([proc.exited, timeoutPromise]) : proc.exited);
114
+ const exitCode = await (timeout || options.signal ? Promise.race([proc.exited, timeoutPromise]) : proc.exited);
115
+ options.signal?.removeEventListener("abort", abortProcess);
97
116
  if (timeout) clearTimeout(timeout);
98
- if (!timedOut && killTimeout) clearTimeout(killTimeout);
99
- if (timedOut) {
117
+ if (!timedOut && !aborted && killTimeout) clearTimeout(killTimeout);
118
+ if (timedOut || aborted) {
100
119
  stdoutCapture.cancel();
101
120
  stderrCapture.cancel();
102
121
  }
@@ -111,9 +130,12 @@ export async function execProcess(cmd: string[], options: ExecOptions = {}): Pro
111
130
  if (timedOut) {
112
131
  const msg = timeoutMessage(cmd, options);
113
132
  stderr = stderr ? `${stderr}\n${msg}` : msg;
133
+ } else if (aborted) {
134
+ const msg = abortMessage(cmd, options);
135
+ stderr = stderr ? `${stderr}\n${msg}` : msg;
114
136
  }
115
137
  return {
116
- ok: !timedOut && exitCode === 0,
138
+ ok: !timedOut && !aborted && exitCode === 0,
117
139
  exitCode,
118
140
  stdout: options.trimStdout === false ? stdout : stdout.trim(),
119
141
  stderr: options.trimStderr === false ? stderr : stderr.trim(),
package/src/relay.ts CHANGED
@@ -10,6 +10,7 @@ export interface RelayClient {
10
10
  heartbeat(): Promise<void>;
11
11
  updateManagedAgents(agents: ManagedAgentReport[], exitedAgents?: ManagedSessionExitDiagnostics[]): Promise<void>;
12
12
  pollCommands(): Promise<RelayCommand[]>;
13
+ getCommand(commandId: string): Promise<RelayCommand | null>;
13
14
  updateCommand(commandId: string, status: string, result?: Record<string, unknown>, error?: string): Promise<boolean>;
14
15
  acquireProviderQuotaLease(orchestratorId: string, input: ProviderQuotaLeaseAcquireInput): Promise<ProviderQuotaLeaseAcquireResult>;
15
16
  releaseProviderQuotaLease(orchestratorId: string, input: ProviderQuotaLeaseAcquireInput & { leaseToken: string }): Promise<{ released: boolean }>;
@@ -219,6 +220,12 @@ export function createRelayClient(config: OrchestratorConfig, probeCache: Provid
219
220
  return await res.json() as RelayCommand[];
220
221
  }
221
222
 
223
+ async function getCommand(commandId: string): Promise<RelayCommand | null> {
224
+ const res = await apiCall("GET", `/commands/${encodeURIComponent(commandId)}`);
225
+ if (!res.ok) return null;
226
+ return await res.json() as RelayCommand;
227
+ }
228
+
222
229
  async function updateCommand(commandId: string, status: string, result?: Record<string, unknown>, error?: string): Promise<boolean> {
223
230
  const res = await apiCall("PATCH", `/commands/${encodeURIComponent(commandId)}`, {
224
231
  status,
@@ -245,6 +252,7 @@ export function createRelayClient(config: OrchestratorConfig, probeCache: Provid
245
252
  heartbeat,
246
253
  updateManagedAgents,
247
254
  pollCommands,
255
+ getCommand,
248
256
  updateCommand,
249
257
  setApiUrl(url: string) { apiUrl = url; },
250
258
  startHeartbeatLoop,
@@ -66,7 +66,7 @@ export function buildEnv(opts: SpawnOptions & { label: string; agentId: string }
66
66
  .filter((v, i, a) => a.indexOf(v) === i)
67
67
  .join(":");
68
68
 
69
- return {
69
+ return sanitizeWorkspaceEnv({
70
70
  ...process.env as Record<string, string>,
71
71
  ...(config.token ? { AGENT_RELAY_TOKEN: config.token } : {}),
72
72
  ...config.env,
@@ -101,7 +101,7 @@ export function buildEnv(opts: SpawnOptions & { label: string; agentId: string }
101
101
  ...(opts.workspace ? { AGENT_RELAY_WORKSPACE_JSON: JSON.stringify(opts.workspace) } : {}),
102
102
  ...(opts.automationId ? { AGENT_RELAY_AUTOMATION_ID: opts.automationId } : {}),
103
103
  ...(opts.automationRunId ? { AGENT_RELAY_AUTOMATION_RUN_ID: opts.automationRunId } : {}),
104
- };
104
+ }, opts.workspaceMode);
105
105
  }
106
106
 
107
107
  function agentProfileEnv(profile: Record<string, unknown> | undefined): Record<string, string> {
@@ -109,3 +109,10 @@ function agentProfileEnv(profile: Record<string, unknown> | undefined): Record<s
109
109
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
110
110
  return Object.fromEntries(Object.entries(raw).filter((entry): entry is [string, string] => typeof entry[1] === "string"));
111
111
  }
112
+
113
+ function sanitizeWorkspaceEnv(env: Record<string, string>, workspaceMode: string | undefined): Record<string, string> {
114
+ if (workspaceMode !== "isolated") return env;
115
+ const isolatedEnv = { ...env };
116
+ delete isolatedEnv.PUBLIC_DIR;
117
+ return isolatedEnv;
118
+ }
@@ -13,7 +13,7 @@ export async function pruneWorktrees(input: { repoRoot?: string }): Promise<{ re
13
13
  // repoRoot points at the PARENT worktree (maybe deleted), not the real checkout (#278/#307);
14
14
  // every linked worktree shares the main `.git` (--git-common-dir), so derive the owner from
15
15
  // the worktree itself, falling back to `fallback` only when it can't be interrogated.
16
- async function owningRepoRoot(worktreePath: string, fallback: string): Promise<string> {
16
+ export async function owningRepoRoot(worktreePath: string, fallback: string): Promise<string> {
17
17
  const res = await git(["rev-parse", "--git-common-dir"], worktreePath);
18
18
  if (!res.ok || !res.stdout) return fallback;
19
19
  let commonDir = res.stdout;
@@ -56,7 +56,7 @@ export async function cleanupWorkspace(workspace: { repoRoot?: string; worktreeP
56
56
  return { workspaceId: workspace.id, removed: true, worktreePath: path, branchDeleted, ...(branchPreservedReason ? { branchPreservedReason } : {}), containerRemoved };
57
57
  }
58
58
 
59
- async function branchSafeToDelete(repo: string, branch: string, baseRef?: string, baseSha?: string): Promise<{ safe: boolean; reason?: string }> {
59
+ export async function branchSafeToDelete(repo: string, branch: string, baseRef?: string, baseSha?: string): Promise<{ safe: boolean; reason?: string }> {
60
60
  const branchRef = await resolveCommit(repo, branch) ? branch : await resolveCommit(repo, `refs/heads/${branch}`) ? `refs/heads/${branch}` : undefined;
61
61
  if (!branchRef) return { safe: true };
62
62
 
@@ -79,6 +79,15 @@ async function branchSafeToDelete(repo: string, branch: string, baseRef?: string
79
79
  : { safe: false, reason: `${unmergedAhead} unlanded commit(s)` };
80
80
  }
81
81
 
82
+ export async function deleteBranchIfSafe(repo: string, branch: string, baseRef?: string, baseSha?: string, signal?: AbortSignal): Promise<{ branchDeleted: boolean; branchPreservedReason?: string }> {
83
+ if (signal?.aborted) throw signal.reason instanceof Error ? signal.reason : new Error("workspace merge aborted");
84
+ const safety = await branchSafeToDelete(repo, branch, baseRef, baseSha);
85
+ if (signal?.aborted) throw signal.reason instanceof Error ? signal.reason : new Error("workspace merge aborted");
86
+ if (!safety.safe) return { branchDeleted: false, ...(safety.reason ? { branchPreservedReason: safety.reason } : {}) };
87
+ const branchDeleted = (await git(["branch", "-D", branch], repo, { signal })).ok;
88
+ return branchDeleted ? { branchDeleted } : { branchDeleted, branchPreservedReason: "branch delete failed" };
89
+ }
90
+
82
91
  async function resolveCleanupBase(repo: string, baseRef?: string, baseSha?: string): Promise<string | undefined> {
83
92
  for (const candidate of [baseRef, baseSha, "main", "master"]) {
84
93
  if (candidate && await resolveCommit(repo, candidate)) return candidate;
@@ -75,8 +75,8 @@ export async function populateMergeState(cwd: string, targetRef: string, state:
75
75
  /** Remote-tracking branch a local base tracks (e.g. `main` -> `origin/main`),
76
76
  * or undefined when base has no upstream or is a bare sha. Squash PRs land on
77
77
  * the remote, so the upstream is the truthful "has this merged?" reference. */
78
- export async function upstreamRef(worktreePath: string, base: string): Promise<string | undefined> {
79
- const res = await git(["rev-parse", "--abbrev-ref", `${base}@{upstream}`], worktreePath);
78
+ export async function upstreamRef(worktreePath: string, base: string, signal?: AbortSignal): Promise<string | undefined> {
79
+ const res = await git(["rev-parse", "--abbrev-ref", `${base}@{upstream}`], worktreePath, { signal });
80
80
  return res.ok && res.stdout ? res.stdout : undefined;
81
81
  }
82
82
 
@@ -88,15 +88,15 @@ export async function upstreamRef(worktreePath: string, base: string): Promise<s
88
88
  * base. Falls back to the local base ref when there is no upstream (no remote) or the
89
89
  * upstream ref can't be resolved — the caller still verifies it contains the merge SHA.
90
90
  */
91
- export async function syncBaseFromOrigin(worktreePath: string, base: string | undefined): Promise<string | undefined> {
91
+ export async function syncBaseFromOrigin(worktreePath: string, base: string | undefined, signal?: AbortSignal): Promise<string | undefined> {
92
92
  if (!base) return undefined;
93
- const upstream = await upstreamRef(worktreePath, base);
93
+ const upstream = await upstreamRef(worktreePath, base, signal);
94
94
  if (!upstream) return base;
95
95
  const slash = upstream.indexOf("/");
96
96
  const remote = slash > 0 ? upstream.slice(0, slash) : undefined;
97
97
  const remoteBranch = slash > 0 ? upstream.slice(slash + 1) : base;
98
- if (remote) await git(["fetch", remote, remoteBranch], worktreePath); // best-effort freshness
99
- return (await git(["rev-parse", "--verify", "--quiet", upstream], worktreePath)).ok ? upstream : base;
98
+ if (remote) await git(["fetch", remote, remoteBranch], worktreePath, { signal }); // best-effort freshness
99
+ return (await git(["rev-parse", "--verify", "--quiet", upstream], worktreePath, { signal })).ok ? upstream : base;
100
100
  }
101
101
 
102
102
  async function resolveBaseRef(worktreePath: string, baseRef?: string, baseSha?: string): Promise<string | undefined> {
@@ -26,20 +26,44 @@ export function mergePhaseTimeoutMs(phase: MergePhase): number {
26
26
  ?? MERGE_PHASE_TIMEOUTS_MS[phase];
27
27
  }
28
28
 
29
- export async function withMergePhaseTimeout<T>(phase: MergePhase, run: () => Promise<T>): Promise<T> {
29
+ function abortError(signal: AbortSignal): Error {
30
+ const reason = signal.reason;
31
+ if (reason instanceof Error) return reason;
32
+ if (typeof reason === "string" && reason) return new Error(reason);
33
+ return new Error("workspace merge aborted");
34
+ }
35
+
36
+ export function throwIfMergeAborted(signal?: AbortSignal): void {
37
+ if (signal?.aborted) throw abortError(signal);
38
+ }
39
+
40
+ export async function withMergePhaseTimeout<T>(
41
+ phase: MergePhase,
42
+ run: (signal: AbortSignal) => Promise<T>,
43
+ options: { signal?: AbortSignal } = {},
44
+ ): Promise<T> {
30
45
  const timeoutMs = mergePhaseTimeoutMs(phase);
46
+ const controller = new AbortController();
31
47
  let timeout: ReturnType<typeof setTimeout> | undefined;
32
- const work = run();
48
+ const abortFromParent = () => controller.abort(abortError(options.signal!));
49
+ if (options.signal?.aborted) controller.abort(abortError(options.signal));
50
+ else options.signal?.addEventListener("abort", abortFromParent, { once: true });
51
+ const work = run(controller.signal);
33
52
  work.catch(() => {});
34
53
  try {
35
54
  return await Promise.race([
36
55
  work,
37
56
  new Promise<T>((_, reject) => {
38
- timeout = setTimeout(() => reject(new Error(`workspace merge phase "${phase}" timed out after ${timeoutMs}ms`)), timeoutMs);
57
+ timeout = setTimeout(() => {
58
+ const error = new Error(`workspace merge phase "${phase}" timed out after ${timeoutMs}ms`);
59
+ controller.abort(error);
60
+ reject(error);
61
+ }, timeoutMs);
39
62
  timeout.unref?.();
40
63
  }),
41
64
  ]);
42
65
  } finally {
43
66
  if (timeout) clearTimeout(timeout);
67
+ options.signal?.removeEventListener("abort", abortFromParent);
44
68
  }
45
69
  }
@@ -4,27 +4,32 @@ import { errMessage, type BaseWorktreeSyncResult, type WorkspaceMergePreview, ty
4
4
  import { git, gitRaw } from "../git";
5
5
  import { execProcess } from "../process";
6
6
  import { prMergedState } from "../workspace-pr";
7
+ import { deleteBranchIfSafe, owningRepoRoot } from "./cleanup";
7
8
  import { refreshWorkspaceDeps } from "./deps";
8
9
  import { type LandGatesResult } from "./land-gates-runner";
9
10
  import { populateMergeState, resolveBranchRef, syncBaseFromOrigin, workspaceGitState } from "./git-state";
10
- import { type MergePhase, mergePhaseTimeoutMs, withMergePhaseTimeout } from "./merge-timeouts";
11
+ import { type MergePhase, mergePhaseTimeoutMs, throwIfMergeAborted, withMergePhaseTimeout } from "./merge-timeouts";
11
12
  import { nextBranchName } from "./names";
12
13
  import { parseWorktrees, shortBranch } from "./parse";
13
14
  import { json, resolveRequestedPath } from "./request";
14
15
  import type { WorkspaceMergeInput } from "./types";
15
16
  import { workspacePushEnabled } from "../config";
16
17
 
17
- async function mergeGit(args: string[], cwd: string, phase: MergePhase, timeoutLabel?: string): ReturnType<typeof git> {
18
+ async function mergeGit(args: string[], cwd: string, phase: MergePhase, timeoutLabel?: string, signal?: AbortSignal): ReturnType<typeof git> {
19
+ throwIfMergeAborted(signal);
18
20
  return git(args, cwd, {
19
21
  timeoutMs: mergePhaseTimeoutMs(phase),
20
22
  timeoutLabel: timeoutLabel ?? `workspace merge ${phase} git ${args.join(" ")}`,
23
+ signal,
21
24
  });
22
25
  }
23
26
 
24
- async function mergeGitRaw(args: string[], cwd: string, phase: MergePhase, timeoutLabel?: string): ReturnType<typeof gitRaw> {
27
+ async function mergeGitRaw(args: string[], cwd: string, phase: MergePhase, timeoutLabel?: string, signal?: AbortSignal): ReturnType<typeof gitRaw> {
28
+ throwIfMergeAborted(signal);
25
29
  return gitRaw(args, cwd, {
26
30
  timeoutMs: mergePhaseTimeoutMs(phase),
27
31
  timeoutLabel: timeoutLabel ?? `workspace merge ${phase} git ${args.join(" ")}`,
32
+ signal,
28
33
  });
29
34
  }
30
35
 
@@ -33,8 +38,8 @@ function gitError(result: Awaited<ReturnType<typeof git>>, fallback: string): st
33
38
  }
34
39
 
35
40
  /** Behind-count of HEAD relative to `base`, from inside `worktreePath`. */
36
- async function countBehind(worktreePath: string, base: string): Promise<{ behind: number } | { error: string }> {
37
- const counts = await mergeGit(["rev-list", "--left-right", "--count", `${base}...HEAD`], worktreePath, "rebase", "workspace merge count behind integration base");
41
+ async function countBehind(worktreePath: string, base: string, signal?: AbortSignal): Promise<{ behind: number } | { error: string }> {
42
+ const counts = await mergeGit(["rev-list", "--left-right", "--count", `${base}...HEAD`], worktreePath, "rebase", "workspace merge count behind integration base", signal);
38
43
  if (!counts.ok || !counts.stdout) return { error: gitError(counts, "failed to count branch behind integration base") };
39
44
  const behind = Number(counts.stdout.split(/\s+/)[0]);
40
45
  return Number.isFinite(behind) ? { behind } : { error: "git rev-list produced an invalid behind count" };
@@ -75,12 +80,12 @@ async function baseBranchName(worktreePath: string, baseRef?: string): Promise<s
75
80
  }
76
81
 
77
82
  /** Locate the worktree (if any) that currently has `branch` checked out. */
78
- async function worktreeForBranch(repoRoot: string, branch: string): Promise<{ path: string; dirty: boolean } | undefined> {
79
- const list = await mergeGit(["worktree", "list", "--porcelain"], repoRoot, "rebase", "workspace merge list worktrees");
83
+ async function worktreeForBranch(repoRoot: string, branch: string, signal?: AbortSignal): Promise<{ path: string; dirty: boolean } | undefined> {
84
+ const list = await mergeGit(["worktree", "list", "--porcelain"], repoRoot, "rebase", "workspace merge list worktrees", signal);
80
85
  if (!list.ok) return undefined;
81
86
  const match = parseWorktrees(list.stdout).find((worktree) => worktree.branch === branch);
82
87
  if (!match) return undefined;
83
- const status = await mergeGit(["status", "--porcelain"], match.path, "rebase", `workspace merge status ${branch} worktree`);
88
+ const status = await mergeGit(["status", "--porcelain"], match.path, "rebase", `workspace merge status ${branch} worktree`, signal);
84
89
  return { path: match.path, dirty: status.ok ? status.stdout.length > 0 : true };
85
90
  }
86
91
 
@@ -88,8 +93,8 @@ function splitNul(stdout: string): string[] {
88
93
  return stdout.split("\0").filter(Boolean);
89
94
  }
90
95
 
91
- async function dirtyPathSet(worktreePath: string): Promise<Set<string>> {
92
- const status = await mergeGitRaw(["status", "--porcelain=v1", "-z", "--untracked-files=all"], worktreePath, "rebase", "workspace merge dirty path scan");
96
+ async function dirtyPathSet(worktreePath: string, signal?: AbortSignal): Promise<Set<string>> {
97
+ const status = await mergeGitRaw(["status", "--porcelain=v1", "-z", "--untracked-files=all"], worktreePath, "rebase", "workspace merge dirty path scan", signal);
93
98
  if (!status.ok) return new Set<string>();
94
99
  const paths = new Set<string>();
95
100
  const entries = splitNul(status.stdout);
@@ -104,26 +109,26 @@ async function dirtyPathSet(worktreePath: string): Promise<Set<string>> {
104
109
  return paths;
105
110
  }
106
111
 
107
- async function changedPathList(worktreePath: string, oldBaseTip: string, newBaseTip: string): Promise<string[]> {
108
- const diff = await mergeGitRaw(["diff", "--name-only", "-z", oldBaseTip, newBaseTip], worktreePath, "rebase", "workspace merge changed path scan");
112
+ async function changedPathList(worktreePath: string, oldBaseTip: string, newBaseTip: string, signal?: AbortSignal): Promise<string[]> {
113
+ const diff = await mergeGitRaw(["diff", "--name-only", "-z", oldBaseTip, newBaseTip], worktreePath, "rebase", "workspace merge changed path scan", signal);
109
114
  return diff.ok ? splitNul(diff.stdout) : [];
110
115
  }
111
116
 
112
- async function restorePathsFromHead(worktreePath: string, paths: string[]): Promise<boolean> {
117
+ async function restorePathsFromHead(worktreePath: string, paths: string[], signal?: AbortSignal): Promise<boolean> {
113
118
  if (paths.length === 0) return true;
114
- return (await mergeGit(["restore", "--staged", "--worktree", "--", ...paths], worktreePath, "rebase", "workspace merge restore clean landed paths")).ok;
119
+ return (await mergeGit(["restore", "--staged", "--worktree", "--", ...paths], worktreePath, "rebase", "workspace merge restore clean landed paths", signal)).ok;
115
120
  }
116
121
 
117
- async function resetIndexPathsToHead(worktreePath: string, paths: string[]): Promise<boolean> {
122
+ async function resetIndexPathsToHead(worktreePath: string, paths: string[], signal?: AbortSignal): Promise<boolean> {
118
123
  if (paths.length === 0) return true;
119
- return (await mergeGit(["reset", "-q", "HEAD", "--", ...paths], worktreePath, "rebase", "workspace merge reset preserved paths")).ok;
124
+ return (await mergeGit(["reset", "-q", "HEAD", "--", ...paths], worktreePath, "rebase", "workspace merge reset preserved paths", signal)).ok;
120
125
  }
121
126
 
122
127
  /** Landed paths whose working copy still differs from the advanced HEAD (ground truth, not
123
128
  * exit codes): the checkout is mixed for these — reads/builds/publishes see STALE files. */
124
- async function pathsDifferingFromHead(worktreePath: string, paths: string[]): Promise<string[]> {
129
+ async function pathsDifferingFromHead(worktreePath: string, paths: string[], signal?: AbortSignal): Promise<string[]> {
125
130
  if (paths.length === 0) return [];
126
- const diff = await mergeGitRaw(["diff", "--name-only", "-z", "HEAD", "--", ...paths], worktreePath, "rebase", "workspace merge verify base worktree sync");
131
+ const diff = await mergeGitRaw(["diff", "--name-only", "-z", "HEAD", "--", ...paths], worktreePath, "rebase", "workspace merge verify base worktree sync", signal);
127
132
  // A failed diff can't prove the checkout is clean — treat every landed path as suspect.
128
133
  return diff.ok ? splitNul(diff.stdout) : [...paths];
129
134
  }
@@ -165,24 +170,25 @@ async function syncDirtyBaseWorktreeAfterRefAdvance(
165
170
  oldBaseTip: string,
166
171
  newBaseTip: string,
167
172
  dirtyBefore: Set<string> | undefined,
173
+ signal?: AbortSignal,
168
174
  ): Promise<BaseWorktreeSyncResult> {
169
175
  if (!baseWorktree?.dirty || !dirtyBefore) return RECONCILED;
170
- const readTree = await mergeGit(["read-tree", "-m", "-u", oldBaseTip, newBaseTip], baseWorktree.path, "rebase", "workspace merge sync dirty base worktree");
176
+ const readTree = await mergeGit(["read-tree", "-m", "-u", oldBaseTip, newBaseTip], baseWorktree.path, "rebase", "workspace merge sync dirty base worktree", signal);
171
177
  if (readTree.ok) return RECONCILED;
172
178
 
173
- const changedPaths = await changedPathList(baseWorktree.path, oldBaseTip, newBaseTip);
179
+ const changedPaths = await changedPathList(baseWorktree.path, oldBaseTip, newBaseTip, signal);
174
180
  const cleanChangedPaths = changedPaths.filter((path) => !dirtyBefore.has(path));
175
181
  const dirtyChangedPaths = changedPaths.filter((path) => dirtyBefore.has(path));
176
182
  // Clean landed paths carry no human WIP — force them to HEAD (restores content AND mode).
177
- await restorePathsFromHead(baseWorktree.path, cleanChangedPaths);
183
+ await restorePathsFromHead(baseWorktree.path, cleanChangedPaths, signal);
178
184
  // Landed paths the human also edited — preserve the WIP, normalize the index to HEAD so the
179
185
  // difference surfaces as an unstaged modification (not a staged reverse diff). #681.
180
- await resetIndexPathsToHead(baseWorktree.path, dirtyChangedPaths);
186
+ await resetIndexPathsToHead(baseWorktree.path, dirtyChangedPaths, signal);
181
187
 
182
188
  // Ground-truth check (#824): re-derive which landed paths are STILL out of sync with the
183
189
  // advanced HEAD rather than trusting the restore/reset exit codes. A clean landed path left
184
190
  // stale here is the silent strand that shipped a stale file into the #823 bootstrap publish.
185
- const unsyncedPaths = await pathsDifferingFromHead(baseWorktree.path, changedPaths);
191
+ const unsyncedPaths = await pathsDifferingFromHead(baseWorktree.path, changedPaths, signal);
186
192
  if (unsyncedPaths.length === 0) return RECONCILED;
187
193
  const preservedWipPaths = unsyncedPaths.filter((path) => dirtyBefore.has(path));
188
194
  return {
@@ -365,6 +371,8 @@ function validPreviewStrategy(strategy: string | null): "pr" | "rebase-ff" | "au
365
371
  */
366
372
  export async function mergeWorkspace(input: WorkspaceMergeInput): Promise<WorkspaceMergeResult> {
367
373
  if (!input.worktreePath) return { strategy: "rebase-ff", merged: false, status: "review_requested", error: "worktreePath required", workspaceId: input.id };
374
+ const signal = input.signal;
375
+ throwIfMergeAborted(signal);
368
376
  const worktreePath = resolve(input.worktreePath);
369
377
  const repoRoot = input.repoRoot ? resolve(input.repoRoot) : worktreePath;
370
378
  console.error(`[orchestrator] workspace.merge prep-start workspace=${input.id ?? "(unknown)"} worktree=${worktreePath} repo=${repoRoot}`);
@@ -372,14 +380,15 @@ export async function mergeWorkspace(input: WorkspaceMergeInput): Promise<Worksp
372
380
  // DB-recorded branch only when the live probe fails (detached HEAD, missing worktree, etc.).
373
381
  // This fixes #232: a stale DB branch value (non-null mismatch) would pass through the
374
382
  // `input.branch ?? ...` guard unchanged and cause git to attempt merging a non-existent ref.
375
- const liveBranch = shortBranch((await mergeGit(["symbolic-ref", "--quiet", "--short", "HEAD"], worktreePath, "prep", "workspace merge resolve live branch")).stdout || undefined);
383
+ const liveBranch = shortBranch((await mergeGit(["symbolic-ref", "--quiet", "--short", "HEAD"], worktreePath, "prep", "workspace merge resolve live branch", signal)).stdout || undefined);
376
384
  const branch = liveBranch ?? input.branch;
377
385
  let preview: WorkspaceMergePreview;
378
386
  try {
379
- preview = await withMergePhaseTimeout("preview", () => previewWorkspaceMerge({ worktreePath, baseRef: input.baseRef, baseSha: input.baseSha, strategy: input.strategy }));
387
+ preview = await withMergePhaseTimeout("preview", () => previewWorkspaceMerge({ worktreePath, baseRef: input.baseRef, baseSha: input.baseSha, strategy: input.strategy }), { signal });
380
388
  } catch (err) {
381
389
  return { workspaceId: input.id, strategy: "rebase-ff", merged: false, status: "review_requested", branch, error: errMessage(err) };
382
390
  }
391
+ throwIfMergeAborted(signal);
383
392
  const strategy = preview.strategy;
384
393
  const head = (field: Partial<WorkspaceMergeResult>): WorkspaceMergeResult => ({ workspaceId: input.id, strategy, merged: false, status: "review_requested", branch, baseRef: preview.baseRef, ...field });
385
394
 
@@ -400,22 +409,23 @@ export async function mergeWorkspace(input: WorkspaceMergeInput): Promise<Worksp
400
409
  // hasn't propagated the PR merge yet, fall through rather than reset onto a base
401
410
  // missing the landed work — never reset on an unverified ground-truth/refs mismatch.
402
411
  if (input.prLanded?.sha && (preview.dirtyCount ?? 0) === 0) {
403
- const startRef = await syncBaseFromOrigin(worktreePath, preview.baseRef);
404
- if (startRef && (await git(["merge-base", "--is-ancestor", input.prLanded.sha, startRef], worktreePath)).ok) {
412
+ const startRef = await syncBaseFromOrigin(worktreePath, preview.baseRef, signal);
413
+ throwIfMergeAborted(signal);
414
+ if (startRef && (await mergeGit(["merge-base", "--is-ancestor", input.prLanded.sha, startRef], worktreePath, "rebase", "workspace merge verify PR landed sha on base", signal)).ok) {
405
415
  const landedPreview: WorkspaceMergePreview = { ...preview, noop: true, prMerged: true, prMergeSha: input.prLanded.sha, reason: "PR merged on remote" };
406
- return await resolveNoopMerge(input, worktreePath, repoRoot, branch, landedPreview, head, startRef);
416
+ return await resolveNoopMerge(input, worktreePath, repoRoot, branch, landedPreview, head, startRef, signal);
407
417
  }
408
418
  }
409
419
  // Nothing to land (ahead=0, clean): the branch tree is already in base. Resolve it
410
420
  // to a terminal state so it leaves the steward queue instead of looping forever in
411
421
  // review_requested (#230). Reclaim the spent worktree/branch when the owner is gone.
412
- if (preview.noop) return await resolveNoopMerge(input, worktreePath, repoRoot, branch, preview, head);
422
+ if (preview.noop) return await resolveNoopMerge(input, worktreePath, repoRoot, branch, preview, head, undefined, signal);
413
423
  if (preview.reason) return head({ status: "review_requested", error: preview.reason });
414
424
  if (preview.conflict) return head({ conflict: true, status: "conflict", error: "merge would conflict with base" });
415
425
 
416
- if (strategy === "pr") return await mergePr(input, worktreePath, branch, preview, head);
426
+ if (strategy === "pr") return await mergePr(input, worktreePath, branch, preview, head, signal);
417
427
  console.error(`[orchestrator] workspace.merge rebase-start workspace=${input.id ?? "(unknown)"} branch=${branch ?? "(unknown)"} base=${preview.baseRef ?? "(unknown)"}`);
418
- return await mergeRebaseFf(input, worktreePath, repoRoot, branch, preview, head);
428
+ return await mergeRebaseFf(input, worktreePath, repoRoot, branch, preview, head, signal);
419
429
  }
420
430
 
421
431
  /**
@@ -440,27 +450,31 @@ async function resolveNoopMerge(
440
450
  // the upstream tip (e.g. origin/main) so the live owner continues off UPDATED base rather
441
451
  // than the worktree's stale local base.
442
452
  startRef?: string,
453
+ signal?: AbortSignal,
443
454
  ): Promise<WorkspaceMergeResult> {
455
+ throwIfMergeAborted(signal);
456
+ const ownerRepo = branch ? await owningRepoRoot(worktreePath, repoRoot) : repoRoot;
444
457
  // Live owner (#327): recycle-to-continue instead of bricking the session.
445
458
  if (input.deleteBranch === false) {
446
459
  const base = preview.baseRef;
447
460
  if (base && branch) {
448
- const fresh = await nextBranchName(repoRoot, branch);
461
+ const fresh = await nextBranchName(ownerRepo, branch);
449
462
  // #478 — cut from the FETCHED upstream tip so even a plain noop recycle (no
450
463
  // startRef) advances to origin/main, not the stale local base. The PR-land
451
464
  // recycle (#423) already passes startRef (the verified upstream sha); this
452
465
  // makes the rebase-ff noop path equally multi-host-correct.
453
- const start = startRef ?? await syncBaseFromOrigin(worktreePath, base) ?? base;
454
- if ((await git(["checkout", "-B", fresh, start], worktreePath)).ok) {
455
- // Old branch's tree is already in base (that's what noop means) safe to drop.
456
- const oldDeleted = (await git(["branch", "-D", branch], repoRoot)).ok;
457
- const baseSha = (await git(["rev-parse", start], worktreePath)).stdout || undefined;
466
+ const start = startRef ?? await syncBaseFromOrigin(worktreePath, base, signal) ?? base;
467
+ throwIfMergeAborted(signal);
468
+ if ((await mergeGit(["checkout", "-B", fresh, start], worktreePath, "rebase", `workspace merge noop recycle worktree to ${fresh}`, signal)).ok) {
469
+ const baseSha = (await mergeGit(["rev-parse", start], worktreePath, "rebase", `workspace merge resolve noop recycle start ${start}`, signal)).stdout || undefined;
470
+ const deleteResult = await deleteBranchIfSafe(ownerRepo, branch, undefined, baseSha ?? start, signal);
458
471
  // Recycled onto base, which may declare deps the symlinked node_modules lacks (#51).
459
- const depsRefresh = await refreshWorkspaceDeps(repoRoot, worktreePath);
472
+ const depsRefresh = await refreshWorkspaceDeps(ownerRepo, worktreePath);
473
+ throwIfMergeAborted(signal);
460
474
  const reportDeps = depsRefresh.refreshed || depsRefresh.stale || depsRefresh.error;
461
475
  // merged:false → no `branch.landed` notice (nothing landed); newBranch makes the
462
476
  // relay repoint the row and return it to `active` rather than terminal `merged`.
463
- return head({ merged: false, noop: true, status: "active", baseSha, worktreeRemoved: false, branch: fresh, newBranch: fresh, branchDeleted: oldDeleted, ...(reportDeps ? { depsRefresh } : {}), error: undefined });
477
+ return head({ merged: false, noop: true, status: "active", baseSha, worktreeRemoved: false, branch: fresh, newBranch: fresh, ...deleteResult, ...(reportDeps ? { depsRefresh } : {}), error: undefined });
464
478
  }
465
479
  }
466
480
  // No base or checkout failed — stay live on the current branch, don't strand at `merged`.
@@ -468,10 +482,11 @@ async function resolveNoopMerge(
468
482
  }
469
483
  // Owner is gone — reclaim the spent worktree/branch and go terminal.
470
484
  if (branch) {
471
- const removed = await git(["worktree", "remove", "--force", worktreePath], repoRoot);
485
+ throwIfMergeAborted(signal);
486
+ const removed = await mergeGit(["worktree", "remove", "--force", worktreePath], ownerRepo, "cleanup", "workspace merge remove noop worktree", signal);
472
487
  const worktreeRemoved = removed.ok;
473
- const branchDeleted = worktreeRemoved ? (await git(["branch", "-D", branch], repoRoot)).ok : false;
474
- return head({ status: "merged", noop: true, worktreeRemoved, branchDeleted, error: undefined });
488
+ const deleteResult = worktreeRemoved ? await deleteBranchIfSafe(ownerRepo, branch, preview.baseRef, undefined, signal) : { branchDeleted: false };
489
+ return head({ status: "merged", noop: true, worktreeRemoved, ...deleteResult, error: undefined });
475
490
  }
476
491
  return head({ status: "merged", noop: true, error: undefined });
477
492
  }
@@ -482,19 +497,22 @@ async function mergePr(
482
497
  branch: string | undefined,
483
498
  preview: WorkspaceMergePreview,
484
499
  head: (field: Partial<WorkspaceMergeResult>) => WorkspaceMergeResult,
500
+ signal?: AbortSignal,
485
501
  ): Promise<WorkspaceMergeResult> {
486
502
  if (!branch) return head({ status: "review_requested", error: "cannot determine branch to push" });
487
503
  const base = preview.baseRef;
488
- const push = await git(["push", "-u", "origin", branch], worktreePath);
504
+ throwIfMergeAborted(signal);
505
+ const push = await mergeGit(["push", "-u", "origin", branch], worktreePath, "rebase", `workspace merge push ${branch} to origin`, signal);
489
506
  if (!push.ok) return head({ status: "review_requested", error: push.stderr || "git push failed" });
490
507
 
491
- const title = input.prTitle || (await git(["log", "-1", "--format=%s"], worktreePath)).stdout || `Merge ${branch}`;
508
+ const title = input.prTitle || (await mergeGit(["log", "-1", "--format=%s"], worktreePath, "rebase", "workspace merge read PR title", signal)).stdout || `Merge ${branch}`;
492
509
  const body = input.prBody || `Automated PR for agent workspace branch \`${branch}\`.`;
493
510
  const args = ["pr", "create", "--head", branch, "--title", title, "--body", body];
494
511
  if (base) args.push("--base", base);
495
512
  // Pass process.env explicitly so runtime env mutations (e.g. test PATH injection)
496
513
  // are visible to the child process. Bun's default is the startup-time env snapshot.
497
- const proc = await execProcess(["gh", ...args], { cwd: worktreePath, env: process.env });
514
+ throwIfMergeAborted(signal);
515
+ const proc = await execProcess(["gh", ...args], { cwd: worktreePath, env: process.env, signal });
498
516
  const stdout = proc.stdout;
499
517
  if (!proc.ok) {
500
518
  return head({ status: "review_requested", error: proc.stderr || "gh pr create failed" });
@@ -518,7 +536,8 @@ async function mergePr(
518
536
  // Never throw — if arming fails (repo has auto-merge disabled) we still return
519
537
  // merge_planned so the relay reconcile scan can finalize when the PR merges.
520
538
  const mergeTarget = prUrl ?? branch!;
521
- const armProc = await execProcess(["gh", "pr", "merge", mergeTarget, "--auto", "--merge"], { cwd: worktreePath, env: process.env });
539
+ throwIfMergeAborted(signal);
540
+ const armProc = await execProcess(["gh", "pr", "merge", mergeTarget, "--auto", "--merge"], { cwd: worktreePath, env: process.env, signal });
522
541
  if (!armProc.ok) {
523
542
  // Arm failed (e.g. repo has auto-merge disabled) — return merge_planned so the
524
543
  // reconcile scan still finalizes when the PR is merged by a human.
@@ -550,35 +569,28 @@ function landMergeMessage(branch: string | undefined, subject: string | undefine
550
569
  // moves (#902/#903) and, via {@link recordNoFfMerge}, to advance base when no working tree is
551
570
  // available. Pure object creation: no ref is touched, so a caller can throw it away freely.
552
571
  async function synthesizeNoFfMerge(
553
- repoRoot: string,
554
- baseSha: string,
555
- branchSha: string,
556
- message: string,
557
- timeoutMs?: number,
572
+ repoRoot: string, baseSha: string, branchSha: string, message: string,
573
+ timeoutMs?: number, signal?: AbortSignal,
558
574
  ): Promise<{ ok: true; mergeSha: string } | { ok: false; conflict?: boolean; error: string }> {
559
- const tree = await git(["merge-tree", "--write-tree", baseSha, branchSha], repoRoot, { timeoutMs, timeoutLabel: "workspace merge synthesize merge-tree" });
575
+ const tree = await git(["merge-tree", "--write-tree", baseSha, branchSha], repoRoot, { timeoutMs, timeoutLabel: "workspace merge synthesize merge-tree", signal });
560
576
  if (!tree.ok) return { ok: false, conflict: true, error: tree.stdout || tree.stderr || "merge conflict computing tree" };
561
577
  const treeOid = tree.stdout.split("\n")[0]?.trim();
562
578
  if (!treeOid) return { ok: false, error: "merge-tree produced no tree oid" };
563
579
  const commit = await git(
564
580
  ["-c", `user.name=${LAND_COMMITTER.name}`, "-c", `user.email=${LAND_COMMITTER.email}`, "commit-tree", treeOid, "-p", baseSha, "-p", branchSha, "-m", message],
565
581
  repoRoot,
566
- { timeoutMs, timeoutLabel: "workspace merge synthesize commit-tree" },
582
+ { timeoutMs, timeoutLabel: "workspace merge synthesize commit-tree", signal },
567
583
  );
568
584
  if (!commit.ok || !commit.stdout) return { ok: false, error: commit.stderr || "commit-tree failed" };
569
585
  return { ok: true, mergeSha: commit.stdout };
570
586
  }
571
587
 
572
588
  async function recordNoFfMerge(
573
- repoRoot: string,
574
- base: string,
575
- baseSha: string,
576
- branchSha: string,
577
- message: string,
589
+ repoRoot: string, base: string, baseSha: string, branchSha: string, message: string, signal?: AbortSignal,
578
590
  ): Promise<{ ok: true; mergeSha: string } | { ok: false; conflict?: boolean; error: string }> {
579
- const synth = await synthesizeNoFfMerge(repoRoot, baseSha, branchSha, message, mergePhaseTimeoutMs("synthesize"));
591
+ const synth = await synthesizeNoFfMerge(repoRoot, baseSha, branchSha, message, mergePhaseTimeoutMs("synthesize"), signal);
580
592
  if (!synth.ok) return synth;
581
- const update = await mergeGit(["update-ref", `refs/heads/${base}`, synth.mergeSha, baseSha], repoRoot, "rebase", `workspace merge advance ${base} to synthesized merge`);
593
+ const update = await mergeGit(["update-ref", `refs/heads/${base}`, synth.mergeSha, baseSha], repoRoot, "rebase", `workspace merge advance ${base} to synthesized merge`, signal);
582
594
  if (!update.ok) return { ok: false, error: update.stderr || "failed to advance base ref" };
583
595
  return { ok: true, mergeSha: synth.mergeSha };
584
596
  }
@@ -623,26 +635,28 @@ async function syncLocalBaseToUpstream(
623
635
  worktreePath: string,
624
636
  base: string,
625
637
  upstream: string,
638
+ signal?: AbortSignal,
626
639
  ): Promise<{ ok: true; baseSync?: BaseWorktreeSyncResult } | { ok: false; error: string }> {
627
- const upstreamSha = (await mergeGit(["rev-parse", "--verify", upstream], worktreePath, "rebase", `workspace merge resolve ${upstream}`)).stdout;
640
+ const upstreamSha = (await mergeGit(["rev-parse", "--verify", upstream], worktreePath, "rebase", `workspace merge resolve ${upstream}`, signal)).stdout;
628
641
  if (!upstreamSha) return { ok: false, error: `cannot resolve ${upstream} to sync ${base}` };
629
- const baseWorktree = await worktreeForBranch(repoRoot, base);
642
+ const baseWorktree = await worktreeForBranch(repoRoot, base, signal);
630
643
  // Sync IN the base worktree only when it's clean — that keeps its working tree consistent
631
644
  // with the advanced ref (the pristine home-repo checkout). When the base worktree is dirty
632
645
  // (#644: a human's WIP in the shared checkout) we must NOT refuse and stall the whole repo's
633
646
  // lands: advance the ref directly with update-ref, then best-effort sync the checked-out
634
647
  // index/worktree forward for paths that are not human-modified (#681).
635
648
  if (baseWorktree && !baseWorktree.dirty) {
636
- const ff = await mergeGit(["merge", "--ff-only", upstream], baseWorktree.path, "rebase", `workspace merge fast-forward ${base} to ${upstream}`);
649
+ const ff = await mergeGit(["merge", "--ff-only", upstream], baseWorktree.path, "rebase", `workspace merge fast-forward ${base} to ${upstream}`, signal);
637
650
  if (!ff.ok) return { ok: false, error: ff.stderr || `failed to fast-forward ${base} to ${upstream}` };
638
651
  return { ok: true };
639
652
  }
640
- const oldBaseTip = (await mergeGit(["rev-parse", base], repoRoot, "rebase", `workspace merge resolve ${base} before upstream sync`)).stdout;
641
- const dirtyBefore = baseWorktree?.dirty ? await dirtyPathSet(baseWorktree.path) : undefined;
653
+ const oldBaseTip = (await mergeGit(["rev-parse", base], repoRoot, "rebase", `workspace merge resolve ${base} before upstream sync`, signal)).stdout;
654
+ const dirtyBefore = baseWorktree?.dirty ? await dirtyPathSet(baseWorktree.path, signal) : undefined;
642
655
  const updateArgs = oldBaseTip ? ["update-ref", `refs/heads/${base}`, upstreamSha, oldBaseTip] : ["update-ref", `refs/heads/${base}`, upstreamSha];
643
- const update = await mergeGit(updateArgs, repoRoot, "rebase", `workspace merge update ${base} to ${upstream}`);
656
+ throwIfMergeAborted(signal);
657
+ const update = await mergeGit(updateArgs, repoRoot, "rebase", `workspace merge update ${base} to ${upstream}`, signal);
644
658
  if (!update.ok) return { ok: false, error: update.stderr || `failed to advance ${base} to ${upstream}` };
645
- const baseSync = oldBaseTip ? await syncDirtyBaseWorktreeAfterRefAdvance(base, baseWorktree, oldBaseTip, upstreamSha, dirtyBefore) : undefined;
659
+ const baseSync = oldBaseTip ? await syncDirtyBaseWorktreeAfterRefAdvance(base, baseWorktree, oldBaseTip, upstreamSha, dirtyBefore, signal) : undefined;
646
660
  return { ok: true, baseSync };
647
661
  }
648
662
 
@@ -653,7 +667,9 @@ async function mergeRebaseFf(
653
667
  branch: string | undefined,
654
668
  preview: WorkspaceMergePreview,
655
669
  head: (field: Partial<WorkspaceMergeResult>) => WorkspaceMergeResult,
670
+ signal?: AbortSignal,
656
671
  ): Promise<WorkspaceMergeResult> {
672
+ throwIfMergeAborted(signal);
657
673
  const base = preview.baseRef;
658
674
  if (!base) return head({ status: "review_requested", error: "no base branch to merge into" });
659
675
  if (!branch) return head({ status: "review_requested", error: "cannot determine agent branch" });
@@ -676,7 +692,7 @@ async function mergeRebaseFf(
676
692
  // points (the upstream sync below and the final land). A mixed state from either is surfaced
677
693
  // loudly rather than swallowed as a log warning (#824).
678
694
  let baseSync: BaseWorktreeSyncResult | undefined;
679
- const upstreamResult = await mergeGit(["rev-parse", "--abbrev-ref", `${base}@{upstream}`], worktreePath, "rebase", `workspace merge resolve upstream for ${base}`);
695
+ const upstreamResult = await mergeGit(["rev-parse", "--abbrev-ref", `${base}@{upstream}`], worktreePath, "rebase", `workspace merge resolve upstream for ${base}`, signal);
680
696
  const upstream = upstreamResult.ok && upstreamResult.stdout ? upstreamResult.stdout : undefined;
681
697
  const slash = upstream ? upstream.indexOf("/") : -1;
682
698
  const remote = slash > 0 ? upstream!.slice(0, slash) : undefined; // remote of a `remote/branch` upstream
@@ -686,38 +702,38 @@ async function mergeRebaseFf(
686
702
  // gives them new SHAs and breaks traceability (the branch.landed SHA must exist on
687
703
  // base verbatim). headSha is the preserved landed commit; when base has advanced we
688
704
  // tie the branch in with a no-ff merge so the agent's commits keep their identity.
689
- const headResult = await mergeGit(["rev-parse", "HEAD"], worktreePath, "rebase", "workspace merge resolve workspace HEAD before gates");
705
+ const headResult = await mergeGit(["rev-parse", "HEAD"], worktreePath, "rebase", "workspace merge resolve workspace HEAD before gates", signal);
690
706
  const headSha = headResult.stdout;
691
707
  if (!headResult.ok || !headSha) return head({ status: "review_requested", error: gitError(headResult, "failed to resolve workspace HEAD before gates") });
692
708
  // Subject of the landed commit for the relay's branch.landed notice (#239). Best-effort:
693
709
  // an empty/failed read just omits it from the message body.
694
- const landedSubject = (await mergeGit(["log", "-1", "--format=%s", headSha], worktreePath, "rebase", "workspace merge read landed commit subject")).stdout || undefined;
710
+ const landedSubject = (await mergeGit(["log", "-1", "--format=%s", headSha], worktreePath, "rebase", "workspace merge read landed commit subject", signal)).stdout || undefined;
695
711
 
696
712
  // Resolve the SHA the work will integrate onto WITHOUT moving the ref. Origin-ahead is
697
713
  // the common concurrent case (#638): we still fetch fresh origin to compute the real merge,
698
714
  // but defer the local-base sync until the final base-ref mutation block below.
699
- const integrationBaseResult = await mergeGit(["rev-parse", base], repoRoot, "rebase", `workspace merge resolve integration base ${base}`);
715
+ const integrationBaseResult = await mergeGit(["rev-parse", base], repoRoot, "rebase", `workspace merge resolve integration base ${base}`, signal);
700
716
  let integrationBaseSha = integrationBaseResult.stdout;
701
717
  if (!integrationBaseResult.ok || !integrationBaseSha) return head({ status: "review_requested", error: gitError(integrationBaseResult, `failed to resolve integration base ${base}`) });
702
718
  let needSync = false;
703
719
  if (upstream && remote && pushEnabled) {
704
720
  try {
705
- const fetch = await withMergePhaseTimeout("fetch", () => git(
721
+ const fetch = await withMergePhaseTimeout("fetch", (fetchSignal) => git(
706
722
  ["fetch", remote, base],
707
723
  worktreePath,
708
- { timeoutMs: mergePhaseTimeoutMs("fetch"), timeoutLabel: `workspace merge fetch ${remote}/${base}` },
709
- ));
724
+ { timeoutMs: mergePhaseTimeoutMs("fetch"), timeoutLabel: `workspace merge fetch ${remote}/${base}`, signal: fetchSignal },
725
+ ), { signal });
710
726
  if (!fetch.ok && fetch.timedOut) return head({ status: "review_requested", error: fetch.stderr || `fetch ${remote}/${base} timed out` });
711
727
  } catch (err) {
712
728
  return head({ status: "review_requested", error: errMessage(err) });
713
729
  }
714
- if (!(await mergeGit(["merge-base", "--is-ancestor", upstream, base], worktreePath, "rebase", `workspace merge compare ${upstream} to ${base}`)).ok) {
730
+ if (!(await mergeGit(["merge-base", "--is-ancestor", upstream, base], worktreePath, "rebase", `workspace merge compare ${upstream} to ${base}`, signal)).ok) {
715
731
  // Origin moved ahead. Sync-then-land iff local base is cleanly behind (ancestor
716
732
  // of upstream); otherwise it's genuine divergence — refuse without mutating.
717
- if (!(await mergeGit(["merge-base", "--is-ancestor", base, upstream], worktreePath, "rebase", `workspace merge compare ${base} to ${upstream}`)).ok) {
733
+ if (!(await mergeGit(["merge-base", "--is-ancestor", base, upstream], worktreePath, "rebase", `workspace merge compare ${base} to ${upstream}`, signal)).ok) {
718
734
  return head({ status: "review_requested", error: `local ${base} has diverged from ${upstream} (commits not on origin); sync before landing` });
719
735
  }
720
- const upstreamSha = (await mergeGit(["rev-parse", "--verify", upstream], worktreePath, "rebase", `workspace merge resolve ${upstream} for integration`)).stdout;
736
+ const upstreamSha = (await mergeGit(["rev-parse", "--verify", upstream], worktreePath, "rebase", `workspace merge resolve ${upstream} for integration`, signal)).stdout;
721
737
  if (!upstreamSha) return head({ status: "review_requested", error: `cannot resolve ${upstream} to sync ${base}` });
722
738
  // Integrate onto fresh origin (the tree that will land), but DON'T advance local base yet.
723
739
  integrationBaseSha = upstreamSha;
@@ -726,7 +742,7 @@ async function mergeRebaseFf(
726
742
  }
727
743
 
728
744
  // Behind relative to the TRUE integration base (origin-ahead ⟹ behind>0 ⟹ a real no-ff merge).
729
- const behindResult = await countBehind(worktreePath, integrationBaseSha);
745
+ const behindResult = await countBehind(worktreePath, integrationBaseSha, signal);
730
746
  if ("error" in behindResult) return head({ status: "review_requested", error: behindResult.error });
731
747
  const behind = behindResult.behind;
732
748
 
@@ -735,6 +751,7 @@ async function mergeRebaseFf(
735
751
  // before base-ref mutation so the rest of the historical land ordering stays intact, but no
736
752
  // gate subprocess or detached gate worktree can be launched at land time.
737
753
  const gateRun = await runLandGatesOnIntegratedTree(repoRoot, worktreePath, behind, integrationBaseSha, headSha, landMergeMessage(branch, landedSubject));
754
+ throwIfMergeAborted(signal);
738
755
  if ("abort" in gateRun) {
739
756
  return gateRun.abort.conflict
740
757
  ? head({ conflict: true, status: "conflict", error: gateRun.abort.error })
@@ -750,7 +767,9 @@ async function mergeRebaseFf(
750
767
  // upstream first (#638); this preserves the pre-existing land ordering while gate execution
751
768
  // remains permanently disabled.
752
769
  if (needSync) {
753
- const synced = await syncLocalBaseToUpstream(repoRoot, worktreePath, base, upstream!);
770
+ throwIfMergeAborted(signal);
771
+ const synced = await syncLocalBaseToUpstream(repoRoot, worktreePath, base, upstream!, signal);
772
+ throwIfMergeAborted(signal);
754
773
  if (!synced.ok) return head({ status: "review_requested", error: synced.error });
755
774
  baseSync = mergeBaseSyncResults(baseSync, synced.baseSync);
756
775
  }
@@ -763,11 +782,12 @@ async function mergeRebaseFf(
763
782
  // land: fall through to ref-plumbing (update-ref / synthesized no-ff merge) below, then
764
783
  // best-effort sync clean landed paths in the dirty checkout while preserving WIP (#681).
765
784
  let baseTip = headSha;
766
- const baseWorktree = await worktreeForBranch(repoRoot, base);
767
- const dirtyBasePathsBefore = baseWorktree?.dirty ? await dirtyPathSet(baseWorktree.path) : undefined;
785
+ throwIfMergeAborted(signal);
786
+ const baseWorktree = await worktreeForBranch(repoRoot, base, signal);
787
+ const dirtyBasePathsBefore = baseWorktree?.dirty ? await dirtyPathSet(baseWorktree.path, signal) : undefined;
768
788
  if (baseWorktree && !baseWorktree.dirty) {
769
789
  if (behind === 0) {
770
- const ff = await mergeGit(["merge", "--ff-only", branch], baseWorktree.path, "rebase", `workspace merge fast-forward ${base} to ${branch}`);
790
+ const ff = await mergeGit(["merge", "--ff-only", branch], baseWorktree.path, "rebase", `workspace merge fast-forward ${base} to ${branch}`, signal);
771
791
  if (!ff.ok) return head({ status: "review_requested", error: ff.stderr || "fast-forward into base failed" });
772
792
  } else {
773
793
  const merge = await mergeGit(
@@ -775,28 +795,31 @@ async function mergeRebaseFf(
775
795
  baseWorktree.path,
776
796
  "rebase",
777
797
  `workspace merge no-ff ${branch} into ${base}`,
798
+ signal,
778
799
  );
779
800
  if (!merge.ok) {
780
- await mergeGit(["merge", "--abort"], baseWorktree.path, "rebase", `workspace merge abort failed no-ff ${branch}`);
801
+ await mergeGit(["merge", "--abort"], baseWorktree.path, "rebase", `workspace merge abort failed no-ff ${branch}`, signal);
781
802
  return head({ conflict: true, status: "conflict", error: merge.stderr || "merge into base failed" });
782
803
  }
783
- baseTip = (await mergeGit(["rev-parse", "HEAD"], baseWorktree.path, "rebase", `workspace merge resolve ${base} after no-ff`)).stdout;
804
+ baseTip = (await mergeGit(["rev-parse", "HEAD"], baseWorktree.path, "rebase", `workspace merge resolve ${base} after no-ff`, signal)).stdout;
784
805
  }
785
806
  } else if (behind === 0) {
786
- const oldBaseTip = (await mergeGit(["rev-parse", base], repoRoot, "rebase", `workspace merge resolve ${base} before fast-forward ref update`)).stdout;
807
+ const oldBaseTip = (await mergeGit(["rev-parse", base], repoRoot, "rebase", `workspace merge resolve ${base} before fast-forward ref update`, signal)).stdout;
808
+ throwIfMergeAborted(signal);
787
809
  const update = oldBaseTip
788
- ? await mergeGit(["update-ref", `refs/heads/${base}`, headSha, oldBaseTip], repoRoot, "rebase", `workspace merge update ${base} fast-forward`)
789
- : await mergeGit(["update-ref", `refs/heads/${base}`, headSha], repoRoot, "rebase", `workspace merge create/update ${base} fast-forward`);
810
+ ? await mergeGit(["update-ref", `refs/heads/${base}`, headSha, oldBaseTip], repoRoot, "rebase", `workspace merge update ${base} fast-forward`, signal)
811
+ : await mergeGit(["update-ref", `refs/heads/${base}`, headSha], repoRoot, "rebase", `workspace merge create/update ${base} fast-forward`, signal);
790
812
  if (!update.ok) return head({ status: "review_requested", error: update.stderr || "failed to advance base ref" });
791
- if (oldBaseTip) baseSync = mergeBaseSyncResults(baseSync, await syncDirtyBaseWorktreeAfterRefAdvance(base, baseWorktree, oldBaseTip, headSha, dirtyBasePathsBefore));
813
+ if (oldBaseTip) baseSync = mergeBaseSyncResults(baseSync, await syncDirtyBaseWorktreeAfterRefAdvance(base, baseWorktree, oldBaseTip, headSha, dirtyBasePathsBefore, signal));
792
814
  } else {
793
- const baseShaResult = await mergeGit(["rev-parse", base], repoRoot, "rebase", `workspace merge resolve ${base} before no-ff ref update`);
815
+ const baseShaResult = await mergeGit(["rev-parse", base], repoRoot, "rebase", `workspace merge resolve ${base} before no-ff ref update`, signal);
794
816
  const baseSha = baseShaResult.stdout;
795
817
  if (!baseShaResult.ok || !baseSha) return head({ status: "review_requested", error: gitError(baseShaResult, `failed to resolve ${base} before no-ff ref update`) });
796
- const merged = await recordNoFfMerge(repoRoot, base, baseSha, headSha, landMergeMessage(branch, landedSubject));
818
+ throwIfMergeAborted(signal);
819
+ const merged = await recordNoFfMerge(repoRoot, base, baseSha, headSha, landMergeMessage(branch, landedSubject), signal);
797
820
  if (!merged.ok) return head(merged.conflict ? { conflict: true, status: "conflict", error: merged.error } : { status: "review_requested", error: merged.error });
798
821
  baseTip = merged.mergeSha;
799
- baseSync = mergeBaseSyncResults(baseSync, await syncDirtyBaseWorktreeAfterRefAdvance(base, baseWorktree, baseSha, baseTip, dirtyBasePathsBefore));
822
+ baseSync = mergeBaseSyncResults(baseSync, await syncDirtyBaseWorktreeAfterRefAdvance(base, baseWorktree, baseSha, baseTip, dirtyBasePathsBefore, signal));
800
823
  }
801
824
 
802
825
  // #824 — a dirty base checkout left out of sync with the advanced HEAD is a MIXED state:
@@ -812,7 +835,8 @@ async function mergeRebaseFf(
812
835
  // means origin raced us — surface it instead of claiming an unpublished land.
813
836
  let pushed = false;
814
837
  if (upstream && remote && pushEnabled) {
815
- const push = await mergeGit(["push", remote, `${base}:${base}`], worktreePath, "rebase", `workspace merge push ${base} to ${remote}`);
838
+ throwIfMergeAborted(signal);
839
+ const push = await mergeGit(["push", remote, `${base}:${base}`], worktreePath, "rebase", `workspace merge push ${base} to ${remote}`, signal);
816
840
  if (!push.ok) return head({ status: "review_requested", mergedSha: headSha, error: push.stderr || `git push to ${remote}/${base} failed` });
817
841
  pushed = true;
818
842
  }
@@ -824,25 +848,27 @@ async function mergeRebaseFf(
824
848
  // state in the same CWD; status returns to `active`. An absent owner gets the
825
849
  // worktree reclaimed.
826
850
  const deleteBranch = input.deleteBranch !== false;
851
+ const ownerRepo = await owningRepoRoot(worktreePath, repoRoot);
827
852
  if (!deleteBranch) {
828
- const fresh = await nextBranchName(repoRoot, branch);
829
- const switched = await mergeGit(["checkout", "-B", fresh, base], worktreePath, "rebase", `workspace merge recycle worktree to ${fresh}`);
853
+ const fresh = await nextBranchName(ownerRepo, branch);
854
+ throwIfMergeAborted(signal);
855
+ const switched = await mergeGit(["checkout", "-B", fresh, base], worktreePath, "rebase", `workspace merge recycle worktree to ${fresh}`, signal);
830
856
  if (switched.ok) {
831
- // The old branch is now fully contained in base (fast-forwarded, or merged in
832
- // as the no-ff merge's second parent) — drop the litter.
833
- const oldDeleted = (await mergeGit(["branch", "-D", branch], repoRoot, "cleanup", `workspace merge delete landed branch ${branch}`)).ok;
857
+ const deleteResult = await deleteBranchIfSafe(ownerRepo, branch, undefined, baseTip, signal);
834
858
  // The worktree just moved onto the advanced base, which may declare deps the
835
859
  // shared (symlinked) node_modules lacks. Re-provision so the recycled session
836
860
  // continues from a buildable state (issue #51). No-op when nothing is stale.
837
- const depsRefresh = await refreshWorkspaceDeps(repoRoot, worktreePath);
861
+ const depsRefresh = await refreshWorkspaceDeps(ownerRepo, worktreePath);
862
+ throwIfMergeAborted(signal);
838
863
  const reportDeps = depsRefresh.refreshed || depsRefresh.stale || depsRefresh.error;
839
- return head({ merged: true, status: "active", mergedSha: headSha, baseSha: baseTip, subject: landedSubject, worktreeRemoved: false, branch: fresh, newBranch: fresh, branchDeleted: oldDeleted, pushed, ...(reportDeps ? { depsRefresh } : {}), ...baseWorktreeSyncField, ...gateWarningsField, error: undefined });
864
+ return head({ merged: true, status: "active", mergedSha: headSha, baseSha: baseTip, subject: landedSubject, worktreeRemoved: false, branch: fresh, newBranch: fresh, ...deleteResult, pushed, ...(reportDeps ? { depsRefresh } : {}), ...baseWorktreeSyncField, ...gateWarningsField, error: undefined });
840
865
  }
841
866
  // Recycle failed — keep the existing branch. Still landed, still active.
842
867
  return head({ merged: true, status: "active", mergedSha: headSha, baseSha: baseTip, subject: landedSubject, worktreeRemoved: false, branchDeleted: false, pushed, ...baseWorktreeSyncField, ...gateWarningsField, error: undefined });
843
868
  }
844
- const removed = await mergeGit(["worktree", "remove", "--force", worktreePath], repoRoot, "cleanup", "workspace merge remove landed worktree");
869
+ throwIfMergeAborted(signal);
870
+ const removed = await mergeGit(["worktree", "remove", "--force", worktreePath], ownerRepo, "cleanup", "workspace merge remove landed worktree", signal);
845
871
  const worktreeRemoved = removed.ok;
846
- const branchDeleted = worktreeRemoved ? (await mergeGit(["branch", "-D", branch], repoRoot, "cleanup", `workspace merge delete landed branch ${branch}`)).ok : false;
847
- return head({ merged: true, status: "merged", mergedSha: headSha, baseSha: baseTip, subject: landedSubject, worktreeRemoved, branchDeleted, pushed, ...baseWorktreeSyncField, ...gateWarningsField, error: undefined });
872
+ const deleteResult = worktreeRemoved ? await deleteBranchIfSafe(ownerRepo, branch, undefined, baseTip, signal) : { branchDeleted: false };
873
+ return head({ merged: true, status: "merged", mergedSha: headSha, baseSha: baseTip, subject: landedSubject, worktreeRemoved, ...deleteResult, pushed, ...baseWorktreeSyncField, ...gateWarningsField, error: undefined });
848
874
  }
@@ -65,4 +65,5 @@ export interface WorkspaceMergeInput {
65
65
  * otherwise loop via review_requested. prMerged is monotonic and was read from THIS host's
66
66
  * own `checkPr` scan, so trusting it is safe; we still refuse on a dirty tree. */
67
67
  prLanded?: { sha?: string; subject?: string };
68
+ signal?: AbortSignal;
68
69
  }