agent-relay-orchestrator 0.117.1 → 0.118.1

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.117.1",
3
+ "version": "0.118.1",
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.99",
20
+ "agent-relay-sdk": "0.2.101",
21
21
  "callmux": "0.23.0"
22
22
  },
23
23
  "devDependencies": {
@@ -8,14 +8,20 @@ interface CommandPollerOptions {
8
8
  relay: Pick<RelayClient, "connected" | "pollCommands">;
9
9
  control: CommandPollerControl;
10
10
  log?: (message: string) => void;
11
+ intervalMs?: number;
12
+ errorBackoffMs?: number;
11
13
  }
12
14
 
13
- export function createCommandPoller({ relay, control, log = console.error }: CommandPollerOptions) {
15
+ export function createCommandPoller({ relay, control, log = console.error, intervalMs = 3_000, errorBackoffMs = 3_000 }: CommandPollerOptions) {
14
16
  let inFlight = false;
17
+ let stopped = true;
18
+ let timer: ReturnType<typeof setTimeout> | undefined;
19
+ let lastTickErrored = false;
15
20
 
16
21
  async function tick(): Promise<boolean> {
17
22
  if (!relay.connected || inFlight) return false;
18
23
  inFlight = true;
24
+ lastTickErrored = false;
19
25
  try {
20
26
  const commands = await relay.pollCommands();
21
27
  if (commands.length > 0) {
@@ -27,6 +33,7 @@ export function createCommandPoller({ relay, control, log = console.error }: Com
27
33
  }
28
34
  return true;
29
35
  } catch (err) {
36
+ lastTickErrored = true;
30
37
  log(`[orchestrator] Poll error: ${err}`);
31
38
  return false;
32
39
  } finally {
@@ -34,8 +41,44 @@ export function createCommandPoller({ relay, control, log = console.error }: Com
34
41
  }
35
42
  }
36
43
 
44
+ function schedule(delayMs: number): void {
45
+ if (stopped) return;
46
+ timer = setTimeout(() => {
47
+ timer = undefined;
48
+ void runCycle();
49
+ }, delayMs);
50
+ timer.unref?.();
51
+ }
52
+
53
+ async function runCycle(): Promise<void> {
54
+ let errored = false;
55
+ try {
56
+ await tick();
57
+ errored = lastTickErrored;
58
+ } catch (err) {
59
+ errored = true;
60
+ log(`[orchestrator] Poll loop error: ${err}`);
61
+ } finally {
62
+ if (!stopped) schedule(errored ? errorBackoffMs : intervalMs);
63
+ }
64
+ }
65
+
66
+ function start(): void {
67
+ if (!stopped) return;
68
+ stopped = false;
69
+ schedule(0);
70
+ }
71
+
72
+ function stop(): void {
73
+ stopped = true;
74
+ if (timer) clearTimeout(timer);
75
+ timer = undefined;
76
+ }
77
+
37
78
  return {
38
79
  tick,
80
+ start,
81
+ stop,
39
82
  get inFlight() {
40
83
  return inFlight;
41
84
  },
package/src/control.ts CHANGED
@@ -33,16 +33,10 @@ export function createControlHandler(
33
33
 
34
34
  async function handleSpawn(ctrl: Record<string, any>): Promise<boolean> {
35
35
  const opts = spawnOptionsFromControl(ctrl, config);
36
-
37
- try {
38
- const agent = await spawnAgent(opts, config);
39
- managedAgents.push(agent);
40
- console.error(`[orchestrator] Spawned ${opts.provider} agent: ${agent.tmuxSession}`);
41
- return true;
42
- } catch (err) {
43
- console.error(`[orchestrator] Spawn failed: ${err}`);
44
- return false;
45
- }
36
+ const agent = await spawnAgent(opts, config);
37
+ managedAgents.push(agent);
38
+ console.error(`[orchestrator] Spawned ${opts.provider} agent: ${agent.tmuxSession}`);
39
+ return true;
46
40
  }
47
41
 
48
42
  async function handleShutdown(ctrl: Record<string, any>, restart = false): Promise<Record<string, unknown>> {
@@ -124,7 +118,7 @@ export function createControlHandler(
124
118
  });
125
119
  await relay.updateCommand(command.id, "succeeded", result);
126
120
  } else if (command.type === "workspace.merge") {
127
- const result = mergeWorkspace({
121
+ const result = await mergeWorkspace({
128
122
  id: typeof command.params.workspaceId === "string" ? command.params.workspaceId : undefined,
129
123
  repoRoot: typeof command.params.repoRoot === "string" ? command.params.repoRoot : undefined,
130
124
  worktreePath: typeof command.params.worktreePath === "string" ? command.params.worktreePath : undefined,
@@ -297,6 +291,7 @@ function spawnOptionsFromRecord(source: Record<string, any>, config: Orchestrato
297
291
  automationRunId: typeof source.automationRunId === "string" ? source.automationRunId : undefined,
298
292
  requestedVia: typeof source.requestedVia === "string" ? source.requestedVia : undefined,
299
293
  resumeWorkspace: parseResumeWorkspace(source.resumeWorkspace),
294
+ acquisition: parseProjectAcquisition(source.acquisition),
300
295
  };
301
296
  }
302
297
 
@@ -331,3 +326,22 @@ function parseResumeWorkspace(value: unknown): import("./workspace-probe/types")
331
326
  baseSha: typeof value.baseSha === "string" ? value.baseSha : undefined,
332
327
  };
333
328
  }
329
+
330
+ function parseProjectAcquisition(value: unknown): import("./spawn/types").ProjectAcquisitionManifest | undefined {
331
+ if (!isRecord(value)) return undefined;
332
+ if (value.mode !== "project-root" || value.sync !== "ff-only") return undefined;
333
+ const projectId = typeof value.projectId === "string" ? value.projectId : undefined;
334
+ const rootPath = typeof value.rootPath === "string" ? value.rootPath : undefined;
335
+ const cwd = typeof value.cwd === "string" ? value.cwd : undefined;
336
+ const remoteUrl = typeof value.remoteUrl === "string" ? value.remoteUrl : undefined;
337
+ if (!projectId || !rootPath || !cwd || !remoteUrl) return undefined;
338
+ return {
339
+ mode: "project-root",
340
+ projectId,
341
+ rootPath,
342
+ cwd,
343
+ remoteUrl,
344
+ ref: typeof value.ref === "string" ? value.ref : undefined,
345
+ sync: "ff-only",
346
+ };
347
+ }
package/src/index.ts CHANGED
@@ -60,7 +60,7 @@ const sharedCallmux = new SharedCallmuxSupervisor(config);
60
60
  const POLL_INTERVAL_MS = 3_000;
61
61
  const REGISTER_RETRY_MS = 5_000;
62
62
  const GUEST_REAP_INTERVAL_MS = 60_000;
63
- let pollTimer: Timer | null = null;
63
+ let commandPoller: ReturnType<typeof createCommandPoller> | null = null;
64
64
  let healthCheckTimer: Timer | null = null;
65
65
  let guestReaperTimer: Timer | null = null;
66
66
  let apiServer: { stop(): void; url: string } | null = null;
@@ -124,10 +124,8 @@ async function startup(): Promise<void> {
124
124
  }
125
125
 
126
126
  function startPolling(): void {
127
- const commandPoller = createCommandPoller({ relay, control });
128
- pollTimer = setInterval(async () => {
129
- await commandPoller.tick();
130
- }, POLL_INTERVAL_MS);
127
+ commandPoller = createCommandPoller({ relay, control, intervalMs: POLL_INTERVAL_MS, errorBackoffMs: POLL_INTERVAL_MS });
128
+ commandPoller.start();
131
129
  }
132
130
 
133
131
  async function registerUntilConnected(): Promise<void> {
@@ -208,7 +206,7 @@ async function healthCheck(): Promise<void> {
208
206
 
209
207
  async function shutdown(): Promise<void> {
210
208
  console.error("[orchestrator] Shutting down...");
211
- if (pollTimer) clearInterval(pollTimer);
209
+ commandPoller?.stop();
212
210
  if (healthCheckTimer) clearInterval(healthCheckTimer);
213
211
  if (guestReaperTimer) clearInterval(guestReaperTimer);
214
212
  if (apiServer) apiServer.stop();
@@ -0,0 +1,202 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, mkdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
3
+ import { basename, dirname, join, resolve } from "node:path";
4
+ import { errMessage, isPathWithinBase } from "agent-relay-sdk";
5
+ import { git, requireGit } from "../git";
6
+ import type { ProjectAcquisitionManifest } from "./types";
7
+
8
+ export interface ProjectAcquisitionResult {
9
+ applied: boolean;
10
+ action: "cloned" | "synced" | "noop";
11
+ rootPath: string;
12
+ remoteUrl: string;
13
+ ref?: string;
14
+ headSha?: string;
15
+ }
16
+
17
+ const inFlight = new Map<string, Promise<ProjectAcquisitionResult>>();
18
+ const LOCK_TIMEOUT_MS = 5 * 60_000;
19
+ const LOCK_POLL_MS = 100;
20
+
21
+ export async function applyProjectAcquisitionManifest(
22
+ manifest: ProjectAcquisitionManifest | undefined,
23
+ baseDir: string,
24
+ ): Promise<ProjectAcquisitionResult | undefined> {
25
+ if (!manifest) return undefined;
26
+ const rootPath = resolve(manifest.rootPath);
27
+ const prior = inFlight.get(rootPath);
28
+ if (prior) return prior;
29
+ const run = withAcquisitionLock(rootPath, baseDir, () => applyManifestLocked(manifest, baseDir));
30
+ inFlight.set(rootPath, run);
31
+ try {
32
+ return await run;
33
+ } finally {
34
+ if (inFlight.get(rootPath) === run) inFlight.delete(rootPath);
35
+ }
36
+ }
37
+
38
+ async function withAcquisitionLock(rootPath: string, baseDir: string, fn: () => ProjectAcquisitionResult): Promise<ProjectAcquisitionResult> {
39
+ const lockDir = join(resolve(baseDir), ".agent-relay", "locks", `acquire-${hash(rootPath)}.lock`);
40
+ mkdirSync(dirname(lockDir), { recursive: true });
41
+ const started = Date.now();
42
+ for (;;) {
43
+ try {
44
+ mkdirSync(lockDir);
45
+ writeFileSync(join(lockDir, "owner"), `${process.pid}\n${rootPath}\n`);
46
+ break;
47
+ } catch (error) {
48
+ if (Date.now() - started > LOCK_TIMEOUT_MS) {
49
+ throw new Error(`repo acquisition lock timed out for ${rootPath}: ${errMessage(error)}`);
50
+ }
51
+ await new Promise((resolve) => setTimeout(resolve, LOCK_POLL_MS));
52
+ }
53
+ }
54
+ try {
55
+ return fn();
56
+ } finally {
57
+ rmSync(lockDir, { recursive: true, force: true });
58
+ }
59
+ }
60
+
61
+ function applyManifestLocked(manifest: ProjectAcquisitionManifest, baseDir: string): ProjectAcquisitionResult {
62
+ validateManifest(manifest, baseDir);
63
+ const rootPath = resolve(manifest.rootPath);
64
+ const remoteUrl = manifest.remoteUrl.trim();
65
+ let action: ProjectAcquisitionResult["action"] = "noop";
66
+ if (!existsSync(rootPath)) {
67
+ cloneRoot(manifest, baseDir);
68
+ action = "cloned";
69
+ }
70
+ const syncAction = syncRoot(manifest);
71
+ if (syncAction === "synced" && action !== "cloned") action = "synced";
72
+ return {
73
+ applied: true,
74
+ action,
75
+ rootPath,
76
+ remoteUrl,
77
+ ...(manifest.ref ? { ref: manifest.ref } : {}),
78
+ headSha: git(["rev-parse", "HEAD"], rootPath).stdout || undefined,
79
+ };
80
+ }
81
+
82
+ function validateManifest(manifest: ProjectAcquisitionManifest, baseDir: string): void {
83
+ const rootPath = resolve(manifest.rootPath);
84
+ if (!manifest.remoteUrl.trim()) throw new Error("project acquisition remoteUrl is required");
85
+ if (!isPathWithinBase(rootPath, baseDir) || rootPath === resolve(baseDir)) {
86
+ throw new Error(`project acquisition rootPath must be within orchestrator baseDir: ${baseDir}`);
87
+ }
88
+ if (!isPathWithinBase(resolve(manifest.cwd), rootPath)) {
89
+ throw new Error(`project acquisition cwd must be within rootPath: ${manifest.cwd}`);
90
+ }
91
+ }
92
+
93
+ function cloneRoot(manifest: ProjectAcquisitionManifest, baseDir: string): void {
94
+ const rootPath = resolve(manifest.rootPath);
95
+ const parent = dirname(rootPath);
96
+ if (!isPathWithinBase(parent, baseDir)) throw new Error(`project acquisition parent must be within orchestrator baseDir: ${parent}`);
97
+ mkdirSync(parent, { recursive: true });
98
+ const tmp = join(parent, `.${basename(rootPath)}.agent-relay-clone-${process.pid}-${Date.now()}`);
99
+ rmSync(tmp, { recursive: true, force: true });
100
+ const args = ["clone", "--origin", "origin"];
101
+ if (manifest.ref) args.push("--branch", manifest.ref);
102
+ args.push(manifest.remoteUrl.trim(), tmp);
103
+ const cloned = runGit(args, parent);
104
+ if (!cloned.ok) {
105
+ rmSync(tmp, { recursive: true, force: true });
106
+ throw new Error(`git clone failed for ${rootPath}: ${cloned.stderr || cloned.stdout}`);
107
+ }
108
+ try {
109
+ renameSync(tmp, rootPath);
110
+ } catch (error) {
111
+ rmSync(tmp, { recursive: true, force: true });
112
+ throw new Error(`failed to install cloned repo at ${rootPath}: ${errMessage(error)}`);
113
+ }
114
+ }
115
+
116
+ function syncRoot(manifest: ProjectAcquisitionManifest): "synced" | "noop" {
117
+ const rootPath = resolve(manifest.rootPath);
118
+ assertExistingGitRoot(rootPath);
119
+ assertRemote(rootPath, manifest.remoteUrl.trim());
120
+ const status = git(["status", "--porcelain"], rootPath);
121
+ if (!status.ok) throw new Error(`git status failed for ${rootPath}: ${status.stderr}`);
122
+ if (status.stdout.trim()) throw new Error(`project root ${rootPath} has local changes; refusing ff-only sync before spawn`);
123
+ const fetch = git(["fetch", "--prune", "origin"], rootPath);
124
+ if (!fetch.ok) throw new Error(`git fetch failed for ${rootPath}: ${fetch.stderr || fetch.stdout}`);
125
+ const target = checkoutSyncTarget(rootPath, manifest.ref);
126
+ if (!target) return "noop";
127
+ const head = requireGit(["rev-parse", "HEAD"], rootPath);
128
+ const targetHead = requireGit(["rev-parse", target], rootPath);
129
+ if (head === targetHead) return "noop";
130
+ if (!git(["merge-base", "--is-ancestor", "HEAD", target], rootPath).ok) {
131
+ throw new Error(`project root ${rootPath} has diverged from ${target}; refusing non-fast-forward sync`);
132
+ }
133
+ const merged = git(["merge", "--ff-only", target], rootPath);
134
+ if (!merged.ok) throw new Error(`git ff-only sync failed for ${rootPath}: ${merged.stderr || merged.stdout}`);
135
+ return "synced";
136
+ }
137
+
138
+ function assertExistingGitRoot(rootPath: string): void {
139
+ let stat;
140
+ try {
141
+ stat = statSync(rootPath);
142
+ } catch (error) {
143
+ throw new Error(`project root does not exist after acquisition: ${rootPath}: ${errMessage(error)}`);
144
+ }
145
+ if (!stat.isDirectory()) throw new Error(`project root exists but is not a directory: ${rootPath}`);
146
+ const top = git(["rev-parse", "--show-toplevel"], rootPath);
147
+ if (!top.ok || resolve(top.stdout) !== rootPath) throw new Error(`project root exists but is not a git checkout root: ${rootPath}`);
148
+ }
149
+
150
+ function assertRemote(rootPath: string, remoteUrl: string): void {
151
+ const current = git(["remote", "get-url", "origin"], rootPath);
152
+ if (!current.ok || !current.stdout) throw new Error(`project root ${rootPath} has no origin remote`);
153
+ if (current.stdout.trim() !== remoteUrl) {
154
+ throw new Error(`project root ${rootPath} origin mismatch: expected ${remoteUrl}, found ${current.stdout.trim()}`);
155
+ }
156
+ }
157
+
158
+ function checkoutSyncTarget(rootPath: string, ref: string | undefined): string | undefined {
159
+ if (ref?.trim()) {
160
+ const name = ref.trim();
161
+ const remoteBranch = `refs/remotes/origin/${name}`;
162
+ if (git(["show-ref", "--verify", "--quiet", remoteBranch], rootPath).ok) {
163
+ if (git(["show-ref", "--verify", "--quiet", `refs/heads/${name}`], rootPath).ok) {
164
+ const checked = git(["checkout", name], rootPath);
165
+ if (!checked.ok) throw new Error(`git checkout ${name} failed for ${rootPath}: ${checked.stderr || checked.stdout}`);
166
+ } else {
167
+ const checked = git(["checkout", "-B", name, `origin/${name}`], rootPath);
168
+ if (!checked.ok) throw new Error(`git checkout ${name} failed for ${rootPath}: ${checked.stderr || checked.stdout}`);
169
+ }
170
+ return `origin/${name}`;
171
+ }
172
+ if (git(["rev-parse", "--verify", name], rootPath).ok) {
173
+ const checked = git(["checkout", name], rootPath);
174
+ if (!checked.ok) throw new Error(`git checkout ${name} failed for ${rootPath}: ${checked.stderr || checked.stdout}`);
175
+ return undefined;
176
+ }
177
+ throw new Error(`project acquisition ref "${name}" not found on origin for ${rootPath}`);
178
+ }
179
+ const upstream = git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], rootPath);
180
+ if (upstream.ok && upstream.stdout) return upstream.stdout;
181
+ const originHead = git(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], rootPath);
182
+ if (originHead.ok && originHead.stdout) return originHead.stdout;
183
+ throw new Error(`project root ${rootPath} has no upstream or origin/HEAD for ff-only sync`);
184
+ }
185
+
186
+ function runGit(args: string[], cwd: string): { ok: boolean; stdout: string; stderr: string } {
187
+ const proc = Bun.spawnSync(["git", ...args], {
188
+ cwd,
189
+ stdin: "ignore",
190
+ stdout: "pipe",
191
+ stderr: "pipe",
192
+ });
193
+ return {
194
+ ok: proc.exitCode === 0,
195
+ stdout: proc.stdout.toString().trim(),
196
+ stderr: proc.stderr.toString().trim(),
197
+ };
198
+ }
199
+
200
+ function hash(value: string): string {
201
+ return createHash("sha1").update(resolve(value)).digest("hex").slice(0, 16);
202
+ }
@@ -1,4 +1,5 @@
1
1
  export * from "./command";
2
+ export * from "./acquisition";
2
3
  export * from "./guests";
3
4
  export * from "./log-utils";
4
5
  export * from "./runtime";
@@ -2,6 +2,7 @@ import { closeSync, existsSync, openSync, rmSync } from "node:fs";
2
2
  import type { OrchestratorConfig } from "../config";
3
3
  import { resolveSpawnWorkspace, workspacesRoot } from "../workspace-probe";
4
4
  import type { ManagedAgentReport } from "../relay";
5
+ import { applyProjectAcquisitionManifest } from "./acquisition";
5
6
  import { buildEnv, buildRunnerCommand, defaultSpawnLabel, isWithinBaseDir, sessionName } from "./command";
6
7
  import { addSessionRecord, currentSessionPid, findSessionRecord, ensureLogDir, ensureRunnerInfoDir, logFilePath, runnerInfoPath, sessionRecordLiveness, sessionReportFields } from "./runtime";
7
8
  import { managedAgentId } from "./sessions";
@@ -10,6 +11,7 @@ import type { SpawnOptions } from "./types";
10
11
 
11
12
  interface SpawnAgentDeps {
12
13
  resolveSpawnWorkspace: typeof resolveSpawnWorkspace;
14
+ applyProjectAcquisitionManifest: typeof applyProjectAcquisitionManifest;
13
15
  spawnRunner: typeof spawnRunner;
14
16
  addSessionRecord: typeof addSessionRecord;
15
17
  findSessionRecord: typeof findSessionRecord;
@@ -24,6 +26,7 @@ interface SpawnAgentDeps {
24
26
 
25
27
  const defaultSpawnAgentDeps: SpawnAgentDeps = {
26
28
  resolveSpawnWorkspace,
29
+ applyProjectAcquisitionManifest,
27
30
  spawnRunner,
28
31
  addSessionRecord,
29
32
  findSessionRecord,
@@ -46,12 +49,13 @@ export async function spawnAgent(
46
49
  const agentId = opts.agentId || managedAgentId(config, opts.provider, label);
47
50
  const name = sessionName(config, opts.provider, label, opts.spawnRequestId ?? agentId);
48
51
 
49
- if (!existsSync(opts.cwd)) {
50
- throw new Error(`cwd does not exist: ${opts.cwd}`);
51
- }
52
52
  if (!isWithinBaseDir(opts.cwd, config.baseDir)) {
53
53
  throw new Error(`cwd must be within base directory: ${config.baseDir}`);
54
54
  }
55
+ await d.applyProjectAcquisitionManifest(opts.acquisition, config.baseDir);
56
+ if (!existsSync(opts.cwd)) {
57
+ throw new Error(`cwd does not exist: ${opts.cwd}`);
58
+ }
55
59
  const existing = existingSpawnSession(opts, d);
56
60
  if (existing) return existing;
57
61
 
@@ -2,6 +2,16 @@ import type { OrchestratorConfig } from "../config";
2
2
  import type { AgentLifecycle, SpawnProvider, WorkspaceMetadata, WorkspaceMode } from "agent-relay-sdk";
3
3
  import type { ResumeWorkspaceTarget } from "../workspace-probe/types";
4
4
 
5
+ export interface ProjectAcquisitionManifest {
6
+ mode: "project-root";
7
+ projectId: string;
8
+ rootPath: string;
9
+ cwd: string;
10
+ remoteUrl: string;
11
+ ref?: string;
12
+ sync: "ff-only";
13
+ }
14
+
5
15
  export interface SpawnOptions {
6
16
  provider: SpawnProvider;
7
17
  cwd: string;
@@ -33,6 +43,8 @@ export interface SpawnOptions {
33
43
  requestedVia?: string;
34
44
  /** #635 — attach to or branch off an existing worktree instead of creating a fresh one. */
35
45
  resumeWorkspace?: ResumeWorkspaceTarget;
46
+ /** #410 — lazy clone/sync manifest, applied on the host before worktree prep. */
47
+ acquisition?: ProjectAcquisitionManifest;
36
48
  }
37
49
 
38
50
  export interface SessionInfo {
@@ -0,0 +1,226 @@
1
+ import { resolve } from "node:path";
2
+ import { errMessage } from "agent-relay-sdk";
3
+ import type { LandGate, LandGateRunResult } from "agent-relay-sdk";
4
+ import { loadRepoLandGates } from "agent-relay-sdk/land-gates";
5
+
6
+ // #902 — execute a repo's configured land gates against the candidate merged tree
7
+ // inside the landing worktree, BEFORE the ref advance in `mergeRebaseFf`. A repo
8
+ // with no `.agent-relay/land-gates.json` loads zero gates, so the caller takes the
9
+ // exact unchanged land path (ironclad opt-in / zero regression). A required gate
10
+ // failing blocks the land (ref NOT advanced); optional gates only warn.
11
+
12
+ /** Per-gate default timeout when the gate didn't declare `timeoutMs`. Land gates
13
+ * are meant to be a FAST high-signal subset, not the full suite — keep the ceiling
14
+ * generous but bounded so a hung gate can't wedge the per-repo merge lease forever. */
15
+ const DEFAULT_GATE_TIMEOUT_MS = 5 * 60 * 1000;
16
+ const TIMEOUT_KILL_GRACE_MS = 1_000;
17
+ const OUTPUT_CANCEL_GRACE_MS = 50;
18
+ /** Cap on the full output streamed to the relay artifact (the notification only ever
19
+ * carries the tail). Errors usually surface at the END, so we keep the tail on overflow. */
20
+ const MAX_FULL_OUTPUT_BYTES = 256 * 1024;
21
+ /** Bytes of the tail put in the notification body — enough to show the failure, small
22
+ * enough never to flood relay. */
23
+ const TAIL_BYTES = 4000;
24
+
25
+ function keepTail(text: string, maxBytes: number, label: string): string {
26
+ if (text.length <= maxBytes) return text;
27
+ const dropped = text.length - maxBytes;
28
+ return `…(${dropped} earlier ${label} truncated)…\n${text.slice(text.length - maxBytes)}`;
29
+ }
30
+
31
+ function combineOutput(stdout: string, stderr: string): string {
32
+ if (stdout && stderr) return `${stdout}\n${stderr}`;
33
+ return stdout || stderr;
34
+ }
35
+
36
+ function sleep(ms: number): Promise<void> {
37
+ return new Promise((resolve) => setTimeout(resolve, ms));
38
+ }
39
+
40
+ interface OutputCapture {
41
+ done: Promise<void>;
42
+ text(): string;
43
+ cancel(): void;
44
+ }
45
+
46
+ function captureProcessOutput(pipe: unknown): OutputCapture {
47
+ if (!(pipe instanceof ReadableStream)) {
48
+ return { done: Promise.resolve(), text: () => "", cancel: () => {} };
49
+ }
50
+
51
+ const reader = pipe.getReader();
52
+ const decoder = new TextDecoder();
53
+ let output = "";
54
+ let canceled = false;
55
+ const done = (async () => {
56
+ try {
57
+ while (!canceled) {
58
+ const chunk = await reader.read();
59
+ if (chunk.done) break;
60
+ output += decoder.decode(chunk.value, { stream: true });
61
+ }
62
+ output += decoder.decode();
63
+ } catch {
64
+ // A timeout cancels the reader intentionally so pipe EOF cannot hold up the gate result.
65
+ } finally {
66
+ try { reader.releaseLock(); } catch {}
67
+ }
68
+ })();
69
+
70
+ return {
71
+ done,
72
+ text: () => output,
73
+ cancel: () => {
74
+ canceled = true;
75
+ void reader.cancel().catch(() => {});
76
+ },
77
+ };
78
+ }
79
+
80
+ function signalGateProcessGroup(proc: ReturnType<typeof Bun.spawn>, signal: NodeJS.Signals): void {
81
+ if (typeof proc.pid === "number" && proc.pid > 0) {
82
+ try {
83
+ process.kill(-proc.pid, signal);
84
+ return;
85
+ } catch (err) {
86
+ if ((err as { code?: string }).code !== "ESRCH") {
87
+ try { proc.kill(signal); } catch {}
88
+ }
89
+ return;
90
+ }
91
+ }
92
+ try { proc.kill(signal); } catch {}
93
+ }
94
+
95
+ /** Run a single gate and capture its outcome. Never throws — a spawn failure (e.g.
96
+ * the command can't launch) is reported as a non-passing result so the caller can
97
+ * decide block-vs-warn from the gate's `optional` flag. */
98
+ export async function runOneLandGate(worktreePath: string, gate: LandGate): Promise<LandGateRunResult> {
99
+ const cwd = gate.cwd ? resolve(worktreePath, gate.cwd) : worktreePath;
100
+ const timeoutMs = gate.timeoutMs ?? DEFAULT_GATE_TIMEOUT_MS;
101
+ const started = Date.now();
102
+ const base = { name: gate.name, command: gate.command, optional: gate.optional === true } as const;
103
+
104
+ let proc: ReturnType<typeof Bun.spawn>;
105
+ let timedOut = false;
106
+ let timeout: ReturnType<typeof setTimeout> | undefined;
107
+ let killTimeout: ReturnType<typeof setTimeout> | undefined;
108
+ try {
109
+ // A login shell so PATH (bun, node, project bins) resolves like the worker's own
110
+ // environment; `env: process.env` makes runtime env mutations visible to the child.
111
+ proc = Bun.spawn(["bash", "-lc", gate.command], {
112
+ cwd,
113
+ env: process.env,
114
+ stdin: "ignore",
115
+ stdout: "pipe",
116
+ stderr: "pipe",
117
+ detached: true,
118
+ });
119
+ } catch (err) {
120
+ const durationMs = Date.now() - started;
121
+ const output = `land gate "${gate.name}" could not be launched: ${errMessage(err)}`;
122
+ return { ...base, passed: false, exitCode: null, timedOut: false, durationMs, outputTail: output, output };
123
+ }
124
+
125
+ const stdoutCapture = captureProcessOutput(proc.stdout);
126
+ const stderrCapture = captureProcessOutput(proc.stderr);
127
+ let timeoutTriggeredResolve: () => void = () => {};
128
+ const timeoutTriggered = new Promise<void>((resolve) => { timeoutTriggeredResolve = resolve; });
129
+ timeout = setTimeout(() => {
130
+ timedOut = true;
131
+ timeoutTriggeredResolve();
132
+ signalGateProcessGroup(proc, "SIGTERM");
133
+ stdoutCapture.cancel();
134
+ stderrCapture.cancel();
135
+ killTimeout = setTimeout(() => {
136
+ signalGateProcessGroup(proc, "SIGKILL");
137
+ stdoutCapture.cancel();
138
+ stderrCapture.cancel();
139
+ }, TIMEOUT_KILL_GRACE_MS);
140
+ killTimeout.unref?.();
141
+ }, timeoutMs);
142
+ timeout.unref?.();
143
+
144
+ const exitCodeRaw = await Promise.race([
145
+ proc.exited,
146
+ timeoutTriggered.then(async () => {
147
+ await Promise.race([proc.exited.then(() => undefined), sleep(TIMEOUT_KILL_GRACE_MS + OUTPUT_CANCEL_GRACE_MS)]);
148
+ return null;
149
+ }),
150
+ ]).finally(() => {
151
+ if (timeout) clearTimeout(timeout);
152
+ if (killTimeout) clearTimeout(killTimeout);
153
+ });
154
+ if (timedOut) {
155
+ stdoutCapture.cancel();
156
+ stderrCapture.cancel();
157
+ await Promise.race([Promise.allSettled([stdoutCapture.done, stderrCapture.done]), sleep(OUTPUT_CANCEL_GRACE_MS)]);
158
+ } else {
159
+ await Promise.all([stdoutCapture.done, stderrCapture.done]);
160
+ }
161
+ const durationMs = Date.now() - started;
162
+ const stdout = stdoutCapture.text();
163
+ const stderr = stderrCapture.text();
164
+ let combined = combineOutput(stdout, stderr);
165
+ const exitCode = timedOut ? null : typeof exitCodeRaw === "number" ? exitCodeRaw : null;
166
+ const passed = exitCode === 0;
167
+ if (timedOut) combined = `${combined}\n[land-gate] timed out after ${timeoutMs}ms`.trimStart();
168
+ if (!combined) combined = passed ? "(gate produced no output)" : "(gate produced no output)";
169
+
170
+ return {
171
+ ...base,
172
+ passed,
173
+ exitCode,
174
+ timedOut,
175
+ durationMs,
176
+ outputTail: keepTail(combined, TAIL_BYTES, "bytes"),
177
+ output: keepTail(combined, MAX_FULL_OUTPUT_BYTES, "bytes"),
178
+ };
179
+ }
180
+
181
+ export interface LandGatesResult {
182
+ /** Number of gates configured + run (0 ⇒ no config ⇒ caller's land path is unchanged). */
183
+ ran: number;
184
+ /** First REQUIRED gate that failed — its presence means the land MUST be aborted
185
+ * without advancing the ref. */
186
+ failure?: LandGateRunResult;
187
+ /** Optional gates that failed (warn-only); the land still proceeds. */
188
+ warnings: LandGateRunResult[];
189
+ }
190
+
191
+ /**
192
+ * Load + run the repo's configured land gates against `worktreePath`. Returns
193
+ * `ran: 0` with no failure/warnings when the repo declares no gates — the load is the
194
+ * ONLY thing that runs in that case (a missing file → empty list, no subprocess), so
195
+ * the opt-in is byte-clean. Stops at the first failing REQUIRED gate (it blocks the
196
+ * land); optional failures accumulate as warnings and never block.
197
+ *
198
+ * A PRESENT-but-malformed `.agent-relay/land-gates.json` is itself a blocking failure
199
+ * (surfaced as a synthetic required gate) rather than an unhandled throw that would
200
+ * crash the merge command — the worker fixes the config and re-lands.
201
+ */
202
+ export async function runLandGates(worktreePath: string): Promise<LandGatesResult> {
203
+ const warnings: LandGateRunResult[] = [];
204
+ let gates: LandGate[];
205
+ try {
206
+ gates = loadRepoLandGates(worktreePath);
207
+ } catch (err) {
208
+ const output = `invalid .agent-relay/land-gates.json: ${errMessage(err)}`;
209
+ return {
210
+ ran: 1,
211
+ failure: { name: "land-gates-config", command: "(config validation)", optional: false, passed: false, exitCode: null, timedOut: false, durationMs: 0, outputTail: output, output },
212
+ warnings,
213
+ };
214
+ }
215
+ if (gates.length === 0) return { ran: 0, warnings };
216
+
217
+ for (const gate of gates) {
218
+ const result = await runOneLandGate(worktreePath, gate);
219
+ if (result.passed) continue;
220
+ if (result.optional) { warnings.push(result); continue; }
221
+ // First required failure blocks the land — don't run the rest (the worker fixes
222
+ // this gate and re-lands, which re-runs from the top).
223
+ return { ran: gates.length, failure: result, warnings };
224
+ }
225
+ return { ran: gates.length, warnings };
226
+ }
@@ -1,9 +1,11 @@
1
- import { existsSync } from "node:fs";
2
- import { resolve } from "node:path";
1
+ import { existsSync, mkdtempSync, rmSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join, resolve } from "node:path";
3
4
  import type { BaseWorktreeSyncResult, WorkspaceMergePreview, WorkspaceMergeResult } from "agent-relay-sdk";
4
5
  import { git, gitRaw } from "../git";
5
6
  import { prMergedState } from "../workspace-pr";
6
7
  import { refreshWorkspaceDeps } from "./deps";
8
+ import { type LandGatesResult, runLandGates } from "./land-gates-runner";
7
9
  import { populateMergeState, resolveBranchRef, syncBaseFromOrigin, upstreamRef, workspaceGitState } from "./git-state";
8
10
  import { nextBranchName } from "./names";
9
11
  import { parseWorktrees, shortBranch } from "./parse";
@@ -344,7 +346,7 @@ function validPreviewStrategy(strategy: string | null): "pr" | "rebase-ff" | "au
344
346
  * Refuses on a dirty worktree, predicted conflicts, or nothing to merge. Never
345
347
  * destroys work on uncertainty.
346
348
  */
347
- export function mergeWorkspace(input: WorkspaceMergeInput): WorkspaceMergeResult {
349
+ export async function mergeWorkspace(input: WorkspaceMergeInput): Promise<WorkspaceMergeResult> {
348
350
  if (!input.worktreePath) return { strategy: "rebase-ff", merged: false, status: "review_requested", error: "worktreePath required", workspaceId: input.id };
349
351
  const worktreePath = resolve(input.worktreePath);
350
352
  const repoRoot = input.repoRoot ? resolve(input.repoRoot) : worktreePath;
@@ -389,7 +391,7 @@ export function mergeWorkspace(input: WorkspaceMergeInput): WorkspaceMergeResult
389
391
  if (preview.conflict) return head({ conflict: true, status: "conflict", error: "merge would conflict with base" });
390
392
 
391
393
  if (strategy === "pr") return mergePr(input, worktreePath, branch, preview, head);
392
- return mergeRebaseFf(input, worktreePath, repoRoot, branch, preview, head);
394
+ return await mergeRebaseFf(input, worktreePath, repoRoot, branch, preview, head);
393
395
  }
394
396
 
395
397
  /**
@@ -523,9 +525,14 @@ function landMergeMessage(branch: string | undefined, subject: string | undefine
523
525
  // with plumbing: compute the merged tree, commit it with both parents (base first, so
524
526
  // `--first-parent` still reads as base's mainline), then advance the ref with a CAS on
525
527
  // the old value. Preserves the branch's commit SHAs without a working tree.
526
- function recordNoFfMerge(
528
+ // Synthesize (but do NOT advance any ref to) the no-ff merge commit of `branchSha` into
529
+ // `baseSha`: compute the merged tree with `merge-tree`, then commit it with both parents
530
+ // (base first, so `--first-parent` reads as base's mainline). This is the exact commit/tree
531
+ // that will become base's new tip — used both to gate the TRUE integrated tree before any ref
532
+ // moves (#902/#903) and, via {@link recordNoFfMerge}, to advance base when no working tree is
533
+ // available. Pure object creation: no ref is touched, so a caller can throw it away freely.
534
+ function synthesizeNoFfMerge(
527
535
  repoRoot: string,
528
- base: string,
529
536
  baseSha: string,
530
537
  branchSha: string,
531
538
  message: string,
@@ -539,10 +546,65 @@ function recordNoFfMerge(
539
546
  repoRoot,
540
547
  );
541
548
  if (!commit.ok || !commit.stdout) return { ok: false, error: commit.stderr || "commit-tree failed" };
542
- const mergeSha = commit.stdout;
543
- const update = git(["update-ref", `refs/heads/${base}`, mergeSha, baseSha], repoRoot);
549
+ return { ok: true, mergeSha: commit.stdout };
550
+ }
551
+
552
+ function recordNoFfMerge(
553
+ repoRoot: string,
554
+ base: string,
555
+ baseSha: string,
556
+ branchSha: string,
557
+ message: string,
558
+ ): { ok: true; mergeSha: string } | { ok: false; conflict?: boolean; error: string } {
559
+ const synth = synthesizeNoFfMerge(repoRoot, baseSha, branchSha, message);
560
+ if (!synth.ok) return synth;
561
+ const update = git(["update-ref", `refs/heads/${base}`, synth.mergeSha, baseSha], repoRoot);
544
562
  if (!update.ok) return { ok: false, error: update.stderr || "failed to advance base ref" };
545
- return { ok: true, mergeSha };
563
+ return { ok: true, mergeSha: synth.mergeSha };
564
+ }
565
+
566
+ /**
567
+ * Run the repo's configured land gates against the EXACT tree that will become base's new tip,
568
+ * BEFORE any ref advance (#902 BLOCKING 1 / closes #903). The candidate tree depends on `behind`:
569
+ * - behind === 0: the landing worktree HEAD already IS that tree (a clean fast-forward), so gates
570
+ * run in `worktreePath` in place — byte-identical to today's straight-FF path.
571
+ * - behind > 0: base has advanced, so the tree that will land is the no-ff MERGE of
572
+ * `integrationBaseSha` and `headSha`, NOT the worktree HEAD. Synthesize that merge commit with
573
+ * plumbing (no ref moved), check it out in a throwaway DETACHED worktree, and gate THAT tree.
574
+ * The temp worktree is torn down on EVERY exit path (including a gate that throws), so a land
575
+ * can never strand an orphan worktree or leave the synthesized merge half-applied.
576
+ * Returns the gate outcome, or an `abort` describing a merge result the caller must return early
577
+ * (a real merge conflict computing the integrated tree, or a failure materializing it).
578
+ */
579
+ async function runLandGatesOnIntegratedTree(
580
+ repoRoot: string,
581
+ worktreePath: string,
582
+ behind: number,
583
+ integrationBaseSha: string,
584
+ headSha: string,
585
+ mergeMessage: string,
586
+ ): Promise<{ gates: LandGatesResult } | { abort: { conflict?: boolean; error: string } }> {
587
+ if (behind === 0) return { gates: await runLandGates(worktreePath) };
588
+
589
+ const synth = synthesizeNoFfMerge(repoRoot, integrationBaseSha, headSha, mergeMessage);
590
+ if (!synth.ok) return { abort: { conflict: synth.conflict, error: synth.error } };
591
+
592
+ // Detached worktree in an isolated temp dir so the gates see the integrated tree on disk
593
+ // without disturbing the landing worktree, the base checkout, or any ref. `git worktree add`
594
+ // creates the leaf, so hand it a not-yet-existing path under a freshly made parent dir.
595
+ const tmpParent = mkdtempSync(join(tmpdir(), "agent-relay-landgate-"));
596
+ const tmpWorktree = join(tmpParent, "tree");
597
+ const add = git(["worktree", "add", "--detach", tmpWorktree, synth.mergeSha], repoRoot);
598
+ if (!add.ok) {
599
+ rmSync(tmpParent, { recursive: true, force: true });
600
+ return { abort: { error: add.stderr || "failed to materialize integrated tree for land gates" } };
601
+ }
602
+ try {
603
+ return { gates: await runLandGates(tmpWorktree) };
604
+ } finally {
605
+ git(["worktree", "remove", "--force", tmpWorktree], repoRoot);
606
+ rmSync(tmpParent, { recursive: true, force: true });
607
+ }
546
608
  }
547
609
 
548
610
  /**
@@ -581,14 +643,14 @@ function syncLocalBaseToUpstream(
581
643
  return { ok: true, baseSync };
582
644
  }
583
645
 
584
- function mergeRebaseFf(
646
+ async function mergeRebaseFf(
585
647
  input: WorkspaceMergeInput,
586
648
  worktreePath: string,
587
649
  repoRoot: string,
588
650
  branch: string | undefined,
589
651
  preview: WorkspaceMergePreview,
590
652
  head: (field: Partial<WorkspaceMergeResult>) => WorkspaceMergeResult,
591
- ): WorkspaceMergeResult {
653
+ ): Promise<WorkspaceMergeResult> {
592
654
  const base = preview.baseRef;
593
655
  if (!base) return head({ status: "review_requested", error: "no base branch to merge into" });
594
656
  if (!branch) return head({ status: "review_requested", error: "cannot determine agent branch" });
@@ -615,6 +677,24 @@ function mergeRebaseFf(
615
677
  const slash = upstream ? upstream.indexOf("/") : -1;
616
678
  const remote = slash > 0 ? upstream!.slice(0, slash) : undefined; // remote of a `remote/branch` upstream
617
679
  const pushEnabled = input.push !== false && workspacePushEnabled() && Boolean(remote);
680
+
681
+ // SHA preservation (#287): never rebase the agent branch — rewriting its commits
682
+ // gives them new SHAs and breaks traceability (the branch.landed SHA must exist on
683
+ // base verbatim). headSha is the preserved landed commit; when base has advanced we
684
+ // tie the branch in with a no-ff merge so the agent's commits keep their identity.
685
+ const headSha = git(["rev-parse", "HEAD"], worktreePath).stdout;
686
+ // Subject of the landed commit for the relay's branch.landed notice (#239). Best-effort:
687
+ // an empty/failed read just omits it from the message body.
688
+ const landedSubject = git(["log", "-1", "--format=%s", headSha], worktreePath).stdout || undefined;
689
+
690
+ // #902 BLOCKING 2 — NO mutation of `refs/heads/<base>` may happen until gates pass, so resolve
691
+ // the SHA the work will integrate onto WITHOUT moving the ref. Origin-ahead is the common
692
+ // concurrent case (#638): we still fetch fresh origin to compute the real merge, but we defer
693
+ // the local-base sync to AFTER the gates. On a gate failure `refs/heads/<base>` must be byte-
694
+ // identical to before the land attempt — so the only `refs/heads/<base>` mutation is the
695
+ // sync+advance below, all of it gated.
696
+ let integrationBaseSha = git(["rev-parse", base], repoRoot).stdout;
697
+ let needSync = false;
618
698
  if (upstream && remote && pushEnabled) {
619
699
  git(["fetch", remote, base], worktreePath); // best-effort freshness; a stale ref can only under-detect divergence
620
700
  if (!git(["merge-base", "--is-ancestor", upstream, base], worktreePath).ok) {
@@ -623,21 +703,47 @@ function mergeRebaseFf(
623
703
  if (!git(["merge-base", "--is-ancestor", base, upstream], worktreePath).ok) {
624
704
  return head({ status: "review_requested", error: `local ${base} has diverged from ${upstream} (commits not on origin); sync before landing` });
625
705
  }
626
- const synced = syncLocalBaseToUpstream(repoRoot, worktreePath, base, upstream);
627
- if (!synced.ok) return head({ status: "review_requested", error: synced.error });
628
- baseSync = mergeBaseSyncResults(baseSync, synced.baseSync);
706
+ const upstreamSha = git(["rev-parse", "--verify", upstream], worktreePath).stdout;
707
+ if (!upstreamSha) return head({ status: "review_requested", error: `cannot resolve ${upstream} to sync ${base}` });
708
+ // Integrate onto fresh origin (the tree that will land), but DON'T advance local base yet.
709
+ integrationBaseSha = upstreamSha;
710
+ needSync = true;
629
711
  }
630
712
  }
631
713
 
632
- // SHA preservation (#287): never rebase the agent branch rewriting its commits
633
- // gives them new SHAs and breaks traceability (the branch.landed SHA must exist on
634
- // base verbatim). headSha is the preserved landed commit; when base has advanced we
635
- // tie the branch in with a no-ff merge so the agent's commits keep their identity.
636
- const headSha = git(["rev-parse", "HEAD"], worktreePath).stdout;
637
- // Subject of the landed commit for the relay's branch.landed notice (#239). Best-effort:
638
- // an empty/failed read just omits it from the message body.
639
- const landedSubject = git(["log", "-1", "--format=%s", headSha], worktreePath).stdout || undefined;
640
- const behind = countBehind(worktreePath, base);
714
+ // Behind relative to the TRUE integration base (origin-ahead behind>0 a real no-ff merge).
715
+ const behind = countBehind(worktreePath, integrationBaseSha);
716
+
717
+ // #902 BLOCKING 1 / closes #903 run the repo's configured land gates against the EXACT tree
718
+ // that will become base's new tip, BEFORE advancing the ref. behind===0 → the worktree HEAD IS
719
+ // that tree (clean fast-forward); behind>0 the synthesized no-ff merge of integrationBaseSha +
720
+ // HEAD, gated in a throwaway worktree (see runLandGatesOnIntegratedTree). A repo with no
721
+ // `.agent-relay/land-gates.json` runs ZERO gates (missing-file empty list, no subprocess), so
722
+ // behind===0 is byte-identical to the prior land path (ironclad opt-in / zero regression). A
723
+ // failing REQUIRED gate aborts the land here WITHOUT advancing/syncing the ref — returned as
724
+ // status:"review_requested" carrying `gateFailure`, NOT "conflict": gate failures bounce back to
725
+ // the worker, never the merge-conflict/steward path. Optional-gate failures ride along as
726
+ // `gateWarnings` on the successful land below.
727
+ const gateRun = await runLandGatesOnIntegratedTree(repoRoot, worktreePath, behind, integrationBaseSha, headSha, landMergeMessage(branch, landedSubject));
728
+ if ("abort" in gateRun) {
729
+ return gateRun.abort.conflict
730
+ ? head({ conflict: true, status: "conflict", error: gateRun.abort.error })
731
+ : head({ status: "review_requested", error: gateRun.abort.error });
732
+ }
733
+ const gates = gateRun.gates;
734
+ if (gates.failure) {
735
+ return head({ status: "review_requested", gateFailure: gates.failure, error: `land gate failed: ${gates.failure.name}` });
736
+ }
737
+ const gateWarningsField = gates.warnings.length ? { gateWarnings: gates.warnings } : {};
738
+
739
+ // Gates passed — only NOW is it safe to touch `refs/heads/<base>`. If origin moved ahead, sync
740
+ // local base up to the fetched upstream first (#638); this is the FIRST mutation of the base ref
741
+ // in the whole land path, so a gate failure above can never leave it advanced (#902 BLOCKING 2).
742
+ if (needSync) {
743
+ const synced = syncLocalBaseToUpstream(repoRoot, worktreePath, base, upstream!);
744
+ if (!synced.ok) return head({ status: "review_requested", error: synced.error });
745
+ baseSync = mergeBaseSyncResults(baseSync, synced.baseSync);
746
+ }
641
747
 
642
748
  // Advance base. `baseTip` is base's new tip after the land: it equals headSha on a
643
749
  // clean fast-forward, or the merge commit on a no-ff merge. Operate IN the base worktree
@@ -716,13 +822,13 @@ function mergeRebaseFf(
716
822
  // continues from a buildable state (issue #51). No-op when nothing is stale.
717
823
  const depsRefresh = refreshWorkspaceDeps(repoRoot, worktreePath);
718
824
  const reportDeps = depsRefresh.refreshed || depsRefresh.stale || depsRefresh.error;
719
- return head({ merged: true, status: "active", mergedSha: headSha, baseSha: baseTip, subject: landedSubject, worktreeRemoved: false, branch: fresh, newBranch: fresh, branchDeleted: oldDeleted, pushed, ...(reportDeps ? { depsRefresh } : {}), ...baseWorktreeSyncField, error: undefined });
825
+ return head({ merged: true, status: "active", mergedSha: headSha, baseSha: baseTip, subject: landedSubject, worktreeRemoved: false, branch: fresh, newBranch: fresh, branchDeleted: oldDeleted, pushed, ...(reportDeps ? { depsRefresh } : {}), ...baseWorktreeSyncField, ...gateWarningsField, error: undefined });
720
826
  }
721
827
  // Recycle failed — keep the existing branch. Still landed, still active.
722
- return head({ merged: true, status: "active", mergedSha: headSha, baseSha: baseTip, subject: landedSubject, worktreeRemoved: false, branchDeleted: false, pushed, ...baseWorktreeSyncField, error: undefined });
828
+ return head({ merged: true, status: "active", mergedSha: headSha, baseSha: baseTip, subject: landedSubject, worktreeRemoved: false, branchDeleted: false, pushed, ...baseWorktreeSyncField, ...gateWarningsField, error: undefined });
723
829
  }
724
830
  const removed = git(["worktree", "remove", "--force", worktreePath], repoRoot);
725
831
  const worktreeRemoved = removed.ok;
726
832
  const branchDeleted = worktreeRemoved ? git(["branch", "-D", branch], repoRoot).ok : false;
727
- return head({ merged: true, status: "merged", mergedSha: headSha, baseSha: baseTip, subject: landedSubject, worktreeRemoved, branchDeleted, pushed, ...baseWorktreeSyncField, error: undefined });
833
+ return head({ merged: true, status: "merged", mergedSha: headSha, baseSha: baseTip, subject: landedSubject, worktreeRemoved, branchDeleted, pushed, ...baseWorktreeSyncField, ...gateWarningsField, error: undefined });
728
834
  }