agent-relay-orchestrator 0.118.4 → 0.119.0

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.118.4",
3
+ "version": "0.119.0",
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.1",
20
- "agent-relay-sdk": "0.2.101",
20
+ "agent-relay-sdk": "0.2.102",
21
21
  "callmux": "0.23.0"
22
22
  },
23
23
  "devDependencies": {
package/src/control.ts CHANGED
@@ -1,11 +1,12 @@
1
- import { errMessage, isRecord, normalizeAgentLifecycle, normalizeWorkspaceMode } from "agent-relay-sdk";
1
+ import { errMessage, isRecord, normalizeAgentLifecycle, normalizeWorkspaceMode, Semaphore } from "agent-relay-sdk";
2
2
  import { getAllManifests, getManifest } from "agent-relay-providers";
3
3
  import type { OrchestratorConfig } from "./config";
4
4
  import type { ManagedAgentReport, RelayClient, RelayCommand } from "./relay";
5
5
  import { handleSelfUpgrade } from "./self-upgrade";
6
6
  import { readLocalProviderConfigs } from "./provider-config-migration";
7
- import { spawnAgent, stopSession, type SpawnOptions } from "./spawn";
7
+ import { findSessionRecord, readRunnerInfo, spawnAgent, stopSession, type SpawnOptions } from "./spawn";
8
8
  import { cleanupWorkspace, discardRecoveryBranch, idleRefreshWorktree, mergeWorkspace, pruneWorktrees, reconcileWorkspace, refreshWorkspaceDeps, workspacesRoot } from "./workspace-probe";
9
+ import { withMergePhaseTimeout } from "./workspace-probe/merge-timeouts";
9
10
  import { armWorkspacePrAutoMerge, mergeWorkspacePr, refreshWorkspacePrBranch } from "./workspace-pr";
10
11
  import type { WorkspaceMergeResult } from "agent-relay-sdk";
11
12
 
@@ -23,22 +24,167 @@ interface ControlHandler {
23
24
  handleCommand(command: RelayCommand): Promise<boolean>;
24
25
  getManagedAgents(): ManagedAgentReport[];
25
26
  setManagedAgents(agents: ManagedAgentReport[]): void;
27
+ // #930/#880 — spawn commands dispatch in the background (claimed synchronously,
28
+ // then run past registration under the concurrency cap). These expose that
29
+ // in-flight background work so callers/tests can observe and settle it.
30
+ spawnsInFlight(): number;
31
+ settleSpawns(): Promise<void>;
32
+ }
33
+
34
+ const DEFAULT_MAX_CONCURRENT_SPAWNS = 2;
35
+
36
+ // #930/#880 — per-host spawn concurrency cap. Bursts of spawns racing worktree
37
+ // materialization + port churn during the registration window are what surface
38
+ // as `EADDRINUSE`/"operation was aborted" on the loser (#880). Bound how many
39
+ // spawns materialize at once. Parsed once at handler construction; invalid or
40
+ // out-of-range values fall back to the safe default rather than throwing.
41
+ export function resolveMaxConcurrentSpawns(env: NodeJS.ProcessEnv = process.env): number {
42
+ const raw = env.AGENT_RELAY_MAX_CONCURRENT_SPAWNS;
43
+ if (raw === undefined || raw.trim() === "") return DEFAULT_MAX_CONCURRENT_SPAWNS;
44
+ const parsed = Number(raw);
45
+ if (!Number.isInteger(parsed) || parsed < 1) return DEFAULT_MAX_CONCURRENT_SPAWNS;
46
+ return parsed;
47
+ }
48
+
49
+ // #930/#880 — how long a spawn slot is held while we wait for the child to
50
+ // register (bind its local control/ws server — the contended resource). This is
51
+ // a fail-safe upper bound only: a child that never registers must not wedge a
52
+ // slot forever, so we release after this deadline even if it hasn't appeared.
53
+ // The happy path releases as soon as the child's runner-info file materializes,
54
+ // well under this ceiling.
55
+ const DEFAULT_SPAWN_REGISTRATION_TIMEOUT_MS = 30_000;
56
+ const SPAWN_REGISTRATION_POLL_MS = 100;
57
+
58
+ export function resolveSpawnRegistrationTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
59
+ const raw = env.AGENT_RELAY_SPAWN_REGISTRATION_TIMEOUT_MS;
60
+ if (raw === undefined || raw.trim() === "") return DEFAULT_SPAWN_REGISTRATION_TIMEOUT_MS;
61
+ const parsed = Number(raw);
62
+ if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_SPAWN_REGISTRATION_TIMEOUT_MS;
63
+ return parsed;
64
+ }
65
+
66
+ /** Awaits a spawn slot until the child registers, or resolves false at the fail-safe deadline. */
67
+ export type WaitForRegistration = (agent: ManagedAgentReport, timeoutMs: number) => Promise<boolean>;
68
+
69
+ // #930/#880 — the orchestrator observes a child registering through its runner
70
+ // info file: the runner writes `controlUrl` immediately after binding its local
71
+ // control/MCP server (runner-core writeRunnerInfoFile, right after startMcpProxy),
72
+ // which is exactly #880's contended ws-bind window. So the file's presence is the
73
+ // host-local signal that the child is past that window; holding the slot until it
74
+ // appears is what bounds how many children materialize→bind at once.
75
+ async function waitForManagedRegistration(agent: ManagedAgentReport, timeoutMs: number): Promise<boolean> {
76
+ const session = agent.sessionName ?? agent.tmuxSession;
77
+ if (!session) return false;
78
+ const deadline = Date.now() + timeoutMs;
79
+ for (;;) {
80
+ const record = findSessionRecord({ tmuxSession: session, agentId: agent.agentId });
81
+ if (record && readRunnerInfo(record)) return true;
82
+ if (Date.now() >= deadline) return false;
83
+ await Bun.sleep(SPAWN_REGISTRATION_POLL_MS);
84
+ }
85
+ }
86
+
87
+ interface ControlHandlerDeps {
88
+ // Injectable spawn dispatch — defaults to the real spawnAgent. Only tests
89
+ // override it, to gate dispatch and observe the concurrency bound directly.
90
+ spawnAgent?: typeof spawnAgent;
91
+ // Override the cap directly, bypassing the env parse (tests).
92
+ maxConcurrentSpawns?: number;
93
+ // Injectable registration wait — defaults to polling the child's runner info
94
+ // file. Tests override it to gate the registration window explicitly.
95
+ waitForRegistration?: WaitForRegistration;
96
+ // Override the fail-safe registration timeout, bypassing the env parse (tests).
97
+ registrationTimeoutMs?: number;
26
98
  }
