agent-relay-orchestrator 0.16.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/api.ts +1 -4
- package/src/artifact-proxy.ts +2 -1
- package/src/control.ts +4 -4
- package/src/relay.ts +5 -42
- package/src/spawn.ts +3 -0
- package/src/version.ts +5 -3
- package/src/workspace-probe.ts +150 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-orchestrator",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "Agent Relay orchestrator — manages agent lifecycle across hosts",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"test": "bun test"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"agent-relay-sdk": "0.2.
|
|
19
|
+
"agent-relay-sdk": "0.2.9"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
22
|
"@types/bun": "latest",
|
package/src/api.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { closeSync, lstatSync, mkdirSync, openSync, readdirSync, readSync, statSync } from "node:fs";
|
|
2
|
+
import { stringValue } from "agent-relay-sdk";
|
|
2
3
|
import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
3
4
|
import type { ServerWebSocket } from "bun";
|
|
4
5
|
import { proxyArtifactRequest } from "./artifact-proxy";
|
|
@@ -797,7 +798,3 @@ function cleanTerminalGuestInput(value: unknown): { agentId?: string; policyName
|
|
|
797
798
|
}
|
|
798
799
|
return result;
|
|
799
800
|
}
|
|
800
|
-
|
|
801
|
-
function stringValue(value: unknown): string | undefined {
|
|
802
|
-
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
803
|
-
}
|
package/src/artifact-proxy.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "no
|
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join, relative, resolve } from "node:path";
|
|
4
4
|
import type { OrchestratorConfig } from "./config";
|
|
5
|
+
import { RELAY_TOKEN_HEADER } from "agent-relay-sdk";
|
|
5
6
|
|
|
6
7
|
const SAFE_ARTIFACT_ID = /^[a-zA-Z0-9._-]{1,160}$/;
|
|
7
8
|
const CONTENT_ROUTE = /^\/api\/artifacts\/([^/]+)\/content$/;
|
|
@@ -77,7 +78,7 @@ function forwardHeaders(req: Request, config: OrchestratorConfig): Headers {
|
|
|
77
78
|
if (value) headers.set(name, value);
|
|
78
79
|
}
|
|
79
80
|
const token = config.token || req.headers.get("x-agent-relay-token");
|
|
80
|
-
if (token) headers.set(
|
|
81
|
+
if (token) headers.set(RELAY_TOKEN_HEADER, token);
|
|
81
82
|
return headers;
|
|
82
83
|
}
|
|
83
84
|
|
package/src/control.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isRecord } from "agent-relay-sdk";
|
|
1
2
|
import type { OrchestratorConfig } from "./config";
|
|
2
3
|
import type { ManagedAgentReport, RelayClient, RelayCommand } from "./relay";
|
|
3
4
|
import { handleSelfUpgrade } from "./self-upgrade";
|
|
@@ -115,6 +116,7 @@ export function createControlHandler(
|
|
|
115
116
|
baseSha: typeof command.params.baseSha === "string" ? command.params.baseSha : undefined,
|
|
116
117
|
strategy: command.params.strategy === "pr" || command.params.strategy === "rebase-ff" || command.params.strategy === "auto" ? command.params.strategy : undefined,
|
|
117
118
|
deleteBranch: command.params.deleteBranch !== false,
|
|
119
|
+
push: command.params.push !== false,
|
|
118
120
|
prTitle: typeof command.params.prTitle === "string" ? command.params.prTitle : undefined,
|
|
119
121
|
prBody: typeof command.params.prBody === "string" ? command.params.prBody : undefined,
|
|
120
122
|
});
|
|
@@ -183,6 +185,7 @@ export function spawnOptionsFromControl(ctrl: Record<string, any>, config: Orche
|
|
|
183
185
|
effort: typeof ctrl.effort === "string" ? ctrl.effort : undefined,
|
|
184
186
|
profile: typeof ctrl.profile === "string" ? ctrl.profile : undefined,
|
|
185
187
|
workspaceMode: workspaceMode(ctrl.workspaceMode),
|
|
188
|
+
workspaceSymlinks: stringArray(ctrl.workspaceSymlinks),
|
|
186
189
|
agentProfile: isRecord(ctrl.agentProfile) ? ctrl.agentProfile : undefined,
|
|
187
190
|
label: typeof ctrl.label === "string" ? ctrl.label : undefined,
|
|
188
191
|
agentId: typeof ctrl.agentId === "string" ? ctrl.agentId : undefined,
|
|
@@ -209,6 +212,7 @@ export function spawnOptionsFromRestartSource(restartSource: Record<string, any>
|
|
|
209
212
|
effort: typeof restartSource.effort === "string" ? restartSource.effort : undefined,
|
|
210
213
|
profile: typeof restartSource.profile === "string" ? restartSource.profile : undefined,
|
|
211
214
|
workspaceMode: workspaceMode(restartSource.workspaceMode),
|
|
215
|
+
workspaceSymlinks: stringArray(restartSource.workspaceSymlinks),
|
|
212
216
|
agentProfile: isRecord(restartSource.agentProfile) ? restartSource.agentProfile : undefined,
|
|
213
217
|
label: typeof restartSource.label === "string" ? restartSource.label : undefined,
|
|
214
218
|
agentId: typeof restartSource.agentId === "string" ? restartSource.agentId : undefined,
|
|
@@ -243,7 +247,3 @@ function stringArray(value: unknown): string[] | undefined {
|
|
|
243
247
|
function workspaceMode(value: unknown): SpawnOptions["workspaceMode"] {
|
|
244
248
|
return value === "isolated" || value === "shared" || value === "inherit" ? value : undefined;
|
|
245
249
|
}
|
|
246
|
-
|
|
247
|
-
function isRecord(value: unknown): value is Record<string, any> {
|
|
248
|
-
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
249
|
-
}
|
package/src/relay.ts
CHANGED
|
@@ -2,7 +2,8 @@ import type { OrchestratorConfig } from "./config";
|
|
|
2
2
|
import type { ProviderProbeCache } from "./provider-probe";
|
|
3
3
|
import { detectSelfSupervision } from "./self-supervision";
|
|
4
4
|
import { GIT_SHA, ORCHESTRATOR_PROTOCOL_VERSION, VERSION, runtimeMetadata } from "./version";
|
|
5
|
-
import type { WorkspaceMetadata, WorkspaceMode } from "agent-relay-sdk";
|
|
5
|
+
import type { WorkspaceMetadata, WorkspaceMode, ManagedSessionExitDiagnostics as SdkManagedSessionExitDiagnostics } from "agent-relay-sdk";
|
|
6
|
+
import { RELAY_TOKEN_HEADER } from "agent-relay-sdk";
|
|
6
7
|
|
|
7
8
|
export interface RelayClient {
|
|
8
9
|
register(): Promise<void>;
|
|
@@ -46,46 +47,8 @@ export interface ManagedAgentReport {
|
|
|
46
47
|
startedAt: number;
|
|
47
48
|
}
|
|
48
49
|
|
|
49
|
-
export
|
|
50
|
-
|
|
51
|
-
provider: "claude" | "codex";
|
|
52
|
-
workspaceMode?: WorkspaceMode;
|
|
53
|
-
workspace?: WorkspaceMetadata;
|
|
54
|
-
sessionName?: string;
|
|
55
|
-
tmuxSession: string;
|
|
56
|
-
cwd: string;
|
|
57
|
-
label?: string;
|
|
58
|
-
policyName?: string;
|
|
59
|
-
spawnRequestId?: string;
|
|
60
|
-
automationRunId?: string;
|
|
61
|
-
supervisor: "process" | "systemd" | "launchd" | "unknown";
|
|
62
|
-
systemdUnit?: string;
|
|
63
|
-
terminalSession?: string;
|
|
64
|
-
terminalAvailable?: boolean;
|
|
65
|
-
pid?: number;
|
|
66
|
-
currentPid?: number;
|
|
67
|
-
startedAt: number;
|
|
68
|
-
detectedAt: number;
|
|
69
|
-
runtimeMs: number;
|
|
70
|
-
logFile?: string;
|
|
71
|
-
logBytes?: number;
|
|
72
|
-
logEmpty?: boolean;
|
|
73
|
-
logTail?: string[];
|
|
74
|
-
runnerInfoFile?: string;
|
|
75
|
-
runnerInfoPresent?: boolean;
|
|
76
|
-
systemd?: {
|
|
77
|
-
unit: string;
|
|
78
|
-
activeState?: string;
|
|
79
|
-
subState?: string;
|
|
80
|
-
result?: string;
|
|
81
|
-
execMainCode?: string;
|
|
82
|
-
execMainStatus?: string;
|
|
83
|
-
mainPid?: number;
|
|
84
|
-
unavailable?: string;
|
|
85
|
-
};
|
|
86
|
-
unavailable?: string[];
|
|
87
|
-
lastError: string;
|
|
88
|
-
}
|
|
50
|
+
// Canonical shape lives in agent-relay-sdk — alias and re-export, never re-declare.
|
|
51
|
+
export type ManagedSessionExitDiagnostics = SdkManagedSessionExitDiagnostics;
|
|
89
52
|
|
|
90
53
|
export interface RelayCommand {
|
|
91
54
|
id: string;
|
|
@@ -133,7 +96,7 @@ export function createRelayClient(config: OrchestratorConfig, probeCache: Provid
|
|
|
133
96
|
function headers(): Record<string, string> {
|
|
134
97
|
const h: Record<string, string> = { "Content-Type": "application/json" };
|
|
135
98
|
const token = runtimeToken ?? config.token;
|
|
136
|
-
if (token) h[
|
|
99
|
+
if (token) h[RELAY_TOKEN_HEADER] = token;
|
|
137
100
|
return h;
|
|
138
101
|
}
|
|
139
102
|
|
package/src/spawn.ts
CHANGED
|
@@ -16,6 +16,8 @@ export interface SpawnOptions {
|
|
|
16
16
|
profile?: string;
|
|
17
17
|
workspaceMode?: WorkspaceMode;
|
|
18
18
|
workspace?: WorkspaceMetadata;
|
|
19
|
+
/** Untracked files/dirs to symlink from main into an isolated worktree (relay's global workspace config). */
|
|
20
|
+
workspaceSymlinks?: string[];
|
|
19
21
|
agentProfile?: Record<string, unknown>;
|
|
20
22
|
label?: string;
|
|
21
23
|
agentId?: string;
|
|
@@ -361,6 +363,7 @@ export async function spawnAgent(
|
|
|
361
363
|
const resolvedWorkspace = await resolveSpawnWorkspace({
|
|
362
364
|
...opts,
|
|
363
365
|
label,
|
|
366
|
+
workspaceSymlinks: opts.workspaceSymlinks,
|
|
364
367
|
workspaceRoot: join(resolve(config.baseDir), ".agent-relay", "workspaces"),
|
|
365
368
|
});
|
|
366
369
|
const spawnOpts = { ...opts, label, agentId, cwd: resolvedWorkspace.cwd, workspace: resolvedWorkspace.workspace };
|
package/src/version.ts
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { CONTRACT_VERSIONS } from "agent-relay-sdk";
|
|
4
5
|
|
|
5
6
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
6
7
|
const pkg = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf8")) as { name?: string; version?: string };
|
|
7
8
|
|
|
8
9
|
export const PACKAGE_NAME = pkg.name || "agent-relay-orchestrator";
|
|
9
10
|
export const VERSION = pkg.version || "0.0.0";
|
|
10
|
-
|
|
11
|
-
export const
|
|
12
|
-
export const
|
|
11
|
+
// Protocol versions are owned by the SDK (CONTRACT_VERSIONS) — derive, never redeclare.
|
|
12
|
+
export const ORCHESTRATOR_PROTOCOL_VERSION = CONTRACT_VERSIONS.orchestratorProtocol;
|
|
13
|
+
export const RUNNER_PROTOCOL_VERSION = CONTRACT_VERSIONS.runnerProtocol;
|
|
14
|
+
export const PROVIDER_PLUGIN_PROTOCOL_VERSION = CONTRACT_VERSIONS.providerPluginProtocol;
|
|
13
15
|
export const GIT_SHA = process.env.AGENT_RELAY_GIT_SHA || process.env.GIT_SHA || undefined;
|
|
14
16
|
|
|
15
17
|
export const CONTRACTS = {
|
package/src/workspace-probe.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { existsSync, lstatSync, mkdirSync, readdirSync, statSync, symlinkSync, type Dirent } from "node:fs";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
|
-
import { basename, join, resolve } from "node:path";
|
|
5
|
-
import type { WorkspaceDepsProvision, WorkspaceDiff, WorkspaceDiffFile, WorkspaceGitState, WorkspaceMergePreview, WorkspaceMergeResult, WorkspaceMetadata, WorkspaceMode, WorkspaceProbe, WorkspaceProbeWorktree, WorkspaceStatus } from "agent-relay-sdk";
|
|
4
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
5
|
+
import type { WorkspaceDepsProvision, WorkspaceDiff, WorkspaceDiffFile, WorkspaceGitState, WorkspaceMergePreview, WorkspaceMergeResult, WorkspaceMetadata, WorkspaceMode, WorkspaceProbe, WorkspaceProbeWorktree, WorkspaceStatus, WorkspaceSymlinkProvision } from "agent-relay-sdk";
|
|
6
6
|
|
|
7
7
|
const MAX_DIFF_PATCH_BYTES = 200_000;
|
|
8
8
|
|
|
@@ -13,6 +13,7 @@ interface WorkspaceResolutionInput {
|
|
|
13
13
|
spawnRequestId?: string;
|
|
14
14
|
workspaceMode?: WorkspaceMode;
|
|
15
15
|
workspaceRoot?: string;
|
|
16
|
+
workspaceSymlinks?: string[];
|
|
16
17
|
automationId?: string;
|
|
17
18
|
automationRunId?: string;
|
|
18
19
|
}
|
|
@@ -134,6 +135,10 @@ export async function resolveSpawnWorkspace(input: WorkspaceResolutionInput): Pr
|
|
|
134
135
|
// runner so the agent can typecheck/test/build without manual setup (#159).
|
|
135
136
|
const deps = provisionWorkspaceDeps(repoRoot, worktreePath);
|
|
136
137
|
|
|
138
|
+
// Symlink configured untracked paths (AGENTS.md, .claude-rig, …) from main. Like
|
|
139
|
+
// node_modules, these are gitignored so the fresh worktree lacks them (#159 follow-up).
|
|
140
|
+
const symlinks = provisionWorkspaceSymlinks(repoRoot, worktreePath, input.workspaceSymlinks ?? []);
|
|
141
|
+
|
|
137
142
|
return {
|
|
138
143
|
cwd: worktreePath,
|
|
139
144
|
workspace: {
|
|
@@ -148,6 +153,7 @@ export async function resolveSpawnWorkspace(input: WorkspaceResolutionInput): Pr
|
|
|
148
153
|
baseSha,
|
|
149
154
|
status: "active",
|
|
150
155
|
deps,
|
|
156
|
+
...(symlinks.linked.length || symlinks.errors ? { symlinks } : {}),
|
|
151
157
|
probe,
|
|
152
158
|
},
|
|
153
159
|
};
|
|
@@ -226,6 +232,74 @@ function symlinkNodeModules(repoRoot: string, worktreePath: string): string[] {
|
|
|
226
232
|
return linked;
|
|
227
233
|
}
|
|
228
234
|
|
|
235
|
+
const GLOB_META = /[*?[\]{}!()]/;
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Resolve one config entry to the relative paths it covers within `repoRoot`.
|
|
239
|
+
* Plain entries are taken literally (match files AND dirs via existsSync); entries
|
|
240
|
+
* with glob metacharacters are expanded against main with dotfiles included. Anything
|
|
241
|
+
* that resolves outside the repo (absolute / `..` traversal) or doesn't exist is dropped.
|
|
242
|
+
*/
|
|
243
|
+
function resolveSymlinkPattern(repoRoot: string, pattern: string): string[] {
|
|
244
|
+
const within = (rel: string): boolean => {
|
|
245
|
+
const abs = resolve(repoRoot, rel);
|
|
246
|
+
const back = relative(repoRoot, abs);
|
|
247
|
+
return back !== "" && !back.startsWith("..") && !isAbsolute(back);
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
if (GLOB_META.test(pattern)) {
|
|
251
|
+
const matches: string[] = [];
|
|
252
|
+
for (const rel of new Bun.Glob(pattern).scanSync({ cwd: repoRoot, dot: true, onlyFiles: false })) {
|
|
253
|
+
if (within(rel)) matches.push(rel);
|
|
254
|
+
}
|
|
255
|
+
return matches;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (!within(pattern)) return [];
|
|
259
|
+
return existsSync(join(repoRoot, pattern)) ? [pattern] : [];
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Symlink configured untracked files/dirs from the main checkout into a fresh isolated
|
|
264
|
+
* worktree. Only links paths that exist in main and aren't already present in the worktree
|
|
265
|
+
* (git-tracked files are left untouched). Best-effort: a failed link never blocks the spawn.
|
|
266
|
+
*/
|
|
267
|
+
export function provisionWorkspaceSymlinks(
|
|
268
|
+
repoRoot: string,
|
|
269
|
+
worktreePath: string,
|
|
270
|
+
patterns: string[],
|
|
271
|
+
): WorkspaceSymlinkProvision {
|
|
272
|
+
const linked: string[] = [];
|
|
273
|
+
const seen = new Set<string>();
|
|
274
|
+
const errors: string[] = [];
|
|
275
|
+
|
|
276
|
+
for (const pattern of patterns) {
|
|
277
|
+
let rels: string[];
|
|
278
|
+
try {
|
|
279
|
+
rels = resolveSymlinkPattern(repoRoot, pattern);
|
|
280
|
+
} catch (error) {
|
|
281
|
+
errors.push(`${pattern}: ${error instanceof Error ? error.message : String(error)}`);
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
for (const rel of rels) {
|
|
285
|
+
if (seen.has(rel)) continue;
|
|
286
|
+
seen.add(rel);
|
|
287
|
+
const source = join(repoRoot, rel);
|
|
288
|
+
const target = join(worktreePath, rel);
|
|
289
|
+
if (pathExists(target)) continue; // git-tracked or already linked — don't clobber
|
|
290
|
+
try {
|
|
291
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
292
|
+
symlinkSync(source, target, lstatSync(source).isDirectory() ? "dir" : "file");
|
|
293
|
+
linked.push(rel);
|
|
294
|
+
} catch (error) {
|
|
295
|
+
errors.push(`${rel}: ${error instanceof Error ? error.message : String(error)}`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return errors.length ? { linked, errors } : { linked };
|
|
301
|
+
}
|
|
302
|
+
|
|
229
303
|
/** Run a package install in each dir that owns a lockfile. Root install covers
|
|
230
304
|
* the package manager's workspace members; standalone sub-projects with their
|
|
231
305
|
* own lockfile (e.g. dashboard) get a separate install. */
|
|
@@ -320,6 +394,8 @@ export function workspaceGitState(input: { worktreePath?: string; baseRef?: stri
|
|
|
320
394
|
const dirtyCount = status.stdout ? status.stdout.split("\n").filter(Boolean).length : 0;
|
|
321
395
|
|
|
322
396
|
const state: WorkspaceGitState = { dirty: dirtyCount > 0, dirtyCount };
|
|
397
|
+
const liveBranch = shortBranch(git(["symbolic-ref", "--quiet", "--short", "HEAD"], path).stdout || undefined);
|
|
398
|
+
if (liveBranch) state.branch = liveBranch;
|
|
323
399
|
|
|
324
400
|
const log = git(["log", "-1", "--format=%H%x1f%ct%x1f%s"], path);
|
|
325
401
|
if (log.ok && log.stdout) {
|
|
@@ -478,10 +554,22 @@ interface WorkspaceMergeInput {
|
|
|
478
554
|
baseSha?: string;
|
|
479
555
|
strategy?: "pr" | "rebase-ff" | "auto";
|
|
480
556
|
deleteBranch?: boolean;
|
|
557
|
+
/** Push the advanced base to its upstream (origin) after a rebase-ff land.
|
|
558
|
+
* Defaults to true; auto-skipped when base has no upstream. Disable with
|
|
559
|
+
* AGENT_RELAY_WORKSPACE_PUSH=0. */
|
|
560
|
+
push?: boolean;
|
|
481
561
|
prTitle?: string;
|
|
482
562
|
prBody?: string;
|
|
483
563
|
}
|
|
484
564
|
|
|
565
|
+
/** Behind-count of HEAD relative to `base`, from inside `worktreePath`. */
|
|
566
|
+
function countBehind(worktreePath: string, base: string): number {
|
|
567
|
+
const counts = git(["rev-list", "--left-right", "--count", `${base}...HEAD`], worktreePath);
|
|
568
|
+
if (!counts.ok || !counts.stdout) return 0;
|
|
569
|
+
const behind = Number(counts.stdout.split(/\s+/)[0]);
|
|
570
|
+
return Number.isFinite(behind) ? behind : 0;
|
|
571
|
+
}
|
|
572
|
+
|
|
485
573
|
function hasOriginRemote(cwd: string): boolean {
|
|
486
574
|
return git(["remote", "get-url", "origin"], cwd).ok;
|
|
487
575
|
}
|
|
@@ -605,7 +693,8 @@ export function previewWorkspaceMerge(input: { worktreePath?: string; baseRef?:
|
|
|
605
693
|
/**
|
|
606
694
|
* Integrate a workspace's work back into its base branch. Two strategies:
|
|
607
695
|
* - rebase-ff: rebase the agent branch onto base, fast-forward base to it,
|
|
608
|
-
*
|
|
696
|
+
* push base to origin, then (unless deleteBranch is false) remove the worktree
|
|
697
|
+
* and delete the branch. Lands work locally and publishes it.
|
|
609
698
|
* - pr: push the branch to origin and open a PR via gh. Leaves the worktree
|
|
610
699
|
* and branch intact (the PR needs them).
|
|
611
700
|
* Refuses on a dirty worktree, predicted conflicts, or nothing to merge. Never
|
|
@@ -668,8 +757,24 @@ function mergeRebaseFf(
|
|
|
668
757
|
if (!base) return head({ status: "review_requested", error: "no base branch to merge into" });
|
|
669
758
|
if (!branch) return head({ status: "review_requested", error: "cannot determine agent branch" });
|
|
670
759
|
|
|
760
|
+
// Reconcile with origin before landing (#190/#203). When base tracks an
|
|
761
|
+
// upstream (e.g. main -> origin/main) and we'll push, fetch it and refuse if
|
|
762
|
+
// origin has moved ahead of local base: pushing would then be a non-fast-forward,
|
|
763
|
+
// and we won't rewrite published history or strand a local-only land. The
|
|
764
|
+
// refusal happens BEFORE we mutate anything, so a diverged base is a clean no-op.
|
|
765
|
+
const upstream = upstreamRef(worktreePath, base);
|
|
766
|
+
const slash = upstream ? upstream.indexOf("/") : -1;
|
|
767
|
+
const remote = slash > 0 ? upstream!.slice(0, slash) : undefined; // remote of a `remote/branch` upstream
|
|
768
|
+
const pushEnabled = input.push !== false && process.env.AGENT_RELAY_WORKSPACE_PUSH !== "0" && Boolean(remote);
|
|
769
|
+
if (upstream && remote && pushEnabled) {
|
|
770
|
+
git(["fetch", remote, base], worktreePath); // best-effort freshness; a stale ref can only under-detect divergence
|
|
771
|
+
if (!git(["merge-base", "--is-ancestor", upstream, base], worktreePath).ok) {
|
|
772
|
+
return head({ status: "review_requested", error: `origin moved ahead of local ${base}; sync ${base} with ${upstream} before landing` });
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
671
776
|
// Rebase the agent branch onto base so base can fast-forward to it.
|
|
672
|
-
if ((
|
|
777
|
+
if (countBehind(worktreePath, base) > 0) {
|
|
673
778
|
const rebase = git(["rebase", base], worktreePath);
|
|
674
779
|
if (!rebase.ok) {
|
|
675
780
|
git(["rebase", "--abort"], worktreePath);
|
|
@@ -691,16 +796,38 @@ function mergeRebaseFf(
|
|
|
691
796
|
if (!update.ok) return head({ status: "review_requested", error: update.stderr || "failed to advance base ref" });
|
|
692
797
|
}
|
|
693
798
|
|
|
694
|
-
//
|
|
799
|
+
// Publish the advanced base so local and origin converge (#190). We verified
|
|
800
|
+
// origin was an ancestor of base above, so this is a fast-forward; a failure
|
|
801
|
+
// means origin raced us — surface it instead of claiming an unpublished land.
|
|
802
|
+
let pushed = false;
|
|
803
|
+
if (upstream && remote && pushEnabled) {
|
|
804
|
+
const push = git(["push", remote, `${base}:${base}`], worktreePath);
|
|
805
|
+
if (!push.ok) return head({ status: "review_requested", mergedSha: headSha, error: push.stderr || `git push to ${remote}/${base} failed` });
|
|
806
|
+
pushed = true;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// Work is landed (and published). Tear down only when the owner is gone:
|
|
810
|
+
// deleteBranch is false for a live owner (the relay sets it from owner liveness,
|
|
811
|
+
// #204). A live owner keeps its worktree and is recycled onto a fresh branch cut
|
|
812
|
+
// from the advanced base (#206) so it continues the next task from clean, current
|
|
813
|
+
// state in the same CWD; status returns to `active`. An absent owner gets the
|
|
814
|
+
// worktree reclaimed.
|
|
695
815
|
const deleteBranch = input.deleteBranch !== false;
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
816
|
+
if (!deleteBranch) {
|
|
817
|
+
const fresh = nextBranchName(repoRoot, branch);
|
|
818
|
+
const switched = git(["checkout", "-B", fresh, base], worktreePath);
|
|
819
|
+
if (switched.ok) {
|
|
820
|
+
// The old branch now equals base (just fast-forwarded) — drop the litter.
|
|
821
|
+
const oldDeleted = git(["branch", "-D", branch], repoRoot).ok;
|
|
822
|
+
return head({ merged: true, status: "active", mergedSha: headSha, worktreeRemoved: false, branch: fresh, newBranch: fresh, branchDeleted: oldDeleted, pushed, error: undefined });
|
|
823
|
+
}
|
|
824
|
+
// Recycle failed — keep the existing branch. Still landed, still active.
|
|
825
|
+
return head({ merged: true, status: "active", mergedSha: headSha, worktreeRemoved: false, branchDeleted: false, pushed, error: undefined });
|
|
702
826
|
}
|
|
703
|
-
|
|
827
|
+
const removed = git(["worktree", "remove", "--force", worktreePath], repoRoot);
|
|
828
|
+
const worktreeRemoved = removed.ok;
|
|
829
|
+
const branchDeleted = worktreeRemoved ? git(["branch", "-D", branch], repoRoot).ok : false;
|
|
830
|
+
return head({ merged: true, status: "merged", mergedSha: headSha, worktreeRemoved, branchDeleted, pushed, error: undefined });
|
|
704
831
|
}
|
|
705
832
|
|
|
706
833
|
async function availableBranch(repoRoot: string, base: string): Promise<string> {
|
|
@@ -722,6 +849,17 @@ function branchName(input: WorkspaceResolutionInput, id: string): string {
|
|
|
722
849
|
return `agent/${safeSegment(owner, 48)}/${safeSegment(id.replace(/^sp[_-]?/, ""), 24)}`;
|
|
723
850
|
}
|
|
724
851
|
|
|
852
|
+
/** Next free `<branch>-N` cycle name for a recycled worktree (#206). Strips any
|
|
853
|
+
* existing -N suffix so cycles increment instead of nesting. */
|
|
854
|
+
function nextBranchName(repoRoot: string, branch: string): string {
|
|
855
|
+
const stem = branch.replace(/-\d+$/, "");
|
|
856
|
+
for (let i = 2; i < 1000; i++) {
|
|
857
|
+
const candidate = `${stem}-${i}`;
|
|
858
|
+
if (!git(["show-ref", "--verify", "--quiet", `refs/heads/${candidate}`], repoRoot).ok) return candidate;
|
|
859
|
+
}
|
|
860
|
+
return `${stem}-${Date.now()}`;
|
|
861
|
+
}
|
|
862
|
+
|
|
725
863
|
function repoSlug(repoRoot: string): string {
|
|
726
864
|
const hash = createHash("sha1").update(resolve(repoRoot)).digest("hex").slice(0, 10);
|
|
727
865
|
return `${safeSegment(basename(repoRoot), 60)}-${hash}`;
|