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