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
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,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
|
-
|
|
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
|