27
99
 
28
100
  export function createControlHandler(
29
101
  config: OrchestratorConfig,
30
102
  relay: RelayClient,
103
+ deps: ControlHandlerDeps = {},
31
104
  ): ControlHandler {
32
105
  let managedAgents: ManagedAgentReport[] = [];
106
+ const dispatchSpawn = deps.spawnAgent ?? spawnAgent;
107
+ // In-memory counter is correct here: a slot only needs to live within this
108
+ // orchestrator process, and a crash resets in-flight spawns anyway (#881 Q1).
109
+ const spawnSlots = new Semaphore(deps.maxConcurrentSpawns ?? resolveMaxConcurrentSpawns());
110
+ const waitForRegistration = deps.waitForRegistration ?? waitForManagedRegistration;
111
+ const registrationTimeoutMs = deps.registrationTimeoutMs ?? resolveSpawnRegistrationTimeoutMs();
112
+ // Backgrounded spawn tasks: each is claimed synchronously (status → accepted)
113
+ // then runs to completion off the poll tick, holding a slot across the child's
114
+ // registration window. Tracked so tests/shutdown can observe and settle them.
115
+ const backgroundSpawns = new Set<Promise<void>>();
33
116
 
34
- async function handleSpawn(ctrl: Record<string, any>): Promise<boolean> {
117
+ async function handleSpawn(ctrl: Record<string, any>): Promise<ManagedAgentReport> {
35
118
  const opts = spawnOptionsFromControl(ctrl, config);
36
- const agent = await spawnAgent(opts, config);
119
+ const agent = await dispatchSpawn(opts, config);
37
120
  managedAgents.push(agent);
38
121
  console.error(`[orchestrator] Spawned ${opts.provider} agent: ${agent.tmuxSession}`);
122
+ return agent;
123
+ }
124
+
125
+ // #930/#880 — dispatch a spawn command concurrently, up to the per-host cap.
126
+ //
127
+ // The command is CLAIMED (status → accepted) synchronously here, BEFORE we
128
+ // return, so the next pollCommands() tick can never re-return this still-pending
129
+ // row and double-dispatch it. The slot itself is taken in the BACKGROUND via
130
+ // acquire() (strict FIFO — no starvation), so the poll tick is never blocked
131
+ // waiting for a free slot, preserving the #864 single-flight of the tick.
132
+ //
133
+ // The background task holds its slot across the child's registration window
134
+ // (#880's contended ws-bind resource) and frees it in `finally` on EVERY path:
135
+ // registration success, spawn failure, or the fail-safe registration timeout.
136
+ async function dispatchSpawnCommand(command: RelayCommand): Promise<boolean> {
137
+ await relay.updateCommand(command.id, "accepted");
138
+ // #930 — guard the backgrounded task against unhandled rejection. runSpawnToRegistration
139
+ // settles its own errors, but a relay blip in the ≤30s in-flight window can reject the
140
+ // status write in its catch block too; without a terminal `.catch` here that escapes as
141
+ // an unhandledRejection (log spam, or a crash if the runtime exits on it). Track the
142
+ // GUARDED promise so settleSpawns() awaits a promise that never rejects.
143
+ const task = runSpawnToRegistration(command).catch((error) => {
144
+ console.error(`[orchestrator] backgrounded spawn task error (swallowed): ${errMessage(error)}`);
145
+ });
146
+ backgroundSpawns.add(task);
147
+ void task.finally(() => backgroundSpawns.delete(task));
39
148
  return true;
40
149
  }
41
150
 
151
+ async function runSpawnToRegistration(command: RelayCommand): Promise<void> {
152
+ // Blocks FIFO until a slot frees — this is what bounds concurrent spawns to N.
153
+ await spawnSlots.acquire();
154
+ try {
155
+ 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)`);
163
+ }
164
+ await relay.updateCommand(command.id, "succeeded", { managedAgents });
165
+ await relay.updateManagedAgents(managedAgents);
166
+ } 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)}`);
176
+ }
177
+ } finally {
178
+ spawnSlots.release();
179
+ }
180
+ }
181
+
182
+ async function settleSpawns(): Promise<void> {
183
+ while (backgroundSpawns.size > 0) {
184
+ await Promise.all([...backgroundSpawns]);
185
+ }
186
+ }
187
+
42
188
  async function handleShutdown(ctrl: Record<string, any>, restart = false): Promise<Record<string, unknown>> {
43
189
  const current = managedAgentShutdownTarget(managedAgents, ctrl);
44
190
  const session = current?.sessionName ?? current?.tmuxSession;
@@ -85,14 +231,18 @@ export function createControlHandler(
85
231
  }
86
232
 
87
233
  async function handleCommand(command: RelayCommand): Promise<boolean> {
234
+ // #930/#880 — spawns dispatch concurrently in the background under the
235
+ // per-host cap (see dispatchSpawnCommand). Every other command is handled
236
+ // inline, synchronously with the poll tick, exactly as before.
237
+ if (command.type === "agent.spawn") return dispatchSpawnCommand(command);
238
+ return handleNonSpawnCommand(command);
239
+ }
240
+
241
+ async function handleNonSpawnCommand(command: RelayCommand): Promise<boolean> {
88
242
  await relay.updateCommand(command.id, "accepted");
89
243
  await relay.updateCommand(command.id, "running");
90
244
  try {
91
- if (command.type === "agent.spawn") {
92
- const handled = await handleSpawn(command.params);
93
- if (!handled) throw new Error("spawn failed");
94
- await relay.updateCommand(command.id, "succeeded", { managedAgents });
95
- } else if (command.type === "agent.shutdown" || command.type === "agent.restart") {
245
+ if (command.type === "agent.shutdown" || command.type === "agent.restart") {
96
246
  const result = await handleShutdown(command.params, command.type === "agent.restart");
97
247
  await relay.updateCommand(command.id, "succeeded", result);
98
248
  } else if (command.type === "workspace.cleanup") {
@@ -118,26 +268,42 @@ export function createControlHandler(
118
268
  });
119
269
  await relay.updateCommand(command.id, "succeeded", result);
120
270
  } else if (command.type === "workspace.merge") {
121
- const result = await mergeWorkspace({
122
- id: typeof command.params.workspaceId === "string" ? command.params.workspaceId : undefined,
123
- repoRoot: typeof command.params.repoRoot === "string" ? command.params.repoRoot : undefined,
124
- worktreePath: typeof command.params.worktreePath === "string" ? command.params.worktreePath : undefined,
125
- branch: typeof command.params.branch === "string" ? command.params.branch : undefined,
126
- baseRef: typeof command.params.baseRef === "string" ? command.params.baseRef : undefined,
127
- baseSha: typeof command.params.baseSha === "string" ? command.params.baseSha : undefined,
128
- strategy: command.params.strategy === "pr" || command.params.strategy === "rebase-ff" || command.params.strategy === "auto" ? command.params.strategy : undefined,
129
- deleteBranch: command.params.deleteBranch !== false,
130
- push: command.params.push !== false,
131
- prTitle: typeof command.params.prTitle === "string" ? command.params.prTitle : undefined,
132
- prBody: typeof command.params.prBody === "string" ? command.params.prBody : undefined,
133
- autoMerge: command.params.autoMerge === "on-green" || command.params.autoMerge === "on-approval" || command.params.autoMerge === "manual" ? command.params.autoMerge : undefined,
134
- prLanded: isRecord(command.params.prLanded)
135
- ? {
136
- sha: typeof command.params.prLanded.sha === "string" ? command.params.prLanded.sha : undefined,
137
- subject: typeof command.params.prLanded.subject === "string" ? command.params.prLanded.subject : undefined,
138
- }
139
- : undefined,
140
- });
271
+ let result: WorkspaceMergeResult;
272
+ try {
273
+ result = await withMergePhaseTimeout("total", () => mergeWorkspace({
274
+ id: typeof command.params.workspaceId === "string" ? command.params.workspaceId : undefined,
275
+ repoRoot: typeof command.params.repoRoot === "string" ? command.params.repoRoot : undefined,
276
+ worktreePath: typeof command.params.worktreePath === "string" ? command.params.worktreePath : undefined,
277
+ branch: typeof command.params.branch === "string" ? command.params.branch : undefined,
278
+ baseRef: typeof command.params.baseRef === "string" ? command.params.baseRef : undefined,
279
+ baseSha: typeof command.params.baseSha === "string" ? command.params.baseSha : undefined,
280
+ strategy: command.params.strategy === "pr" || command.params.strategy === "rebase-ff" || command.params.strategy === "auto" ? command.params.strategy : undefined,
281
+ deleteBranch: command.params.deleteBranch !== false,
282
+ push: command.params.push !== false,
283
+ prTitle: typeof command.params.prTitle === "string" ? command.params.prTitle : undefined,
284
+ prBody: typeof command.params.prBody === "string" ? command.params.prBody : undefined,
285
+ autoMerge: command.params.autoMerge === "on-green" || command.params.autoMerge === "on-approval" || command.params.autoMerge === "manual" ? command.params.autoMerge : undefined,
286
+ prLanded: isRecord(command.params.prLanded)
287
+ ? {
288
+ sha: typeof command.params.prLanded.sha === "string" ? command.params.prLanded.sha : undefined,
289
+ subject: typeof command.params.prLanded.subject === "string" ? command.params.prLanded.subject : undefined,
290
+ }
291
+ : undefined,
292
+ }));
293
+ } catch (err) {
294
+ const branch = typeof command.params.branch === "string" ? command.params.branch : undefined;
295
+ const baseRef = typeof command.params.baseRef === "string" ? command.params.baseRef : undefined;
296
+ result = {
297
+ workspaceId: typeof command.params.workspaceId === "string" ? command.params.workspaceId : undefined,
298
+ strategy: command.params.strategy === "pr" ? "pr" : "rebase-ff",
299
+ merged: false,
300
+ status: "review_requested",
301
+ ...(branch ? { branch } : {}),
302
+ ...(baseRef ? { baseRef } : {}),
303
+ error: errMessage(err),
304
+ };
305
+ console.error(`[orchestrator] workspace.merge failed before completion: ${result.error}`);
306
+ }
141
307
  // #638 — settle `failed` (carrying the error) for a no-op merge instead of
142
308
  // `succeeded`; see mergeCommandStatus.
143
309
  await relay.updateCommand(command.id, mergeCommandStatus(result), result as unknown as Record<string, unknown>, result.error);
@@ -233,7 +399,7 @@ export function createControlHandler(
233
399
  managedAgents = agents;
234
400
  }
235
401
 
236
- return { handleCommand, getManagedAgents, setManagedAgents };
402
+ return { handleCommand, getManagedAgents, setManagedAgents, spawnsInFlight: () => backgroundSpawns.size, settleSpawns };
237
403
  }
238
404
 
239
405
  export function managedAgentShutdownTarget(agents: ManagedAgentReport[], ctrl: Record<string, any>): ManagedAgentReport | undefined {
@@ -1,8 +1,12 @@
1
- export type MergePhase = "preview" | "fetch" | "synthesize" | "worktree-add" | "cleanup";
1
+ export type MergePhase = "total" | "prep" | "preview" | "fetch" | "rebase" | "gates" | "synthesize" | "worktree-add" | "cleanup";
2
2
 
3
3
  const MERGE_PHASE_TIMEOUTS_MS: Record<MergePhase, number> = {
4
+ total: 10 * 60_000,
5
+ prep: 60_000,
4
6
  preview: 30_000,
5
7
  fetch: 60_000,
8
+ rebase: 60_000,
9
+ gates: 5 * 60_000,
6
10
  synthesize: 60_000,
7
11
  "worktree-add": 60_000,
8
12
  cleanup: 30_000,
@@ -1,35 +1,50 @@
1
- import { existsSync, mkdtempSync, rmSync } from "node:fs";
2
- import { tmpdir } from "node:os";
1
+ import { existsSync } from "node:fs";
3
2
  import { join, resolve } from "node:path";
4
3
  import { errMessage, type BaseWorktreeSyncResult, type WorkspaceMergePreview, type WorkspaceMergeResult } from "agent-relay-sdk";
5
4
  import { git, gitRaw } from "../git";
6
5
  import { execProcess } from "../process";
7
6
  import { prMergedState } from "../workspace-pr";
8
7
  import { refreshWorkspaceDeps } from "./deps";
9
- import { type LandGatesResult, runLandGates } from "./land-gates-runner";
10
- import { populateMergeState, resolveBranchRef, syncBaseFromOrigin, upstreamRef, workspaceGitState } from "./git-state";
11
- import { mergePhaseTimeoutMs, withMergePhaseTimeout } from "./merge-timeouts";
8
+ import { type LandGatesResult } from "./land-gates-runner";
9
+ import { populateMergeState, resolveBranchRef, syncBaseFromOrigin, workspaceGitState } from "./git-state";
10
+ import { type MergePhase, mergePhaseTimeoutMs, withMergePhaseTimeout } from "./merge-timeouts";
12
11
  import { nextBranchName } from "./names";
13
12
  import { parseWorktrees, shortBranch } from "./parse";
14
13
  import { json, resolveRequestedPath } from "./request";
15
14
  import type { WorkspaceMergeInput } from "./types";
16
15
  import { workspacePushEnabled } from "../config";
17
16
 
17
+ async function mergeGit(args: string[], cwd: string, phase: MergePhase, timeoutLabel?: string): ReturnType<typeof git> {
18
+ return git(args, cwd, {
19
+ timeoutMs: mergePhaseTimeoutMs(phase),
20
+ timeoutLabel: timeoutLabel ?? `workspace merge ${phase} git ${args.join(" ")}`,
21
+ });
22
+ }
23
+
24
+ async function mergeGitRaw(args: string[], cwd: string, phase: MergePhase, timeoutLabel?: string): ReturnType<typeof gitRaw> {
25
+ return gitRaw(args, cwd, {
26
+ timeoutMs: mergePhaseTimeoutMs(phase),
27
+ timeoutLabel: timeoutLabel ?? `workspace merge ${phase} git ${args.join(" ")}`,
28
+ });
29
+ }
30
+
31
+ function gitError(result: Awaited<ReturnType<typeof git>>, fallback: string): string {
32
+ return result.stderr || result.stdout || fallback;
33
+ }
34
+
18
35
  /** Behind-count of HEAD relative to `base`, from inside `worktreePath`. */
19
- async function countBehind(worktreePath: string, base: string): Promise<number> {
20
- const counts = await git(["rev-list", "--left-right", "--count", `${base}...HEAD`], worktreePath);
21
- if (!counts.ok || !counts.stdout) return 0;
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");
38
+ if (!counts.ok || !counts.stdout) return { error: gitError(counts, "failed to count branch behind integration base") };
22
39
  const behind = Number(counts.stdout.split(/\s+/)[0]);
23
- return Number.isFinite(behind) ? behind : 0;
40
+ return Number.isFinite(behind) ? { behind } : { error: "git rev-list produced an invalid behind count" };
24
41
  }
25
42
 
26
43
  async function hasOriginRemote(cwd: string): Promise<boolean> {
27
44
  return (await git(["remote", "get-url", "origin"], cwd)).ok;
28
45
  }
29
46
 
30
- function ghAvailable(): boolean {
31
- return Boolean(Bun.which("gh"));
32
- }
47
+ function ghAvailable(): boolean { return Boolean(Bun.which("gh")); }
33
48
 
34
49
  /**
35
50
  * Ground-truth merge state for `branch`, via gh.
@@ -61,11 +76,11 @@ async function baseBranchName(worktreePath: string, baseRef?: string): Promise<s
61
76
 
62
77
  /** Locate the worktree (if any) that currently has `branch` checked out. */
63
78
  async function worktreeForBranch(repoRoot: string, branch: string): Promise<{ path: string; dirty: boolean } | undefined> {
64
- const list = await git(["worktree", "list", "--porcelain"], repoRoot);
79
+ const list = await mergeGit(["worktree", "list", "--porcelain"], repoRoot, "rebase", "workspace merge list worktrees");
65
80
  if (!list.ok) return undefined;
66
81
  const match = parseWorktrees(list.stdout).find((worktree) => worktree.branch === branch);
67
82
  if (!match) return undefined;
68
- const status = await git(["status", "--porcelain"], match.path);
83
+ const status = await mergeGit(["status", "--porcelain"], match.path, "rebase", `workspace merge status ${branch} worktree`);
69
84
  return { path: match.path, dirty: status.ok ? status.stdout.length > 0 : true };
70
85
  }
71
86
 
@@ -74,7 +89,7 @@ function splitNul(stdout: string): string[] {
74
89
  }
75
90
 
76
91
  async function dirtyPathSet(worktreePath: string): Promise<Set<string>> {
77
- const status = await gitRaw(["status", "--porcelain=v1", "-z", "--untracked-files=all"], worktreePath);
92
+ const status = await mergeGitRaw(["status", "--porcelain=v1", "-z", "--untracked-files=all"], worktreePath, "rebase", "workspace merge dirty path scan");
78
93
  if (!status.ok) return new Set<string>();
79
94
  const paths = new Set<string>();
80
95
  const entries = splitNul(status.stdout);
@@ -90,25 +105,25 @@ async function dirtyPathSet(worktreePath: string): Promise<Set<string>> {
90
105
  }
91
106
 
92
107
  async function changedPathList(worktreePath: string, oldBaseTip: string, newBaseTip: string): Promise<string[]> {
93
- const diff = await gitRaw(["diff", "--name-only", "-z", oldBaseTip, newBaseTip], worktreePath);
108
+ const diff = await mergeGitRaw(["diff", "--name-only", "-z", oldBaseTip, newBaseTip], worktreePath, "rebase", "workspace merge changed path scan");
94
109
  return diff.ok ? splitNul(diff.stdout) : [];
95
110
  }
96
111
 
97
112
  async function restorePathsFromHead(worktreePath: string, paths: string[]): Promise<boolean> {
98
113
  if (paths.length === 0) return true;
99
- return (await git(["restore", "--staged", "--worktree", "--", ...paths], worktreePath)).ok;
114
+ return (await mergeGit(["restore", "--staged", "--worktree", "--", ...paths], worktreePath, "rebase", "workspace merge restore clean landed paths")).ok;
100
115
  }
101
116
 
102
117
  async function resetIndexPathsToHead(worktreePath: string, paths: string[]): Promise<boolean> {
103
118
  if (paths.length === 0) return true;
104
- return (await git(["reset", "-q", "HEAD", "--", ...paths], worktreePath)).ok;
119
+ return (await mergeGit(["reset", "-q", "HEAD", "--", ...paths], worktreePath, "rebase", "workspace merge reset preserved paths")).ok;
105
120
  }
106
121
 
107
122
  /** Landed paths whose working copy still differs from the advanced HEAD (ground truth, not
108
123
  * exit codes): the checkout is mixed for these — reads/builds/publishes see STALE files. */
109
124
  async function pathsDifferingFromHead(worktreePath: string, paths: string[]): Promise<string[]> {
110
125
  if (paths.length === 0) return [];
111
- const diff = await gitRaw(["diff", "--name-only", "-z", "HEAD", "--", ...paths], worktreePath);
126
+ const diff = await mergeGitRaw(["diff", "--name-only", "-z", "HEAD", "--", ...paths], worktreePath, "rebase", "workspace merge verify base worktree sync");
112
127
  // A failed diff can't prove the checkout is clean — treat every landed path as suspect.
113
128
  return diff.ok ? splitNul(diff.stdout) : [...paths];
114
129
  }
@@ -152,7 +167,7 @@ async function syncDirtyBaseWorktreeAfterRefAdvance(
152
167
  dirtyBefore: Set<string> | undefined,
153
168
  ): Promise<BaseWorktreeSyncResult> {
154
169
  if (!baseWorktree?.dirty || !dirtyBefore) return RECONCILED;
155
- const readTree = await git(["read-tree", "-m", "-u", oldBaseTip, newBaseTip], baseWorktree.path);
170
+ const readTree = await mergeGit(["read-tree", "-m", "-u", oldBaseTip, newBaseTip], baseWorktree.path, "rebase", "workspace merge sync dirty base worktree");
156
171
  if (readTree.ok) return RECONCILED;
157
172
 
158
173
  const changedPaths = await changedPathList(baseWorktree.path, oldBaseTip, newBaseTip);
@@ -352,11 +367,12 @@ export async function mergeWorkspace(input: WorkspaceMergeInput): Promise<Worksp
352
367
  if (!input.worktreePath) return { strategy: "rebase-ff", merged: false, status: "review_requested", error: "worktreePath required", workspaceId: input.id };
353
368
  const worktreePath = resolve(input.worktreePath);
354
369
  const repoRoot = input.repoRoot ? resolve(input.repoRoot) : worktreePath;
370
+ console.error(`[orchestrator] workspace.merge prep-start workspace=${input.id ?? "(unknown)"} worktree=${worktreePath} repo=${repoRoot}`);
355
371
  // Probe the live HEAD branch first — it's the authoritative source. Fall back to the
356
372
  // DB-recorded branch only when the live probe fails (detached HEAD, missing worktree, etc.).
357
373
  // This fixes #232: a stale DB branch value (non-null mismatch) would pass through the
358
374
  // `input.branch ?? ...` guard unchanged and cause git to attempt merging a non-existent ref.
359
- const liveBranch = shortBranch((await git(["symbolic-ref", "--quiet", "--short", "HEAD"], worktreePath)).stdout || undefined);
375
+ const liveBranch = shortBranch((await mergeGit(["symbolic-ref", "--quiet", "--short", "HEAD"], worktreePath, "prep", "workspace merge resolve live branch")).stdout || undefined);
360
376
  const branch = liveBranch ?? input.branch;
361
377
  let preview: WorkspaceMergePreview;
362
378
  try {
@@ -398,6 +414,7 @@ export async function mergeWorkspace(input: WorkspaceMergeInput): Promise<Worksp
398
414
  if (preview.conflict) return head({ conflict: true, status: "conflict", error: "merge would conflict with base" });
399
415
 
400
416
  if (strategy === "pr") return await mergePr(input, worktreePath, branch, preview, head);
417
+ console.error(`[orchestrator] workspace.merge rebase-start workspace=${input.id ?? "(unknown)"} branch=${branch ?? "(unknown)"} base=${preview.baseRef ?? "(unknown)"}`);
401
418
  return await mergeRebaseFf(input, worktreePath, repoRoot, branch, preview, head);
402
419
  }
403
420
 
@@ -559,25 +576,23 @@ async function recordNoFfMerge(
559
576
  branchSha: string,
560
577
  message: string,
561
578
  ): Promise<{ ok: true; mergeSha: string } | { ok: false; conflict?: boolean; error: string }> {
562
- const synth = await synthesizeNoFfMerge(repoRoot, baseSha, branchSha, message);
579
+ const synth = await synthesizeNoFfMerge(repoRoot, baseSha, branchSha, message, mergePhaseTimeoutMs("synthesize"));
563
580
  if (!synth.ok) return synth;
564
- const update = await git(["update-ref", `refs/heads/${base}`, synth.mergeSha, baseSha], repoRoot);
581
+ const update = await mergeGit(["update-ref", `refs/heads/${base}`, synth.mergeSha, baseSha], repoRoot, "rebase", `workspace merge advance ${base} to synthesized merge`);
565
582
  if (!update.ok) return { ok: false, error: update.stderr || "failed to advance base ref" };
566
583
  return { ok: true, mergeSha: synth.mergeSha };
567
584
  }
568
585
 
586
+ function decommissionedLandGatesResult(): LandGatesResult {
587
+ return { ran: 0, warnings: [] };
588
+ }
589
+
569
590
  /**
570
- * Run the repo's configured land gates against the EXACT tree that will become base's new tip,
571
- * BEFORE any ref advance (#902 BLOCKING 1 / closes #903). The candidate tree depends on `behind`:
572
- * - behind === 0: the landing worktree HEAD already IS that tree (a clean fast-forward), so gates
573
- * run in `worktreePath` in place byte-identical to today's straight-FF path.
574
- * - behind > 0: base has advanced, so the tree that will land is the no-ff MERGE of
575
- * `integrationBaseSha` and `headSha`, NOT the worktree HEAD. Synthesize that merge commit with
576
- * plumbing (no ref moved), check it out in a throwaway DETACHED worktree, and gate THAT tree.
577
- * The temp worktree is torn down on EVERY exit path (including a gate that throws), so a land
578
- * can never strand an orphan worktree or leave the synthesized merge half-applied.
579
- * Returns the gate outcome, or an `abort` describing a merge result the caller must return early
580
- * (a real merge conflict computing the integrated tree, or a failure materializing it).
591
+ * Land gates are decommissioned (#923). Release-time gates in `release.ts` are the
592
+ * quality boundary; workspace landing must never launch repo-configured gate subprocesses.
593
+ * Keep this function as the single merge-path kill switch so populated
594
+ * `.agent-relay/land-gates.json` files are treated as always-empty without changing the
595
+ * rest of the rebase/fetch/no-ff landing flow.
581
596
  */
582
597
  async function runLandGatesOnIntegratedTree(
583
598
  repoRoot: string,
@@ -587,50 +602,12 @@ async function runLandGatesOnIntegratedTree(
587
602
  headSha: string,
588
603
  mergeMessage: string,
589
604
  ): Promise<{ gates: LandGatesResult } | { abort: { conflict?: boolean; error: string } }> {
590
- if (behind === 0) return { gates: await runLandGates(worktreePath) };
591
-
592
- let synth: Awaited<ReturnType<typeof synthesizeNoFfMerge>>;
593
- try {
594
- synth = await withMergePhaseTimeout("synthesize", () => synthesizeNoFfMerge(repoRoot, integrationBaseSha, headSha, mergeMessage, mergePhaseTimeoutMs("synthesize")));
595
- } catch (err) {
596
- return { abort: { error: errMessage(err) } };
597
- }
598
- if (!synth.ok) return { abort: { conflict: synth.conflict, error: synth.error } };
599
-
600
- // Detached worktree in an isolated temp dir so the gates see the integrated tree on disk
601
- // without disturbing the landing worktree, the base checkout, or any ref. `git worktree add`
602
- // creates the leaf, so hand it a not-yet-existing path under a freshly made parent dir.
603
- const tmpParent = mkdtempSync(join(tmpdir(), "agent-relay-landgate-"));
604
- const tmpWorktree = join(tmpParent, "tree");
605
- let add: Awaited<ReturnType<typeof git>>;
606
- try {
607
- add = await withMergePhaseTimeout("worktree-add", () => git(
608
- ["worktree", "add", "--detach", tmpWorktree, synth.mergeSha],
609
- repoRoot,
610
- { timeoutMs: mergePhaseTimeoutMs("worktree-add"), timeoutLabel: "workspace merge land-gate worktree add" },
611
- ));
612
- } catch (err) {
613
- rmSync(tmpParent, { recursive: true, force: true });
614
- return { abort: { error: errMessage(err) } };
615
- }
616
- if (!add.ok) {
617
- rmSync(tmpParent, { recursive: true, force: true });
618
- return { abort: { error: add.stderr || "failed to materialize integrated tree for land gates" } };
619
- }
620
- try {
621
- return { gates: await runLandGates(tmpWorktree) };
622
- } finally {
623
- try {
624
- await withMergePhaseTimeout("cleanup", () => git(
625
- ["worktree", "remove", "--force", tmpWorktree],
626
- repoRoot,
627
- { timeoutMs: mergePhaseTimeoutMs("cleanup"), timeoutLabel: "workspace merge land-gate worktree cleanup" },
628
- ));
629
- } catch (err) {
630
- console.error(`[orchestrator] land-gate integrated worktree cleanup timed out/failed: ${errMessage(err)}`);
631
- }
632
- rmSync(tmpParent, { recursive: true, force: true });
633
- }
605
+ void repoRoot;
606
+ void integrationBaseSha;
607
+ void headSha;
608
+ void mergeMessage;
609
+ console.error(`[orchestrator] workspace.merge gate-skip worktree=${worktreePath} behind=${behind} reason=land-gates-decommissioned`);
610
+ return { gates: decommissionedLandGatesResult() };
634
611
  }
635
612
 
636
613
  /**
@@ -647,7 +624,7 @@ async function syncLocalBaseToUpstream(
647
624
  base: string,
648
625
  upstream: string,
649
626
  ): Promise<{ ok: true; baseSync?: BaseWorktreeSyncResult } | { ok: false; error: string }> {
650
- const upstreamSha = (await git(["rev-parse", "--verify", upstream], worktreePath)).stdout;
627
+ const upstreamSha = (await mergeGit(["rev-parse", "--verify", upstream], worktreePath, "rebase", `workspace merge resolve ${upstream}`)).stdout;
651
628
  if (!upstreamSha) return { ok: false, error: `cannot resolve ${upstream} to sync ${base}` };
652
629
  const baseWorktree = await worktreeForBranch(repoRoot, base);
653
630
  // Sync IN the base worktree only when it's clean — that keeps its working tree consistent
@@ -656,14 +633,14 @@ async function syncLocalBaseToUpstream(
656
633
  // lands: advance the ref directly with update-ref, then best-effort sync the checked-out
657
634
  // index/worktree forward for paths that are not human-modified (#681).
658
635
  if (baseWorktree && !baseWorktree.dirty) {
659
- const ff = await git(["merge", "--ff-only", upstream], baseWorktree.path);
636
+ const ff = await mergeGit(["merge", "--ff-only", upstream], baseWorktree.path, "rebase", `workspace merge fast-forward ${base} to ${upstream}`);
660
637
  if (!ff.ok) return { ok: false, error: ff.stderr || `failed to fast-forward ${base} to ${upstream}` };
661
638
  return { ok: true };
662
639
  }
663
- const oldBaseTip = (await git(["rev-parse", base], repoRoot)).stdout;
640
+ const oldBaseTip = (await mergeGit(["rev-parse", base], repoRoot, "rebase", `workspace merge resolve ${base} before upstream sync`)).stdout;
664
641
  const dirtyBefore = baseWorktree?.dirty ? await dirtyPathSet(baseWorktree.path) : undefined;
665
642
  const updateArgs = oldBaseTip ? ["update-ref", `refs/heads/${base}`, upstreamSha, oldBaseTip] : ["update-ref", `refs/heads/${base}`, upstreamSha];
666
- const update = await git(updateArgs, repoRoot);
643
+ const update = await mergeGit(updateArgs, repoRoot, "rebase", `workspace merge update ${base} to ${upstream}`);
667
644
  if (!update.ok) return { ok: false, error: update.stderr || `failed to advance ${base} to ${upstream}` };
668
645
  const baseSync = oldBaseTip ? await syncDirtyBaseWorktreeAfterRefAdvance(base, baseWorktree, oldBaseTip, upstreamSha, dirtyBefore) : undefined;
669
646
  return { ok: true, baseSync };
@@ -699,7 +676,8 @@ async function mergeRebaseFf(
699
676
  // points (the upstream sync below and the final land). A mixed state from either is surfaced
700
677
  // loudly rather than swallowed as a log warning (#824).
701
678
  let baseSync: BaseWorktreeSyncResult | undefined;
702
- const upstream = await upstreamRef(worktreePath, base);
679
+ const upstreamResult = await mergeGit(["rev-parse", "--abbrev-ref", `${base}@{upstream}`], worktreePath, "rebase", `workspace merge resolve upstream for ${base}`);
680
+ const upstream = upstreamResult.ok && upstreamResult.stdout ? upstreamResult.stdout : undefined;
703
681
  const slash = upstream ? upstream.indexOf("/") : -1;
704
682
  const remote = slash > 0 ? upstream!.slice(0, slash) : undefined; // remote of a `remote/branch` upstream
705
683
  const pushEnabled = input.push !== false && workspacePushEnabled() && Boolean(remote);
@@ -708,18 +686,19 @@ async function mergeRebaseFf(
708
686
  // gives them new SHAs and breaks traceability (the branch.landed SHA must exist on
709
687
  // base verbatim). headSha is the preserved landed commit; when base has advanced we
710
688
  // tie the branch in with a no-ff merge so the agent's commits keep their identity.
711
- const headSha = (await git(["rev-parse", "HEAD"], worktreePath)).stdout;
689
+ const headResult = await mergeGit(["rev-parse", "HEAD"], worktreePath, "rebase", "workspace merge resolve workspace HEAD before gates");
690
+ const headSha = headResult.stdout;
691
+ if (!headResult.ok || !headSha) return head({ status: "review_requested", error: gitError(headResult, "failed to resolve workspace HEAD before gates") });
712
692
  // Subject of the landed commit for the relay's branch.landed notice (#239). Best-effort:
713
693
  // an empty/failed read just omits it from the message body.
714
- const landedSubject = (await git(["log", "-1", "--format=%s", headSha], worktreePath)).stdout || undefined;
715
-
716
- // #902 BLOCKING 2 NO mutation of `refs/heads/<base>` may happen until gates pass, so resolve
717
- // the SHA the work will integrate onto WITHOUT moving the ref. Origin-ahead is the common
718
- // concurrent case (#638): we still fetch fresh origin to compute the real merge, but we defer
719
- // the local-base sync to AFTER the gates. On a gate failure `refs/heads/<base>` must be byte-
720
- // identical to before the land attempt — so the only `refs/heads/<base>` mutation is the
721
- // sync+advance below, all of it gated.
722
- let integrationBaseSha = (await git(["rev-parse", base], repoRoot)).stdout;
694
+ const landedSubject = (await mergeGit(["log", "-1", "--format=%s", headSha], worktreePath, "rebase", "workspace merge read landed commit subject")).stdout || undefined;
695
+
696
+ // Resolve the SHA the work will integrate onto WITHOUT moving the ref. Origin-ahead is
697
+ // the common concurrent case (#638): we still fetch fresh origin to compute the real merge,
698
+ // 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}`);
700
+ let integrationBaseSha = integrationBaseResult.stdout;
701
+ if (!integrationBaseResult.ok || !integrationBaseSha) return head({ status: "review_requested", error: gitError(integrationBaseResult, `failed to resolve integration base ${base}`) });
723
702
  let needSync = false;
724
703
  if (upstream && remote && pushEnabled) {
725
704
  try {
@@ -732,13 +711,13 @@ async function mergeRebaseFf(
732
711
  } catch (err) {
733
712
  return head({ status: "review_requested", error: errMessage(err) });
734
713
  }
735
- if (!(await git(["merge-base", "--is-ancestor", upstream, base], worktreePath)).ok) {
714
+ if (!(await mergeGit(["merge-base", "--is-ancestor", upstream, base], worktreePath, "rebase", `workspace merge compare ${upstream} to ${base}`)).ok) {
736
715
  // Origin moved ahead. Sync-then-land iff local base is cleanly behind (ancestor
737
716
  // of upstream); otherwise it's genuine divergence — refuse without mutating.
738
- if (!(await git(["merge-base", "--is-ancestor", base, upstream], worktreePath)).ok) {
717
+ if (!(await mergeGit(["merge-base", "--is-ancestor", base, upstream], worktreePath, "rebase", `workspace merge compare ${base} to ${upstream}`)).ok) {
739
718
  return head({ status: "review_requested", error: `local ${base} has diverged from ${upstream} (commits not on origin); sync before landing` });
740
719
  }
741
- const upstreamSha = (await git(["rev-parse", "--verify", upstream], worktreePath)).stdout;
720
+ const upstreamSha = (await mergeGit(["rev-parse", "--verify", upstream], worktreePath, "rebase", `workspace merge resolve ${upstream} for integration`)).stdout;
742
721
  if (!upstreamSha) return head({ status: "review_requested", error: `cannot resolve ${upstream} to sync ${base}` });
743
722
  // Integrate onto fresh origin (the tree that will land), but DON'T advance local base yet.
744
723
  integrationBaseSha = upstreamSha;
@@ -747,18 +726,14 @@ async function mergeRebaseFf(
747
726
  }
748
727
 
749
728
  // Behind relative to the TRUE integration base (origin-ahead ⟹ behind>0 ⟹ a real no-ff merge).
750
- const behind = await countBehind(worktreePath, integrationBaseSha);
751
-
752
- // #902 BLOCKING 1 / closes #903 — run the repo's configured land gates against the EXACT tree
753
- // that will become base's new tip, BEFORE advancing the ref. behind===0 → the worktree HEAD IS
754
- // that tree (clean fast-forward); behind>0 the synthesized no-ff merge of integrationBaseSha +
755
- // HEAD, gated in a throwaway worktree (see runLandGatesOnIntegratedTree). A repo with no
756
- // `.agent-relay/land-gates.json` runs ZERO gates (missing-file empty list, no subprocess), so
757
- // behind===0 is byte-identical to the prior land path (ironclad opt-in / zero regression). A
758
- // failing REQUIRED gate aborts the land here WITHOUT advancing/syncing the ref — returned as
759
- // status:"review_requested" carrying `gateFailure`, NOT "conflict": gate failures bounce back to
760
- // the worker, never the merge-conflict/steward path. Optional-gate failures ride along as
761
- // `gateWarnings` on the successful land below.
729
+ const behindResult = await countBehind(worktreePath, integrationBaseSha);
730
+ if ("error" in behindResult) return head({ status: "review_requested", error: behindResult.error });
731
+ const behind = behindResult.behind;
732
+
733
+ // #923 land gates are decommissioned. This call is now a hard kill switch that always
734
+ // returns an empty gate set, even when `.agent-relay/land-gates.json` is populated. Keep it
735
+ // before base-ref mutation so the rest of the historical land ordering stays intact, but no
736
+ // gate subprocess or detached gate worktree can be launched at land time.
762
737
  const gateRun = await runLandGatesOnIntegratedTree(repoRoot, worktreePath, behind, integrationBaseSha, headSha, landMergeMessage(branch, landedSubject));
763
738
  if ("abort" in gateRun) {
764
739
  return gateRun.abort.conflict
@@ -771,9 +746,9 @@ async function mergeRebaseFf(
771
746
  }
772
747
  const gateWarningsField = gates.warnings.length ? { gateWarnings: gates.warnings } : {};
773
748
 
774
- // Gates passed — only NOW is it safe to touch `refs/heads/<base>`. If origin moved ahead, sync
775
- // local base up to the fetched upstream first (#638); this is the FIRST mutation of the base ref
776
- // in the whole land path, so a gate failure above can never leave it advanced (#902 BLOCKING 2).
749
+ // Only NOW touch `refs/heads/<base>`. If origin moved ahead, sync local base up to the fetched
750
+ // upstream first (#638); this preserves the pre-existing land ordering while gate execution
751
+ // remains permanently disabled.
777
752
  if (needSync) {
778
753
  const synced = await syncLocalBaseToUpstream(repoRoot, worktreePath, base, upstream!);
779
754
  if (!synced.ok) return head({ status: "review_requested", error: synced.error });
@@ -792,28 +767,32 @@ async function mergeRebaseFf(
792
767
  const dirtyBasePathsBefore = baseWorktree?.dirty ? await dirtyPathSet(baseWorktree.path) : undefined;
793
768
  if (baseWorktree && !baseWorktree.dirty) {
794
769
  if (behind === 0) {
795
- const ff = await git(["merge", "--ff-only", branch], baseWorktree.path);
770
+ const ff = await mergeGit(["merge", "--ff-only", branch], baseWorktree.path, "rebase", `workspace merge fast-forward ${base} to ${branch}`);
796
771
  if (!ff.ok) return head({ status: "review_requested", error: ff.stderr || "fast-forward into base failed" });
797
772
  } else {
798
- const merge = await git(
773
+ const merge = await mergeGit(
799
774
  ["-c", `user.name=${LAND_COMMITTER.name}`, "-c", `user.email=${LAND_COMMITTER.email}`, "merge", "--no-ff", "-m", landMergeMessage(branch, landedSubject), branch],
800
775
  baseWorktree.path,
776
+ "rebase",
777
+ `workspace merge no-ff ${branch} into ${base}`,
801
778
  );
802
779
  if (!merge.ok) {
803
- await git(["merge", "--abort"], baseWorktree.path);
780
+ await mergeGit(["merge", "--abort"], baseWorktree.path, "rebase", `workspace merge abort failed no-ff ${branch}`);
804
781
  return head({ conflict: true, status: "conflict", error: merge.stderr || "merge into base failed" });
805
782
  }
806
- baseTip = (await git(["rev-parse", "HEAD"], baseWorktree.path)).stdout;
783
+ baseTip = (await mergeGit(["rev-parse", "HEAD"], baseWorktree.path, "rebase", `workspace merge resolve ${base} after no-ff`)).stdout;
807
784
  }
808
785
  } else if (behind === 0) {
809
- const oldBaseTip = (await git(["rev-parse", base], repoRoot)).stdout;
786
+ const oldBaseTip = (await mergeGit(["rev-parse", base], repoRoot, "rebase", `workspace merge resolve ${base} before fast-forward ref update`)).stdout;
810
787
  const update = oldBaseTip
811
- ? await git(["update-ref", `refs/heads/${base}`, headSha, oldBaseTip], repoRoot)
812
- : await git(["update-ref", `refs/heads/${base}`, headSha], repoRoot);
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`);
813
790
  if (!update.ok) return head({ status: "review_requested", error: update.stderr || "failed to advance base ref" });
814
791
  if (oldBaseTip) baseSync = mergeBaseSyncResults(baseSync, await syncDirtyBaseWorktreeAfterRefAdvance(base, baseWorktree, oldBaseTip, headSha, dirtyBasePathsBefore));
815
792
  } else {
816
- const baseSha = (await git(["rev-parse", base], repoRoot)).stdout;
793
+ const baseShaResult = await mergeGit(["rev-parse", base], repoRoot, "rebase", `workspace merge resolve ${base} before no-ff ref update`);
794
+ const baseSha = baseShaResult.stdout;
795
+ if (!baseShaResult.ok || !baseSha) return head({ status: "review_requested", error: gitError(baseShaResult, `failed to resolve ${base} before no-ff ref update`) });
817
796
  const merged = await recordNoFfMerge(repoRoot, base, baseSha, headSha, landMergeMessage(branch, landedSubject));
818
797
  if (!merged.ok) return head(merged.conflict ? { conflict: true, status: "conflict", error: merged.error } : { status: "review_requested", error: merged.error });
819
798
  baseTip = merged.mergeSha;
@@ -833,7 +812,7 @@ async function mergeRebaseFf(
833
812
  // means origin raced us — surface it instead of claiming an unpublished land.
834
813
  let pushed = false;
835
814
  if (upstream && remote && pushEnabled) {
836
- const push = await git(["push", remote, `${base}:${base}`], worktreePath);
815
+ const push = await mergeGit(["push", remote, `${base}:${base}`], worktreePath, "rebase", `workspace merge push ${base} to ${remote}`);
837
816
  if (!push.ok) return head({ status: "review_requested", mergedSha: headSha, error: push.stderr || `git push to ${remote}/${base} failed` });
838
817
  pushed = true;
839
818
  }
@@ -847,11 +826,11 @@ async function mergeRebaseFf(
847
826
  const deleteBranch = input.deleteBranch !== false;
848
827
  if (!deleteBranch) {
849
828
  const fresh = await nextBranchName(repoRoot, branch);
850
- const switched = await git(["checkout", "-B", fresh, base], worktreePath);
829
+ const switched = await mergeGit(["checkout", "-B", fresh, base], worktreePath, "rebase", `workspace merge recycle worktree to ${fresh}`);
851
830
  if (switched.ok) {
852
831
  // The old branch is now fully contained in base (fast-forwarded, or merged in
853
832
  // as the no-ff merge's second parent) — drop the litter.
854
- const oldDeleted = (await git(["branch", "-D", branch], repoRoot)).ok;
833
+ const oldDeleted = (await mergeGit(["branch", "-D", branch], repoRoot, "cleanup", `workspace merge delete landed branch ${branch}`)).ok;
855
834
  // The worktree just moved onto the advanced base, which may declare deps the
856
835
  // shared (symlinked) node_modules lacks. Re-provision so the recycled session
857
836
  // continues from a buildable state (issue #51). No-op when nothing is stale.
@@ -862,8 +841,8 @@ async function mergeRebaseFf(
862
841
  // Recycle failed — keep the existing branch. Still landed, still active.
863
842
  return head({ merged: true, status: "active", mergedSha: headSha, baseSha: baseTip, subject: landedSubject, worktreeRemoved: false, branchDeleted: false, pushed, ...baseWorktreeSyncField, ...gateWarningsField, error: undefined });
864
843
  }
865
- const removed = await git(["worktree", "remove", "--force", worktreePath], repoRoot);
844
+ const removed = await mergeGit(["worktree", "remove", "--force", worktreePath], repoRoot, "cleanup", "workspace merge remove landed worktree");
866
845
  const worktreeRemoved = removed.ok;
867
- const branchDeleted = worktreeRemoved ? (await git(["branch", "-D", branch], repoRoot)).ok : false;
846
+ const branchDeleted = worktreeRemoved ? (await mergeGit(["branch", "-D", branch], repoRoot, "cleanup", `workspace merge delete landed branch ${branch}`)).ok : false;
868
847
  return head({ merged: true, status: "merged", mergedSha: headSha, baseSha: baseTip, subject: landedSubject, worktreeRemoved, branchDeleted, pushed, ...baseWorktreeSyncField, ...gateWarningsField, error: undefined });
869
848
  }