agent-relay-orchestrator 0.118.3 → 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.3",
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);
package/src/git.ts CHANGED
@@ -3,21 +3,23 @@
3
3
  // `git -C` invocation lives in one place.
4
4
 
5
5
  import { execProcess } from "./process";
6
+ import type { ExecResult } from "./process";
6
7
 
7
- interface GitResult {
8
- ok: boolean;
9
- stdout: string;
10
- stderr: string;
8
+ type GitResult = Pick<ExecResult, "ok" | "stdout" | "stderr" | "exitCode" | "timedOut">;
9
+
10
+ interface GitOptions {
11
+ timeoutMs?: number;
12
+ timeoutLabel?: string;
11
13
  }
12
14
 
13
15
  /** Run `git -C cwd <args>` and capture trimmed stdout/stderr; never throws. */
14
- export async function git(args: string[], cwd: string): Promise<GitResult> {
15
- return await execProcess(["git", "-C", cwd, ...args]);
16
+ export async function git(args: string[], cwd: string, options: GitOptions = {}): Promise<GitResult> {
17
+ return await execProcess(["git", "-C", cwd, ...args], options);
16
18
  }
17
19
 
18
20
  /** Run `git -C cwd <args>` and preserve stdout exactly for path-safe parsers. */
19
- export async function gitRaw(args: string[], cwd: string): Promise<GitResult> {
20
- return await execProcess(["git", "-C", cwd, ...args], { trimStdout: false });
21
+ export async function gitRaw(args: string[], cwd: string, options: GitOptions = {}): Promise<GitResult> {
22
+ return await execProcess(["git", "-C", cwd, ...args], { ...options, trimStdout: false });
21
23
  }
22
24
 
23
25
  /** Like {@link git} but throws on a non-zero exit, returning stdout on success. */
package/src/process.ts CHANGED
@@ -3,6 +3,7 @@ export interface ExecResult {
3
3
  exitCode: number | null;
4
4
  stdout: string;
5
5
  stderr: string;
6
+ timedOut?: boolean;
6
7
  }
7
8
 
8
9
  interface ExecOptions {
@@ -12,11 +13,56 @@ interface ExecOptions {
12
13
  stderr?: "pipe" | "ignore";
13
14
  trimStdout?: boolean;
14
15
  trimStderr?: boolean;
16
+ timeoutMs?: number;
17
+ timeoutLabel?: string;
18
+ streamDrainGraceMs?: number;
15
19
  }
16
20
 
17
- async function readStream(stream: ReadableStream<Uint8Array> | undefined): Promise<string> {
18
- if (!stream) return "";
19
- return await new Response(stream).text();
21
+ const DEFAULT_STREAM_DRAIN_GRACE_MS = 1_000;
22
+
23
+ interface StreamCapture {
24
+ done: Promise<void>;
25
+ text(): string;
26
+ cancel(): void;
27
+ }
28
+
29
+ function captureStream(stream: ReadableStream<Uint8Array> | undefined): StreamCapture {
30
+ if (!stream) return { done: Promise.resolve(), text: () => "", cancel: () => {} };
31
+ const reader = stream.getReader();
32
+ const decoder = new TextDecoder();
33
+ let output = "";
34
+ let canceled = false;
35
+ const done = (async () => {
36
+ try {
37
+ while (!canceled) {
38
+ const chunk = await reader.read();
39
+ if (chunk.done) break;
40
+ output += decoder.decode(chunk.value, { stream: true });
41
+ }
42
+ output += decoder.decode();
43
+ } catch {
44
+ // Intentional cancellation on process timeout or a stuck post-exit pipe.
45
+ } finally {
46
+ try { reader.releaseLock(); } catch {}
47
+ }
48
+ })();
49
+ return {
50
+ done,
51
+ text: () => output,
52
+ cancel: () => {
53
+ canceled = true;
54
+ void reader.cancel().catch(() => {});
55
+ },
56
+ };
57
+ }
58
+
59
+ function sleep(ms: number): Promise<void> {
60
+ return new Promise((resolve) => setTimeout(resolve, ms));
61
+ }
62
+
63
+ function timeoutMessage(cmd: string[], options: ExecOptions): string {
64
+ const label = options.timeoutLabel ?? cmd.join(" ");
65
+ return `${label} timed out after ${options.timeoutMs}ms`;
20
66
  }
21
67
 
22
68
  export async function execProcess(cmd: string[], options: ExecOptions = {}): Promise<ExecResult> {
@@ -27,15 +73,50 @@ export async function execProcess(cmd: string[], options: ExecOptions = {}): Pro
27
73
  stdout: options.stdout ?? "pipe",
28
74
  stderr: options.stderr ?? "pipe",
29
75
  });
30
- const [exitCode, stdout, stderr] = await Promise.all([
31
- proc.exited,
32
- options.stdout === "ignore" ? Promise.resolve("") : readStream(proc.stdout),
33
- options.stderr === "ignore" ? Promise.resolve("") : readStream(proc.stderr),
34
- ]);
76
+ const stdoutCapture = options.stdout === "ignore" ? captureStream(undefined) : captureStream(proc.stdout);
77
+ const stderrCapture = options.stderr === "ignore" ? captureStream(undefined) : captureStream(proc.stderr);
78
+ let timedOut = false;
79
+ let timeout: ReturnType<typeof setTimeout> | undefined;
80
+ let killTimeout: ReturnType<typeof setTimeout> | undefined;
81
+ let timeoutResolve: ((value: null) => void) | undefined;
82
+ const timeoutPromise = new Promise<null>((resolve) => { timeoutResolve = resolve; });
83
+ if (options.timeoutMs && options.timeoutMs > 0) {
84
+ timeout = setTimeout(() => {
85
+ 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);
92
+ }, options.timeoutMs);
93
+ timeout.unref?.();
94
+ }
95
+
96
+ const exitCode = await (timeout ? Promise.race([proc.exited, timeoutPromise]) : proc.exited);
97
+ if (timeout) clearTimeout(timeout);
98
+ if (!timedOut && killTimeout) clearTimeout(killTimeout);
99
+ if (timedOut) {
100
+ stdoutCapture.cancel();
101
+ stderrCapture.cancel();
102
+ }
103
+
104
+ const drainGraceMs = options.streamDrainGraceMs ?? DEFAULT_STREAM_DRAIN_GRACE_MS;
105
+ await Promise.race([Promise.allSettled([stdoutCapture.done, stderrCapture.done]), sleep(drainGraceMs)]);
106
+ stdoutCapture.cancel();
107
+ stderrCapture.cancel();
108
+
109
+ const stdout = stdoutCapture.text();
110
+ let stderr = stderrCapture.text();
111
+ if (timedOut) {
112
+ const msg = timeoutMessage(cmd, options);
113
+ stderr = stderr ? `${stderr}\n${msg}` : msg;
114
+ }
35
115
  return {
36
- ok: exitCode === 0,
116
+ ok: !timedOut && exitCode === 0,
37
117
  exitCode,
38
118
  stdout: options.trimStdout === false ? stdout : stdout.trim(),
39
119
  stderr: options.trimStderr === false ? stderr : stderr.trim(),
120
+ ...(timedOut ? { timedOut } : {}),
40
121
  };
41
122
  }
@@ -0,0 +1,45 @@
1
+ export type MergePhase = "total" | "prep" | "preview" | "fetch" | "rebase" | "gates" | "synthesize" | "worktree-add" | "cleanup";
2
+
3
+ const MERGE_PHASE_TIMEOUTS_MS: Record<MergePhase, number> = {
4
+ total: 10 * 60_000,
5
+ prep: 60_000,
6
+ preview: 30_000,
7
+ fetch: 60_000,
8
+ rebase: 60_000,
9
+ gates: 5 * 60_000,
10
+ synthesize: 60_000,
11
+ "worktree-add": 60_000,
12
+ cleanup: 30_000,
13
+ };
14
+
15
+ function positiveEnvMs(name: string): number | undefined {
16
+ const raw = process.env[name];
17
+ if (!raw) return undefined;
18
+ const parsed = Number(raw);
19
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
20
+ }
21
+
22
+ export function mergePhaseTimeoutMs(phase: MergePhase): number {
23
+ const key = `AGENT_RELAY_WORKSPACE_MERGE_${phase.toUpperCase().replace(/-/g, "_")}_TIMEOUT_MS`;
24
+ return positiveEnvMs(key)
25
+ ?? positiveEnvMs("AGENT_RELAY_WORKSPACE_MERGE_PHASE_TIMEOUT_MS")
26
+ ?? MERGE_PHASE_TIMEOUTS_MS[phase];
27
+ }
28
+
29
+ export async function withMergePhaseTimeout<T>(phase: MergePhase, run: () => Promise<T>): Promise<T> {
30
+ const timeoutMs = mergePhaseTimeoutMs(phase);
31
+ let timeout: ReturnType<typeof setTimeout> | undefined;
32
+ const work = run();
33
+ work.catch(() => {});
34
+ try {
35
+ return await Promise.race([
36
+ work,
37
+ new Promise<T>((_, reject) => {
38
+ timeout = setTimeout(() => reject(new Error(`workspace merge phase "${phase}" timed out after ${timeoutMs}ms`)), timeoutMs);
39
+ timeout.unref?.();
40
+ }),
41
+ ]);
42
+ } finally {
43
+ if (timeout) clearTimeout(timeout);
44
+ }
45
+ }
@@ -1,34 +1,51 @@
1
1
  import { existsSync, mkdtempSync, rmSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join, resolve } from "node:path";
4
- import type { BaseWorktreeSyncResult, WorkspaceMergePreview, WorkspaceMergeResult } from "agent-relay-sdk";
4
+ import { errMessage, type BaseWorktreeSyncResult, type WorkspaceMergePreview, type WorkspaceMergeResult } from "agent-relay-sdk";
5
5
  import { git, gitRaw } from "../git";
6
6
  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";
10
+ import { populateMergeState, resolveBranchRef, syncBaseFromOrigin, workspaceGitState } from "./git-state";
11
+ import { type MergePhase, mergePhaseTimeoutMs, 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
 
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
+
17
36
  /** Behind-count of HEAD relative to `base`, from inside `worktreePath`. */
18
- async function countBehind(worktreePath: string, base: string): Promise<number> {
19
- const counts = await git(["rev-list", "--left-right", "--count", `${base}...HEAD`], worktreePath);
20
- 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") };
21
40
  const behind = Number(counts.stdout.split(/\s+/)[0]);
22
- return Number.isFinite(behind) ? behind : 0;
41
+ return Number.isFinite(behind) ? { behind } : { error: "git rev-list produced an invalid behind count" };
23
42
  }
24
43
 
25
44
  async function hasOriginRemote(cwd: string): Promise<boolean> {
26
45
  return (await git(["remote", "get-url", "origin"], cwd)).ok;
27
46
  }
28
47
 
29
- function ghAvailable(): boolean {
30
- return Boolean(Bun.which("gh"));
31
- }
48
+ function ghAvailable(): boolean { return Boolean(Bun.which("gh")); }
32
49
 
33
50
  /**
34
51
  * Ground-truth merge state for `branch`, via gh.
@@ -60,11 +77,11 @@ async function baseBranchName(worktreePath: string, baseRef?: string): Promise<s
60
77
 
61
78
  /** Locate the worktree (if any) that currently has `branch` checked out. */
62
79
  async function worktreeForBranch(repoRoot: string, branch: string): Promise<{ path: string; dirty: boolean } | undefined> {
63
- const list = await git(["worktree", "list", "--porcelain"], repoRoot);
80
+ const list = await mergeGit(["worktree", "list", "--porcelain"], repoRoot, "rebase", "workspace merge list worktrees");
64
81
  if (!list.ok) return undefined;
65
82
  const match = parseWorktrees(list.stdout).find((worktree) => worktree.branch === branch);
66
83
  if (!match) return undefined;
67
- const status = await git(["status", "--porcelain"], match.path);
84
+ const status = await mergeGit(["status", "--porcelain"], match.path, "rebase", `workspace merge status ${branch} worktree`);
68
85
  return { path: match.path, dirty: status.ok ? status.stdout.length > 0 : true };
69
86
  }
70
87
 
@@ -73,7 +90,7 @@ function splitNul(stdout: string): string[] {
73
90
  }
74
91
 
75
92
  async function dirtyPathSet(worktreePath: string): Promise<Set<string>> {
76
- 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");
77
94
  if (!status.ok) return new Set<string>();
78
95
  const paths = new Set<string>();
79
96
  const entries = splitNul(status.stdout);
@@ -89,25 +106,25 @@ async function dirtyPathSet(worktreePath: string): Promise<Set<string>> {
89
106
  }
90
107
 
91
108
  async function changedPathList(worktreePath: string, oldBaseTip: string, newBaseTip: string): Promise<string[]> {
92
- 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");
93
110
  return diff.ok ? splitNul(diff.stdout) : [];
94
111
  }
95
112
 
96
113
  async function restorePathsFromHead(worktreePath: string, paths: string[]): Promise<boolean> {
97
114
  if (paths.length === 0) return true;
98
- 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;
99
116
  }
100
117
 
101
118
  async function resetIndexPathsToHead(worktreePath: string, paths: string[]): Promise<boolean> {
102
119
  if (paths.length === 0) return true;
103
- 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;
104
121
  }
105
122
 
106
123
  /** Landed paths whose working copy still differs from the advanced HEAD (ground truth, not
107
124
  * exit codes): the checkout is mixed for these — reads/builds/publishes see STALE files. */
108
125
  async function pathsDifferingFromHead(worktreePath: string, paths: string[]): Promise<string[]> {
109
126
  if (paths.length === 0) return [];
110
- 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");
111
128
  // A failed diff can't prove the checkout is clean — treat every landed path as suspect.
112
129
  return diff.ok ? splitNul(diff.stdout) : [...paths];
113
130
  }
@@ -151,7 +168,7 @@ async function syncDirtyBaseWorktreeAfterRefAdvance(
151
168
  dirtyBefore: Set<string> | undefined,
152
169
  ): Promise<BaseWorktreeSyncResult> {
153
170
  if (!baseWorktree?.dirty || !dirtyBefore) return RECONCILED;
154
- 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");
155
172
  if (readTree.ok) return RECONCILED;
156
173
 
157
174
  const changedPaths = await changedPathList(baseWorktree.path, oldBaseTip, newBaseTip);
@@ -351,13 +368,19 @@ export async function mergeWorkspace(input: WorkspaceMergeInput): Promise<Worksp
351
368
  if (!input.worktreePath) return { strategy: "rebase-ff", merged: false, status: "review_requested", error: "worktreePath required", workspaceId: input.id };
352
369
  const worktreePath = resolve(input.worktreePath);
353
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}`);
354
372
  // Probe the live HEAD branch first — it's the authoritative source. Fall back to the
355
373
  // DB-recorded branch only when the live probe fails (detached HEAD, missing worktree, etc.).
356
374
  // This fixes #232: a stale DB branch value (non-null mismatch) would pass through the
357
375
  // `input.branch ?? ...` guard unchanged and cause git to attempt merging a non-existent ref.
358
- 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);
359
377
  const branch = liveBranch ?? input.branch;
360
- const preview = await previewWorkspaceMerge({ worktreePath, baseRef: input.baseRef, baseSha: input.baseSha, strategy: input.strategy });
378
+ let preview: WorkspaceMergePreview;
379
+ try {
380
+ preview = await withMergePhaseTimeout("preview", () => previewWorkspaceMerge({ worktreePath, baseRef: input.baseRef, baseSha: input.baseSha, strategy: input.strategy }));
381
+ } catch (err) {
382
+ return { workspaceId: input.id, strategy: "rebase-ff", merged: false, status: "review_requested", branch, error: errMessage(err) };
383
+ }
361
384
  const strategy = preview.strategy;
362
385
  const head = (field: Partial<WorkspaceMergeResult>): WorkspaceMergeResult => ({ workspaceId: input.id, strategy, merged: false, status: "review_requested", branch, baseRef: preview.baseRef, ...field });
363
386
 
@@ -392,6 +415,7 @@ export async function mergeWorkspace(input: WorkspaceMergeInput): Promise<Worksp
392
415
  if (preview.conflict) return head({ conflict: true, status: "conflict", error: "merge would conflict with base" });
393
416
 
394
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)"}`);
395
419
  return await mergeRebaseFf(input, worktreePath, repoRoot, branch, preview, head);
396
420
  }
397
421
 
@@ -531,14 +555,16 @@ async function synthesizeNoFfMerge(
531
555
  baseSha: string,
532
556
  branchSha: string,
533
557
  message: string,
558
+ timeoutMs?: number,
534
559
  ): Promise<{ ok: true; mergeSha: string } | { ok: false; conflict?: boolean; error: string }> {
535
- const tree = await git(["merge-tree", "--write-tree", baseSha, branchSha], repoRoot);
560
+ const tree = await git(["merge-tree", "--write-tree", baseSha, branchSha], repoRoot, { timeoutMs, timeoutLabel: "workspace merge synthesize merge-tree" });
536
561
  if (!tree.ok) return { ok: false, conflict: true, error: tree.stdout || tree.stderr || "merge conflict computing tree" };
537
562
  const treeOid = tree.stdout.split("\n")[0]?.trim();
538
563
  if (!treeOid) return { ok: false, error: "merge-tree produced no tree oid" };
539
564
  const commit = await git(
540
565
  ["-c", `user.name=${LAND_COMMITTER.name}`, "-c", `user.email=${LAND_COMMITTER.email}`, "commit-tree", treeOid, "-p", baseSha, "-p", branchSha, "-m", message],
541
566
  repoRoot,
567
+ { timeoutMs, timeoutLabel: "workspace merge synthesize commit-tree" },
542
568
  );
543
569
  if (!commit.ok || !commit.stdout) return { ok: false, error: commit.stderr || "commit-tree failed" };
544
570
  return { ok: true, mergeSha: commit.stdout };
@@ -551,9 +577,9 @@ async function recordNoFfMerge(
551
577
  branchSha: string,
552
578
  message: string,
553
579
  ): Promise<{ ok: true; mergeSha: string } | { ok: false; conflict?: boolean; error: string }> {
554
- const synth = await synthesizeNoFfMerge(repoRoot, baseSha, branchSha, message);
580
+ const synth = await synthesizeNoFfMerge(repoRoot, baseSha, branchSha, message, mergePhaseTimeoutMs("synthesize"));
555
581
  if (!synth.ok) return synth;
556
- 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`);
557
583
  if (!update.ok) return { ok: false, error: update.stderr || "failed to advance base ref" };
558
584
  return { ok: true, mergeSha: synth.mergeSha };
559
585
  }
@@ -579,9 +605,15 @@ async function runLandGatesOnIntegratedTree(
579
605
  headSha: string,
580
606
  mergeMessage: string,
581
607
  ): Promise<{ gates: LandGatesResult } | { abort: { conflict?: boolean; error: string } }> {
582
- 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)) };
583
610
 
584
- const synth = await synthesizeNoFfMerge(repoRoot, integrationBaseSha, headSha, mergeMessage);
611
+ let synth: Awaited<ReturnType<typeof synthesizeNoFfMerge>>;
612
+ try {
613
+ synth = await withMergePhaseTimeout("synthesize", () => synthesizeNoFfMerge(repoRoot, integrationBaseSha, headSha, mergeMessage, mergePhaseTimeoutMs("synthesize")));
614
+ } catch (err) {
615
+ return { abort: { error: errMessage(err) } };
616
+ }
585
617
  if (!synth.ok) return { abort: { conflict: synth.conflict, error: synth.error } };
586
618
 
587
619
  // Detached worktree in an isolated temp dir so the gates see the integrated tree on disk
@@ -589,15 +621,33 @@ async function runLandGatesOnIntegratedTree(
589
621
  // creates the leaf, so hand it a not-yet-existing path under a freshly made parent dir.
590
622
  const tmpParent = mkdtempSync(join(tmpdir(), "agent-relay-landgate-"));
591
623
  const tmpWorktree = join(tmpParent, "tree");
592
- const add = await git(["worktree", "add", "--detach", tmpWorktree, synth.mergeSha], repoRoot);
624
+ let add: Awaited<ReturnType<typeof git>>;
625
+ try {
626
+ add = await withMergePhaseTimeout("worktree-add", () => git(
627
+ ["worktree", "add", "--detach", tmpWorktree, synth.mergeSha],
628
+ repoRoot,
629
+ { timeoutMs: mergePhaseTimeoutMs("worktree-add"), timeoutLabel: "workspace merge land-gate worktree add" },
630
+ ));
631
+ } catch (err) {
632
+ rmSync(tmpParent, { recursive: true, force: true });
633
+ return { abort: { error: errMessage(err) } };
634
+ }
593
635
  if (!add.ok) {
594
636
  rmSync(tmpParent, { recursive: true, force: true });
595
637
  return { abort: { error: add.stderr || "failed to materialize integrated tree for land gates" } };
596
638
  }
597
639
  try {
598
- return { gates: await runLandGates(tmpWorktree) };
640
+ return { gates: await withMergePhaseTimeout("gates", () => runLandGates(tmpWorktree)) };
599
641
  } finally {
600
- await git(["worktree", "remove", "--force", tmpWorktree], repoRoot);
642
+ try {
643
+ await withMergePhaseTimeout("cleanup", () => git(
644
+ ["worktree", "remove", "--force", tmpWorktree],
645
+ repoRoot,
646
+ { timeoutMs: mergePhaseTimeoutMs("cleanup"), timeoutLabel: "workspace merge land-gate worktree cleanup" },
647
+ ));
648
+ } catch (err) {
649
+ console.error(`[orchestrator] land-gate integrated worktree cleanup timed out/failed: ${errMessage(err)}`);
650
+ }
601
651
  rmSync(tmpParent, { recursive: true, force: true });
602
652
  }
603
653
  }
@@ -616,7 +666,7 @@ async function syncLocalBaseToUpstream(
616
666
  base: string,
617
667
  upstream: string,
618
668
  ): Promise<{ ok: true; baseSync?: BaseWorktreeSyncResult } | { ok: false; error: string }> {
619
- 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;
620
670
  if (!upstreamSha) return { ok: false, error: `cannot resolve ${upstream} to sync ${base}` };
621
671
  const baseWorktree = await worktreeForBranch(repoRoot, base);
622
672
  // Sync IN the base worktree only when it's clean — that keeps its working tree consistent
@@ -625,14 +675,14 @@ async function syncLocalBaseToUpstream(
625
675
  // lands: advance the ref directly with update-ref, then best-effort sync the checked-out
626
676
  // index/worktree forward for paths that are not human-modified (#681).
627
677
  if (baseWorktree && !baseWorktree.dirty) {
628
- 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}`);
629
679
  if (!ff.ok) return { ok: false, error: ff.stderr || `failed to fast-forward ${base} to ${upstream}` };
630
680
  return { ok: true };
631
681
  }
632
- 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;
633
683
  const dirtyBefore = baseWorktree?.dirty ? await dirtyPathSet(baseWorktree.path) : undefined;
634
684
  const updateArgs = oldBaseTip ? ["update-ref", `refs/heads/${base}`, upstreamSha, oldBaseTip] : ["update-ref", `refs/heads/${base}`, upstreamSha];
635
- const update = await git(updateArgs, repoRoot);
685
+ const update = await mergeGit(updateArgs, repoRoot, "rebase", `workspace merge update ${base} to ${upstream}`);
636
686
  if (!update.ok) return { ok: false, error: update.stderr || `failed to advance ${base} to ${upstream}` };
637
687
  const baseSync = oldBaseTip ? await syncDirtyBaseWorktreeAfterRefAdvance(base, baseWorktree, oldBaseTip, upstreamSha, dirtyBefore) : undefined;
638
688
  return { ok: true, baseSync };
@@ -668,7 +718,8 @@ async function mergeRebaseFf(
668
718
  // points (the upstream sync below and the final land). A mixed state from either is surfaced
669
719
  // loudly rather than swallowed as a log warning (#824).
670
720
  let baseSync: BaseWorktreeSyncResult | undefined;
671
- 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;
672
723
  const slash = upstream ? upstream.indexOf("/") : -1;
673
724
  const remote = slash > 0 ? upstream!.slice(0, slash) : undefined; // remote of a `remote/branch` upstream
674
725
  const pushEnabled = input.push !== false && workspacePushEnabled() && Boolean(remote);
@@ -677,10 +728,12 @@ async function mergeRebaseFf(
677
728
  // gives them new SHAs and breaks traceability (the branch.landed SHA must exist on
678
729
  // base verbatim). headSha is the preserved landed commit; when base has advanced we
679
730
  // tie the branch in with a no-ff merge so the agent's commits keep their identity.
680
- 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") });
681
734
  // Subject of the landed commit for the relay's branch.landed notice (#239). Best-effort:
682
735
  // an empty/failed read just omits it from the message body.
683
- 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;
684
737
 
685
738
  // #902 BLOCKING 2 — NO mutation of `refs/heads/<base>` may happen until gates pass, so resolve
686
739
  // the SHA the work will integrate onto WITHOUT moving the ref. Origin-ahead is the common
@@ -688,17 +741,28 @@ async function mergeRebaseFf(
688
741
  // the local-base sync to AFTER the gates. On a gate failure `refs/heads/<base>` must be byte-
689
742
  // identical to before the land attempt — so the only `refs/heads/<base>` mutation is the
690
743
  // sync+advance below, all of it gated.
691
- 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}`) });
692
747
  let needSync = false;
693
748
  if (upstream && remote && pushEnabled) {
694
- await git(["fetch", remote, base], worktreePath); // best-effort freshness; a stale ref can only under-detect divergence
695
- if (!(await git(["merge-base", "--is-ancestor", upstream, base], worktreePath)).ok) {
749
+ try {
750
+ const fetch = await withMergePhaseTimeout("fetch", () => git(
751
+ ["fetch", remote, base],
752
+ worktreePath,
753
+ { timeoutMs: mergePhaseTimeoutMs("fetch"), timeoutLabel: `workspace merge fetch ${remote}/${base}` },
754
+ ));
755
+ if (!fetch.ok && fetch.timedOut) return head({ status: "review_requested", error: fetch.stderr || `fetch ${remote}/${base} timed out` });
756
+ } catch (err) {
757
+ return head({ status: "review_requested", error: errMessage(err) });
758
+ }
759
+ if (!(await mergeGit(["merge-base", "--is-ancestor", upstream, base], worktreePath, "rebase", `workspace merge compare ${upstream} to ${base}`)).ok) {
696
760
  // Origin moved ahead. Sync-then-land iff local base is cleanly behind (ancestor
697
761
  // of upstream); otherwise it's genuine divergence — refuse without mutating.
698
- 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) {
699
763
  return head({ status: "review_requested", error: `local ${base} has diverged from ${upstream} (commits not on origin); sync before landing` });
700
764
  }
701
- 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;
702
766
  if (!upstreamSha) return head({ status: "review_requested", error: `cannot resolve ${upstream} to sync ${base}` });
703
767
  // Integrate onto fresh origin (the tree that will land), but DON'T advance local base yet.
704
768
  integrationBaseSha = upstreamSha;
@@ -707,7 +771,9 @@ async function mergeRebaseFf(
707
771
  }
708
772
 
709
773
  // Behind relative to the TRUE integration base (origin-ahead ⟹ behind>0 ⟹ a real no-ff merge).
710
- 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;
711
777
 
712
778
  // #902 BLOCKING 1 / closes #903 — run the repo's configured land gates against the EXACT tree
713
779
  // that will become base's new tip, BEFORE advancing the ref. behind===0 → the worktree HEAD IS
@@ -752,28 +818,32 @@ async function mergeRebaseFf(
752
818
  const dirtyBasePathsBefore = baseWorktree?.dirty ? await dirtyPathSet(baseWorktree.path) : undefined;
753
819
  if (baseWorktree && !baseWorktree.dirty) {
754
820
  if (behind === 0) {
755
- 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}`);
756
822
  if (!ff.ok) return head({ status: "review_requested", error: ff.stderr || "fast-forward into base failed" });
757
823
  } else {
758
- const merge = await git(
824
+ const merge = await mergeGit(
759
825
  ["-c", `user.name=${LAND_COMMITTER.name}`, "-c", `user.email=${LAND_COMMITTER.email}`, "merge", "--no-ff", "-m", landMergeMessage(branch, landedSubject), branch],
760
826
  baseWorktree.path,
827
+ "rebase",
828
+ `workspace merge no-ff ${branch} into ${base}`,
761
829
  );
762
830
  if (!merge.ok) {
763
- await git(["merge", "--abort"], baseWorktree.path);
831
+ await mergeGit(["merge", "--abort"], baseWorktree.path, "rebase", `workspace merge abort failed no-ff ${branch}`);
764
832
  return head({ conflict: true, status: "conflict", error: merge.stderr || "merge into base failed" });
765
833
  }
766
- 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;
767
835
  }
768
836
  } else if (behind === 0) {
769
- 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;
770
838
  const update = oldBaseTip
771
- ? await git(["update-ref", `refs/heads/${base}`, headSha, oldBaseTip], repoRoot)
772
- : 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`);
773
841
  if (!update.ok) return head({ status: "review_requested", error: update.stderr || "failed to advance base ref" });
774
842
  if (oldBaseTip) baseSync = mergeBaseSyncResults(baseSync, await syncDirtyBaseWorktreeAfterRefAdvance(base, baseWorktree, oldBaseTip, headSha, dirtyBasePathsBefore));
775
843
  } else {
776
- 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`) });
777
847
  const merged = await recordNoFfMerge(repoRoot, base, baseSha, headSha, landMergeMessage(branch, landedSubject));
