agent-relay-orchestrator 0.127.8 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-orchestrator",
3
- "version": "0.127.8",
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.121",
20
+ "agent-relay-sdk": "0.2.122",
21
21
  "callmux": "0.23.0"
22
22
  },
23
23
  "devDependencies": {
@@ -1,12 +1,28 @@
1
- import { mkdtempSync, rmSync } from "node:fs";
1
+ import { existsSync, lstatSync, mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { errMessage } from "agent-relay-sdk";
5
+ import type { LandGate } from "agent-relay-sdk";
5
6
  import { git } from "../git";
6
7
  import { runLandGates, type LandGatesResult } from "./land-gates-runner";
7
8
  import { mergePhaseTimeoutMs, withMergePhaseTimeout } from "./merge-timeouts";
8
9
 
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;
10
26
 
11
27
  // Synthesize, but do not advance any ref to, the no-ff merge commit of branchSha
12
28
  // into baseSha. The resulting commit/tree is the exact integrated tree that will
@@ -32,12 +48,40 @@ export async function synthesizeNoFfMerge(
32
48
  return { ok: true, mergeSha: commit.stdout };
33
49
  }
34
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
+
35
78
  /**
36
79
  * Run land gates against the exact tree that would land.
37
80
  *
38
- * - behind === 0: the landing worktree HEAD already is that tree, so run in place.
39
- * - behind > 0: synthesize the no-ff merge commit without moving a ref, materialize
40
- * it in a detached temp worktree, and gate that integrated tree.
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.
41
85
  */
42
86
  export async function runLandGatesOnIntegratedTree(
43
87
  repoRoot: string,
@@ -49,30 +93,32 @@ export async function runLandGatesOnIntegratedTree(
49
93
  signal?: AbortSignal,
50
94
  ): Promise<{ gates: LandGatesResult } | { abort: { conflict?: boolean; error: string } }> {
51
95
  console.error("[orchestrator] workspace.merge gate-start worktree=" + worktreePath + " behind=" + behind);
52
- if (behind === 0) {
53
- return { gates: await withMergePhaseTimeout("gates", () => runLandGates(worktreePath), { signal }) };
54
- }
55
96
 
56
- let synth: Awaited<ReturnType<typeof synthesizeNoFfMerge>>;
57
- try {
58
- synth = await withMergePhaseTimeout(
59
- "synthesize",
60
- (phaseSignal) => synthesizeNoFfMerge(repoRoot, integrationBaseSha, headSha, mergeMessage, mergePhaseTimeoutMs("synthesize"), phaseSignal),
61
- { signal },
62
- );
63
- } catch (err) {
64
- return { abort: { error: errMessage(err) } };
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;
65
112
  }
66
- if (!synth.ok) return { abort: { conflict: synth.conflict, error: synth.error } };
67
113
 
68
114
  const tmpParent = mkdtempSync(join(tmpdir(), "agent-relay-landgate-"));
69
- const tmpWorktree = join(tmpParent, "tree");
115
+ const tmpWorktree = join(tmpParent, "checkout");
70
116
  let add: Awaited<ReturnType<typeof git>>;
71
117
  try {
72
118
  add = await withMergePhaseTimeout(
73
119
  "worktree-add",
74
120
  (phaseSignal) => git(
75
- ["worktree", "add", "--detach", tmpWorktree, synth.mergeSha],
121
+ ["worktree", "add", "--detach", tmpWorktree, gateRef],
76
122
  repoRoot,
77
123
  { timeoutMs: mergePhaseTimeoutMs("worktree-add"), timeoutLabel: "workspace merge land-gate worktree add", signal: phaseSignal },
78
124
  ),
@@ -88,7 +134,8 @@ export async function runLandGatesOnIntegratedTree(
88
134
  }
89
135
 
90
136
  try {
91
- return { gates: await withMergePhaseTimeout("gates", () => runLandGates(tmpWorktree), { signal }) };
137
+ linkDependencyDirs(worktreePath, tmpWorktree);
138
+ return { gates: await withMergePhaseTimeout("gates", () => runLandGates(tmpWorktree, requiredGates), { signal }) };
92
139
  } finally {
93
140
  try {
94
141
  await withMergePhaseTimeout(
@@ -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 a repo's configured land gates against the candidate merged tree
7
- // inside the landing worktree, BEFORE the ref advance in `mergeRebaseFf`. A repo
8
- // with no `.agent-relay/land-gates.json` loads zero gates, so the caller takes the
9
- // exact unchanged land path (ironclad opt-in / zero regression). A required gate
10
- // failing blocks the land (ref NOT advanced); optional gates only warn.
11
-
12
- /** Per-gate default timeout when the gate didn't declare `timeoutMs`. Land gates
13
- * are meant to be a FAST high-signal subset, not the full suite — keep the ceiling
14
- * generous but bounded so a hung gate can't wedge the per-repo merge lease forever. */
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`. Returns
193
- * `ran: 0` with no failure/warnings when the repo declares no gates — the load is the
194
- * ONLY thing that runs in that case (a missing file empty list, no subprocess), so
195
- * the opt-in is byte-clean. Stops at the first failing REQUIRED gate (it blocks the
196
- * land); optional failures accumulate as warnings and never block.
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: 10 * 60_000,
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: 5 * 60_000,
9
+ gates: 35 * 60_000,
10
10
  synthesize: 60_000,
11
11
  "worktree-add": 60_000,
12
12
  cleanup: 30_000,