agent-relay-orchestrator 0.118.5 → 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 +159 -10
- package/src/workspace-probe/merge.ts +27 -78
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,10 +1,10 @@
|
|
|
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
9
|
import { withMergePhaseTimeout } from "./workspace-probe/merge-timeouts";
|
|
10
10
|
import { armWorkspacePrAutoMerge, mergeWorkspacePr, refreshWorkspacePrBranch } from "./workspace-pr";
|
|
@@ -24,22 +24,167 @@ interface ControlHandler {
|
|
|
24
24
|
handleCommand(command: RelayCommand): Promise<boolean>;
|
|
25
25
|
getManagedAgents(): ManagedAgentReport[];
|
|
26
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;
|
|
27
98
|
}
|
|
28
99
|
|
|
29
100
|
export function createControlHandler(
|
|
30
101
|
config: OrchestratorConfig,
|
|
31
102
|
relay: RelayClient,
|
|
103
|
+
deps: ControlHandlerDeps = {},
|
|
32
104
|
): ControlHandler {
|
|
33
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>>();
|
|
34
116
|
|
|
35
|
-
async function handleSpawn(ctrl: Record<string, any>): Promise<
|
|
117
|
+
async function handleSpawn(ctrl: Record<string, any>): Promise<ManagedAgentReport> {
|
|
36
118
|
const opts = spawnOptionsFromControl(ctrl, config);
|
|
37
|
-
const agent = await
|
|
119
|
+
const agent = await dispatchSpawn(opts, config);
|
|
38
120
|
managedAgents.push(agent);
|
|
39
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));
|
|
40
148
|
return true;
|
|
41
149
|
}
|
|
42
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
|
+
|
|
43
188
|
async function handleShutdown(ctrl: Record<string, any>, restart = false): Promise<Record<string, unknown>> {
|
|
44
189
|
const current = managedAgentShutdownTarget(managedAgents, ctrl);
|
|
45
190
|
const session = current?.sessionName ?? current?.tmuxSession;
|
|
@@ -86,14 +231,18 @@ export function createControlHandler(
|
|
|
86
231
|
}
|
|
87
232
|
|
|
88
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> {
|
|
89
242
|
await relay.updateCommand(command.id, "accepted");
|
|
90
243
|
await relay.updateCommand(command.id, "running");
|
|
91
244
|
try {
|
|
92
|
-
if (command.type === "agent.
|
|
93
|
-
const handled = await handleSpawn(command.params);
|
|
94
|
-
if (!handled) throw new Error("spawn failed");
|
|
95
|
-
await relay.updateCommand(command.id, "succeeded", { managedAgents });
|
|
96
|
-
} else if (command.type === "agent.shutdown" || command.type === "agent.restart") {
|
|
245
|
+
if (command.type === "agent.shutdown" || command.type === "agent.restart") {
|
|
97
246
|
const result = await handleShutdown(command.params, command.type === "agent.restart");
|
|
98
247
|
await relay.updateCommand(command.id, "succeeded", result);
|
|
99
248
|
} else if (command.type === "workspace.cleanup") {
|
|
@@ -250,7 +399,7 @@ export function createControlHandler(
|
|
|
250
399
|
managedAgents = agents;
|
|
251
400
|
}
|
|
252
401
|
|
|
253
|
-
return { handleCommand, getManagedAgents, setManagedAgents };
|
|
402
|
+
return { handleCommand, getManagedAgents, setManagedAgents, spawnsInFlight: () => backgroundSpawns.size, settleSpawns };
|
|
254
403
|
}
|
|
255
404
|
|
|
256
405
|
export function managedAgentShutdownTarget(agents: ManagedAgentReport[], ctrl: Record<string, any>): ManagedAgentReport | undefined {
|
|
@@ -1,12 +1,11 @@
|
|
|
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
|
|
8
|
+
import { type LandGatesResult } from "./land-gates-runner";
|
|
10
9
|
import { populateMergeState, resolveBranchRef, syncBaseFromOrigin, workspaceGitState } from "./git-state";
|
|
11
10
|
import { type MergePhase, mergePhaseTimeoutMs, withMergePhaseTimeout } from "./merge-timeouts";
|
|
12
11
|
import { nextBranchName } from "./names";
|
|
@@ -584,18 +583,16 @@ async function recordNoFfMerge(
|
|
|
584
583
|
return { ok: true, mergeSha: synth.mergeSha };
|
|
585
584
|
}
|
|
586
585
|
|
|
586
|
+
function decommissionedLandGatesResult(): LandGatesResult {
|
|
587
|
+
return { ran: 0, warnings: [] };
|
|
588
|
+
}
|
|
589
|
+
|
|
587
590
|
/**
|
|
588
|
-
*
|
|
589
|
-
*
|
|
590
|
-
*
|
|
591
|
-
*
|
|
592
|
-
*
|
|
593
|
-
* `integrationBaseSha` and `headSha`, NOT the worktree HEAD. Synthesize that merge commit with
|
|
594
|
-
* plumbing (no ref moved), check it out in a throwaway DETACHED worktree, and gate THAT tree.
|
|
595
|
-
* The temp worktree is torn down on EVERY exit path (including a gate that throws), so a land
|
|
596
|
-
* can never strand an orphan worktree or leave the synthesized merge half-applied.
|
|
597
|
-
* Returns the gate outcome, or an `abort` describing a merge result the caller must return early
|
|
598
|
-
* (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.
|
|
599
596
|
*/
|
|
600
597
|
async function runLandGatesOnIntegratedTree(
|
|
601
598
|
repoRoot: string,
|
|
@@ -605,51 +602,12 @@ async function runLandGatesOnIntegratedTree(
|
|
|
605
602
|
headSha: string,
|
|
606
603
|
mergeMessage: string,
|
|
607
604
|
): Promise<{ gates: LandGatesResult } | { abort: { conflict?: boolean; error: string } }> {
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
} catch (err) {
|
|
615
|
-
return { abort: { error: errMessage(err) } };
|
|
616
|
-
}
|
|
617
|
-
if (!synth.ok) return { abort: { conflict: synth.conflict, error: synth.error } };
|
|
618
|
-
|
|
619
|
-
// Detached worktree in an isolated temp dir so the gates see the integrated tree on disk
|
|
620
|
-
// without disturbing the landing worktree, the base checkout, or any ref. `git worktree add`
|
|
621
|
-
// creates the leaf, so hand it a not-yet-existing path under a freshly made parent dir.
|
|
622
|
-
const tmpParent = mkdtempSync(join(tmpdir(), "agent-relay-landgate-"));
|
|
623
|
-
const tmpWorktree = join(tmpParent, "tree");
|
|
624
|
-
let add: Awaited<ReturnType<typeof git>>;
|
|
625
|
-
try {
|
|
626
|
-
add = await withMergePhaseTimeout("worktree-add", () => git(
|
|
627
|
-
["worktree", "add", "--detach", tmpWorktree, synth.mergeSha],
|
|
628
|
-
repoRoot,
|
|
629
|
-
{ timeoutMs: mergePhaseTimeoutMs("worktree-add"), timeoutLabel: "workspace merge land-gate worktree add" },
|
|
630
|
-
));
|
|
631
|
-
} catch (err) {
|
|
632
|
-
rmSync(tmpParent, { recursive: true, force: true });
|
|
633
|
-
return { abort: { error: errMessage(err) } };
|
|
634
|
-
}
|
|
635
|
-
if (!add.ok) {
|
|
636
|
-
rmSync(tmpParent, { recursive: true, force: true });
|
|
637
|
-
return { abort: { error: add.stderr || "failed to materialize integrated tree for land gates" } };
|
|
638
|
-
}
|
|
639
|
-
try {
|
|
640
|
-
return { gates: await withMergePhaseTimeout("gates", () => runLandGates(tmpWorktree)) };
|
|
641
|
-
} finally {
|
|
642
|
-
try {
|
|
643
|
-
await withMergePhaseTimeout("cleanup", () => git(
|
|
644
|
-
["worktree", "remove", "--force", tmpWorktree],
|
|
645
|
-
repoRoot,
|
|
646
|
-
{ timeoutMs: mergePhaseTimeoutMs("cleanup"), timeoutLabel: "workspace merge land-gate worktree cleanup" },
|
|
647
|
-
));
|
|
648
|
-
} catch (err) {
|
|
649
|
-
console.error(`[orchestrator] land-gate integrated worktree cleanup timed out/failed: ${errMessage(err)}`);
|
|
650
|
-
}
|
|
651
|
-
rmSync(tmpParent, { recursive: true, force: true });
|
|
652
|
-
}
|
|
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() };
|
|
653
611
|
}
|
|
654
612
|
|
|
655
613
|
/**
|
|
@@ -735,12 +693,9 @@ async function mergeRebaseFf(
|
|
|
735
693
|
// an empty/failed read just omits it from the message body.
|
|
736
694
|
const landedSubject = (await mergeGit(["log", "-1", "--format=%s", headSha], worktreePath, "rebase", "workspace merge read landed commit subject")).stdout || undefined;
|
|
737
695
|
|
|
738
|
-
//
|
|
739
|
-
// the
|
|
740
|
-
//
|
|
741
|
-
// the local-base sync to AFTER the gates. On a gate failure `refs/heads/<base>` must be byte-
|
|
742
|
-
// identical to before the land attempt — so the only `refs/heads/<base>` mutation is the
|
|
743
|
-
// sync+advance below, all of it gated.
|
|
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.
|
|
744
699
|
const integrationBaseResult = await mergeGit(["rev-parse", base], repoRoot, "rebase", `workspace merge resolve integration base ${base}`);
|
|
745
700
|
let integrationBaseSha = integrationBaseResult.stdout;
|
|
746
701
|
if (!integrationBaseResult.ok || !integrationBaseSha) return head({ status: "review_requested", error: gitError(integrationBaseResult, `failed to resolve integration base ${base}`) });
|
|
@@ -775,16 +730,10 @@ async function mergeRebaseFf(
|
|
|
775
730
|
if ("error" in behindResult) return head({ status: "review_requested", error: behindResult.error });
|
|
776
731
|
const behind = behindResult.behind;
|
|
777
732
|
|
|
778
|
-
// #
|
|
779
|
-
//
|
|
780
|
-
//
|
|
781
|
-
//
|
|
782
|
-
// `.agent-relay/land-gates.json` runs ZERO gates (missing-file → empty list, no subprocess), so
|
|
783
|
-
// behind===0 is byte-identical to the prior land path (ironclad opt-in / zero regression). A
|
|
784
|
-
// failing REQUIRED gate aborts the land here WITHOUT advancing/syncing the ref — returned as
|
|
785
|
-
// status:"review_requested" carrying `gateFailure`, NOT "conflict": gate failures bounce back to
|
|
786
|
-
// the worker, never the merge-conflict/steward path. Optional-gate failures ride along as
|
|
787
|
-
// `gateWarnings` on the successful land below.
|
|
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.
|
|
788
737
|
const gateRun = await runLandGatesOnIntegratedTree(repoRoot, worktreePath, behind, integrationBaseSha, headSha, landMergeMessage(branch, landedSubject));
|
|
789
738
|
if ("abort" in gateRun) {
|
|
790
739
|
return gateRun.abort.conflict
|
|
@@ -797,9 +746,9 @@ async function mergeRebaseFf(
|
|
|
797
746
|
}
|
|
798
747
|
const gateWarningsField = gates.warnings.length ? { gateWarnings: gates.warnings } : {};
|
|
799
748
|
|
|
800
|
-
//
|
|
801
|
-
//
|
|
802
|
-
//
|
|
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.
|
|
803
752
|
if (needSync) {
|
|
804
753
|
const synced = await syncLocalBaseToUpstream(repoRoot, worktreePath, base, upstream!);
|
|
805
754
|
if (!synced.ok) return head({ status: "review_requested", error: synced.error });
|