agent-relay-orchestrator 0.120.0 → 0.120.2
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/control.ts +2 -0
- package/src/process.ts +3 -2
- package/src/spawn/supervisor.ts +57 -6
- package/src/workspace-probe/cleanup.ts +41 -2
- package/src/workspace-probe/merge.ts +16 -20
- package/src/workspace-probe/pr-preview.ts +22 -0
- package/src/workspace-probe/probe.ts +7 -5
- package/src/workspace-probe/protected-tip.ts +108 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-orchestrator",
|
|
3
|
-
"version": "0.120.
|
|
3
|
+
"version": "0.120.2",
|
|
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.3",
|
|
20
|
-
"agent-relay-sdk": "0.2.
|
|
20
|
+
"agent-relay-sdk": "0.2.108",
|
|
21
21
|
"callmux": "0.23.0"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
package/src/control.ts
CHANGED
|
@@ -373,6 +373,8 @@ export function createControlHandler(
|
|
|
373
373
|
baseRef: typeof command.params.baseRef === "string" ? command.params.baseRef : undefined,
|
|
374
374
|
baseSha: typeof command.params.baseSha === "string" ? command.params.baseSha : undefined,
|
|
375
375
|
deleteBranch: command.params.deleteBranch !== false,
|
|
376
|
+
force: command.params.force === true,
|
|
377
|
+
reason: typeof command.params.reason === "string" ? command.params.reason : undefined,
|
|
376
378
|
workspacesRoot: workspacesRoot(config.baseDir),
|
|
377
379
|
});
|
|
378
380
|
await relay.updateCommand(command.id, "succeeded", result);
|
package/src/process.ts
CHANGED
|
@@ -271,9 +271,10 @@ export async function execProcess(cmd: string[], options: ExecOptions = {}): Pro
|
|
|
271
271
|
const msg = abortMessage(cmd, options);
|
|
272
272
|
stderr = stderr ? `${stderr}\n${msg}` : msg;
|
|
273
273
|
}
|
|
274
|
+
const reportedExitCode = timedOut || aborted || outputLimitExceeded ? null : exitCode;
|
|
274
275
|
return {
|
|
275
|
-
ok: !timedOut && !aborted && !outputLimitExceeded &&
|
|
276
|
-
exitCode,
|
|
276
|
+
ok: !timedOut && !aborted && !outputLimitExceeded && reportedExitCode === 0,
|
|
277
|
+
exitCode: reportedExitCode,
|
|
277
278
|
stdout: options.trimStdout === false ? stdout : stdout.trim(),
|
|
278
279
|
stderr: options.trimStderr === false ? stderr : stderr.trim(),
|
|
279
280
|
...(timedOut ? { timedOut } : {}),
|
package/src/spawn/supervisor.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { chmodSync, closeSync, existsSync, openSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { chmodSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import type { OrchestratorConfig } from "../config";
|
|
4
4
|
import type { ManagedSessionExitDiagnostics } from "../relay";
|
|
@@ -15,26 +15,33 @@ import { systemdMainPidAsync, systemdUnitDiagnostics, systemdUnitName } from "./
|
|
|
15
15
|
import type { SessionRecord, SessionSupervisor, SpawnedRunner } from "./types";
|
|
16
16
|
import { execProcess } from "../process";
|
|
17
17
|
|
|
18
|
+
const FILE_BACKED_ENV_PAYLOADS = new Map<string, string>([
|
|
19
|
+
["AGENT_RELAY_AGENT_PROFILE_JSON", "agent-profile.json"],
|
|
20
|
+
["AGENT_RELAY_WORKSPACE_JSON", "workspace.json"],
|
|
21
|
+
["AGENT_RELAY_INJECTION_EVENTS_JSON", "relay-injection-events.json"],
|
|
22
|
+
]);
|
|
23
|
+
|
|
18
24
|
export async function spawnRunner(name: string, command: string[], cwd: string, env: Record<string, string>, logFile: string): Promise<SpawnedRunner> {
|
|
25
|
+
const launchScript = launchScriptPath(name);
|
|
26
|
+
const launch = materializeLaunchPayload(launchScript, command, env);
|
|
19
27
|
if (await shouldUseSystemdSupervisor()) {
|
|
20
28
|
try {
|
|
21
|
-
return await spawnSystemdRunner(name, command, cwd, env, logFile);
|
|
29
|
+
return await spawnSystemdRunner(name, launch.command, cwd, launch.env, logFile);
|
|
22
30
|
} catch (error) {
|
|
23
31
|
console.error(`[orchestrator] systemd runner supervisor unavailable for ${name}: ${errMessage(error)}`);
|
|
24
32
|
console.error("[orchestrator] Falling back to process child; this agent will not survive orchestrator service restart.");
|
|
25
33
|
}
|
|
26
34
|
}
|
|
27
35
|
|
|
28
|
-
const launchScript = launchScriptPath(name);
|
|
29
36
|
ensureSessionDir();
|
|
30
|
-
writeFileSync(launchScript, buildLaunchScript(command, cwd, env), { mode: 0o700 });
|
|
37
|
+
writeFileSync(launchScript, buildLaunchScript(launch.command, cwd, launch.env), { mode: 0o700 });
|
|
31
38
|
chmodSync(launchScript, 0o700);
|
|
32
39
|
|
|
33
40
|
const logFd = openSync(logFile, "a");
|
|
34
41
|
try {
|
|
35
42
|
const proc = Bun.spawn([launchScript], {
|
|
36
43
|
cwd,
|
|
37
|
-
env,
|
|
44
|
+
env: launch.env,
|
|
38
45
|
stdin: "ignore",
|
|
39
46
|
stdout: logFd,
|
|
40
47
|
stderr: logFd,
|
|
@@ -86,6 +93,47 @@ function launchScriptPath(session: string): string {
|
|
|
86
93
|
return join(SESSION_DIR, `${safe}.sh`);
|
|
87
94
|
}
|
|
88
95
|
|
|
96
|
+
function launchPayloadDirPath(launchScript: string): string {
|
|
97
|
+
return `${launchScript}.d`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function payloadFilePath(launchScript: string, filename: string): string {
|
|
101
|
+
return join(launchPayloadDirPath(launchScript), filename);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function materializeEnvPayload(env: Record<string, string>, launchScript: string): Record<string, string> {
|
|
105
|
+
const nextEnv = { ...env };
|
|
106
|
+
for (const [envKey, filename] of FILE_BACKED_ENV_PAYLOADS) {
|
|
107
|
+
const value = nextEnv[envKey];
|
|
108
|
+
if (typeof value !== "string" || value.length === 0) continue;
|
|
109
|
+
const filePath = payloadFilePath(launchScript, filename);
|
|
110
|
+
mkdirSync(launchPayloadDirPath(launchScript), { recursive: true });
|
|
111
|
+
writeFileSync(filePath, value);
|
|
112
|
+
delete nextEnv[envKey];
|
|
113
|
+
nextEnv[`${envKey}_FILE`] = filePath;
|
|
114
|
+
}
|
|
115
|
+
return nextEnv;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function materializeCommandArg(command: string[], env: Record<string, string>, launchScript: string, flag: string, envKey: string, filename: string): void {
|
|
119
|
+
const index = command.indexOf(flag);
|
|
120
|
+
if (index < 0 || !command[index + 1]) return;
|
|
121
|
+
const filePath = payloadFilePath(launchScript, filename);
|
|
122
|
+
mkdirSync(launchPayloadDirPath(launchScript), { recursive: true });
|
|
123
|
+
writeFileSync(filePath, command[index + 1]!);
|
|
124
|
+
command.splice(index, 2);
|
|
125
|
+
env[`${envKey}_FILE`] = filePath;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function materializeLaunchPayload(launchScript: string, command: string[], env: Record<string, string>): { command: string[]; env: Record<string, string> } {
|
|
129
|
+
const nextCommand = [...command];
|
|
130
|
+
const nextEnv = materializeEnvPayload(env, launchScript);
|
|
131
|
+
materializeCommandArg(nextCommand, nextEnv, launchScript, "--prompt", "AGENT_RELAY_PROMPT", "prompt.txt");
|
|
132
|
+
materializeCommandArg(nextCommand, nextEnv, launchScript, "--system-prompt-append", "AGENT_RELAY_SYSTEM_PROMPT_APPEND", "system-prompt-append.txt");
|
|
133
|
+
materializeCommandArg(nextCommand, nextEnv, launchScript, "--append-system-prompt", "AGENT_RELAY_SYSTEM_PROMPT_APPEND", "system-prompt-append.txt");
|
|
134
|
+
return { command: nextCommand, env: nextEnv };
|
|
135
|
+
}
|
|
136
|
+
|
|
89
137
|
export function buildLaunchScript(command: string[], cwd: string, env: Record<string, string>): string {
|
|
90
138
|
const exports = Object.entries(env)
|
|
91
139
|
.filter(([key, value]) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(key) && value !== undefined)
|
|
@@ -252,7 +300,10 @@ function killSystemdUnit(unit: string): void {
|
|
|
252
300
|
|
|
253
301
|
function cleanupSupervisor(supervisor: SessionSupervisor): void {
|
|
254
302
|
if (supervisor.type === "systemd" && supervisor.unit) stopSystemdUnit(supervisor.unit);
|
|
255
|
-
if (supervisor.launchScript)
|
|
303
|
+
if (supervisor.launchScript) {
|
|
304
|
+
rmSync(supervisor.launchScript, { force: true });
|
|
305
|
+
rmSync(launchPayloadDirPath(supervisor.launchScript), { recursive: true, force: true });
|
|
306
|
+
}
|
|
256
307
|
}
|
|
257
308
|
|
|
258
309
|
export function cleanupSessionRecord(record: SessionRecord): void {
|
|
@@ -22,13 +22,20 @@ export async function owningRepoRoot(worktreePath: string, fallback: string): Pr
|
|
|
22
22
|
return existsSync(root) ? root : fallback;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
export async function cleanupWorkspace(workspace: { repoRoot?: string; worktreePath?: string; id?: string; branch?: string; baseRef?: string; baseSha?: string; deleteBranch?: boolean; workspacesRoot?: string }): Promise<{ workspaceId?: string; removed: boolean; worktreePath?: string; branchDeleted?: boolean; branchPreservedReason?: string; containerRemoved?: boolean }> {
|
|
25
|
+
export async function cleanupWorkspace(workspace: { repoRoot?: string; worktreePath?: string; id?: string; branch?: string; baseRef?: string; baseSha?: string; deleteBranch?: boolean; workspacesRoot?: string; force?: boolean; reason?: string }): Promise<{ workspaceId?: string; removed: boolean; worktreePath?: string; branchDeleted?: boolean; branchPreservedReason?: string; containerRemoved?: boolean; dirtySnapshotRef?: string }> {
|
|
26
26
|
if (!workspace.worktreePath) throw new Error("worktreePath required");
|
|
27
27
|
const path = resolve(workspace.worktreePath);
|
|
28
28
|
const recordedRepo = workspace.repoRoot ? resolve(workspace.repoRoot) : path;
|
|
29
29
|
// Remove via the REAL owning repo, not the recorded (chained/deleted) repoRoot which
|
|
30
30
|
// would silently no-op and leak the dir while the relay marks it `cleaned` (#307).
|
|
31
31
|
const repo = await owningRepoRoot(path, recordedRepo);
|
|
32
|
+
let dirtySnapshotRef: string | undefined;
|
|
33
|
+
if (existsSync(path)) {
|
|
34
|
+
const safety = await worktreeSafeToRemove(path, workspace.baseRef, workspace.baseSha);
|
|
35
|
+
const explicitForce = workspace.force === true && Boolean(workspace.reason?.trim());
|
|
36
|
+
if (!safety.safe && !explicitForce) throw new Error(`unsafe workspace cleanup refused: ${safety.reason}`);
|
|
37
|
+
if (!safety.safe) dirtySnapshotRef = await snapshotDirtyWorktree(path, workspace.id);
|
|
38
|
+
}
|
|
32
39
|
const result = await git(["worktree", "remove", "--force", path], repo);
|
|
33
40
|
// Trust the filesystem, not git's exit code (a remove against the wrong repo "succeeds"
|
|
34
41
|
// touching nothing). If the dir survives, surface failure so the relay leaves the row
|
|
@@ -53,7 +60,39 @@ export async function cleanupWorkspace(workspace: { repoRoot?: string; worktreeP
|
|
|
53
60
|
}
|
|
54
61
|
}
|
|
55
62
|
const containerRemoved = workspace.workspacesRoot ? removeEmptyContainer(dirname(path), resolve(workspace.workspacesRoot)) : false;
|
|
56
|
-
return { workspaceId: workspace.id, removed: true, worktreePath: path, branchDeleted, ...(branchPreservedReason ? { branchPreservedReason } : {}), containerRemoved };
|
|
63
|
+
return { workspaceId: workspace.id, removed: true, worktreePath: path, branchDeleted, ...(branchPreservedReason ? { branchPreservedReason } : {}), containerRemoved, ...(dirtySnapshotRef ? { dirtySnapshotRef } : {}) };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function worktreeSafeToRemove(worktreePath: string, baseRef?: string, baseSha?: string): Promise<{ safe: boolean; reason?: string }> {
|
|
67
|
+
const status = await git(["status", "--porcelain"], worktreePath);
|
|
68
|
+
if (!status.ok) return { safe: false, reason: status.stderr || "git status unavailable" };
|
|
69
|
+
const dirtyCount = status.stdout ? status.stdout.split("\n").filter(Boolean).length : 0;
|
|
70
|
+
if (dirtyCount > 0) return { safe: false, reason: `${dirtyCount} uncommitted change(s)` };
|
|
71
|
+
|
|
72
|
+
const base = await resolveCleanupBase(worktreePath, baseRef, baseSha);
|
|
73
|
+
if (!base) return { safe: false, reason: "base ref unavailable" };
|
|
74
|
+
const counts = await git(["rev-list", "--left-right", "--count", `${base}...HEAD`], worktreePath);
|
|
75
|
+
if (!counts.ok || !counts.stdout) return { safe: false, reason: counts.stderr || "ahead count unavailable" };
|
|
76
|
+
const ahead = Number(counts.stdout.split(/\s+/)[1]);
|
|
77
|
+
if (!Number.isFinite(ahead)) return { safe: false, reason: "ahead count unavailable" };
|
|
78
|
+
if (ahead === 0) return { safe: true };
|
|
79
|
+
|
|
80
|
+
const cherryBase = await upstreamRef(worktreePath, base) ?? base;
|
|
81
|
+
if ((await git(["diff", "--quiet", cherryBase, "HEAD"], worktreePath)).ok) return { safe: true };
|
|
82
|
+
const cherry = await git(["cherry", cherryBase, "HEAD"], worktreePath);
|
|
83
|
+
if (!cherry.ok) return { safe: false, reason: cherry.stderr || "unmerged commit check unavailable" };
|
|
84
|
+
const unmergedAhead = cherry.stdout ? cherry.stdout.split("\n").filter((line) => line.startsWith("+")).length : 0;
|
|
85
|
+
return unmergedAhead === 0 ? { safe: true } : { safe: false, reason: `${unmergedAhead} unlanded commit(s)` };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function snapshotDirtyWorktree(worktreePath: string, workspaceId: string | undefined): Promise<string | undefined> {
|
|
89
|
+
const snapshot = await git(["stash", "create", `agent-relay cleanup snapshot ${workspaceId ?? "workspace"}`], worktreePath);
|
|
90
|
+
const commit = snapshot.ok ? snapshot.stdout.trim() : "";
|
|
91
|
+
if (!commit) return undefined;
|
|
92
|
+
const safe = (workspaceId ?? basename(worktreePath)).replace(/[^A-Za-z0-9._/-]+/g, "-").replace(/^\/+|\/+$/g, "").slice(0, 180) || "workspace";
|
|
93
|
+
const ref = `refs/agent-relay/workspace-tips/${safe}-dirty-${Date.now()}`;
|
|
94
|
+
const updated = await git(["update-ref", ref, commit], worktreePath);
|
|
95
|
+
return updated.ok ? ref : undefined;
|
|
57
96
|
}
|
|
58
97
|
|
|
59
98
|
export async function branchSafeToDelete(repo: string, branch: string, baseRef?: string, baseSha?: string): Promise<{ safe: boolean; reason?: string }> {
|
|
@@ -3,7 +3,6 @@ import { join, resolve } from "node:path";
|
|
|
3
3
|
import { errMessage, type BaseWorktreeSyncResult, type WorkspaceMergePreview, type WorkspaceMergeResult } from "agent-relay-sdk";
|
|
4
4
|
import { git, gitRaw } from "../git";
|
|
5
5
|
import { execProcess } from "../process";
|
|
6
|
-
import { prMergedState } from "../workspace-pr";
|
|
7
6
|
import { deleteBranchIfSafe, owningRepoRoot } from "./cleanup";
|
|
8
7
|
import { refreshWorkspaceDeps } from "./deps";
|
|
9
8
|
import { type LandGatesResult } from "./land-gates-runner";
|
|
@@ -11,6 +10,8 @@ import { populateMergeState, resolveBranchRef, syncBaseFromOrigin, upstreamRef,
|
|
|
11
10
|
import { type MergePhase, mergePhaseTimeoutMs, throwIfMergeAborted, withMergePhaseTimeout } from "./merge-timeouts";
|
|
12
11
|
import { nextBranchName } from "./names";
|
|
13
12
|
import { parseWorktrees, shortBranch } from "./parse";
|
|
13
|
+
import { applyPrMergeState } from "./pr-preview";
|
|
14
|
+
import { protectWorkspaceTip } from "./protected-tip";
|
|
14
15
|
import { json, resolveRequestedPath } from "./request";
|
|
15
16
|
import type { WorkspaceMergeInput } from "./types";
|
|
16
17
|
import { workspacePushEnabled } from "../config";
|
|
@@ -210,24 +211,6 @@ function logBaseWorktreeStrand(base: string, worktreePath: string, sync: BaseWor
|
|
|
210
211
|
);
|
|
211
212
|
}
|
|
212
213
|
|
|
213
|
-
// Populate a preview's PR fields from `gh` ground truth; returns whether the PR is merged
|
|
214
|
-
// (#168/#304). One home for the PR-landing rule so the worktree- and repoRoot-based previews
|
|
215
|
-
// can't drift. On a merge it stamps the merge SHA so the relay can finalize a parked pr land.
|
|
216
|
-
async function applyPrMergeState(base: WorkspaceMergePreview, cwd: string, branch: string | undefined): Promise<boolean> {
|
|
217
|
-
const pr = await prMergedState(cwd, branch);
|
|
218
|
-
if (!pr) return false;
|
|
219
|
-
base.prState = pr.state;
|
|
220
|
-
if (pr.url) base.prUrl = pr.url;
|
|
221
|
-
if (pr.number) base.prNumber = pr.number;
|
|
222
|
-
if (pr.reviewDecision) base.reviewDecision = pr.reviewDecision;
|
|
223
|
-
if (pr.statusCheckRollup) base.statusCheckRollup = pr.statusCheckRollup; if (pr.mergeable) base.mergeable = pr.mergeable; if (pr.mergeStateStatus) base.mergeStateStatus = pr.mergeStateStatus;
|
|
224
|
-
if (pr.state !== "merged") return false;
|
|
225
|
-
base.prMerged = true;
|
|
226
|
-
if (pr.sha) base.prMergeSha = pr.sha;
|
|
227
|
-
if (pr.title) base.prMergeSubject = pr.title;
|
|
228
|
-
return true;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
214
|
export async function previewBranchMerge(input: {
|
|
232
215
|
repoRoot?: string;
|
|
233
216
|
branch?: string;
|
|
@@ -283,6 +266,7 @@ export async function mergePreviewResponse(url: URL, baseDir: string): Promise<R
|
|
|
283
266
|
baseSha: url.searchParams.get("baseSha") || undefined,
|
|
284
267
|
strategy: validPreviewStrategy(strategy),
|
|
285
268
|
checkPr: url.searchParams.get("checkPr") === "1",
|
|
269
|
+
workspaceId: url.searchParams.get("workspaceId") || undefined, protectTip: url.searchParams.get("protectTip") === "1",
|
|
286
270
|
}));
|
|
287
271
|
} catch (e) {
|
|
288
272
|
return json({ error: (e as Error).message }, 400);
|
|
@@ -311,7 +295,7 @@ export async function branchMergePreviewResponse(url: URL, baseDir: string): Pro
|
|
|
311
295
|
* `auto` would pick plus whether the merge is clean, would conflict, or is a
|
|
312
296
|
* no-op — so the dashboard can warn before the user commits to it.
|
|
313
297
|
*/
|
|
314
|
-
export async function previewWorkspaceMerge(input: { worktreePath?: string; baseRef?: string; baseSha?: string; strategy?: "pr" | "rebase-ff" | "auto"; checkPr?: boolean }): Promise<WorkspaceMergePreview> {
|
|
298
|
+
export async function previewWorkspaceMerge(input: { worktreePath?: string; baseRef?: string; baseSha?: string; strategy?: "pr" | "rebase-ff" | "auto"; checkPr?: boolean; workspaceId?: string; protectTip?: boolean }): Promise<WorkspaceMergePreview> {
|
|
315
299
|
const gitState = await workspaceGitState(input);
|
|
316
300
|
const remote = input.worktreePath ? await hasOriginRemote(resolve(input.worktreePath)) : false;
|
|
317
301
|
const gh = ghAvailable();
|
|
@@ -328,6 +312,7 @@ export async function previewWorkspaceMerge(input: { worktreePath?: string; base
|
|
|
328
312
|
base.unmergedAhead = gitState.unmergedAhead;
|
|
329
313
|
base.landed = gitState.landed;
|
|
330
314
|
base.behind = gitState.behind;
|
|
315
|
+
base.headSha = gitState.lastCommit?.sha;
|
|
331
316
|
base.dirtyCount = gitState.dirtyCount;
|
|
332
317
|
if ((gitState.dirtyCount ?? 0) > 0) return { ...base, reason: "worktree has uncommitted changes" };
|
|
333
318
|
let effectiveAhead = gitState.landed ? 0 : (gitState.unmergedAhead ?? gitState.ahead ?? 0);
|
|
@@ -337,6 +322,17 @@ export async function previewWorkspaceMerge(input: { worktreePath?: string; base
|
|
|
337
322
|
if (input.checkPr && strategy === "pr" && input.worktreePath) {
|
|
338
323
|
if (await applyPrMergeState(base, resolve(input.worktreePath), gitState.branch)) effectiveAhead = 0;
|
|
339
324
|
}
|
|
325
|
+
if (input.protectTip && input.worktreePath) {
|
|
326
|
+
const protection = await protectWorkspaceTip({
|
|
327
|
+
worktreePath: resolve(input.worktreePath), workspaceId: input.workspaceId, branch: gitState.branch,
|
|
328
|
+
baseRef: gitState.baseRef, headSha: gitState.lastCommit?.sha, dirtyCount: gitState.dirtyCount, effectiveAhead,
|
|
329
|
+
});
|
|
330
|
+
Object.assign(base, protection);
|
|
331
|
+
if (protection.protectedTipRestored) {
|
|
332
|
+
return { ...await previewWorkspaceMerge({ ...input, protectTip: false }), ...protection };
|
|
333
|
+
}
|
|
334
|
+
if (protection.protectedTipReachable === false) return base;
|
|
335
|
+
}
|
|
340
336
|
if (effectiveAhead === 0) {
|
|
341
337
|
const reason = base.prMerged
|
|
342
338
|
? "PR merged on remote"
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { WorkspaceMergePreview } from "agent-relay-sdk";
|
|
2
|
+
import { prMergedState } from "../workspace-pr";
|
|
3
|
+
|
|
4
|
+
// Populate a preview's PR fields from `gh` ground truth; returns whether the PR is merged
|
|
5
|
+
// (#168/#304). One home for the PR-landing rule so the worktree- and repoRoot-based previews
|
|
6
|
+
// can't drift. On a merge it stamps the merge SHA so the relay can finalize a parked pr land.
|
|
7
|
+
export async function applyPrMergeState(base: WorkspaceMergePreview, cwd: string, branch: string | undefined): Promise<boolean> {
|
|
8
|
+
const pr = await prMergedState(cwd, branch);
|
|
9
|
+
if (!pr) return false;
|
|
10
|
+
base.prState = pr.state;
|
|
11
|
+
if (pr.url) base.prUrl = pr.url;
|
|
12
|
+
if (pr.number) base.prNumber = pr.number;
|
|
13
|
+
if (pr.reviewDecision) base.reviewDecision = pr.reviewDecision;
|
|
14
|
+
if (pr.statusCheckRollup) base.statusCheckRollup = pr.statusCheckRollup;
|
|
15
|
+
if (pr.mergeable) base.mergeable = pr.mergeable;
|
|
16
|
+
if (pr.mergeStateStatus) base.mergeStateStatus = pr.mergeStateStatus;
|
|
17
|
+
if (pr.state !== "merged") return false;
|
|
18
|
+
base.prMerged = true;
|
|
19
|
+
if (pr.sha) base.prMergeSha = pr.sha;
|
|
20
|
+
if (pr.title) base.prMergeSubject = pr.title;
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
@@ -91,7 +91,9 @@ export async function resolveSpawnWorkspace(input: WorkspaceResolutionInput): Pr
|
|
|
91
91
|
if (input.resumeWorkspace?.mode === "branch-from") {
|
|
92
92
|
const resume = input.resumeWorkspace;
|
|
93
93
|
const repoRoot = probe.repoRoot;
|
|
94
|
-
const
|
|
94
|
+
const startSha = await requireGit(["rev-parse", resume.branch], repoRoot);
|
|
95
|
+
const baseRef = resume.baseRef ?? await terminalBaseRef(repoRoot, resume.branch);
|
|
96
|
+
const baseSha = resume.baseSha ?? (baseRef ? await requireGit(["rev-parse", baseRef], repoRoot) : undefined);
|
|
95
97
|
const id = workspaceId(input);
|
|
96
98
|
const workspaceRoot = input.workspaceRoot ? resolve(input.workspaceRoot) : workspacesRoot(homedir());
|
|
97
99
|
const worktreePath = join(workspaceRoot, repoSlug(repoRoot), id);
|
|
@@ -103,7 +105,7 @@ export async function resolveSpawnWorkspace(input: WorkspaceResolutionInput): Pr
|
|
|
103
105
|
sourceCwd,
|
|
104
106
|
worktreePath,
|
|
105
107
|
branch: existing.branch,
|
|
106
|
-
baseRef
|
|
108
|
+
baseRef,
|
|
107
109
|
baseSha,
|
|
108
110
|
requestedMode,
|
|
109
111
|
probe,
|
|
@@ -112,7 +114,7 @@ export async function resolveSpawnWorkspace(input: WorkspaceResolutionInput): Pr
|
|
|
112
114
|
|
|
113
115
|
const branch = await availableBranch(repoRoot, branchName(input, id));
|
|
114
116
|
mkdirSync(join(worktreePath, ".."), { recursive: true });
|
|
115
|
-
await requireGit(["worktree", "add", "-b", branch, worktreePath,
|
|
117
|
+
await requireGit(["worktree", "add", "-b", branch, worktreePath, startSha], repoRoot);
|
|
116
118
|
const deps = await provisionWorkspaceDeps(repoRoot, worktreePath);
|
|
117
119
|
const symlinks = provisionWorkspaceSymlinks(repoRoot, worktreePath, input.workspaceSymlinks ?? []);
|
|
118
120
|
return {
|
|
@@ -125,8 +127,8 @@ export async function resolveSpawnWorkspace(input: WorkspaceResolutionInput): Pr
|
|
|
125
127
|
sourceCwd,
|
|
126
128
|
worktreePath,
|
|
127
129
|
branch,
|
|
128
|
-
baseRef:
|
|
129
|
-
baseSha,
|
|
130
|
+
...(baseRef ? { baseRef } : {}),
|
|
131
|
+
...(baseSha ? { baseSha } : {}),
|
|
130
132
|
status: "active",
|
|
131
133
|
deps,
|
|
132
134
|
...(symlinks.linked.length || symlinks.errors ? { symlinks } : {}),
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { git } from "../git";
|
|
2
|
+
import { mergePhaseTimeoutMs, throwIfMergeAborted } from "./merge-timeouts";
|
|
3
|
+
|
|
4
|
+
async function previewGit(args: string[], cwd: string, timeoutLabel: string, signal?: AbortSignal): ReturnType<typeof git> {
|
|
5
|
+
throwIfMergeAborted(signal);
|
|
6
|
+
return git(args, cwd, {
|
|
7
|
+
timeoutMs: mergePhaseTimeoutMs("preview"),
|
|
8
|
+
timeoutLabel,
|
|
9
|
+
signal,
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function gitError(result: Awaited<ReturnType<typeof git>>, fallback: string): string {
|
|
14
|
+
return result.stderr || result.stdout || fallback;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function protectedTipRef(workspaceId: string | undefined): string | undefined {
|
|
18
|
+
const safe = workspaceId?.replace(/[^A-Za-z0-9._/-]+/g, "-").replace(/^\/+|\/+$/g, "").slice(0, 180);
|
|
19
|
+
return safe ? `refs/agent-relay/workspace-tips/${safe}` : undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function discardedTipRef(ref: string): string {
|
|
23
|
+
return `${ref}-discarded-${Date.now()}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function readCommit(cwd: string, ref: string, signal?: AbortSignal): Promise<string | undefined> {
|
|
27
|
+
const result = await previewGit(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], cwd, `workspace preview read ${ref}`, signal);
|
|
28
|
+
return result.ok && result.stdout ? result.stdout.trim() : undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function isAncestor(cwd: string, ancestor: string, descendant: string, signal?: AbortSignal): Promise<boolean> {
|
|
32
|
+
return (await previewGit(["merge-base", "--is-ancestor", ancestor, descendant], cwd, "workspace preview ancestor check", signal)).ok;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function patchEquivalentOrContained(cwd: string, oldTip: string, currentTip: string, signal?: AbortSignal): Promise<boolean> {
|
|
36
|
+
const cherry = await previewGit(["cherry", currentTip, oldTip], cwd, "workspace preview protected-tip patch-equivalence", signal);
|
|
37
|
+
if (!cherry.ok) return false;
|
|
38
|
+
return !cherry.stdout.split("\n").some((line) => line.startsWith("+"));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ProtectedTipCheck {
|
|
42
|
+
protectedTipRef?: string;
|
|
43
|
+
protectedTip?: string;
|
|
44
|
+
protectedTipReachable?: boolean;
|
|
45
|
+
protectedTipRestored?: boolean;
|
|
46
|
+
protectedTipDiscardedRef?: string;
|
|
47
|
+
protectedTipDiscardedHead?: string;
|
|
48
|
+
protectedTipError?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function protectWorkspaceTip(input: {
|
|
52
|
+
worktreePath: string;
|
|
53
|
+
workspaceId?: string;
|
|
54
|
+
branch?: string;
|
|
55
|
+
baseRef?: string;
|
|
56
|
+
headSha?: string;
|
|
57
|
+
dirtyCount?: number;
|
|
58
|
+
effectiveAhead: number;
|
|
59
|
+
signal?: AbortSignal;
|
|
60
|
+
}): Promise<ProtectedTipCheck> {
|
|
61
|
+
const ref = protectedTipRef(input.workspaceId);
|
|
62
|
+
if (!ref || !input.headSha) return {};
|
|
63
|
+
|
|
64
|
+
const existing = await readCommit(input.worktreePath, ref, input.signal);
|
|
65
|
+
if (!existing) {
|
|
66
|
+
if (input.effectiveAhead <= 0) return { protectedTipRef: ref };
|
|
67
|
+
const update = await previewGit(["update-ref", ref, input.headSha], input.worktreePath, "workspace preview protect worker tip", input.signal);
|
|
68
|
+
return update.ok
|
|
69
|
+
? { protectedTipRef: ref, protectedTip: input.headSha, protectedTipReachable: true }
|
|
70
|
+
: { protectedTipRef: ref, protectedTipError: gitError(update, "failed to protect worker branch tip") };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (await isAncestor(input.worktreePath, existing, input.headSha, input.signal)) {
|
|
74
|
+
if (input.effectiveAhead > 0 && existing !== input.headSha) {
|
|
75
|
+
const update = await previewGit(["update-ref", ref, input.headSha, existing], input.worktreePath, "workspace preview advance protected worker tip", input.signal);
|
|
76
|
+
if (!update.ok) return { protectedTipRef: ref, protectedTip: existing, protectedTipReachable: true, protectedTipError: gitError(update, "failed to advance protected worker tip") };
|
|
77
|
+
return { protectedTipRef: ref, protectedTip: input.headSha, protectedTipReachable: true };
|
|
78
|
+
}
|
|
79
|
+
return { protectedTipRef: ref, protectedTip: existing, protectedTipReachable: true };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (input.baseRef && await isAncestor(input.worktreePath, existing, input.baseRef, input.signal)) {
|
|
83
|
+
return { protectedTipRef: ref, protectedTip: existing, protectedTipReachable: true };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (await patchEquivalentOrContained(input.worktreePath, existing, input.headSha, input.signal)) {
|
|
87
|
+
const update = await previewGit(["update-ref", ref, input.headSha, existing], input.worktreePath, "workspace preview advance rebased protected worker tip", input.signal);
|
|
88
|
+
if (!update.ok) return { protectedTipRef: ref, protectedTip: existing, protectedTipReachable: true, protectedTipError: gitError(update, "failed to advance rebased protected worker tip") };
|
|
89
|
+
return { protectedTipRef: ref, protectedTip: input.headSha, protectedTipReachable: true };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if ((input.dirtyCount ?? 0) > 0) {
|
|
93
|
+
return { protectedTipRef: ref, protectedTip: existing, protectedTipReachable: false, protectedTipError: "protected worker tip is no longer reachable, and the worktree is dirty" };
|
|
94
|
+
}
|
|
95
|
+
if (!input.branch) {
|
|
96
|
+
return { protectedTipRef: ref, protectedTip: existing, protectedTipReachable: false, protectedTipError: "protected worker tip is no longer reachable, and the current branch is unknown" };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const discardedRef = discardedTipRef(ref);
|
|
100
|
+
const snapshot = await previewGit(["update-ref", discardedRef, input.headSha], input.worktreePath, "workspace preview snapshot discarded worker head", input.signal);
|
|
101
|
+
if (!snapshot.ok) {
|
|
102
|
+
return { protectedTipRef: ref, protectedTip: existing, protectedTipReachable: false, protectedTipError: gitError(snapshot, "failed to snapshot discarded worker head before protected-tip restore") };
|
|
103
|
+
}
|
|
104
|
+
const restore = await previewGit(["checkout", "-B", input.branch, existing], input.worktreePath, "workspace preview restore protected worker tip", input.signal);
|
|
105
|
+
return restore.ok
|
|
106
|
+
? { protectedTipRef: ref, protectedTip: existing, protectedTipReachable: true, protectedTipRestored: true, protectedTipDiscardedRef: discardedRef, protectedTipDiscardedHead: input.headSha }
|
|
107
|
+
: { protectedTipRef: ref, protectedTip: existing, protectedTipReachable: false, protectedTipDiscardedRef: discardedRef, protectedTipDiscardedHead: input.headSha, protectedTipError: gitError(restore, "failed to restore protected worker tip") };
|
|
108
|
+
}
|