agent-relay-orchestrator 0.118.4 → 0.118.5

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.118.5",
4
4
  "description": "Agent Relay orchestrator — manages agent lifecycle across hosts",
5
5
  "type": "module",
6
6
  "bin": {
package/src/control.ts CHANGED
@@ -6,6 +6,7 @@ import { handleSelfUpgrade } from "./self-upgrade";
6
6
  import { readLocalProviderConfigs } from "./provider-config-migration";
7
7
  import { 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
 
@@ -118,26 +119,42 @@ export function createControlHandler(
118
119
  });
119
120
  await relay.updateCommand(command.id, "succeeded", result);
120
121
  } 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
- });
122
+ let result: WorkspaceMergeResult;
123
+ try {
124
+ result = await withMergePhaseTimeout("total", () => mergeWorkspace({
125
+ id: typeof command.params.workspaceId === "string" ? command.params.workspaceId : undefined,
126
+ repoRoot: typeof command.params.repoRoot === "string" ? command.params.repoRoot : undefined,
127
+ worktreePath: typeof command.params.worktreePath === "string" ? command.params.worktreePath : undefined,
128
+ branch: typeof command.params.branch === "string" ? command.params.branch : undefined,
129
+ baseRef: typeof command.params.baseRef === "string" ? command.params.baseRef : undefined,
130
+ baseSha: typeof command.params.baseSha === "string" ? command.params.baseSha : undefined,
131
+ strategy: command.params.strategy === "pr" || command.params.strategy === "rebase-ff" || command.params.strategy === "auto" ? command.params.strategy : undefined,
132
+ deleteBranch: command.params.deleteBranch !== false,
133
+ push: command.params.push !== false,
134
+ prTitle: typeof command.params.prTitle === "string" ? command.params.prTitle : undefined,
135
+ prBody: typeof command.params.prBody === "string" ? command.params.prBody : undefined,
136
+ autoMerge: command.params.autoMerge === "on-green" || command.params.autoMerge === "on-approval" || command.params.autoMerge === "manual" ? command.params.autoMerge : undefined,
137
+ prLanded: isRecord(command.params.prLanded)
138
+ ? {
139
+ sha: typeof command.params.prLanded.sha === "string" ? command.params.prLanded.sha : undefined,
140
+ subject: typeof command.params.prLanded.subject === "string" ? command.params.prLanded.subject : undefined,
141
+ }
142
+ : undefined,
143
+ }));
144
+ } catch (err) {
145
+ const branch = typeof command.params.branch === "string" ? command.params.branch : undefined;
146
+ const baseRef = typeof command.params.baseRef === "string" ? command.params.baseRef : undefined;
147
+ result = {
148
+ workspaceId: typeof command.params.workspaceId === "string" ? command.params.workspaceId : undefined,
149
+ strategy: command.params.strategy === "pr" ? "pr" : "rebase-ff",
150
+ merged: false,
151
+ status: "review_requested",
152
+ ...(branch ? { branch } : {}),
153
+ ...(baseRef ? { baseRef } : {}),
154
+ error: errMessage(err),
155
+ };
156
+ console.error(`[orchestrator] workspace.merge failed before completion: ${result.error}`);
157
+ }
141
158
  // #638 — settle `failed` (carrying the error) for a no-op merge instead of
142
159
  // `succeeded`; see mergeCommandStatus.
143
160
  await relay.updateCommand(command.id, mergeCommandStatus(result), result as unknown as Record<string, unknown>, result.error);
@@ -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,
@@ -7,29 +7,45 @@ import { execProcess } from "../process";
7
7
  import { prMergedState } from "../workspace-pr";
8
8
  import { refreshWorkspaceDeps } from "./deps";
9
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";
10
+ import { populateMergeState, resolveBranchRef, syncBaseFromOrigin, workspaceGitState } from "./git-state";
11
+ import { type MergePhase, mergePhaseTimeoutMs, withMergePhaseTimeout } from "./merge-timeouts";
12
12
  import { nextBranchName } from "./names";