778
848
  if (!merged.ok) return head(merged.conflict ? { conflict: true, status: "conflict", error: merged.error } : { status: "review_requested", error: merged.error });
779
849
  baseTip = merged.mergeSha;
@@ -793,7 +863,7 @@ async function mergeRebaseFf(
793
863
  // means origin raced us — surface it instead of claiming an unpublished land.
794
864
  let pushed = false;
795
865
  if (upstream && remote && pushEnabled) {
796
- 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}`);
797
867
  if (!push.ok) return head({ status: "review_requested", mergedSha: headSha, error: push.stderr || `git push to ${remote}/${base} failed` });
798
868
  pushed = true;
799
869
  }
@@ -807,11 +877,11 @@ async function mergeRebaseFf(
807
877
  const deleteBranch = input.deleteBranch !== false;
808
878
  if (!deleteBranch) {
809
879
  const fresh = await nextBranchName(repoRoot, branch);
810
- 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}`);
811
881
  if (switched.ok) {
812
882
  // The old branch is now fully contained in base (fast-forwarded, or merged in
813
883
  // as the no-ff merge's second parent) — drop the litter.
814
- 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;
815
885
  // The worktree just moved onto the advanced base, which may declare deps the
816
886
  // shared (symlinked) node_modules lacks. Re-provision so the recycled session
817
887
  // continues from a buildable state (issue #51). No-op when nothing is stale.
@@ -822,8 +892,8 @@ async function mergeRebaseFf(
822
892
  // Recycle failed — keep the existing branch. Still landed, still active.
823
893
  return head({ merged: true, status: "active", mergedSha: headSha, baseSha: baseTip, subject: landedSubject, worktreeRemoved: false, branchDeleted: false, pushed, ...baseWorktreeSyncField, ...gateWarningsField, error: undefined });
824
894
  }
825
- 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");
826
896
  const worktreeRemoved = removed.ok;
827
- 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;
828
898
  return head({ merged: true, status: "merged", mergedSha: headSha, baseSha: baseTip, subject: landedSubject, worktreeRemoved, branchDeleted, pushed, ...baseWorktreeSyncField, ...gateWarningsField, error: undefined });
829
899
  }