agent-relay-orchestrator 0.117.1 → 0.118.1
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/command-poller.ts +44 -1
- package/src/control.ts +25 -11
- package/src/index.ts +4 -6
- package/src/spawn/acquisition.ts +202 -0
- package/src/spawn/index.ts +1 -0
- package/src/spawn/spawn-agent.ts +7 -3
- package/src/spawn/types.ts +12 -0
- package/src/workspace-probe/land-gates-runner.ts +226 -0
- package/src/workspace-probe/merge.ts +132 -26
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-orchestrator",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.118.1",
|
|
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.
|
|
20
|
+
"agent-relay-sdk": "0.2.101",
|
|
21
21
|
"callmux": "0.23.0"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
package/src/command-poller.ts
CHANGED
|
@@ -8,14 +8,20 @@ interface CommandPollerOptions {
|
|
|
8
8
|
relay: Pick<RelayClient, "connected" | "pollCommands">;
|
|
9
9
|
control: CommandPollerControl;
|
|
10
10
|
log?: (message: string) => void;
|
|
11
|
+
intervalMs?: number;
|
|
12
|
+
errorBackoffMs?: number;
|
|
11
13
|
}
|
|
12
14
|
|
|
13
|
-
export function createCommandPoller({ relay, control, log = console.error }: CommandPollerOptions) {
|
|
15
|
+
export function createCommandPoller({ relay, control, log = console.error, intervalMs = 3_000, errorBackoffMs = 3_000 }: CommandPollerOptions) {
|
|
14
16
|
let inFlight = false;
|
|
17
|
+
let stopped = true;
|
|
18
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
19
|
+
let lastTickErrored = false;
|
|
15
20
|
|
|
16
21
|
async function tick(): Promise<boolean> {
|
|
17
22
|
if (!relay.connected || inFlight) return false;
|
|
18
23
|
inFlight = true;
|
|
24
|
+
lastTickErrored = false;
|
|
19
25
|
try {
|
|
20
26
|
const commands = await relay.pollCommands();
|
|
21
27
|
if (commands.length > 0) {
|
|
@@ -27,6 +33,7 @@ export function createCommandPoller({ relay, control, log = console.error }: Com
|
|
|
27
33
|
}
|
|
28
34
|
return true;
|
|
29
35
|
} catch (err) {
|
|
36
|
+
lastTickErrored = true;
|
|
30
37
|
log(`[orchestrator] Poll error: ${err}`);
|
|
31
38
|
return false;
|
|
32
39
|
} finally {
|
|
@@ -34,8 +41,44 @@ export function createCommandPoller({ relay, control, log = console.error }: Com
|
|
|
34
41
|
}
|
|
35
42
|
}
|
|
36
43
|
|
|
44
|
+
function schedule(delayMs: number): void {
|
|
45
|
+
if (stopped) return;
|
|
46
|
+
timer = setTimeout(() => {
|
|
47
|
+
timer = undefined;
|
|
48
|
+
void runCycle();
|
|
49
|
+
}, delayMs);
|
|
50
|
+
timer.unref?.();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function runCycle(): Promise<void> {
|
|
54
|
+
let errored = false;
|
|
55
|
+
try {
|
|
56
|
+
await tick();
|
|
57
|
+
errored = lastTickErrored;
|
|
58
|
+
} catch (err) {
|
|
59
|
+
errored = true;
|
|
60
|
+
log(`[orchestrator] Poll loop error: ${err}`);
|
|
61
|
+
} finally {
|
|
62
|
+
if (!stopped) schedule(errored ? errorBackoffMs : intervalMs);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function start(): void {
|
|
67
|
+
if (!stopped) return;
|
|
68
|
+
stopped = false;
|
|
69
|
+
schedule(0);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function stop(): void {
|
|
73
|
+
stopped = true;
|
|
74
|
+
if (timer) clearTimeout(timer);
|
|
75
|
+
timer = undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
37
78
|
return {
|
|
38
79
|
tick,
|
|
80
|
+
start,
|
|
81
|
+
stop,
|
|
39
82
|
get inFlight() {
|
|
40
83
|
return inFlight;
|
|
41
84
|
},
|
package/src/control.ts
CHANGED
|
@@ -33,16 +33,10 @@ export function createControlHandler(
|
|
|
33
33
|
|
|
34
34
|
async function handleSpawn(ctrl: Record<string, any>): Promise<boolean> {
|
|
35
35
|
const opts = spawnOptionsFromControl(ctrl, config);
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
console.error(`[orchestrator] Spawned ${opts.provider} agent: ${agent.tmuxSession}`);
|
|
41
|
-
return true;
|
|
42
|
-
} catch (err) {
|
|
43
|
-
console.error(`[orchestrator] Spawn failed: ${err}`);
|
|
44
|
-
return false;
|
|
45
|
-
}
|
|
36
|
+
const agent = await spawnAgent(opts, config);
|
|
37
|
+
managedAgents.push(agent);
|
|
38
|
+
console.error(`[orchestrator] Spawned ${opts.provider} agent: ${agent.tmuxSession}`);
|
|
39
|
+
return true;
|
|
46
40
|
}
|
|
47
41
|
|
|
48
42
|
async function handleShutdown(ctrl: Record<string, any>, restart = false): Promise<Record<string, unknown>> {
|
|
@@ -124,7 +118,7 @@ export function createControlHandler(
|
|
|
124
118
|
});
|
|
125
119
|
await relay.updateCommand(command.id, "succeeded", result);
|
|
126
120
|
} else if (command.type === "workspace.merge") {
|
|
127
|
-
const result = mergeWorkspace({
|
|
121
|
+
const result = await mergeWorkspace({
|
|
128
122
|
id: typeof command.params.workspaceId === "string" ? command.params.workspaceId : undefined,
|
|
129
123
|
repoRoot: typeof command.params.repoRoot === "string" ? command.params.repoRoot : undefined,
|
|
130
124
|
worktreePath: typeof command.params.worktreePath === "string" ? command.params.worktreePath : undefined,
|
|
@@ -297,6 +291,7 @@ function spawnOptionsFromRecord(source: Record<string, any>, config: Orchestrato
|
|
|
297
291
|
automationRunId: typeof source.automationRunId === "string" ? source.automationRunId : undefined,
|
|
298
292
|
requestedVia: typeof source.requestedVia === "string" ? source.requestedVia : undefined,
|
|
299
293
|
resumeWorkspace: parseResumeWorkspace(source.resumeWorkspace),
|
|
294
|
+
acquisition: parseProjectAcquisition(source.acquisition),
|
|
300
295
|
};
|
|
301
296
|
}
|
|
302
297
|
|
|
@@ -331,3 +326,22 @@ function parseResumeWorkspace(value: unknown): import("./workspace-probe/types")
|
|
|
331
326
|
baseSha: typeof value.baseSha === "string" ? value.baseSha : undefined,
|
|
332
327
|
};
|
|
333
328
|
}
|
|
329
|
+
|
|
330
|
+
function parseProjectAcquisition(value: unknown): import("./spawn/types").ProjectAcquisitionManifest | undefined {
|
|
331
|
+
if (!isRecord(value)) return undefined;
|
|
332
|
+
if (value.mode !== "project-root" || value.sync !== "ff-only") return undefined;
|
|
333
|
+
const projectId = typeof value.projectId === "string" ? value.projectId : undefined;
|
|
334
|
+
const rootPath = typeof value.rootPath === "string" ? value.rootPath : undefined;
|
|
335
|
+
const cwd = typeof value.cwd === "string" ? value.cwd : undefined;
|
|
336
|
+
const remoteUrl = typeof value.remoteUrl === "string" ? value.remoteUrl : undefined;
|
|
337
|
+
if (!projectId || !rootPath || !cwd || !remoteUrl) return undefined;
|
|
338
|
+
return {
|
|
339
|
+
mode: "project-root",
|
|
340
|
+
projectId,
|
|
341
|
+
rootPath,
|
|
342
|
+
cwd,
|
|
343
|
+
remoteUrl,
|
|
344
|
+
ref: typeof value.ref === "string" ? value.ref : undefined,
|
|
345
|
+
sync: "ff-only",
|
|
346
|
+
};
|
|
347
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -60,7 +60,7 @@ const sharedCallmux = new SharedCallmuxSupervisor(config);
|
|
|
60
60
|
const POLL_INTERVAL_MS = 3_000;
|
|
61
61
|
const REGISTER_RETRY_MS = 5_000;
|
|
62
62
|
const GUEST_REAP_INTERVAL_MS = 60_000;
|
|
63
|
-
let
|
|
63
|
+
let commandPoller: ReturnType<typeof createCommandPoller> | null = null;
|
|
64
64
|
let healthCheckTimer: Timer | null = null;
|
|
65
65
|
let guestReaperTimer: Timer | null = null;
|
|
66
66
|
let apiServer: { stop(): void; url: string } | null = null;
|
|
@@ -124,10 +124,8 @@ async function startup(): Promise<void> {
|
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
function startPolling(): void {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
await commandPoller.tick();
|
|
130
|
-
}, POLL_INTERVAL_MS);
|
|
127
|
+
commandPoller = createCommandPoller({ relay, control, intervalMs: POLL_INTERVAL_MS, errorBackoffMs: POLL_INTERVAL_MS });
|
|
128
|
+
commandPoller.start();
|
|
131
129
|
}
|
|
132
130
|
|
|
133
131
|
async function registerUntilConnected(): Promise<void> {
|
|
@@ -208,7 +206,7 @@ async function healthCheck(): Promise<void> {
|
|
|
208
206
|
|
|
209
207
|
async function shutdown(): Promise<void> {
|
|
210
208
|
console.error("[orchestrator] Shutting down...");
|
|
211
|
-
|
|
209
|
+
commandPoller?.stop();
|
|
212
210
|
if (healthCheckTimer) clearInterval(healthCheckTimer);
|
|
213
211
|
if (guestReaperTimer) clearInterval(guestReaperTimer);
|
|
214
212
|
if (apiServer) apiServer.stop();
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
4
|
+
import { errMessage, isPathWithinBase } from "agent-relay-sdk";
|
|
5
|
+
import { git, requireGit } from "../git";
|
|
6
|
+
import type { ProjectAcquisitionManifest } from "./types";
|
|
7
|
+
|
|
8
|
+
export interface ProjectAcquisitionResult {
|
|
9
|
+
applied: boolean;
|
|
10
|
+
action: "cloned" | "synced" | "noop";
|
|
11
|
+
rootPath: string;
|
|
12
|
+
remoteUrl: string;
|
|
13
|
+
ref?: string;
|
|
14
|
+
headSha?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const inFlight = new Map<string, Promise<ProjectAcquisitionResult>>();
|
|
18
|
+
const LOCK_TIMEOUT_MS = 5 * 60_000;
|
|
19
|
+
const LOCK_POLL_MS = 100;
|
|
20
|
+
|
|
21
|
+
export async function applyProjectAcquisitionManifest(
|
|
22
|
+
manifest: ProjectAcquisitionManifest | undefined,
|
|
23
|
+
baseDir: string,
|
|
24
|
+
): Promise<ProjectAcquisitionResult | undefined> {
|
|
25
|
+
if (!manifest) return undefined;
|
|
26
|
+
const rootPath = resolve(manifest.rootPath);
|
|
27
|
+
const prior = inFlight.get(rootPath);
|
|
28
|
+
if (prior) return prior;
|
|
29
|
+
const run = withAcquisitionLock(rootPath, baseDir, () => applyManifestLocked(manifest, baseDir));
|
|
30
|
+
inFlight.set(rootPath, run);
|
|
31
|
+
try {
|
|
32
|
+
return await run;
|
|
33
|
+
} finally {
|
|
34
|
+
if (inFlight.get(rootPath) === run) inFlight.delete(rootPath);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function withAcquisitionLock(rootPath: string, baseDir: string, fn: () => ProjectAcquisitionResult): Promise<ProjectAcquisitionResult> {
|
|
39
|
+
const lockDir = join(resolve(baseDir), ".agent-relay", "locks", `acquire-${hash(rootPath)}.lock`);
|
|
40
|
+
mkdirSync(dirname(lockDir), { recursive: true });
|
|
41
|
+
const started = Date.now();
|
|
42
|
+
for (;;) {
|
|
43
|
+
try {
|
|
44
|
+
mkdirSync(lockDir);
|
|
45
|
+
writeFileSync(join(lockDir, "owner"), `${process.pid}\n${rootPath}\n`);
|
|
46
|
+
break;
|
|
47
|
+
} catch (error) {
|
|
48
|
+
if (Date.now() - started > LOCK_TIMEOUT_MS) {
|
|
49
|
+
throw new Error(`repo acquisition lock timed out for ${rootPath}: ${errMessage(error)}`);
|
|
50
|
+
}
|
|
51
|
+
await new Promise((resolve) => setTimeout(resolve, LOCK_POLL_MS));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
return fn();
|
|
56
|
+
} finally {
|
|
57
|
+
rmSync(lockDir, { recursive: true, force: true });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function applyManifestLocked(manifest: ProjectAcquisitionManifest, baseDir: string): ProjectAcquisitionResult {
|
|
62
|
+
validateManifest(manifest, baseDir);
|
|
63
|
+
const rootPath = resolve(manifest.rootPath);
|
|
64
|
+
const remoteUrl = manifest.remoteUrl.trim();
|
|
65
|
+
let action: ProjectAcquisitionResult["action"] = "noop";
|
|
66
|
+
if (!existsSync(rootPath)) {
|
|
67
|
+
cloneRoot(manifest, baseDir);
|
|
68
|
+
action = "cloned";
|
|
69
|
+
}
|
|
70
|
+
const syncAction = syncRoot(manifest);
|
|
71
|
+
if (syncAction === "synced" && action !== "cloned") action = "synced";
|
|
72
|
+
return {
|
|
73
|
+
applied: true,
|
|
74
|
+
action,
|
|
75
|
+
rootPath,
|
|
76
|
+
remoteUrl,
|
|
77
|
+
...(manifest.ref ? { ref: manifest.ref } : {}),
|
|
78
|
+
headSha: git(["rev-parse", "HEAD"], rootPath).stdout || undefined,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function validateManifest(manifest: ProjectAcquisitionManifest, baseDir: string): void {
|
|
83
|
+
const rootPath = resolve(manifest.rootPath);
|
|
84
|
+
if (!manifest.remoteUrl.trim()) throw new Error("project acquisition remoteUrl is required");
|
|
85
|
+
if (!isPathWithinBase(rootPath, baseDir) || rootPath === resolve(baseDir)) {
|
|
86
|
+
throw new Error(`project acquisition rootPath must be within orchestrator baseDir: ${baseDir}`);
|
|
87
|
+
}
|
|
88
|
+
if (!isPathWithinBase(resolve(manifest.cwd), rootPath)) {
|
|
89
|
+
throw new Error(`project acquisition cwd must be within rootPath: ${manifest.cwd}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function cloneRoot(manifest: ProjectAcquisitionManifest, baseDir: string): void {
|
|
94
|
+
const rootPath = resolve(manifest.rootPath);
|
|
95
|
+
const parent = dirname(rootPath);
|
|
96
|
+
if (!isPathWithinBase(parent, baseDir)) throw new Error(`project acquisition parent must be within orchestrator baseDir: ${parent}`);
|
|
97
|
+
mkdirSync(parent, { recursive: true });
|
|
98
|
+
const tmp = join(parent, `.${basename(rootPath)}.agent-relay-clone-${process.pid}-${Date.now()}`);
|
|
99
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
100
|
+
const args = ["clone", "--origin", "origin"];
|
|
101
|
+
if (manifest.ref) args.push("--branch", manifest.ref);
|
|
102
|
+
args.push(manifest.remoteUrl.trim(), tmp);
|
|
103
|
+
const cloned = runGit(args, parent);
|
|
104
|
+
if (!cloned.ok) {
|
|
105
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
106
|
+
throw new Error(`git clone failed for ${rootPath}: ${cloned.stderr || cloned.stdout}`);
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
renameSync(tmp, rootPath);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
112
|
+
throw new Error(`failed to install cloned repo at ${rootPath}: ${errMessage(error)}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function syncRoot(manifest: ProjectAcquisitionManifest): "synced" | "noop" {
|
|
117
|
+
const rootPath = resolve(manifest.rootPath);
|
|
118
|
+
assertExistingGitRoot(rootPath);
|
|
119
|
+
assertRemote(rootPath, manifest.remoteUrl.trim());
|
|
120
|
+
const status = git(["status", "--porcelain"], rootPath);
|
|
121
|
+
if (!status.ok) throw new Error(`git status failed for ${rootPath}: ${status.stderr}`);
|
|
122
|
+
if (status.stdout.trim()) throw new Error(`project root ${rootPath} has local changes; refusing ff-only sync before spawn`);
|
|
123
|
+
const fetch = git(["fetch", "--prune", "origin"], rootPath);
|
|
124
|
+
if (!fetch.ok) throw new Error(`git fetch failed for ${rootPath}: ${fetch.stderr || fetch.stdout}`);
|
|
125
|
+
const target = checkoutSyncTarget(rootPath, manifest.ref);
|
|
126
|
+
if (!target) return "noop";
|
|
127
|
+
const head = requireGit(["rev-parse", "HEAD"], rootPath);
|
|
128
|
+
const targetHead = requireGit(["rev-parse", target], rootPath);
|
|
129
|
+
if (head === targetHead) return "noop";
|
|
130
|
+
if (!git(["merge-base", "--is-ancestor", "HEAD", target], rootPath).ok) {
|
|
131
|
+
throw new Error(`project root ${rootPath} has diverged from ${target}; refusing non-fast-forward sync`);
|
|
132
|
+
}
|
|
133
|
+
const merged = git(["merge", "--ff-only", target], rootPath);
|
|
134
|
+
if (!merged.ok) throw new Error(`git ff-only sync failed for ${rootPath}: ${merged.stderr || merged.stdout}`);
|
|
135
|
+
return "synced";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function assertExistingGitRoot(rootPath: string): void {
|
|
139
|
+
let stat;
|
|
140
|
+
try {
|
|
141
|
+
stat = statSync(rootPath);
|
|
142
|
+
} catch (error) {
|
|
143
|
+
throw new Error(`project root does not exist after acquisition: ${rootPath}: ${errMessage(error)}`);
|
|
144
|
+
}
|
|
145
|
+
if (!stat.isDirectory()) throw new Error(`project root exists but is not a directory: ${rootPath}`);
|
|
146
|
+
const top = git(["rev-parse", "--show-toplevel"], rootPath);
|
|
147
|
+
if (!top.ok || resolve(top.stdout) !== rootPath) throw new Error(`project root exists but is not a git checkout root: ${rootPath}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function assertRemote(rootPath: string, remoteUrl: string): void {
|
|
151
|
+
const current = git(["remote", "get-url", "origin"], rootPath);
|
|
152
|
+
if (!current.ok || !current.stdout) throw new Error(`project root ${rootPath} has no origin remote`);
|
|
153
|
+
if (current.stdout.trim() !== remoteUrl) {
|
|
154
|
+
throw new Error(`project root ${rootPath} origin mismatch: expected ${remoteUrl}, found ${current.stdout.trim()}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function checkoutSyncTarget(rootPath: string, ref: string | undefined): string | undefined {
|
|
159
|
+
if (ref?.trim()) {
|
|
160
|
+
const name = ref.trim();
|
|
161
|
+
const remoteBranch = `refs/remotes/origin/${name}`;
|
|
162
|
+
if (git(["show-ref", "--verify", "--quiet", remoteBranch], rootPath).ok) {
|
|
163
|
+
if (git(["show-ref", "--verify", "--quiet", `refs/heads/${name}`], rootPath).ok) {
|
|
164
|
+
const checked = git(["checkout", name], rootPath);
|
|
165
|
+
if (!checked.ok) throw new Error(`git checkout ${name} failed for ${rootPath}: ${checked.stderr || checked.stdout}`);
|
|
166
|
+
} else {
|
|
167
|
+
const checked = git(["checkout", "-B", name, `origin/${name}`], rootPath);
|
|
168
|
+
if (!checked.ok) throw new Error(`git checkout ${name} failed for ${rootPath}: ${checked.stderr || checked.stdout}`);
|
|
169
|
+
}
|
|
170
|
+
return `origin/${name}`;
|
|
171
|
+
}
|
|
172
|
+
if (git(["rev-parse", "--verify", name], rootPath).ok) {
|
|
173
|
+
const checked = git(["checkout", name], rootPath);
|
|
174
|
+
if (!checked.ok) throw new Error(`git checkout ${name} failed for ${rootPath}: ${checked.stderr || checked.stdout}`);
|
|
175
|
+
return undefined;
|
|
176
|
+
}
|
|
177
|
+
throw new Error(`project acquisition ref "${name}" not found on origin for ${rootPath}`);
|
|
178
|
+
}
|
|
179
|
+
const upstream = git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], rootPath);
|
|
180
|
+
if (upstream.ok && upstream.stdout) return upstream.stdout;
|
|
181
|
+
const originHead = git(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], rootPath);
|
|
182
|
+
if (originHead.ok && originHead.stdout) return originHead.stdout;
|
|
183
|
+
throw new Error(`project root ${rootPath} has no upstream or origin/HEAD for ff-only sync`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function runGit(args: string[], cwd: string): { ok: boolean; stdout: string; stderr: string } {
|
|
187
|
+
const proc = Bun.spawnSync(["git", ...args], {
|
|
188
|
+
cwd,
|
|
189
|
+
stdin: "ignore",
|
|
190
|
+
stdout: "pipe",
|
|
191
|
+
stderr: "pipe",
|
|
192
|
+
});
|
|
193
|
+
return {
|
|
194
|
+
ok: proc.exitCode === 0,
|
|
195
|
+
stdout: proc.stdout.toString().trim(),
|
|
196
|
+
stderr: proc.stderr.toString().trim(),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function hash(value: string): string {
|
|
201
|
+
return createHash("sha1").update(resolve(value)).digest("hex").slice(0, 16);
|
|
202
|
+
}
|
package/src/spawn/index.ts
CHANGED
package/src/spawn/spawn-agent.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { closeSync, existsSync, openSync, rmSync } from "node:fs";
|
|
|
2
2
|
import type { OrchestratorConfig } from "../config";
|
|
3
3
|
import { resolveSpawnWorkspace, workspacesRoot } from "../workspace-probe";
|
|
4
4
|
import type { ManagedAgentReport } from "../relay";
|
|
5
|
+
import { applyProjectAcquisitionManifest } from "./acquisition";
|
|
5
6
|
import { buildEnv, buildRunnerCommand, defaultSpawnLabel, isWithinBaseDir, sessionName } from "./command";
|
|
6
7
|
import { addSessionRecord, currentSessionPid, findSessionRecord, ensureLogDir, ensureRunnerInfoDir, logFilePath, runnerInfoPath, sessionRecordLiveness, sessionReportFields } from "./runtime";
|
|
7
8
|
import { managedAgentId } from "./sessions";
|
|
@@ -10,6 +11,7 @@ import type { SpawnOptions } from "./types";
|
|
|
10
11
|
|
|
11
12
|
interface SpawnAgentDeps {
|
|
12
13
|
resolveSpawnWorkspace: typeof resolveSpawnWorkspace;
|
|
14
|
+
applyProjectAcquisitionManifest: typeof applyProjectAcquisitionManifest;
|
|
13
15
|
spawnRunner: typeof spawnRunner;
|
|
14
16
|
addSessionRecord: typeof addSessionRecord;
|
|
15
17
|
findSessionRecord: typeof findSessionRecord;
|
|
@@ -24,6 +26,7 @@ interface SpawnAgentDeps {
|
|
|
24
26
|
|
|
25
27
|
const defaultSpawnAgentDeps: SpawnAgentDeps = {
|
|
26
28
|
resolveSpawnWorkspace,
|
|
29
|
+
applyProjectAcquisitionManifest,
|
|
27
30
|
spawnRunner,
|
|
28
31
|
addSessionRecord,
|
|
29
32
|
findSessionRecord,
|
|
@@ -46,12 +49,13 @@ export async function spawnAgent(
|
|
|
46
49
|
const agentId = opts.agentId || managedAgentId(config, opts.provider, label);
|
|
47
50
|
const name = sessionName(config, opts.provider, label, opts.spawnRequestId ?? agentId);
|
|
48
51
|
|
|
49
|
-
if (!existsSync(opts.cwd)) {
|
|
50
|
-
throw new Error(`cwd does not exist: ${opts.cwd}`);
|
|
51
|
-
}
|
|
52
52
|
if (!isWithinBaseDir(opts.cwd, config.baseDir)) {
|
|
53
53
|
throw new Error(`cwd must be within base directory: ${config.baseDir}`);
|
|
54
54
|
}
|
|
55
|
+
await d.applyProjectAcquisitionManifest(opts.acquisition, config.baseDir);
|
|
56
|
+
if (!existsSync(opts.cwd)) {
|
|
57
|
+
throw new Error(`cwd does not exist: ${opts.cwd}`);
|
|
58
|
+
}
|
|
55
59
|
const existing = existingSpawnSession(opts, d);
|
|
56
60
|
if (existing) return existing;
|
|
57
61
|
|
package/src/spawn/types.ts
CHANGED
|
@@ -2,6 +2,16 @@ import type { OrchestratorConfig } from "../config";
|
|
|
2
2
|
import type { AgentLifecycle, SpawnProvider, WorkspaceMetadata, WorkspaceMode } from "agent-relay-sdk";
|
|
3
3
|
import type { ResumeWorkspaceTarget } from "../workspace-probe/types";
|
|
4
4
|
|
|
5
|
+
export interface ProjectAcquisitionManifest {
|
|
6
|
+
mode: "project-root";
|
|
7
|
+
projectId: string;
|
|
8
|
+
rootPath: string;
|
|
9
|
+
cwd: string;
|
|
10
|
+
remoteUrl: string;
|
|
11
|
+
ref?: string;
|
|
12
|
+
sync: "ff-only";
|
|
13
|
+
}
|
|
14
|
+
|
|
5
15
|
export interface SpawnOptions {
|
|
6
16
|
provider: SpawnProvider;
|
|
7
17
|
cwd: string;
|
|
@@ -33,6 +43,8 @@ export interface SpawnOptions {
|
|
|
33
43
|
requestedVia?: string;
|
|
34
44
|
/** #635 — attach to or branch off an existing worktree instead of creating a fresh one. */
|
|
35
45
|
resumeWorkspace?: ResumeWorkspaceTarget;
|
|
46
|
+
/** #410 — lazy clone/sync manifest, applied on the host before worktree prep. */
|
|
47
|
+
acquisition?: ProjectAcquisitionManifest;
|
|
36
48
|
}
|
|
37
49
|
|
|
38
50
|
export interface SessionInfo {
|
|
@@ -0,0 +1,226 @@
|
|
|
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
|
+
const TIMEOUT_KILL_GRACE_MS = 1_000;
|
|
17
|
+
const OUTPUT_CANCEL_GRACE_MS = 50;
|
|
18
|
+
/** Cap on the full output streamed to the relay artifact (the notification only ever
|
|
19
|
+
* carries the tail). Errors usually surface at the END, so we keep the tail on overflow. */
|
|
20
|
+
const MAX_FULL_OUTPUT_BYTES = 256 * 1024;
|
|
21
|
+
/** Bytes of the tail put in the notification body — enough to show the failure, small
|
|
22
|
+
* enough never to flood relay. */
|
|
23
|
+
const TAIL_BYTES = 4000;
|
|
24
|
+
|
|
25
|
+
function keepTail(text: string, maxBytes: number, label: string): string {
|
|
26
|
+
if (text.length <= maxBytes) return text;
|
|
27
|
+
const dropped = text.length - maxBytes;
|
|
28
|
+
return `…(${dropped} earlier ${label} truncated)…\n${text.slice(text.length - maxBytes)}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function combineOutput(stdout: string, stderr: string): string {
|
|
32
|
+
if (stdout && stderr) return `${stdout}\n${stderr}`;
|
|
33
|
+
return stdout || stderr;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function sleep(ms: number): Promise<void> {
|
|
37
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface OutputCapture {
|
|
41
|
+
done: Promise<void>;
|
|
42
|
+
text(): string;
|
|
43
|
+
cancel(): void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function captureProcessOutput(pipe: unknown): OutputCapture {
|
|
47
|
+
if (!(pipe instanceof ReadableStream)) {
|
|
48
|
+
return { done: Promise.resolve(), text: () => "", cancel: () => {} };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const reader = pipe.getReader();
|
|
52
|
+
const decoder = new TextDecoder();
|
|
53
|
+
let output = "";
|
|
54
|
+
let canceled = false;
|
|
55
|
+
const done = (async () => {
|
|
56
|
+
try {
|
|
57
|
+
while (!canceled) {
|
|
58
|
+
const chunk = await reader.read();
|
|
59
|
+
if (chunk.done) break;
|
|
60
|
+
output += decoder.decode(chunk.value, { stream: true });
|
|
61
|
+
}
|
|
62
|
+
output += decoder.decode();
|
|
63
|
+
} catch {
|
|
64
|
+
// A timeout cancels the reader intentionally so pipe EOF cannot hold up the gate result.
|
|
65
|
+
} finally {
|
|
66
|
+
try { reader.releaseLock(); } catch {}
|
|
67
|
+
}
|
|
68
|
+
})();
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
done,
|
|
72
|
+
text: () => output,
|
|
73
|
+
cancel: () => {
|
|
74
|
+
canceled = true;
|
|
75
|
+
void reader.cancel().catch(() => {});
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function signalGateProcessGroup(proc: ReturnType<typeof Bun.spawn>, signal: NodeJS.Signals): void {
|
|
81
|
+
if (typeof proc.pid === "number" && proc.pid > 0) {
|
|
82
|
+
try {
|
|
83
|
+
process.kill(-proc.pid, signal);
|
|
84
|
+
return;
|
|
85
|
+
} catch (err) {
|
|
86
|
+
if ((err as { code?: string }).code !== "ESRCH") {
|
|
87
|
+
try { proc.kill(signal); } catch {}
|
|
88
|
+
}
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
try { proc.kill(signal); } catch {}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Run a single gate and capture its outcome. Never throws — a spawn failure (e.g.
|
|
96
|
+
* the command can't launch) is reported as a non-passing result so the caller can
|
|
97
|
+
* decide block-vs-warn from the gate's `optional` flag. */
|
|
98
|
+
export async function runOneLandGate(worktreePath: string, gate: LandGate): Promise<LandGateRunResult> {
|
|
99
|
+
const cwd = gate.cwd ? resolve(worktreePath, gate.cwd) : worktreePath;
|
|
100
|
+
const timeoutMs = gate.timeoutMs ?? DEFAULT_GATE_TIMEOUT_MS;
|
|
101
|
+
const started = Date.now();
|
|
102
|
+
const base = { name: gate.name, command: gate.command, optional: gate.optional === true } as const;
|
|
103
|
+
|
|
104
|
+
let proc: ReturnType<typeof Bun.spawn>;
|
|
105
|
+
let timedOut = false;
|
|
106
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
107
|
+
let killTimeout: ReturnType<typeof setTimeout> | undefined;
|
|
108
|
+
try {
|
|
109
|
+
// A login shell so PATH (bun, node, project bins) resolves like the worker's own
|
|
110
|
+
// environment; `env: process.env` makes runtime env mutations visible to the child.
|
|
111
|
+
proc = Bun.spawn(["bash", "-lc", gate.command], {
|
|
112
|
+
cwd,
|
|
113
|
+
env: process.env,
|
|
114
|
+
stdin: "ignore",
|
|
115
|
+
stdout: "pipe",
|
|
116
|
+
stderr: "pipe",
|
|
117
|
+
detached: true,
|
|
118
|
+
});
|
|
119
|
+
} catch (err) {
|
|
120
|
+
const durationMs = Date.now() - started;
|
|
121
|
+
const output = `land gate "${gate.name}" could not be launched: ${errMessage(err)}`;
|
|
122
|
+
return { ...base, passed: false, exitCode: null, timedOut: false, durationMs, outputTail: output, output };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const stdoutCapture = captureProcessOutput(proc.stdout);
|
|
126
|
+
const stderrCapture = captureProcessOutput(proc.stderr);
|
|
127
|
+
let timeoutTriggeredResolve: () => void = () => {};
|
|
128
|
+
const timeoutTriggered = new Promise<void>((resolve) => { timeoutTriggeredResolve = resolve; });
|
|
129
|
+
timeout = setTimeout(() => {
|
|
130
|
+
timedOut = true;
|
|
131
|
+
timeoutTriggeredResolve();
|
|
132
|
+
signalGateProcessGroup(proc, "SIGTERM");
|
|
133
|
+
stdoutCapture.cancel();
|
|
134
|
+
stderrCapture.cancel();
|
|
135
|
+
killTimeout = setTimeout(() => {
|
|
136
|
+
signalGateProcessGroup(proc, "SIGKILL");
|
|
137
|
+
stdoutCapture.cancel();
|
|
138
|
+
stderrCapture.cancel();
|
|
139
|
+
}, TIMEOUT_KILL_GRACE_MS);
|
|
140
|
+
killTimeout.unref?.();
|
|
141
|
+
}, timeoutMs);
|
|
142
|
+
timeout.unref?.();
|
|
143
|
+
|
|
144
|
+
const exitCodeRaw = await Promise.race([
|
|
145
|
+
proc.exited,
|
|
146
|
+
timeoutTriggered.then(async () => {
|
|
147
|
+
await Promise.race([proc.exited.then(() => undefined), sleep(TIMEOUT_KILL_GRACE_MS + OUTPUT_CANCEL_GRACE_MS)]);
|
|
148
|
+
return null;
|
|
149
|
+
}),
|
|
150
|
+
]).finally(() => {
|
|
151
|
+
if (timeout) clearTimeout(timeout);
|
|
152
|
+
if (killTimeout) clearTimeout(killTimeout);
|
|
153
|
+
});
|
|
154
|
+
if (timedOut) {
|
|
155
|
+
stdoutCapture.cancel();
|
|
156
|
+
stderrCapture.cancel();
|
|
157
|
+
await Promise.race([Promise.allSettled([stdoutCapture.done, stderrCapture.done]), sleep(OUTPUT_CANCEL_GRACE_MS)]);
|
|
158
|
+
} else {
|
|
159
|
+
await Promise.all([stdoutCapture.done, stderrCapture.done]);
|
|
160
|
+
}
|
|
161
|
+
const durationMs = Date.now() - started;
|
|
162
|
+
const stdout = stdoutCapture.text();
|
|
163
|
+
const stderr = stderrCapture.text();
|
|
164
|
+
let combined = combineOutput(stdout, stderr);
|
|
165
|
+
const exitCode = timedOut ? null : typeof exitCodeRaw === "number" ? exitCodeRaw : null;
|
|
166
|
+
const passed = exitCode === 0;
|
|
167
|
+
if (timedOut) combined = `${combined}\n[land-gate] timed out after ${timeoutMs}ms`.trimStart();
|
|
168
|
+
if (!combined) combined = passed ? "(gate produced no output)" : "(gate produced no output)";
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
...base,
|
|
172
|
+
passed,
|
|
173
|
+
exitCode,
|
|
174
|
+
timedOut,
|
|
175
|
+
durationMs,
|
|
176
|
+
outputTail: keepTail(combined, TAIL_BYTES, "bytes"),
|
|
177
|
+
output: keepTail(combined, MAX_FULL_OUTPUT_BYTES, "bytes"),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export interface LandGatesResult {
|
|
182
|
+
/** Number of gates configured + run (0 ⇒ no config ⇒ caller's land path is unchanged). */
|
|
183
|
+
ran: number;
|
|
184
|
+
/** First REQUIRED gate that failed — its presence means the land MUST be aborted
|
|
185
|
+
* without advancing the ref. */
|
|
186
|
+
failure?: LandGateRunResult;
|
|
187
|
+
/** Optional gates that failed (warn-only); the land still proceeds. */
|
|
188
|
+
warnings: LandGateRunResult[];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
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.
|
|
197
|
+
*
|
|
198
|
+
* A PRESENT-but-malformed `.agent-relay/land-gates.json` is itself a blocking failure
|
|
199
|
+
* (surfaced as a synthetic required gate) rather than an unhandled throw that would
|
|
200
|
+
* crash the merge command — the worker fixes the config and re-lands.
|
|
201
|
+
*/
|
|
202
|
+
export async function runLandGates(worktreePath: string): Promise<LandGatesResult> {
|
|
203
|
+
const warnings: LandGateRunResult[] = [];
|
|
204
|
+
let gates: LandGate[];
|
|
205
|
+
try {
|
|
206
|
+
gates = loadRepoLandGates(worktreePath);
|
|
207
|
+
} catch (err) {
|
|
208
|
+
const output = `invalid .agent-relay/land-gates.json: ${errMessage(err)}`;
|
|
209
|
+
return {
|
|
210
|
+
ran: 1,
|
|
211
|
+
failure: { name: "land-gates-config", command: "(config validation)", optional: false, passed: false, exitCode: null, timedOut: false, durationMs: 0, outputTail: output, output },
|
|
212
|
+
warnings,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
if (gates.length === 0) return { ran: 0, warnings };
|
|
216
|
+
|
|
217
|
+
for (const gate of gates) {
|
|
218
|
+
const result = await runOneLandGate(worktreePath, gate);
|
|
219
|
+
if (result.passed) continue;
|
|
220
|
+
if (result.optional) { warnings.push(result); continue; }
|
|
221
|
+
// First required failure blocks the land — don't run the rest (the worker fixes
|
|
222
|
+
// this gate and re-lands, which re-runs from the top).
|
|
223
|
+
return { ran: gates.length, failure: result, warnings };
|
|
224
|
+
}
|
|
225
|
+
return { ran: gates.length, warnings };
|
|
226
|
+
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { existsSync } from "node:fs";
|
|
2
|
-
import {
|
|
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";
|
|
@@ -344,7 +346,7 @@ function validPreviewStrategy(strategy: string | null): "pr" | "rebase-ff" | "au
|
|
|
344
346
|
* Refuses on a dirty worktree, predicted conflicts, or nothing to merge. Never
|
|
345
347
|
* destroys work on uncertainty.
|
|
346
348
|
*/
|
|
347
|
-
export function mergeWorkspace(input: WorkspaceMergeInput): WorkspaceMergeResult {
|
|
349
|
+
export async function mergeWorkspace(input: WorkspaceMergeInput): Promise<WorkspaceMergeResult> {
|
|
348
350
|
if (!input.worktreePath) return { strategy: "rebase-ff", merged: false, status: "review_requested", error: "worktreePath required", workspaceId: input.id };
|
|
349
351
|
const worktreePath = resolve(input.worktreePath);
|
|
350
352
|
const repoRoot = input.repoRoot ? resolve(input.repoRoot) : worktreePath;
|
|
@@ -389,7 +391,7 @@ export function mergeWorkspace(input: WorkspaceMergeInput): WorkspaceMergeResult
|
|
|
389
391
|
if (preview.conflict) return head({ conflict: true, status: "conflict", error: "merge would conflict with base" });
|
|
390
392
|
|
|
391
393
|
if (strategy === "pr") return mergePr(input, worktreePath, branch, preview, head);
|
|
392
|
-
return mergeRebaseFf(input, worktreePath, repoRoot, branch, preview, head);
|
|
394
|
+
return await mergeRebaseFf(input, worktreePath, repoRoot, branch, preview, head);
|
|
393
395
|
}
|
|
394
396
|
|
|
395
397
|
/**
|
|
@@ -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
|
-
|
|
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
|
-
|
|
543
|
-
|
|
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
|
+
async function runLandGatesOnIntegratedTree(
|
|
580
|
+
repoRoot: string,
|
|
581
|
+
worktreePath: string,
|
|
582
|
+
behind: number,
|
|
583
|
+
integrationBaseSha: string,
|
|
584
|
+
headSha: string,
|
|
585
|
+
mergeMessage: string,
|
|
586
|
+
): Promise<{ gates: LandGatesResult } | { abort: { conflict?: boolean; error: string } }> {
|
|
587
|
+
if (behind === 0) return { gates: await 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: await 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
|
/**
|
|
@@ -581,14 +643,14 @@ function syncLocalBaseToUpstream(
|
|
|
581
643
|
return { ok: true, baseSync };
|
|
582
644
|
}
|
|
583
645
|
|
|
584
|
-
function mergeRebaseFf(
|
|
646
|
+
async function mergeRebaseFf(
|
|
585
647
|
input: WorkspaceMergeInput,
|
|
586
648
|
worktreePath: string,
|
|
587
649
|
repoRoot: string,
|
|
588
650
|
branch: string | undefined,
|
|
589
651
|
preview: WorkspaceMergePreview,
|
|
590
652
|
head: (field: Partial<WorkspaceMergeResult>) => WorkspaceMergeResult,
|
|
591
|
-
): WorkspaceMergeResult {
|
|
653
|
+
): Promise<WorkspaceMergeResult> {
|
|
592
654
|
const base = preview.baseRef;
|
|
593
655
|
if (!base) return head({ status: "review_requested", error: "no base branch to merge into" });
|
|
594
656
|
if (!branch) return head({ status: "review_requested", error: "cannot determine agent branch" });
|
|
@@ -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
|
|
627
|
-
if (!
|
|
628
|
-
|
|
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
|
-
//
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
//
|
|
636
|
-
|
|
637
|
-
//
|
|
638
|
-
//
|
|
639
|
-
|
|
640
|
-
|
|
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 = await 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
|
}
|