agent-relay-orchestrator 0.127.7 → 0.127.9
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/spawn/command.ts +33 -2
- package/src/spawn/spawn-agent.ts +2 -2
- package/src/workspace-probe/integrated-land-gates.ts +155 -0
- package/src/workspace-probe/land-gates-runner.ts +13 -16
- package/src/workspace-probe/merge-timeouts.ts +2 -2
- package/src/workspace-probe/merge.ts +2 -56
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-orchestrator",
|
|
3
|
-
"version": "0.127.
|
|
3
|
+
"version": "0.127.9",
|
|
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.4",
|
|
20
|
-
"agent-relay-sdk": "0.2.
|
|
20
|
+
"agent-relay-sdk": "0.2.122",
|
|
21
21
|
"callmux": "0.23.0"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
package/src/spawn/command.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
2
3
|
import { homedir } from "node:os";
|
|
3
4
|
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
4
5
|
import { artifactProxyBaseUrl } from "../artifact-proxy";
|
|
@@ -14,10 +15,40 @@ export function isWithinBaseDir(path: string, baseDir: string): boolean {
|
|
|
14
15
|
return rel === "" || (!!rel && !rel.startsWith("..") && !isAbsolute(rel));
|
|
15
16
|
}
|
|
16
17
|
|
|
18
|
+
// Linux sockaddr_un.sun_path is 108 bytes including the trailing NUL. With the
|
|
19
|
+
// default tmux socket dir `/tmp/tmux-<uid>/` and `agent-relay-` socket prefix,
|
|
20
|
+
// an 80-byte session name keeps the full path under 108 bytes on normal hosts.
|
|
21
|
+
export const TMUX_SESSION_NAME_MAX_BYTES = 80;
|
|
22
|
+
|
|
23
|
+
function shortHash(value: string): string {
|
|
24
|
+
return createHash("sha256").update(value).digest("hex").slice(0, 8);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function boundedSlug(value: string, maxBytes: number): string {
|
|
28
|
+
const clean = sanitizeFsName(value, { replacement: "-", collapseReplacement: true, trimEdge: true, lowercase: true, fallback: "agent" });
|
|
29
|
+
if (Buffer.byteLength(clean) <= maxBytes) return clean;
|
|
30
|
+
const suffix = `-${shortHash(clean)}`;
|
|
31
|
+
const headBytes = maxBytes - suffix.length;
|
|
32
|
+
if (headBytes <= 0) return shortHash(clean).slice(0, Math.max(1, maxBytes));
|
|
33
|
+
return `${clean.slice(0, headBytes).replace(/-+$/g, "")}${suffix}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function sessionLabelForSpawn(opts: Pick<SpawnOptions, "label" | "taskId"> & { label: string }): string {
|
|
37
|
+
if (opts.taskId === undefined) return opts.label;
|
|
38
|
+
const base = `task-${opts.taskId}`;
|
|
39
|
+
const label = opts.label.trim();
|
|
40
|
+
if (!label || label.toLowerCase() === base || label.toLowerCase().startsWith(`${base}:`)) return base;
|
|
41
|
+
return `${base}-${label}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
17
44
|
export function sessionName(config: OrchestratorConfig, provider: string, label: string, uniqueId?: string): string {
|
|
18
|
-
const clean = sanitizeFsName(label, { replacement: "-", lowercase: true });
|
|
19
45
|
const suffix = uniqueId ? `-${sanitizeFsName(uniqueId, { replacement: "-", lowercase: true }).slice(-8)}` : "";
|
|
20
|
-
|
|
46
|
+
const prefix = `${config.tmuxPrefix}-${provider}-`;
|
|
47
|
+
const labelBudget = TMUX_SESSION_NAME_MAX_BYTES - Buffer.byteLength(prefix) - Buffer.byteLength(suffix);
|
|
48
|
+
if (labelBudget < 8) {
|
|
49
|
+
throw new Error("derived name too long for tmux socket; pass a short `label`");
|
|
50
|
+
}
|
|
51
|
+
return `${prefix}${boundedSlug(label, labelBudget)}${suffix}`;
|
|
21
52
|
}
|
|
22
53
|
|
|
23
54
|
export function defaultSpawnLabel(now = Date.now()): string {
|
package/src/spawn/spawn-agent.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { OrchestratorConfig } from "../config";
|
|
|
3
3
|
import { resolveSpawnWorkspace, workspacesRoot } from "../workspace-probe";
|
|
4
4
|
import type { ManagedAgentReport } from "../relay";
|
|
5
5
|
import { applyProjectAcquisitionManifest } from "./acquisition";
|
|
6
|
-
import { buildEnv, buildRunnerCommand, defaultSpawnLabel, isWithinBaseDir, sessionName } from "./command";
|
|
6
|
+
import { buildEnv, buildRunnerCommand, defaultSpawnLabel, isWithinBaseDir, sessionLabelForSpawn, sessionName } from "./command";
|
|
7
7
|
import { addSessionRecord, currentSessionPid, findSessionRecord, ensureLogDir, ensureRunnerInfoDir, logFilePath, runnerInfoPath, sessionRecordLiveness, sessionReportFields } from "./runtime";
|
|
8
8
|
import { managedAgentId } from "./sessions";
|
|
9
9
|
import { spawnRunner } from "./supervisor";
|
|
@@ -47,7 +47,7 @@ export async function spawnAgent(
|
|
|
47
47
|
const d = { ...defaultSpawnAgentDeps, ...deps };
|
|
48
48
|
const label = opts.label || defaultSpawnLabel();
|
|
49
49
|
const agentId = opts.agentId || managedAgentId(config, opts.provider, label);
|
|
50
|
-
const name = sessionName(config, opts.provider, label, opts.spawnRequestId ?? agentId);
|
|
50
|
+
const name = sessionName(config, opts.provider, sessionLabelForSpawn({ ...opts, label }), opts.spawnRequestId ?? agentId);
|
|
51
51
|
|
|
52
52
|
if (!isWithinBaseDir(opts.cwd, config.baseDir)) {
|
|
53
53
|
throw new Error(`cwd must be within base directory: ${config.baseDir}`);
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { existsSync, lstatSync, mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { errMessage } from "agent-relay-sdk";
|
|
5
|
+
import type { LandGate } from "agent-relay-sdk";
|
|
6
|
+
import { git } from "../git";
|
|
7
|
+
import { runLandGates, type LandGatesResult } from "./land-gates-runner";
|
|
8
|
+
import { mergePhaseTimeoutMs, withMergePhaseTimeout } from "./merge-timeouts";
|
|
9
|
+
|
|
10
|
+
const LAND_COMMITTER = { name: "Agent Relay", email: "agent-relay@noreply" } as const;
|
|
11
|
+
const FULL_CI_LAND_GATE: LandGate = {
|
|
12
|
+
name: "ci-land-full",
|
|
13
|
+
command: "AGENT_RELAY_CI_LAND_FULL=1 bun run ci:land",
|
|
14
|
+
timeoutMs: 30 * 60 * 1000,
|
|
15
|
+
};
|
|
16
|
+
const DEPENDENCY_LINK_PATHS = [
|
|
17
|
+
"node_modules",
|
|
18
|
+
"dashboard/node_modules",
|
|
19
|
+
"sdk/node_modules",
|
|
20
|
+
"runner/node_modules",
|
|
21
|
+
"orchestrator/node_modules",
|
|
22
|
+
"codex/node_modules",
|
|
23
|
+
"claude/node_modules",
|
|
24
|
+
"client/node_modules",
|
|
25
|
+
] as const;
|
|
26
|
+
|
|
27
|
+
// Synthesize, but do not advance any ref to, the no-ff merge commit of branchSha
|
|
28
|
+
// into baseSha. The resulting commit/tree is the exact integrated tree that will
|
|
29
|
+
// land, so callers can gate it before mutating base.
|
|
30
|
+
export async function synthesizeNoFfMerge(
|
|
31
|
+
repoRoot: string,
|
|
32
|
+
baseSha: string,
|
|
33
|
+
branchSha: string,
|
|
34
|
+
message: string,
|
|
35
|
+
timeoutMs?: number,
|
|
36
|
+
signal?: AbortSignal,
|
|
37
|
+
): Promise<{ ok: true; mergeSha: string } | { ok: false; conflict?: boolean; error: string }> {
|
|
38
|
+
const tree = await git(["merge-tree", "--write-tree", baseSha, branchSha], repoRoot, { timeoutMs, timeoutLabel: "workspace merge synthesize merge-tree", signal });
|
|
39
|
+
if (!tree.ok) return { ok: false, conflict: true, error: tree.stdout || tree.stderr || "merge conflict computing tree" };
|
|
40
|
+
const treeOid = tree.stdout.split("\n")[0]?.trim();
|
|
41
|
+
if (!treeOid) return { ok: false, error: "merge-tree produced no tree oid" };
|
|
42
|
+
const commit = await git(
|
|
43
|
+
["-c", "user.name=" + LAND_COMMITTER.name, "-c", "user.email=" + LAND_COMMITTER.email, "commit-tree", treeOid, "-p", baseSha, "-p", branchSha, "-m", message],
|
|
44
|
+
repoRoot,
|
|
45
|
+
{ timeoutMs, timeoutLabel: "workspace merge synthesize commit-tree", signal },
|
|
46
|
+
);
|
|
47
|
+
if (!commit.ok || !commit.stdout) return { ok: false, error: commit.stderr || "commit-tree failed" };
|
|
48
|
+
return { ok: true, mergeSha: commit.stdout };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function baseDeclaresCiLand(repoRoot: string, baseSha: string, signal?: AbortSignal): Promise<boolean> {
|
|
52
|
+
const pkg = await git(["show", `${baseSha}:package.json`], repoRoot, { timeoutMs: mergePhaseTimeoutMs("gates"), timeoutLabel: "workspace merge inspect base package scripts", signal });
|
|
53
|
+
if (!pkg.ok || !pkg.stdout) return false;
|
|
54
|
+
try {
|
|
55
|
+
const parsed = JSON.parse(pkg.stdout) as { scripts?: Record<string, unknown> };
|
|
56
|
+
return typeof parsed.scripts?.["ci:land"] === "string";
|
|
57
|
+
} catch {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function linkDependencyDirs(sourceWorktree: string, gateWorktree: string): void {
|
|
63
|
+
for (const rel of DEPENDENCY_LINK_PATHS) {
|
|
64
|
+
const from = join(sourceWorktree, rel);
|
|
65
|
+
const to = join(gateWorktree, rel);
|
|
66
|
+
if (!existsSync(from) || existsSync(to)) continue;
|
|
67
|
+
try {
|
|
68
|
+
const stat = lstatSync(from);
|
|
69
|
+
if (!stat.isDirectory() && !stat.isSymbolicLink()) continue;
|
|
70
|
+
mkdirSync(join(to, ".."), { recursive: true });
|
|
71
|
+
symlinkSync(realpathSync(from), to, "dir");
|
|
72
|
+
} catch (err) {
|
|
73
|
+
console.error(`[orchestrator] land-gate dependency link skipped ${rel}: ${errMessage(err)}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Run land gates against the exact tree that would land.
|
|
80
|
+
*
|
|
81
|
+
* Always materializes that tree in a detached checkout. Even a fast-forwardable
|
|
82
|
+
* branch is gated outside the worker worktree so worker-only topology, untracked
|
|
83
|
+
* files, and symlink layout cannot make the boundary check pass differently than
|
|
84
|
+
* the tree that will advance the base ref.
|
|
85
|
+
*/
|
|
86
|
+
export async function runLandGatesOnIntegratedTree(
|
|
87
|
+
repoRoot: string,
|
|
88
|
+
worktreePath: string,
|
|
89
|
+
behind: number,
|
|
90
|
+
integrationBaseSha: string,
|
|
91
|
+
headSha: string,
|
|
92
|
+
mergeMessage: string,
|
|
93
|
+
signal?: AbortSignal,
|
|
94
|
+
): Promise<{ gates: LandGatesResult } | { abort: { conflict?: boolean; error: string } }> {
|
|
95
|
+
console.error("[orchestrator] workspace.merge gate-start worktree=" + worktreePath + " behind=" + behind);
|
|
96
|
+
|
|
97
|
+
const requiredGates: LandGate[] = await baseDeclaresCiLand(repoRoot, integrationBaseSha, signal) ? [FULL_CI_LAND_GATE] : [];
|
|
98
|
+
let gateRef = headSha;
|
|
99
|
+
if (behind > 0) {
|
|
100
|
+
let synth: Awaited<ReturnType<typeof synthesizeNoFfMerge>>;
|
|
101
|
+
try {
|
|
102
|
+
synth = await withMergePhaseTimeout(
|
|
103
|
+
"synthesize",
|
|
104
|
+
(phaseSignal) => synthesizeNoFfMerge(repoRoot, integrationBaseSha, headSha, mergeMessage, mergePhaseTimeoutMs("synthesize"), phaseSignal),
|
|
105
|
+
{ signal },
|
|
106
|
+
);
|
|
107
|
+
} catch (err) {
|
|
108
|
+
return { abort: { error: errMessage(err) } };
|
|
109
|
+
}
|
|
110
|
+
if (!synth.ok) return { abort: { conflict: synth.conflict, error: synth.error } };
|
|
111
|
+
gateRef = synth.mergeSha;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const tmpParent = mkdtempSync(join(tmpdir(), "agent-relay-landgate-"));
|
|
115
|
+
const tmpWorktree = join(tmpParent, "checkout");
|
|
116
|
+
let add: Awaited<ReturnType<typeof git>>;
|
|
117
|
+
try {
|
|
118
|
+
add = await withMergePhaseTimeout(
|
|
119
|
+
"worktree-add",
|
|
120
|
+
(phaseSignal) => git(
|
|
121
|
+
["worktree", "add", "--detach", tmpWorktree, gateRef],
|
|
122
|
+
repoRoot,
|
|
123
|
+
{ timeoutMs: mergePhaseTimeoutMs("worktree-add"), timeoutLabel: "workspace merge land-gate worktree add", signal: phaseSignal },
|
|
124
|
+
),
|
|
125
|
+
{ signal },
|
|
126
|
+
);
|
|
127
|
+
} catch (err) {
|
|
128
|
+
rmSync(tmpParent, { recursive: true, force: true });
|
|
129
|
+
return { abort: { error: errMessage(err) } };
|
|
130
|
+
}
|
|
131
|
+
if (!add.ok) {
|
|
132
|
+
rmSync(tmpParent, { recursive: true, force: true });
|
|
133
|
+
return { abort: { error: add.stderr || "failed to materialize integrated tree for land gates" } };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
linkDependencyDirs(worktreePath, tmpWorktree);
|
|
138
|
+
return { gates: await withMergePhaseTimeout("gates", () => runLandGates(tmpWorktree, requiredGates), { signal }) };
|
|
139
|
+
} finally {
|
|
140
|
+
try {
|
|
141
|
+
await withMergePhaseTimeout(
|
|
142
|
+
"cleanup",
|
|
143
|
+
(phaseSignal) => git(
|
|
144
|
+
["worktree", "remove", "--force", tmpWorktree],
|
|
145
|
+
repoRoot,
|
|
146
|
+
{ timeoutMs: mergePhaseTimeoutMs("cleanup"), timeoutLabel: "workspace merge land-gate worktree cleanup", signal: phaseSignal },
|
|
147
|
+
),
|
|
148
|
+
{ signal },
|
|
149
|
+
);
|
|
150
|
+
} catch (err) {
|
|
151
|
+
console.error("[orchestrator] land-gate integrated worktree cleanup timed out/failed: " + errMessage(err));
|
|
152
|
+
}
|
|
153
|
+
rmSync(tmpParent, { recursive: true, force: true });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -3,15 +3,13 @@ import { errMessage } from "agent-relay-sdk";
|
|
|
3
3
|
import type { LandGate, LandGateRunResult } from "agent-relay-sdk";
|
|
4
4
|
import { loadRepoLandGates } from "agent-relay-sdk/land-gates";
|
|
5
5
|
|
|
6
|
-
// #902 — execute
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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. */
|
|
6
|
+
// #902/#1199 — execute mandatory boundary gates and any repo-configured land gates
|
|
7
|
+
// against the candidate merged tree BEFORE the ref advance in `mergeRebaseFf`.
|
|
8
|
+
// A required gate failing blocks the land (ref NOT advanced); optional gates only warn.
|
|
9
|
+
|
|
10
|
+
/** Per-gate default timeout when the gate didn't declare `timeoutMs`. Keep the
|
|
11
|
+
* ceiling generous but bounded so a hung gate can't wedge the per-repo merge
|
|
12
|
+
* lease forever. Full-suite boundary gates should declare their own timeout. */
|
|
15
13
|
const DEFAULT_GATE_TIMEOUT_MS = 5 * 60 * 1000;
|
|
16
14
|
const TIMEOUT_KILL_GRACE_MS = 1_000;
|
|
17
15
|
const OUTPUT_CANCEL_GRACE_MS = 50;
|
|
@@ -189,21 +187,20 @@ export interface LandGatesResult {
|
|
|
189
187
|
}
|
|
190
188
|
|
|
191
189
|
/**
|
|
192
|
-
* Load + run the repo's configured land gates against `worktreePath`.
|
|
193
|
-
* `ran: 0` with no failure/warnings when the
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
* land); optional failures accumulate as warnings and never block.
|
|
190
|
+
* Load + run required boundary gates plus the repo's configured land gates against `worktreePath`.
|
|
191
|
+
* Returns `ran: 0` with no failure/warnings only when neither the boundary nor the repo
|
|
192
|
+
* declares gates. Stops at the first failing REQUIRED gate (it blocks the land);
|
|
193
|
+
* optional failures accumulate as warnings and never block.
|
|
197
194
|
*
|
|
198
195
|
* A PRESENT-but-malformed `.agent-relay/land-gates.json` is itself a blocking failure
|
|
199
196
|
* (surfaced as a synthetic required gate) rather than an unhandled throw that would
|
|
200
197
|
* crash the merge command — the worker fixes the config and re-lands.
|
|
201
198
|
*/
|
|
202
|
-
export async function runLandGates(worktreePath: string): Promise<LandGatesResult> {
|
|
199
|
+
export async function runLandGates(worktreePath: string, requiredGates: LandGate[] = []): Promise<LandGatesResult> {
|
|
203
200
|
const warnings: LandGateRunResult[] = [];
|
|
204
201
|
let gates: LandGate[];
|
|
205
202
|
try {
|
|
206
|
-
gates = loadRepoLandGates(worktreePath);
|
|
203
|
+
gates = [...requiredGates, ...loadRepoLandGates(worktreePath)];
|
|
207
204
|
} catch (err) {
|
|
208
205
|
const output = `invalid .agent-relay/land-gates.json: ${errMessage(err)}`;
|
|
209
206
|
return {
|
|
@@ -1,12 +1,12 @@
|
|
|
1
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:
|
|
4
|
+
total: 40 * 60_000,
|
|
5
5
|
prep: 60_000,
|
|
6
6
|
preview: 30_000,
|
|
7
7
|
fetch: 60_000,
|
|
8
8
|
rebase: 60_000,
|
|
9
|
-
gates:
|
|
9
|
+
gates: 35 * 60_000,
|
|
10
10
|
synthesize: 60_000,
|
|
11
11
|
"worktree-add": 60_000,
|
|
12
12
|
cleanup: 30_000,
|
|
@@ -5,8 +5,8 @@ import { git, gitRaw } from "../git";
|
|
|
5
5
|
import { execProcess } from "../process";
|
|
6
6
|
import { deleteBranchIfSafe, owningRepoRoot } from "./cleanup";
|
|
7
7
|
import { refreshWorkspaceDeps } from "./deps";
|
|
8
|
-
import { type LandGatesResult } from "./land-gates-runner";
|
|
9
8
|
import { populateMergeState, resolveBranchRef, syncBaseFromOrigin, upstreamRef, workspaceGitState } from "./git-state";
|
|
9
|
+
import { runLandGatesOnIntegratedTree, synthesizeNoFfMerge } from "./integrated-land-gates";
|
|
10
10
|
import { type MergePhase, mergePhaseTimeoutMs, throwIfMergeAborted, withMergePhaseTimeout } from "./merge-timeouts";
|
|
11
11
|
import { nextBranchName } from "./names";
|
|
12
12
|
import { parseWorktrees, shortBranch } from "./parse";
|
|
@@ -628,29 +628,6 @@ function landMergeMessage(branch: string | undefined, subject: string | undefine
|
|
|
628
628
|
// with plumbing: compute the merged tree, commit it with both parents (base first, so
|
|
629
629
|
// `--first-parent` still reads as base's mainline), then advance the ref with a CAS on
|
|
630
630
|
// the old value. Preserves the branch's commit SHAs without a working tree.
|
|
631
|
-
// Synthesize (but do NOT advance any ref to) the no-ff merge commit of `branchSha` into
|
|
632
|
-
// `baseSha`: compute the merged tree with `merge-tree`, then commit it with both parents
|
|
633
|
-
// (base first, so `--first-parent` reads as base's mainline). This is the exact commit/tree
|
|
634
|
-
// that will become base's new tip — used both to gate the TRUE integrated tree before any ref
|
|
635
|
-
// moves (#902/#903) and, via {@link recordNoFfMerge}, to advance base when no working tree is
|
|
636
|
-
// available. Pure object creation: no ref is touched, so a caller can throw it away freely.
|
|
637
|
-
async function synthesizeNoFfMerge(
|
|
638
|
-
repoRoot: string, baseSha: string, branchSha: string, message: string,
|
|
639
|
-
timeoutMs?: number, signal?: AbortSignal,
|
|
640
|
-
): Promise<{ ok: true; mergeSha: string } | { ok: false; conflict?: boolean; error: string }> {
|
|
641
|
-
const tree = await git(["merge-tree", "--write-tree", baseSha, branchSha], repoRoot, { timeoutMs, timeoutLabel: "workspace merge synthesize merge-tree", signal });
|
|
642
|
-
if (!tree.ok) return { ok: false, conflict: true, error: tree.stdout || tree.stderr || "merge conflict computing tree" };
|
|
643
|
-
const treeOid = tree.stdout.split("\n")[0]?.trim();
|
|
644
|
-
if (!treeOid) return { ok: false, error: "merge-tree produced no tree oid" };
|
|
645
|
-
const commit = await git(
|
|
646
|
-
["-c", `user.name=${LAND_COMMITTER.name}`, "-c", `user.email=${LAND_COMMITTER.email}`, "commit-tree", treeOid, "-p", baseSha, "-p", branchSha, "-m", message],
|
|
647
|
-
repoRoot,
|
|
648
|
-
{ timeoutMs, timeoutLabel: "workspace merge synthesize commit-tree", signal },
|
|
649
|
-
);
|
|
650
|
-
if (!commit.ok || !commit.stdout) return { ok: false, error: commit.stderr || "commit-tree failed" };
|
|
651
|
-
return { ok: true, mergeSha: commit.stdout };
|
|
652
|
-
}
|
|
653
|
-
|
|
654
631
|
async function recordNoFfMerge(
|
|
655
632
|
repoRoot: string, base: string, baseSha: string, branchSha: string, message: string, signal?: AbortSignal,
|
|
656
633
|
): Promise<{ ok: true; mergeSha: string } | { ok: false; conflict?: boolean; error: string }> {
|
|
@@ -661,33 +638,6 @@ async function recordNoFfMerge(
|
|
|
661
638
|
return { ok: true, mergeSha: synth.mergeSha };
|
|
662
639
|
}
|
|
663
640
|
|
|
664
|
-
function decommissionedLandGatesResult(): LandGatesResult {
|
|
665
|
-
return { ran: 0, warnings: [] };
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
/**
|
|
669
|
-
* Land gates are decommissioned (#923). Release-time gates in `release.ts` are the
|
|
670
|
-
* quality boundary; workspace landing must never launch repo-configured gate subprocesses.
|
|
671
|
-
* Keep this function as the single merge-path kill switch so populated
|
|
672
|
-
* `.agent-relay/land-gates.json` files are treated as always-empty without changing the
|
|
673
|
-
* rest of the rebase/fetch/no-ff landing flow.
|
|
674
|
-
*/
|
|
675
|
-
async function runLandGatesOnIntegratedTree(
|
|
676
|
-
repoRoot: string,
|
|
677
|
-
worktreePath: string,
|
|
678
|
-
behind: number,
|
|
679
|
-
integrationBaseSha: string,
|
|
680
|
-
headSha: string,
|
|
681
|
-
mergeMessage: string,
|
|
682
|
-
): Promise<{ gates: LandGatesResult } | { abort: { conflict?: boolean; error: string } }> {
|
|
683
|
-
void repoRoot;
|
|
684
|
-
void integrationBaseSha;
|
|
685
|
-
void headSha;
|
|
686
|
-
void mergeMessage;
|
|
687
|
-
console.error(`[orchestrator] workspace.merge gate-skip worktree=${worktreePath} behind=${behind} reason=land-gates-decommissioned`);
|
|
688
|
-
return { gates: decommissionedLandGatesResult() };
|
|
689
|
-
}
|
|
690
|
-
|
|
691
641
|
/**
|
|
692
642
|
* Fast-forward the local `base` ref to its fetched `upstream` tip (#638 concurrent-lane
|
|
693
643
|
* recovery). The caller has verified base is a strict ancestor of upstream — a clean ff,
|
|
@@ -951,11 +901,7 @@ async function mergeRebaseFf(
|
|
|
951
901
|
if ("error" in behindResult) return head({ status: "review_requested", error: behindResult.error });
|
|
952
902
|
const behind = behindResult.behind;
|
|
953
903
|
|
|
954
|
-
|
|
955
|
-
// returns an empty gate set, even when `.agent-relay/land-gates.json` is populated. Keep it
|
|
956
|
-
// before base-ref mutation so the rest of the historical land ordering stays intact, but no
|
|
957
|
-
// gate subprocess or detached gate worktree can be launched at land time.
|
|
958
|
-
const gateRun = await runLandGatesOnIntegratedTree(repoRoot, worktreePath, behind, integrationBaseSha, headSha, landMergeMessage(branch, landedSubject));
|
|
904
|
+
const gateRun = await runLandGatesOnIntegratedTree(repoRoot, worktreePath, behind, integrationBaseSha, headSha, landMergeMessage(branch, landedSubject), signal);
|
|
959
905
|
throwIfMergeAborted(signal);
|
|
960
906
|
if ("abort" in gateRun) {
|
|
961
907
|
return gateRun.abort.conflict
|