13
13
  import { parseWorktrees, shortBranch } from "./parse";
14
14
  import { json, resolveRequestedPath } from "./request";
15
15
  import type { WorkspaceMergeInput } from "./types";
16
16
  import { workspacePushEnabled } from "../config";
17
17
 
18
+ async function mergeGit(args: string[], cwd: string, phase: MergePhase, timeoutLabel?: string): ReturnType<typeof git> {
19
+ return git(args, cwd, {
20
+ timeoutMs: mergePhaseTimeoutMs(phase),
21
+ timeoutLabel: timeoutLabel ?? `workspace merge ${phase} git ${args.join(" ")}`,
22
+ });
23
+ }
24
+
25
+ async function mergeGitRaw(args: string[], cwd: string, phase: MergePhase, timeoutLabel?: string): ReturnType<typeof gitRaw> {
26
+ return gitRaw(args, cwd, {
27
+ timeoutMs: mergePhaseTimeoutMs(phase),
28
+ timeoutLabel: timeoutLabel ?? `workspace merge ${phase} git ${args.join(" ")}`,
29
+ });
30
+ }
31
+
32
+ function gitError(result: Awaited<ReturnType<typeof git>>, fallback: string): string {
33
+ return result.stderr || result.stdout || fallback;
34
+ }
35
+
18
36
  /** 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;
37
+ async function countBehind(worktreePath: string, base: string): Promise<{ behind: number } | { error: string }> {
38
+ const counts = await mergeGit(["rev-list", "--left-right", "--count", `${base}...HEAD`], worktreePath, "rebase", "workspace merge count behind integration base");
39
+ if (!counts.ok || !counts.stdout) return { error: gitError(counts, "failed to count branch behind integration base") };
22
40
  const behind = Number(counts.stdout.split(/\s+/)[0]);
23
- return Number.isFinite(behind) ? behind : 0;
41
+ return Number.isFinite(behind) ? { behind } : { error: "git rev-list produced an invalid behind count" };
24
42
  }
25
43
 
26
44
  async function hasOriginRemote(cwd: string): Promise<boolean> {
27
45
  return (await git(["remote", "get-url", "origin"], cwd)).ok;
28
46
  }
29
47
 
30
- function ghAvailable(): boolean {
31
- return Boolean(Bun.which("gh"));
32
- }
48
+ function ghAvailable(): boolean { return Boolean(Bun.which("gh")); }
33
49
 
34
50
  /**
35
51
  * Ground-truth merge state for `branch`, via gh.
@@ -61,11 +77,11 @@ async function baseBranchName(worktreePath: string, baseRef?: string): Promise<s
61
77
 
62
78
  /** Locate the worktree (if any) that currently has `branch` checked out. */
63
79
  async function worktreeForBranch(repoRoot: string, branch: string): Promise<{ path: string; dirty: boolean } | undefined> {
64
- const list = await git(["worktree", "list", "--porcelain"], repoRoot);
80
+ const list = await mergeGit(["worktree", "list", "--porcelain"], repoRoot, "rebase", "workspace merge list worktrees");
65
81
  if (!list.ok) return undefined;
66
82
  const match = parseWorktrees(list.stdout).find((worktree) => worktree.branch === branch);
67
83
  if (!match) return undefined;
68
- const status = await git(["status", "--porcelain"], match.path);
84
+ const status = await mergeGit(["status", "--porcelain"], match.path, "rebase", `workspace merge status ${branch} worktree`);
69
85
  return { path: match.path, dirty: status.ok ? status.stdout.length > 0 : true };
70
86
  }
71
87
 
@@ -74,7 +90,7 @@ function splitNul(stdout: string): string[] {
74
90
  }
75
91
 
