agent-relay-orchestrator 0.127.6 → 0.127.8

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.6",
3
+ "version": "0.127.8",
4
4
  "description": "Agent Relay orchestrator — manages agent lifecycle across hosts",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- return `${config.tmuxPrefix}-${provider}-${clean}${suffix}`;
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 {
@@ -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,108 @@
1
+ import { mkdtempSync, rmSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { errMessage } from "agent-relay-sdk";
5
+ import { git } from "../git";
6
+ import { runLandGates, type LandGatesResult } from "./land-gates-runner";
7
+ import { mergePhaseTimeoutMs, withMergePhaseTimeout } from "./merge-timeouts";
8
+
9
+ const LAND_COMMITTER = { name: "Agent Relay", email: "agent-relay@noreply" } as const;
10
+
11
+ // Synthesize, but do not advance any ref to, the no-ff merge commit of branchSha
12
+ // into baseSha. The resulting commit/tree is the exact integrated tree that will
13
+ // land, so callers can gate it before mutating base.
14
+ export async function synthesizeNoFfMerge(
15
+ repoRoot: string,
16
+ baseSha: string,
17
+ branchSha: string,
18
+ message: string,
19
+ timeoutMs?: number,
20
+ signal?: AbortSignal,
21
+ ): Promise<{ ok: true; mergeSha: string } | { ok: false; conflict?: boolean; error: string }> {
22
+ const tree = await git(["merge-tree", "--write-tree", baseSha, branchSha], repoRoot, { timeoutMs, timeoutLabel: "workspace merge synthesize merge-tree", signal });
23
+ if (!tree.ok) return { ok: false, conflict: true, error: tree.stdout || tree.stderr || "merge conflict computing tree" };
24
+ const treeOid = tree.stdout.split("\n")[0]?.trim();
25
+ if (!treeOid) return { ok: false, error: "merge-tree produced no tree oid" };
26
+ const commit = await git(
27
+ ["-c", "user.name=" + LAND_COMMITTER.name, "-c", "user.email=" + LAND_COMMITTER.email, "commit-tree", treeOid, "-p", baseSha, "-p", branchSha, "-m", message],
28
+ repoRoot,
29
+ { timeoutMs, timeoutLabel: "workspace merge synthesize commit-tree", signal },
30
+ );
31
+ if (!commit.ok || !commit.stdout) return { ok: false, error: commit.stderr || "commit-tree failed" };
32
+ return { ok: true, mergeSha: commit.stdout };
33
+ }
34
+
35
+ /**
36
+ * Run land gates against the exact tree that would land.
37
+ *
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.
41
+ */
42
+ export async function runLandGatesOnIntegratedTree(
43
+ repoRoot: string,
44
+ worktreePath: string,
45
+ behind: number,
46
+ integrationBaseSha: string,
47
+ headSha: string,
48
+ mergeMessage: string,
49
+ signal?: AbortSignal,
50
+ ): Promise<{ gates: LandGatesResult } | { abort: { conflict?: boolean; error: string } }> {
51
+ 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
+
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) } };
65
+ }
66
+ if (!synth.ok) return { abort: { conflict: synth.conflict, error: synth.error } };
67
+
68
+ const tmpParent = mkdtempSync(join(tmpdir(), "agent-relay-landgate-"));
69
+ const tmpWorktree = join(tmpParent, "tree");
70
+ let add: Awaited<ReturnType<typeof git>>;
71
+ try {
72
+ add = await withMergePhaseTimeout(
73
+ "worktree-add",
74
+ (phaseSignal) => git(
75
+ ["worktree", "add", "--detach", tmpWorktree, synth.mergeSha],
76
+ repoRoot,
77
+ { timeoutMs: mergePhaseTimeoutMs("worktree-add"), timeoutLabel: "workspace merge land-gate worktree add", signal: phaseSignal },
78
+ ),
79
+ { signal },
80
+ );
81
+ } catch (err) {
82
+ rmSync(tmpParent, { recursive: true, force: true });
83
+ return { abort: { error: errMessage(err) } };
84
+ }
85
+ if (!add.ok) {
86
+ rmSync(tmpParent, { recursive: true, force: true });
87
+ return { abort: { error: add.stderr || "failed to materialize integrated tree for land gates" } };
88
+ }
89
+
90
+ try {
91
+ return { gates: await withMergePhaseTimeout("gates", () => runLandGates(tmpWorktree), { signal }) };
92
+ } finally {
93
+ try {
94
+ await withMergePhaseTimeout(
95
+ "cleanup",
96
+ (phaseSignal) => git(
97
+ ["worktree", "remove", "--force", tmpWorktree],
98
+ repoRoot,
99
+ { timeoutMs: mergePhaseTimeoutMs("cleanup"), timeoutLabel: "workspace merge land-gate worktree cleanup", signal: phaseSignal },
100
+ ),
101
+ { signal },
102
+ );
103
+ } catch (err) {
104
+ console.error("[orchestrator] land-gate integrated worktree cleanup timed out/failed: " + errMessage(err));
105
+ }
106
+ rmSync(tmpParent, { recursive: true, force: true });
107
+ }
108
+ }
@@ -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
- // #923 land gates are decommissioned. This call is now a hard kill switch that always
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