agent-relay-orchestrator 0.117.0 → 0.118.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-orchestrator",
3
- "version": "0.117.0",
3
+ "version": "0.118.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.99",
20
+ "agent-relay-sdk": "0.2.100",
21
21
  "callmux": "0.23.0"
22
22
  },
23
23
  "devDependencies": {
@@ -0,0 +1,127 @@
1
+ import { resolve } from "node:path";
2
+ import { errMessage } from "agent-relay-sdk";
3
+ import type { LandGate, LandGateRunResult } from "agent-relay-sdk";
4
+ import { loadRepoLandGates } from "agent-relay-sdk/land-gates";
5
+
6
+ // #902 — execute a repo's configured land gates against the candidate merged tree
7
+ // inside the landing worktree, BEFORE the ref advance in `mergeRebaseFf`. A repo
8
+ // with no `.agent-relay/land-gates.json` loads zero gates, so the caller takes the
9
+ // exact unchanged land path (ironclad opt-in / zero regression). A required gate
10
+ // failing blocks the land (ref NOT advanced); optional gates only warn.
11
+
12
+ /** Per-gate default timeout when the gate didn't declare `timeoutMs`. Land gates
13
+ * are meant to be a FAST high-signal subset, not the full suite — keep the ceiling
14
+ * generous but bounded so a hung gate can't wedge the per-repo merge lease forever. */
15
+ const DEFAULT_GATE_TIMEOUT_MS = 5 * 60 * 1000;
16
+ /** Cap on the full output streamed to the relay artifact (the notification only ever
17
+ * carries the tail). Errors usually surface at the END, so we keep the tail on overflow. */
18
+ const MAX_FULL_OUTPUT_BYTES = 256 * 1024;
19
+ /** Bytes of the tail put in the notification body — enough to show the failure, small
20
+ * enough never to flood relay. */
21
+ const TAIL_BYTES = 4000;
22
+
23
+ function keepTail(text: string, maxBytes: number, label: string): string {
24
+ if (text.length <= maxBytes) return text;
25
+ const dropped = text.length - maxBytes;
26
+ return `…(${dropped} earlier ${label} truncated)…\n${text.slice(text.length - maxBytes)}`;
27
+ }
28
+
29
+ function combineOutput(stdout: string, stderr: string): string {
30
+ if (stdout && stderr) return `${stdout}\n${stderr}`;
31
+ return stdout || stderr;
32
+ }
33
+
34
+ /** Run a single gate and capture its outcome. Never throws — a spawn failure (e.g.
35
+ * the command can't launch) is reported as a non-passing result so the caller can
36
+ * decide block-vs-warn from the gate's `optional` flag. */
37
+ export function runOneLandGate(worktreePath: string, gate: LandGate): LandGateRunResult {
38
+ const cwd = gate.cwd ? resolve(worktreePath, gate.cwd) : worktreePath;
39
+ const timeoutMs = gate.timeoutMs ?? DEFAULT_GATE_TIMEOUT_MS;
40
+ const started = Date.now();
41
+ const base = { name: gate.name, command: gate.command, optional: gate.optional === true } as const;
42
+
43
+ let proc: ReturnType<typeof Bun.spawnSync>;
44
+ try {
45
+ // A login shell so PATH (bun, node, project bins) resolves like the worker's own
46
+ // environment; `env: process.env` makes runtime env mutations visible to the child.
47
+ proc = Bun.spawnSync(["bash", "-lc", gate.command], {
48
+ cwd,
49
+ env: process.env,
50
+ stdin: "ignore",
51
+ stdout: "pipe",
52
+ stderr: "pipe",
53
+ timeout: timeoutMs,
54
+ });
55
+ } catch (err) {
56
+ const durationMs = Date.now() - started;
57
+ const output = `land gate "${gate.name}" could not be launched: ${errMessage(err)}`;
58
+ return { ...base, passed: false, exitCode: null, timedOut: false, durationMs, outputTail: output, output };
59
+ }
60
+
61
+ const durationMs = Date.now() - started;
62
+ const stdout = proc.stdout ? proc.stdout.toString() : "";
63
+ const stderr = proc.stderr ? proc.stderr.toString() : "";
64
+ let combined = combineOutput(stdout, stderr);
65
+ const exitCode = typeof proc.exitCode === "number" ? proc.exitCode : null;
66
+ const timedOut = proc.exitedDueToTimeout === true;
67
+ const passed = exitCode === 0;
68
+ if (timedOut) combined = `${combined}\n[land-gate] timed out after ${timeoutMs}ms`.trimStart();
69
+ if (!combined) combined = passed ? "(gate produced no output)" : "(gate produced no output)";
70
+
71
+ return {
72
+ ...base,
73
+ passed,
74
+ exitCode,
75
+ timedOut,
76
+ durationMs,
77
+ outputTail: keepTail(combined, TAIL_BYTES, "bytes"),
78
+ output: keepTail(combined, MAX_FULL_OUTPUT_BYTES, "bytes"),
79
+ };
80
+ }
81
+
82
+ export interface LandGatesResult {
83
+ /** Number of gates configured + run (0 ⇒ no config ⇒ caller's land path is unchanged). */
84
+ ran: number;
85
+ /** First REQUIRED gate that failed — its presence means the land MUST be aborted
86
+ * without advancing the ref. */
87
+ failure?: LandGateRunResult;
88
+ /** Optional gates that failed (warn-only); the land still proceeds. */
89
+ warnings: LandGateRunResult[];
90
+ }
91
+
92
+ /**
93
+ * Load + run the repo's configured land gates against `worktreePath`. Returns
94
+ * `ran: 0` with no failure/warnings when the repo declares no gates — the load is the
95
+ * ONLY thing that runs in that case (a missing file → empty list, no subprocess), so
96
+ * the opt-in is byte-clean. Stops at the first failing REQUIRED gate (it blocks the
97
+ * land); optional failures accumulate as warnings and never block.
98
+ *
99
+ * A PRESENT-but-malformed `.agent-relay/land-gates.json` is itself a blocking failure
100
+ * (surfaced as a synthetic required gate) rather than an unhandled throw that would
101
+ * crash the merge command — the worker fixes the config and re-lands.
102
+ */
103
+ export function runLandGates(worktreePath: string): LandGatesResult {
104
+ const warnings: LandGateRunResult[] = [];
105
+ let gates: LandGate[];
106
+ try {
107
+ gates = loadRepoLandGates(worktreePath);
108
+ } catch (err) {
109
+ const output = `invalid .agent-relay/land-gates.json: ${errMessage(err)}`;
110
+ return {
111
+ ran: 1,
112
+ failure: { name: "land-gates-config", command: "(config validation)", optional: false, passed: false, exitCode: null, timedOut: false, durationMs: 0, outputTail: output, output },
113
+ warnings,
114
+ };
115
+ }
116
+ if (gates.length === 0) return { ran: 0, warnings };
117
+
118
+ for (const gate of gates) {
119
+ const result = runOneLandGate(worktreePath, gate);
120
+ if (result.passed) continue;
121
+ if (result.optional) { warnings.push(result); continue; }
122
+ // First required failure blocks the land — don't run the rest (the worker fixes
123
+ // this gate and re-lands, which re-runs from the top).
124
+ return { ran: gates.length, failure: result, warnings };
125
+ }
126
+ return { ran: gates.length, warnings };
127
+ }
@@ -1,9 +1,11 @@
1
- import { existsSync } from "node:fs";
2
- import { resolve } from "node:path";
1
+ import { existsSync, mkdtempSync, rmSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join, resolve } from "node:path";
3
4
  import type { BaseWorktreeSyncResult, WorkspaceMergePreview, WorkspaceMergeResult } from "agent-relay-sdk";
4
5
  import { git, gitRaw } from "../git";
5
6
  import { prMergedState } from "../workspace-pr";
6
7
  import { refreshWorkspaceDeps } from "./deps";
8
+ import { type LandGatesResult, runLandGates } from "./land-gates-runner";
7
9
  import { populateMergeState, resolveBranchRef, syncBaseFromOrigin, upstreamRef, workspaceGitState } from "./git-state";
8
10
  import { nextBranchName } from "./names";
9
11
  import { parseWorktrees, shortBranch } from "./parse";
@@ -523,9 +525,14 @@ function landMergeMessage(branch: string | undefined, subject: string | undefine
523
525
  // with plumbing: compute the merged tree, commit it with both parents (base first, so
524
526
  // `--first-parent` still reads as base's mainline), then advance the ref with a CAS on
525
527
  // the old value. Preserves the branch's commit SHAs without a working tree.
526
- function recordNoFfMerge(
528
+ // Synthesize (but do NOT advance any ref to) the no-ff merge commit of `branchSha` into
529
+ // `baseSha`: compute the merged tree with `merge-tree`, then commit it with both parents
530
+ // (base first, so `--first-parent` reads as base's mainline). This is the exact commit/tree
531
+ // that will become base's new tip — used both to gate the TRUE integrated tree before any ref
532
+ // moves (#902/#903) and, via {@link recordNoFfMerge}, to advance base when no working tree is
533
+ // available. Pure object creation: no ref is touched, so a caller can throw it away freely.
534
+ function synthesizeNoFfMerge(
527
535
  repoRoot: string,
528
- base: string,
529
536
  baseSha: string,
530
537
  branchSha: string,
531
538
  message: string,
@@ -539,10 +546,65 @@ function recordNoFfMerge(
539
546
  repoRoot,
540
547
  );
541
548
  if (!commit.ok || !commit.stdout) return { ok: false, error: commit.stderr || "commit-tree failed" };
542
- const mergeSha = commit.stdout;
543
- const update = git(["update-ref", `refs/heads/${base}`, mergeSha, baseSha], repoRoot);
549
+ return { ok: true, mergeSha: commit.stdout };
550
+ }
551
+
552
+ function recordNoFfMerge(
553
+ repoRoot: string,
554
+ base: string,
555
+ baseSha: string,
556
+ branchSha: string,
557
+ message: string,
558
+ ): { ok: true; mergeSha: string } | { ok: false; conflict?: boolean; error: string } {
559
+ const synth = synthesizeNoFfMerge(repoRoot, baseSha, branchSha, message);
560
+ if (!synth.ok) return synth;
561
+ const update = git(["update-ref", `refs/heads/${base}`, synth.mergeSha, baseSha], repoRoot);
544
562
  if (!update.ok) return { ok: false, error: update.stderr || "failed to advance base ref" };
545
- return { ok: true, mergeSha };
563
+ return { ok: true, mergeSha: synth.mergeSha };
564
+ }
565
+
566
+ /**
567
+ * Run the repo's configured land gates against the EXACT tree that will become base's new tip,
568
+ * BEFORE any ref advance (#902 BLOCKING 1 / closes #903). The candidate tree depends on `behind`:
569
+ * - behind === 0: the landing worktree HEAD already IS that tree (a clean fast-forward), so gates
570
+ * run in `worktreePath` in place — byte-identical to today's straight-FF path.
571
+ * - behind > 0: base has advanced, so the tree that will land is the no-ff MERGE of
572
+ * `integrationBaseSha` and `headSha`, NOT the worktree HEAD. Synthesize that merge commit with
573
+ * plumbing (no ref moved), check it out in a throwaway DETACHED worktree, and gate THAT tree.
574
+ * The temp worktree is torn down on EVERY exit path (including a gate that throws), so a land
575
+ * can never strand an orphan worktree or leave the synthesized merge half-applied.
576
+ * Returns the gate outcome, or an `abort` describing a merge result the caller must return early
577
+ * (a real merge conflict computing the integrated tree, or a failure materializing it).
578
+ */
579
+ function runLandGatesOnIntegratedTree(
580
+ repoRoot: string,
581
+ worktreePath: string,
582
+ behind: number,
583
+ integrationBaseSha: string,
584
+ headSha: string,
585
+ mergeMessage: string,
586
+ ): { gates: LandGatesResult } | { abort: { conflict?: boolean; error: string } } {
587
+ if (behind === 0) return { gates: runLandGates(worktreePath) };
588
+
589
+ const synth = synthesizeNoFfMerge(repoRoot, integrationBaseSha, headSha, mergeMessage);
590
+ if (!synth.ok) return { abort: { conflict: synth.conflict, error: synth.error } };
591
+
592
+ // Detached worktree in an isolated temp dir so the gates see the integrated tree on disk
593
+ // without disturbing the landing worktree, the base checkout, or any ref. `git worktree add`
594
+ // creates the leaf, so hand it a not-yet-existing path under a freshly made parent dir.
595
+ const tmpParent = mkdtempSync(join(tmpdir(), "agent-relay-landgate-"));
596
+ const tmpWorktree = join(tmpParent, "tree");
597
+ const add = git(["worktree", "add", "--detach", tmpWorktree, synth.mergeSha], repoRoot);
598
+ if (!add.ok) {
599
+ rmSync(tmpParent, { recursive: true, force: true });
600
+ return { abort: { error: add.stderr || "failed to materialize integrated tree for land gates" } };
601
+ }
602
+ try {
603
+ return { gates: runLandGates(tmpWorktree) };
604
+ } finally {
605
+ git(["worktree", "remove", "--force", tmpWorktree], repoRoot);
606
+ rmSync(tmpParent, { recursive: true, force: true });
607
+ }
546
608
  }
547
609
 
548
610
  /**
@@ -615,6 +677,24 @@ function mergeRebaseFf(
615
677
  const slash = upstream ? upstream.indexOf("/") : -1;
616
678
  const remote = slash > 0 ? upstream!.slice(0, slash) : undefined; // remote of a `remote/branch` upstream
617
679
  const pushEnabled = input.push !== false && workspacePushEnabled() && Boolean(remote);
680
+
681
+ // SHA preservation (#287): never rebase the agent branch — rewriting its commits
682
+ // gives them new SHAs and breaks traceability (the branch.landed SHA must exist on
683
+ // base verbatim). headSha is the preserved landed commit; when base has advanced we
684
+ // tie the branch in with a no-ff merge so the agent's commits keep their identity.
685
+ const headSha = git(["rev-parse", "HEAD"], worktreePath).stdout;
686
+ // Subject of the landed commit for the relay's branch.landed notice (#239). Best-effort:
687
+ // an empty/failed read just omits it from the message body.
688
+ const landedSubject = git(["log", "-1", "--format=%s", headSha], worktreePath).stdout || undefined;
689
+
690
+ // #902 BLOCKING 2 — NO mutation of `refs/heads/<base>` may happen until gates pass, so resolve
691
+ // the SHA the work will integrate onto WITHOUT moving the ref. Origin-ahead is the common
692
+ // concurrent case (#638): we still fetch fresh origin to compute the real merge, but we defer
693
+ // the local-base sync to AFTER the gates. On a gate failure `refs/heads/<base>` must be byte-
694
+ // identical to before the land attempt — so the only `refs/heads/<base>` mutation is the
695
+ // sync+advance below, all of it gated.
696
+ let integrationBaseSha = git(["rev-parse", base], repoRoot).stdout;
697
+ let needSync = false;
618
698
  if (upstream && remote && pushEnabled) {
619
699
  git(["fetch", remote, base], worktreePath); // best-effort freshness; a stale ref can only under-detect divergence
620
700
  if (!git(["merge-base", "--is-ancestor", upstream, base], worktreePath).ok) {
@@ -623,21 +703,47 @@ function mergeRebaseFf(
623
703
  if (!git(["merge-base", "--is-ancestor", base, upstream], worktreePath).ok) {
624
704
  return head({ status: "review_requested", error: `local ${base} has diverged from ${upstream} (commits not on origin); sync before landing` });
625
705
  }
626
- const synced = syncLocalBaseToUpstream(repoRoot, worktreePath, base, upstream);
627
- if (!synced.ok) return head({ status: "review_requested", error: synced.error });
628
- baseSync = mergeBaseSyncResults(baseSync, synced.baseSync);
706
+ const upstreamSha = git(["rev-parse", "--verify", upstream], worktreePath).stdout;
707
+ if (!upstreamSha) return head({ status: "review_requested", error: `cannot resolve ${upstream} to sync ${base}` });
708
+ // Integrate onto fresh origin (the tree that will land), but DON'T advance local base yet.
709
+ integrationBaseSha = upstreamSha;
710
+ needSync = true;
629
711
  }
630
712
  }
631
713
 
632
- // SHA preservation (#287): never rebase the agent branch rewriting its commits
633
- // gives them new SHAs and breaks traceability (the branch.landed SHA must exist on
634
- // base verbatim). headSha is the preserved landed commit; when base has advanced we
635
- // tie the branch in with a no-ff merge so the agent's commits keep their identity.
636
- const headSha = git(["rev-parse", "HEAD"], worktreePath).stdout;
637
- // Subject of the landed commit for the relay's branch.landed notice (#239). Best-effort:
638
- // an empty/failed read just omits it from the message body.
639
- const landedSubject = git(["log", "-1", "--format=%s", headSha], worktreePath).stdout || undefined;
640
- const behind = countBehind(worktreePath, base);
714
+ // Behind relative to the TRUE integration base (origin-ahead behind>0 a real no-ff merge).
715
+ const behind = countBehind(worktreePath, integrationBaseSha);
716
+
717
+ // #902 BLOCKING 1 / closes #903 run the repo's configured land gates against the EXACT tree
718
+ // that will become base's new tip, BEFORE advancing the ref. behind===0 → the worktree HEAD IS
719
+ // that tree (clean fast-forward); behind>0 the synthesized no-ff merge of integrationBaseSha +
720
+ // HEAD, gated in a throwaway worktree (see runLandGatesOnIntegratedTree). A repo with no
721
+ // `.agent-relay/land-gates.json` runs ZERO gates (missing-file empty list, no subprocess), so
722
+ // behind===0 is byte-identical to the prior land path (ironclad opt-in / zero regression). A
723
+ // failing REQUIRED gate aborts the land here WITHOUT advancing/syncing the ref — returned as
724
+ // status:"review_requested" carrying `gateFailure`, NOT "conflict": gate failures bounce back to
725
+ // the worker, never the merge-conflict/steward path. Optional-gate failures ride along as
726
+ // `gateWarnings` on the successful land below.
727
+ const gateRun = runLandGatesOnIntegratedTree(repoRoot, worktreePath, behind, integrationBaseSha, headSha, landMergeMessage(branch, landedSubject));
728
+ if ("abort" in gateRun) {
729
+ return gateRun.abort.conflict
730
+ ? head({ conflict: true, status: "conflict", error: gateRun.abort.error })
731
+ : head({ status: "review_requested", error: gateRun.abort.error });
732
+ }
733
+ const gates = gateRun.gates;
734
+ if (gates.failure) {
735
+ return head({ status: "review_requested", gateFailure: gates.failure, error: `land gate failed: ${gates.failure.name}` });
736
+ }
737
+ const gateWarningsField = gates.warnings.length ? { gateWarnings: gates.warnings } : {};
738
+
739
+ // Gates passed — only NOW is it safe to touch `refs/heads/<base>`. If origin moved ahead, sync
740
+ // local base up to the fetched upstream first (#638); this is the FIRST mutation of the base ref
741
+ // in the whole land path, so a gate failure above can never leave it advanced (#902 BLOCKING 2).
742
+ if (needSync) {
743
+ const synced = syncLocalBaseToUpstream(repoRoot, worktreePath, base, upstream!);
744
+ if (!synced.ok) return head({ status: "review_requested", error: synced.error });
745
+ baseSync = mergeBaseSyncResults(baseSync, synced.baseSync);
746
+ }
641
747
 
642
748
  // Advance base. `baseTip` is base's new tip after the land: it equals headSha on a
643
749
  // clean fast-forward, or the merge commit on a no-ff merge. Operate IN the base worktree
@@ -716,13 +822,13 @@ function mergeRebaseFf(
716
822
  // continues from a buildable state (issue #51). No-op when nothing is stale.
717
823
  const depsRefresh = refreshWorkspaceDeps(repoRoot, worktreePath);
718
824
  const reportDeps = depsRefresh.refreshed || depsRefresh.stale || depsRefresh.error;
719
- return head({ merged: true, status: "active", mergedSha: headSha, baseSha: baseTip, subject: landedSubject, worktreeRemoved: false, branch: fresh, newBranch: fresh, branchDeleted: oldDeleted, pushed, ...(reportDeps ? { depsRefresh } : {}), ...baseWorktreeSyncField, error: undefined });
825
+ return head({ merged: true, status: "active", mergedSha: headSha, baseSha: baseTip, subject: landedSubject, worktreeRemoved: false, branch: fresh, newBranch: fresh, branchDeleted: oldDeleted, pushed, ...(reportDeps ? { depsRefresh } : {}), ...baseWorktreeSyncField, ...gateWarningsField, error: undefined });
720
826
  }
721
827
  // Recycle failed — keep the existing branch. Still landed, still active.
722
- return head({ merged: true, status: "active", mergedSha: headSha, baseSha: baseTip, subject: landedSubject, worktreeRemoved: false, branchDeleted: false, pushed, ...baseWorktreeSyncField, error: undefined });
828
+ return head({ merged: true, status: "active", mergedSha: headSha, baseSha: baseTip, subject: landedSubject, worktreeRemoved: false, branchDeleted: false, pushed, ...baseWorktreeSyncField, ...gateWarningsField, error: undefined });
723
829
  }
724
830
  const removed = git(["worktree", "remove", "--force", worktreePath], repoRoot);
725
831
  const worktreeRemoved = removed.ok;
726
832
  const branchDeleted = worktreeRemoved ? git(["branch", "-D", branch], repoRoot).ok : false;
727
- return head({ merged: true, status: "merged", mergedSha: headSha, baseSha: baseTip, subject: landedSubject, worktreeRemoved, branchDeleted, pushed, ...baseWorktreeSyncField, error: undefined });
833
+ return head({ merged: true, status: "merged", mergedSha: headSha, baseSha: baseTip, subject: landedSubject, worktreeRemoved, branchDeleted, pushed, ...baseWorktreeSyncField, ...gateWarningsField, error: undefined });
728
834
  }