76
92
  async function dirtyPathSet(worktreePath: string): Promise<Set<string>> {
77
- const status = await gitRaw(["status", "--porcelain=v1", "-z", "--untracked-files=all"], worktreePath);
93
+ const status = await mergeGitRaw(["status", "--porcelain=v1", "-z", "--untracked-files=all"], worktreePath, "rebase", "workspace merge dirty path scan");
78
94
  if (!status.ok) return new Set<string>();
79
95
  const paths = new Set<string>();
80
96
  const entries = splitNul(status.stdout);
@@ -90,25 +106,25 @@ async function dirtyPathSet(worktreePath: string): Promise<Set<string>> {
90
106
  }
91
107
 
92
108
  async function changedPathList(worktreePath: string, oldBaseTip: string, newBaseTip: string): Promise<string[]> {
93
- const diff = await gitRaw(["diff", "--name-only", "-z", oldBaseTip, newBaseTip], worktreePath);
109
+ const diff = await mergeGitRaw(["diff", "--name-only", "-z", oldBaseTip, newBaseTip], worktreePath, "rebase", "workspace merge changed path scan");
94
110
  return diff.ok ? splitNul(diff.stdout) : [];
95
111
  }
96
112
 
97
113
  async function restorePathsFromHead(worktreePath: string, paths: string[]): Promise<boolean> {
98
114
  if (paths.length === 0) return true;
99
- return (await git(["restore", "--staged", "--worktree", "--", ...paths], worktreePath)).ok;
115
+ return (await mergeGit(["restore", "--staged", "--worktree", "--", ...paths], worktreePath, "rebase", "workspace merge restore clean landed paths")).ok;
100
116
  }
101
117
 
102
118
  async function resetIndexPathsToHead(worktreePath: string, paths: string[]): Promise<boolean> {
103
119
  if (paths.length === 0) return true;
104
- return (await git(["reset", "-q", "HEAD", "--", ...paths], worktreePath)).ok;
120
+ return (await mergeGit(["reset", "-q", "HEAD", "--", ...paths], worktreePath, "rebase", "workspace merge reset preserved paths")).ok;
105
121
  }
106
122
 
107
123
  /** Landed paths whose working copy still differs from the advanced HEAD (ground truth, not
108
124
  * exit codes): the checkout is mixed for these — reads/builds/publishes see STALE files. */
109
125
  async function pathsDifferingFromHead(worktreePath: string, paths: string[]): Promise<string[]> {
110
126
  if (paths.length === 0) return [];
111
- const diff = await gitRaw(["diff", "--name-only", "-z", "HEAD", "--", ...paths], worktreePath);
127
+ const diff = await mergeGitRaw(["diff", "--name-only", "-z", "HEAD", "--", ...paths], worktreePath, "rebase", "workspace merge verify base worktree sync");
112
128
  // A failed diff can't prove the checkout is clean — treat every landed path as suspect.
113
129
  return diff.ok ? splitNul(diff.stdout) : [...paths];
114
130
  }
@@ -152,7 +168,7 @@ async function syncDirtyBaseWorktreeAfterRefAdvance(
152
168
  dirtyBefore: Set<string> | undefined,
153
169
  ): Promise<BaseWorktreeSyncResult> {
154
170
  if (!baseWorktree?.dirty || !dirtyBefore) return RECONCILED;
155
- const readTree = await git(["read-tree", "-m", "-u", oldBaseTip, newBaseTip], baseWorktree.path);
171
+ const readTree = await mergeGit(["read-tree", "-m", "-u", oldBaseTip, newBaseTip], baseWorktree.path, "rebase", "workspace merge sync dirty base worktree");
156
172
  if (readTree.ok) return RECONCILED;
157
173
 
158
174
  const changedPaths = await changedPathList(baseWorktree.path, oldBaseTip, newBaseTip);
@@ -352,11 +368,12 @@ export async function mergeWorkspace(input: WorkspaceMergeInput): Promise<Worksp
352
368
  if (!input.worktreePath) return { strategy: "rebase-ff", merged: false, status: "review_requested", error: "worktreePath required", workspaceId: input.id };
353
369
  const worktreePath = resolve(input.worktreePath);
354
370
  const repoRoot = input.repoRoot ? resolve(input.repoRoot) : worktreePath;
371
+ console.error(`[orchestrator] workspace.merge prep-start workspace=${input.id ?? "(unknown)"} worktree=${worktreePath} repo=${repoRoot}`);
355
372
  // Probe the live HEAD branch first — it's the authoritative source. Fall back to the
356
373
  // DB-recorded branch only when the live probe fails (detached HEAD, missing worktree, etc.).
357
374
  // This fixes #232: a stale DB branch value (non-null mismatch) would pass through the
358
375
  // `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);
376
+ const liveBranch = shortBranch((await mergeGit(["symbolic-ref", "--quiet", "--short", "HEAD"], worktreePath, "prep", "workspace merge resolve live branch")).stdout || undefined);
360
377
  const branch = liveBranch ?? input.branch;
361
378
  let preview: WorkspaceMergePreview;
362
379
  try {
@@ -398,6 +415,7 @@ export async function mergeWorkspace(input: WorkspaceMergeInput): Promise<Worksp
398
415
  if (preview.conflict) return head({ conflict: true, status: "conflict", error: "merge would conflict with base" });
399
416
 
400
417
  if (strategy === "pr") return await mergePr(input, worktreePath, branch, preview, head);
418
+ console.error(`[orchestrator] workspace.merge rebase-start workspace=${input.id ?? "(unknown)"} branch=${branch ?? "(unknown)"} base=${preview.baseRef ?? "(unknown)"}`);
401
419
  return await mergeRebaseFf(input, worktreePath, repoRoot, branch, preview, head);
402
420
  }
403
421
 
@@ -559,9 +577,9 @@ async function recordNoFfMerge(
559
577
  branchSha: string,
560
578
  message: string,
561
579
  ): Promise<{ ok: true; mergeSha: string } | { ok: false; conflict?: boolean; error: string }> {
562
- const synth = await synthesizeNoFfMerge(repoRoot, baseSha, branchSha, message);
580
+ const synth = await synthesizeNoFfMerge(repoRoot, baseSha, branchSha, message, mergePhaseTimeoutMs("synthesize"));
563
581
  if (!synth.ok) return synth;
564
- const update = await git(["update-ref", `refs/heads/${base}`, synth.mergeSha, baseSha], repoRoot);
582
+ const update = await mergeGit(["update-ref", `refs/heads/${base}`, synth.mergeSha, baseSha], repoRoot, "rebase", `workspace merge advance ${base} to synthesized merge`);
565
583
  if (!update.ok) return { ok: false, error: update.stderr || "failed to advance base ref" };
566
584
  return { ok: true, mergeSha: synth.mergeSha };
567
585
  }
@@ -587,7 +605,8 @@ async function runLandGatesOnIntegratedTree(
587
605
  headSha: string,
588
606
  mergeMessage: string,
589
607
  ): Promise<{ gates: LandGatesResult } | { abort: { conflict?: boolean; error: string } }> {
590
- if (behind === 0) return { gates: await runLandGates(worktreePath) };
608
+ console.error(`[orchestrator] workspace.merge gate-start worktree=${worktreePath} behind=${behind}`);
609
+ if (behind === 0) return { gates: await withMergePhaseTimeout("gates", () => runLandGates(worktreePath)) };
591
610
 
592
611
  let synth: Awaited<ReturnType<typeof synthesizeNoFfMerge>>;
593
612
  try {
@@ -618,7 +637,7 @@ async function runLandGatesOnIntegratedTree(
618
637
  return { abort: { error: add.stderr || "failed to materialize integrated tree for land gates" } };
619
638
  }
620
639
  try {
621
- return { gates: await runLandGates(tmpWorktree) };
640
+ return { gates: await withMergePhaseTimeout("gates", () => runLandGates(tmpWorktree)) };
622
641
  } finally {
623
642
  try {
624
643
  await withMergePhaseTimeout("cleanup", () => git(
@@ -647,7 +666,7 @@ async function syncLocalBaseToUpstream(
647
666
  base: string,
648
667
  upstream: string,
649
668
  ): Promise<{ ok: true; baseSync?: BaseWorktreeSyncResult } | { ok: false; error: string }> {
650
- const upstreamSha = (await git(["rev-parse", "--verify", upstream], worktreePath)).stdout;
669
+ const upstreamSha = (await mergeGit(["rev-parse", "--verify", upstream], worktreePath, "rebase", `workspace merge resolve ${upstream}`)).stdout;
651
670
  if (!upstreamSha) return { ok: false, error: `cannot resolve ${upstream} to sync ${base}` };
652
671
  const baseWorktree = await worktreeForBranch(repoRoot, base);
653
672
  // Sync IN the base worktree only when it's clean — that keeps its working tree consistent
@@ -656,14 +675,14 @@ async function syncLocalBaseToUpstream(
656
675
  // lands: advance the ref directly with update-ref, then best-effort sync the checked-out
657
676
  // index/worktree forward for paths that are not human-modified (#681).
658
677
  if (baseWorktree && !baseWorktree.dirty) {
659
- const ff = await git(["merge", "--ff-only", upstream], baseWorktree.path);
678
+ const ff = await mergeGit(["merge", "--ff-only", upstream], baseWorktree.path, "rebase", `workspace merge fast-forward ${base} to ${upstream}`);
660
679
  if (!ff.ok) return { ok: false, error: ff.stderr || `failed to fast-forward ${base} to ${upstream}` };
661
680
  return { ok: true };
662
681
  }
663
- const oldBaseTip = (await git(["rev-parse", base], repoRoot)).stdout;
682
+ const oldBaseTip = (await mergeGit(["rev-parse", base], repoRoot, "rebase", `workspace merge resolve ${base} before upstream sync`)).stdout;
664
683
  const dirtyBefore = baseWorktree?.dirty ? await dirtyPathSet(baseWorktree.path) : undefined;
665
684
  const updateArgs = oldBaseTip ? ["update-ref", `refs/heads/${base}`, upstreamSha, oldBaseTip] : ["update-ref", `refs/heads/${base}`, upstreamSha];
666
- const update = await git(updateArgs, repoRoot);
685
+ const update = await mergeGit(updateArgs, repoRoot, "rebase", `workspace merge update ${base} to ${upstream}`);
667
686
  if (!update.ok) return { ok: false, error: update.stderr || `failed to advance ${base} to ${upstream}` };
668
687
  const baseSync = oldBaseTip ? await syncDirtyBaseWorktreeAfterRefAdvance(base, baseWorktree, oldBaseTip, upstreamSha, dirtyBefore) : undefined;
669
688
  return { ok: true, baseSync };
@@ -699,7 +718,8 @@ async function mergeRebaseFf(
699
718
  // points (the upstream sync below and the final land). A mixed state from either is surfaced
700
719
  // loudly rather than swallowed as a log warning (#824).
701
720
  let baseSync: BaseWorktreeSyncResult | undefined;
702
- const upstream = await upstreamRef(worktreePath, base);
721
+ const upstreamResult = await mergeGit(["rev-parse", "--abbrev-ref", `${base}@{upstream}`], worktreePath, "rebase", `workspace merge resolve upstream for ${base}`);
722
+ const upstream = upstreamResult.ok && upstreamResult.stdout ? upstreamResult.stdout : undefined;
703
723
  const slash = upstream ? upstream.indexOf("/") : -1;
704
724
  const remote = slash > 0 ? upstream!.slice(0, slash) : undefined; // remote of a `remote/branch` upstream
705
725
  const pushEnabled = input.push !== false && workspacePushEnabled() && Boolean(remote);
@@ -708,10 +728,12 @@ async function mergeRebaseFf(
708
728
  // gives them new SHAs and breaks traceability (the branch.landed SHA must exist on
709
729
  // base verbatim). headSha is the preserved landed commit; when base has advanced we
710
730
  // 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;
731
+ const headResult = await mergeGit(["rev-parse", "HEAD"], worktreePath, "rebase", "workspace merge resolve workspace HEAD before gates");
732
+ const headSha = headResult.stdout;
733
+ if (!headResult.ok || !headSha) return head({ status: "review_requested", error: gitError(headResult, "failed to resolve workspace HEAD before gates") });
712
734
  // Subject of the landed commit for the relay's branch.landed notice (#239). Best-effort:
713
735
  // an empty/failed read just omits it from the message body.
714
- const landedSubject = (await git(["log", "-1", "--format=%s", headSha], worktreePath)).stdout || undefined;
736
+ const landedSubject = (await mergeGit(["log", "-1", "--format=%s", headSha], worktreePath, "rebase", "workspace merge read landed commit subject")).stdout || undefined;
715
737
 
716
738
  // #902 BLOCKING 2 — NO mutation of `refs/heads/<base>` may happen until gates pass, so resolve
717
739
  // the SHA the work will integrate onto WITHOUT moving the ref. Origin-ahead is the common
@@ -719,7 +741,9 @@ async function mergeRebaseFf(
719
741
  // the local-base sync to AFTER the gates. On a gate failure `refs/heads/<base>` must be byte-
720
742
  // identical to before the land attempt — so the only `refs/heads/<base>` mutation is the
721
743
  // sync+advance below, all of it gated.
722
- let integrationBaseSha = (await git(["rev-parse", base], repoRoot)).stdout;
744
+ const integrationBaseResult = await mergeGit(["rev-parse", base], repoRoot, "rebase", `workspace merge resolve integration base ${base}`);
745
+ let integrationBaseSha = integrationBaseResult.stdout;
746
+ if (!integrationBaseResult.ok || !integrationBaseSha) return head({ status: "review_requested", error: gitError(integrationBaseResult, `failed to resolve integration base ${base}`) });
723
747
  let needSync = false;
724
748
  if (upstream && remote && pushEnabled) {
725
749
  try {
@@ -732,13 +756,13 @@ async function mergeRebaseFf(
732
756
  } catch (err) {
733
757
  return head({ status: "review_requested", error: errMessage(err) });
734
758
  }
735
- if (!(await git(["merge-base", "--is-ancestor", upstream, base], worktreePath)).ok) {
759
+ if (!(await mergeGit(["merge-base", "--is-ancestor", upstream, base], worktreePath, "rebase", `workspace merge compare ${upstream} to ${base}`)).ok) {
736
760
  // Origin moved ahead. Sync-then-land iff local base is cleanly behind (ancestor
737
761
  // of upstream); otherwise it's genuine divergence — refuse without mutating.
738
- if (!(await git(["merge-base", "--is-ancestor", base, upstream], worktreePath)).ok) {
762
+ if (!(await mergeGit(["merge-base", "--is-ancestor", base, upstream], worktreePath, "rebase", `workspace merge compare ${base} to ${upstream}`)).ok) {
739
763
  return head({ status: "review_requested", error: `local ${base} has diverged from ${upstream} (commits not on origin); sync before landing` });
740
764
  }
741
- const upstreamSha = (await git(["rev-parse", "--verify", upstream], worktreePath)).stdout;
765
+ const upstreamSha = (await mergeGit(["rev-parse", "--verify", upstream], worktreePath, "rebase", `workspace merge resolve ${upstream} for integration`)).stdout;
742
766
  if (!upstreamSha) return head({ status: "review_requested", error: `cannot resolve ${upstream} to sync ${base}` });
743
767
  // Integrate onto fresh origin (the tree that will land), but DON'T advance local base yet.
744
768
  integrationBaseSha = upstreamSha;
@@ -747,7 +771,9 @@ async function mergeRebaseFf(
747
771
  }
748
772
 
749
773
  // Behind relative to the TRUE integration base (origin-ahead ⟹ behind>0 ⟹ a real no-ff merge).
750
- const behind = await countBehind(worktreePath, integrationBaseSha);
774
+ const behindResult = await countBehind(worktreePath, integrationBaseSha);
775
+ if ("error" in behindResult) return head({ status: "review_requested", error: behindResult.error });
776
+ const behind = behindResult.behind;
751
777
 
752
778
  // #902 BLOCKING 1 / closes #903 — run the repo's configured land gates against the EXACT tree
753
779
  // that will become base's new tip, BEFORE advancing the ref. behind===0 → the worktree HEAD IS
@@ -792,28 +818,32 @@ async function mergeRebaseFf(
792
818
  const dirtyBasePathsBefore = baseWorktree?.dirty ? await dirtyPathSet(baseWorktree.path) : undefined;
793
819
  if (baseWorktree && !baseWorktree.dirty) {
794
820
  if (behind === 0) {
795
- const ff = await git(["merge", "--ff-only", branch], baseWorktree.path);
821
+ const ff = await mergeGit(["merge", "--ff-only", branch], baseWorktree.path, "rebase", `workspace merge fast-forward ${base} to ${branch}`);
796
822
  if (!ff.ok) return head({ status: "review_requested", error: ff.stderr || "fast-forward into base failed" });
797
823
  } else {
798
- const merge = await git(
824
+ const merge = await mergeGit(
799
825
  ["-c", `user.name=${LAND_COMMITTER.name}`, "-c", `user.email=${LAND_COMMITTER.email}`, "merge", "--no-ff", "-m", landMergeMessage(branch, landedSubject), branch],
800
826
  baseWorktree.path,
827
+ "rebase",
828
+ `workspace merge no-ff ${branch} into ${base}`,
801
829
  );
802
830
  if (!merge.ok) {
803
- await git(["merge", "--abort"], baseWorktree.path);
831
+ await mergeGit(["merge", "--abort"], baseWorktree.path, "rebase", `workspace merge abort failed no-ff ${branch}`);
804
832
  return head({ conflict: true, status: "conflict", error: merge.stderr || "merge into base failed" });
805
833
  }
806
- baseTip = (await git(["rev-parse", "HEAD"], baseWorktree.path)).stdout;
834
+ baseTip = (await mergeGit(["rev-parse", "HEAD"], baseWorktree.path, "rebase", `workspace merge resolve ${base} after no-ff`)).stdout;
807
835
  }
808
836
  } else if (behind === 0) {
809
- const oldBaseTip = (await git(["rev-parse", base], repoRoot)).stdout;
837
+ const oldBaseTip = (await mergeGit(["rev-parse", base], repoRoot, "rebase", `workspace merge resolve ${base} before fast-forward ref update`)).stdout;
810
838
  const update = oldBaseTip
811
- ? await git(["update-ref", `refs/heads/${base}`, headSha, oldBaseTip], repoRoot)
812
- : await git(["update-ref", `refs/heads/${base}`, headSha], repoRoot);
839
+ ? await mergeGit(["update-ref", `refs/heads/${base}`, headSha, oldBaseTip], repoRoot, "rebase", `workspace merge update ${base} fast-forward`)
840
+ : await mergeGit(["update-ref", `refs/heads/${base}`, headSha], repoRoot, "rebase", `workspace merge create/update ${base} fast-forward`);
813
841
  if (!update.ok) return head({ status: "review_requested", error: update.stderr || "failed to advance base ref" });
814
842
  if (oldBaseTip) baseSync = mergeBaseSyncResults(baseSync, await syncDirtyBaseWorktreeAfterRefAdvance(base, baseWorktree, oldBaseTip, headSha, dirtyBasePathsBefore));
815
843
  } else {
816
- const baseSha = (await git(["rev-parse", base], repoRoot)).stdout;
844
+ const baseShaResult = await mergeGit(["rev-parse", base], repoRoot, "rebase", `workspace merge resolve ${base} before no-ff ref update`);
845
+ const baseSha = baseShaResult.stdout;
846
+ if (!baseShaResult.ok || !baseSha) return head({ status: "review_requested", error: gitError(baseShaResult, `failed to resolve ${base} before no-ff ref update`) });
817
847
  const merged = await recordNoFfMerge(repoRoot, base, baseSha, headSha, landMergeMessage(branch, landedSubject));
818
848
  if (!merged.ok) return head(merged.conflict ? { conflict: true, status: "conflict", error: merged.error } : { status: "review_requested", error: merged.error });
819
849
  baseTip = merged.mergeSha;
@@ -833,7 +863,7 @@ async function mergeRebaseFf(
833
863
  // means origin raced us — surface it instead of claiming an unpublished land.
834
864
  let pushed = false;
835
865
  if (upstream && remote && pushEnabled) {
836
- const push = await git(["push", remote, `${base}:${base}`], worktreePath);
866
+ const push = await mergeGit(["push", remote, `${base}:${base}`], worktreePath, "rebase", `workspace merge push ${base} to ${remote}`);
837
867
  if (!push.ok) return head({ status: "review_requested", mergedSha: headSha, error: push.stderr || `git push to ${remote}/${base} failed` });
838
868
  pushed = true;
839
869
  }
@@ -847,11 +877,11 @@ async function mergeRebaseFf(
847
877
  const deleteBranch = input.deleteBranch !== false;
848
878
  if (!deleteBranch) {
849
879
  const fresh = await nextBranchName(repoRoot, branch);
850
- const switched = await git(["checkout", "-B", fresh, base], worktreePath);
880
+ const switched = await mergeGit(["checkout", "-B", fresh, base], worktreePath, "rebase", `workspace merge recycle worktree to ${fresh}`);
851
881
  if (switched.ok) {
852
882
  // The old branch is now fully contained in base (fast-forwarded, or merged in
853
883
  // as the no-ff merge's second parent) — drop the litter.
854
- const oldDeleted = (await git(["branch", "-D", branch], repoRoot)).ok;
884
+ const oldDeleted = (await mergeGit(["branch", "-D", branch], repoRoot, "cleanup", `workspace merge delete landed branch ${branch}`)).ok;
855
885
  // The worktree just moved onto the advanced base, which may declare deps the
856
886
  // shared (symlinked) node_modules lacks. Re-provision so the recycled session
857
887
  // continues from a buildable state (issue #51). No-op when nothing is stale.
@@ -862,8 +892,8 @@ async function mergeRebaseFf(
862
892
  // Recycle failed — keep the existing branch. Still landed, still active.
863
893
  return head({ merged: true, status: "active", mergedSha: headSha, baseSha: baseTip, subject: landedSubject, worktreeRemoved: false, branchDeleted: false, pushed, ...baseWorktreeSyncField, ...gateWarningsField, error: undefined });
864
894
  }
865
- const removed = await git(["worktree", "remove", "--force", worktreePath], repoRoot);
895
+ const removed = await mergeGit(["worktree", "remove", "--force", worktreePath], repoRoot, "cleanup", "workspace merge remove landed worktree");
866
896
  const worktreeRemoved = removed.ok;
867
- const branchDeleted = worktreeRemoved ? (await git(["branch", "-D", branch], repoRoot)).ok : false;
897
+ const branchDeleted = worktreeRemoved ? (await mergeGit(["branch", "-D", branch], repoRoot, "cleanup", `workspace merge delete landed branch ${branch}`)).ok : false;
868
898
  return head({ merged: true, status: "merged", mergedSha: headSha, baseSha: baseTip, subject: landedSubject, worktreeRemoved, branchDeleted, pushed, ...baseWorktreeSyncField, ...gateWarningsField, error: undefined });
869
899
  }