agent-relay-orchestrator 0.127.11 → 0.129.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/control.ts +99 -20
- package/src/relay.ts +15 -0
- package/src/spawn/runtime.ts +1 -0
- package/src/spawn/types.ts +1 -0
- package/src/workspace-probe/git-state.ts +5 -1
- package/src/workspace-probe/index.ts +1 -0
- package/src/workspace-probe/plain-git.ts +96 -0
- package/src/workspace-probe/types.ts +3 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-orchestrator",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.129.0",
|
|
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.4",
|
|
20
|
-
"agent-relay-sdk": "0.2.
|
|
20
|
+
"agent-relay-sdk": "0.2.125",
|
|
21
21
|
"callmux": "0.23.0"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
package/src/control.ts
CHANGED
|
@@ -4,8 +4,8 @@ import { SCHEDULER_COMMAND_MAX_CONCURRENT, SCHEDULER_COMMAND_MAX_OUTPUT_BYTES, t
|
|
|
4
4
|
import type { ManagedAgentReport, RelayClient, RelayCommand } from "./relay";
|
|
5
5
|
import { handleSelfUpgrade } from "./self-upgrade";
|
|
6
6
|
import { readLocalProviderConfigs } from "./provider-config-migration";
|
|
7
|
-
import { findSessionRecord, readRunnerInfo, spawnAgent, stopSession, type SpawnOptions } from "./spawn";
|
|
8
|
-
import { cleanupWorkspace, discardRecoveryBranch, idleRefreshWorktree, mergeWorkspace, pruneWorktrees, reconcileWorkspace, refreshWorkspaceDeps, workspacesRoot } from "./workspace-probe";
|
|
7
|
+
import { findSessionRecord, isSessionAlive, readRunnerInfo, spawnAgent, stopSession, type SpawnOptions } from "./spawn";
|
|
8
|
+
import { cleanupWorkspace, discardRecoveryBranch, idleRefreshWorktree, mergeWorkspace, mergeWorkspacePlainGit, pruneWorktrees, reconcileWorkspace, refreshWorkspaceDeps, workspacesRoot } from "./workspace-probe";
|
|
9
9
|
import { withMergePhaseTimeout } from "./workspace-probe/merge-timeouts";
|
|
10
10
|
import { withRepoLock } from "./repo-lock";
|
|
11
11
|
import { armWorkspacePrAutoMerge, mergeWorkspacePr, refreshWorkspacePrBranch } from "./workspace-pr";
|
|
@@ -74,6 +74,13 @@ export function resolveMaxConcurrentSpawns(env: NodeJS.ProcessEnv = process.env)
|
|
|
74
74
|
const DEFAULT_SPAWN_REGISTRATION_TIMEOUT_MS = 30_000;
|
|
75
75
|
const SPAWN_REGISTRATION_POLL_MS = 100;
|
|
76
76
|
|
|
77
|
+
// #1201 F3 — post-registration liveness settle. waitForManagedRegistration returns on the FIRST
|
|
78
|
+
// `registeredAt` write; a successor that crashes microseconds later would, for a NON-managed agent
|
|
79
|
+
// (no policy → no backoff respawn), be lost the instant we stop the predecessor. Before tearing
|
|
80
|
+
// down the predecessor we wait this beat and confirm the successor is STILL alive. Managed agents
|
|
81
|
+
// have backoff respawn as a net; non-managed must never be strandable.
|
|
82
|
+
const DEFAULT_SPAWN_LIVENESS_SETTLE_MS = 300;
|
|
83
|
+
|
|
77
84
|
export function resolveSpawnRegistrationTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
|
|
78
85
|
const raw = env.AGENT_RELAY_SPAWN_REGISTRATION_TIMEOUT_MS;
|
|
79
86
|
if (raw === undefined || raw.trim() === "") return DEFAULT_SPAWN_REGISTRATION_TIMEOUT_MS;
|
|
@@ -101,19 +108,17 @@ export function resolveSchedulerCommandMaxOutputBytes(env: NodeJS.ProcessEnv = p
|
|
|
101
108
|
/** Awaits a spawn slot until the child registers, or resolves false at the fail-safe deadline. */
|
|
102
109
|
export type WaitForRegistration = (agent: ManagedAgentReport, timeoutMs: number) => Promise<boolean>;
|
|
103
110
|
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
// host-local signal that the child is past that window; holding the slot until it
|
|
109
|
-
// appears is what bounds how many children materialize→bind at once.
|
|
111
|
+
// Registration is explicit in the runner-info file. `controlUrl` appears before the
|
|
112
|
+
// Relay bus register completes, so using file existence alone creates a false-success
|
|
113
|
+
// window where a restart can kill the predecessor before the successor owns its Relay
|
|
114
|
+
// identity. `registeredAt` is written only after registerWithinDeadline resolves.
|
|
110
115
|
async function waitForManagedRegistration(agent: ManagedAgentReport, timeoutMs: number): Promise<boolean> {
|
|
111
116
|
const session = agent.sessionName ?? agent.tmuxSession;
|
|
112
117
|
if (!session) return false;
|
|
113
118
|
const deadline = Date.now() + timeoutMs;
|
|
114
119
|
for (;;) {
|
|
115
120
|
const record = findSessionRecord({ tmuxSession: session, agentId: agent.agentId });
|
|
116
|
-
if (record && readRunnerInfo(record)) return true;
|
|
121
|
+
if (record && readRunnerInfo(record)?.registeredAt) return true;
|
|
117
122
|
if (Date.now() >= deadline) return false;
|
|
118
123
|
await Bun.sleep(SPAWN_REGISTRATION_POLL_MS);
|
|
119
124
|
}
|
|
@@ -134,6 +139,12 @@ interface ControlHandlerDeps {
|
|
|
134
139
|
stopSession?: typeof stopSession;
|
|
135
140
|
maxConcurrentSchedulerCommands?: number;
|
|
136
141
|
schedulerCommandMaxOutputBytes?: number;
|
|
142
|
+
// #1201 F3 — injectable post-registration liveness settle. Defaults to sleeping settleMs then
|
|
143
|
+
// checking the successor session is still alive. Tests override it to simulate a register-then-crash.
|
|
144
|
+
settleRegistration?: (agent: ManagedAgentReport, settleMs: number) => Promise<boolean>;
|
|
145
|
+
registrationSettleMs?: number;
|
|
146
|
+
// #1201 F2 — injectable safe-point re-check. Defaults to relay.recheckSelfRestartSafePoint.
|
|
147
|
+
recheckSafePoint?: (agentId: string) => Promise<{ ok: boolean; reason?: string }>;
|
|
137
148
|
}
|
|
138
149
|
|
|
139
150
|
export function createControlHandler(
|
|
@@ -149,6 +160,13 @@ export function createControlHandler(
|
|
|
149
160
|
const spawnSlots = new Semaphore(deps.maxConcurrentSpawns ?? resolveMaxConcurrentSpawns());
|
|
150
161
|
const waitForRegistration = deps.waitForRegistration ?? waitForManagedRegistration;
|
|
151
162
|
const registrationTimeoutMs = deps.registrationTimeoutMs ?? resolveSpawnRegistrationTimeoutMs();
|
|
163
|
+
const registrationSettleMs = deps.registrationSettleMs ?? DEFAULT_SPAWN_LIVENESS_SETTLE_MS;
|
|
164
|
+
const settleRegistration = deps.settleRegistration ?? (async (agent, settleMs) => {
|
|
165
|
+
if (settleMs > 0) await Bun.sleep(settleMs);
|
|
166
|
+
const session = agent.sessionName ?? agent.tmuxSession;
|
|
167
|
+
return session ? isSessionAlive(session) : false;
|
|
168
|
+
});
|
|
169
|
+
const recheckSafePoint = deps.recheckSafePoint ?? ((agentId: string) => relay.recheckSelfRestartSafePoint(agentId));
|
|
152
170
|
const schedulerCommandSlots = new Semaphore(deps.maxConcurrentSchedulerCommands ?? resolveMaxConcurrentSchedulerCommands());
|
|
153
171
|
const schedulerCommandMaxOutputBytes = deps.schedulerCommandMaxOutputBytes ?? resolveSchedulerCommandMaxOutputBytes();
|
|
154
172
|
// Backgrounded spawn tasks: each is claimed synchronously (status → accepted)
|
|
@@ -164,18 +182,33 @@ export function createControlHandler(
|
|
|
164
182
|
return agent;
|
|
165
183
|
}
|
|
166
184
|
|
|
167
|
-
async function spawnAndWaitForRegistration(opts: SpawnOptions, action = "Spawned"): Promise<ManagedAgentReport> {
|
|
185
|
+
async function spawnAndWaitForRegistration(opts: SpawnOptions, action = "Spawned", requireRegistration = false): Promise<ManagedAgentReport> {
|
|
168
186
|
const agent = await spawnManagedAgent(opts, action);
|
|
169
187
|
const registered = await waitForRegistration(agent, registrationTimeoutMs);
|
|
170
188
|
if (!registered) {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
189
|
+
if (requireRegistration) {
|
|
190
|
+
const candidateSession = agent.sessionName ?? agent.tmuxSession;
|
|
191
|
+
if (candidateSession) {
|
|
192
|
+
await dispatchStop(candidateSession, config, "successor failed to register", false, 5_000).catch(() => undefined);
|
|
193
|
+
managedAgents = managedAgents.filter((item) => (item.sessionName ?? item.tmuxSession) !== candidateSession);
|
|
194
|
+
}
|
|
195
|
+
throw new Error(`successor ${candidateSession ?? agent.agentId} did not register within ${registrationTimeoutMs}ms`);
|
|
196
|
+
}
|
|
174
197
|
console.error(`[orchestrator] spawn ${agent.tmuxSession} did not register within ${registrationTimeoutMs}ms; releasing slot (fail-safe)`);
|
|
175
198
|
}
|
|
176
199
|
return agent;
|
|
177
200
|
}
|
|
178
201
|
|
|
202
|
+
// #1201 F2/F3 — reconcile an aborted self-restart teardown by removing the SUCCESSOR (keeping
|
|
203
|
+
// the predecessor). Best-effort stop; drop it from the managed roster so we don't report a
|
|
204
|
+
// generation we just killed.
|
|
205
|
+
async function tearDownSuccessor(successor: ManagedAgentReport, reason: string): Promise<void> {
|
|
206
|
+
const successorSession = successor.sessionName ?? successor.tmuxSession;
|
|
207
|
+
if (!successorSession) return;
|
|
208
|
+
await dispatchStop(successorSession, config, reason, false, 5_000).catch(() => undefined);
|
|
209
|
+
managedAgents = managedAgents.filter((item) => (item.sessionName ?? item.tmuxSession) !== successorSession);
|
|
210
|
+
}
|
|
211
|
+
|
|
179
212
|
async function markSpawnCommandFailed(command: RelayCommand, error: unknown): Promise<void> {
|
|
180
213
|
console.error(`[orchestrator] spawn dispatch failed: ${errMessage(error)}`);
|
|
181
214
|
// #930 — the `failed` status write can itself reject during a relay blip. Wrap it so
|
|
@@ -264,7 +297,7 @@ export function createControlHandler(
|
|
|
264
297
|
if (restart && restartSpawn) {
|
|
265
298
|
await spawnSlots.acquire();
|
|
266
299
|
try {
|
|
267
|
-
restarted = await spawnAndWaitForRegistration(spawnOptionsFromRestartSource(restartSpawn, config), "Restarted");
|
|
300
|
+
restarted = await spawnAndWaitForRegistration(spawnOptionsFromRestartSource(restartSpawn, config), "Restarted", true);
|
|
268
301
|
} finally {
|
|
269
302
|
spawnSlots.release();
|
|
270
303
|
}
|
|
@@ -279,14 +312,46 @@ export function createControlHandler(
|
|
|
279
312
|
spawnRequestId: ctrl.spawnRequestId,
|
|
280
313
|
};
|
|
281
314
|
}
|
|
282
|
-
const result = await dispatchStop(session, config, typeof ctrl.reason === "string" ? ctrl.reason : restart ? "restart" : "shutdown", ctrl.graceful !== false, shutdownTimeoutMs(ctrl));
|
|
283
|
-
managedAgents = managedAgents.filter((agent) => (agent.sessionName ?? agent.tmuxSession) !== session);
|
|
284
315
|
// A managed restart carries a fresh spawnRequestId in restartSpawn — keep it.
|
|
285
316
|
// Falling back to the live agent's params would reuse the stale id and break
|
|
286
317
|
// relay correlation, so drop it and let spawnAgent assign a new identity.
|
|
287
318
|
const restartSource = (restartSpawn ?? (current ? { ...current, spawnRequestId: undefined } : undefined)) as Record<string, any> | undefined;
|
|
288
319
|
let restarted: ManagedAgentReport | undefined;
|
|
289
|
-
if (restart && restartSource) {
|
|
320
|
+
if (restart && restartSpawn && restartSource) {
|
|
321
|
+
await spawnSlots.acquire();
|
|
322
|
+
try {
|
|
323
|
+
// Transactional swap: the successor must complete Relay registration before the
|
|
324
|
+
// predecessor session is touched. A spawn/registration failure throws with the old
|
|
325
|
+
// process still alive and the durable continuation artifact still fetchable.
|
|
326
|
+
restarted = await spawnAndWaitForRegistration(spawnOptionsFromRestartSource(restartSource, config), "Restarted", true);
|
|
327
|
+
} finally {
|
|
328
|
+
spawnSlots.release();
|
|
329
|
+
}
|
|
330
|
+
// #1201 F3 — liveness settle: confirm the successor is STILL alive a beat after its first
|
|
331
|
+
// `registeredAt` write, BEFORE the predecessor teardown becomes irreversible. A successor that
|
|
332
|
+
// crashes microseconds after registering would otherwise strand a non-managed agent (no
|
|
333
|
+
// policy → no backoff respawn). On failure: tear down the (dead) successor, keep predecessor.
|
|
334
|
+
if (!(await settleRegistration(restarted, registrationSettleMs))) {
|
|
335
|
+
await tearDownSuccessor(restarted, "successor died during post-registration liveness settle");
|
|
336
|
+
throw new Error(`successor ${restarted.sessionName ?? restarted.tmuxSession} did not stay alive after registration settle; predecessor kept`);
|
|
337
|
+
}
|
|
338
|
+
// #1201 F2 — TOCTOU safe-point re-check for a SELF-restart, as late as possible before the
|
|
339
|
+
// kill. register-before-kill left the predecessor running after it got {ok:true}, so it could
|
|
340
|
+
// have created WIP / spawned a child / taken a merge lease in the window. Reconciliation on a
|
|
341
|
+
// now-violated safe-point: tear down the SUCCESSOR and KEEP the predecessor — never two live
|
|
342
|
+
// generations, never a lost agent. The caller's own live process simply keeps running; the
|
|
343
|
+
// failed command records why. Fail-closed: an unavailable re-check is treated as a violation.
|
|
344
|
+
if (isRecord(ctrl.selfRestart) && typeof ctrl.agentId === "string") {
|
|
345
|
+
const recheck = await recheckSafePoint(ctrl.agentId);
|
|
346
|
+
if (!recheck.ok) {
|
|
347
|
+
await tearDownSuccessor(restarted, `self-restart safe-point re-check failed: ${recheck.reason ?? "unknown"}`);
|
|
348
|
+
throw new Error(`self-restart aborted at teardown: ${recheck.reason ?? "safe-point re-check failed"}; predecessor kept, successor torn down`);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
const result = await dispatchStop(session, config, typeof ctrl.reason === "string" ? ctrl.reason : restart ? "restart" : "shutdown", ctrl.graceful !== false, shutdownTimeoutMs(ctrl));
|
|
353
|
+
managedAgents = managedAgents.filter((agent) => (agent.sessionName ?? agent.tmuxSession) !== session);
|
|
354
|
+
if (restart && !restartSpawn && restartSource) {
|
|
290
355
|
await spawnSlots.acquire();
|
|
291
356
|
try {
|
|
292
357
|
restarted = await spawnAndWaitForRegistration(spawnOptionsFromRestartSource(restartSource, config), "Restarted");
|
|
@@ -387,6 +452,17 @@ export function createControlHandler(
|
|
|
387
452
|
}
|
|
388
453
|
|
|
389
454
|
async function runNonSpawnCommand(command: RelayCommand): Promise<boolean> {
|
|
455
|
+
// #1201 F4 — at-most-once guard for restart/shutdown, mirroring runSpawnToRegistration's
|
|
456
|
+
// terminal-status re-fetch. A redelivered agent.restart (e.g. relay reconcile after an
|
|
457
|
+
// orchestrator bounce) must not spawn a SECOND successor sharing the predecessor's fresh
|
|
458
|
+
// spawnRequestId. If the command already reached a terminal state, this is a replay — skip it.
|
|
459
|
+
if (command.type === "agent.restart" || command.type === "agent.shutdown") {
|
|
460
|
+
const current = await relay.getCommand(command.id);
|
|
461
|
+
if (current && TERMINAL_COMMAND_STATUSES.has(current.status)) {
|
|
462
|
+
console.error(`[orchestrator] skipping ${command.type} ${command.id}: already ${current.status} (replay guard)`);
|
|
463
|
+
return true;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
390
466
|
await relay.updateCommand(command.id, "accepted");
|
|
391
467
|
await relay.updateCommand(command.id, "running");
|
|
392
468
|
try {
|
|
@@ -420,7 +496,7 @@ export function createControlHandler(
|
|
|
420
496
|
} else if (command.type === "workspace.merge") {
|
|
421
497
|
let result: WorkspaceMergeResult;
|
|
422
498
|
try {
|
|
423
|
-
|
|
499
|
+
const mergeInput = {
|
|
424
500
|
id: typeof command.params.workspaceId === "string" ? command.params.workspaceId : undefined,
|
|
425
501
|
repoRoot: typeof command.params.repoRoot === "string" ? command.params.repoRoot : undefined,
|
|
426
502
|
worktreePath: typeof command.params.worktreePath === "string" ? command.params.worktreePath : undefined,
|
|
@@ -428,6 +504,7 @@ export function createControlHandler(
|
|
|
428
504
|
baseRef: typeof command.params.baseRef === "string" ? command.params.baseRef : undefined,
|
|
429
505
|
baseSha: typeof command.params.baseSha === "string" ? command.params.baseSha : undefined,
|
|
430
506
|
strategy: command.params.strategy === "pr" || command.params.strategy === "rebase-ff" || command.params.strategy === "auto" ? command.params.strategy : undefined,
|
|
507
|
+
landMechanism: command.params.landMechanism === "plain-git" ? "plain-git" : "managed",
|
|
431
508
|
deleteBranch: command.params.deleteBranch !== false,
|
|
432
509
|
push: command.params.push !== false,
|
|
433
510
|
prTitle: typeof command.params.prTitle === "string" ? command.params.prTitle : undefined,
|
|
@@ -442,8 +519,10 @@ export function createControlHandler(
|
|
|
442
519
|
subject: typeof command.params.prLanded.subject === "string" ? command.params.prLanded.subject : undefined,
|
|
443
520
|
}
|
|
444
521
|
: undefined,
|
|
445
|
-
|
|
446
|
-
|
|
522
|
+
} as const;
|
|
523
|
+
result = await withMergePhaseTimeout("total", (signal) => mergeInput.landMechanism === "plain-git"
|
|
524
|
+
? mergeWorkspacePlainGit({ ...mergeInput, signal })
|
|
525
|
+
: mergeWorkspace({ ...mergeInput, signal }));
|
|
447
526
|
} catch (err) {
|
|
448
527
|
const branch = typeof command.params.branch === "string" ? command.params.branch : undefined;
|
|
449
528
|
const baseRef = typeof command.params.baseRef === "string" ? command.params.baseRef : undefined;
|
package/src/relay.ts
CHANGED
|
@@ -20,6 +20,10 @@ export interface RelayClient {
|
|
|
20
20
|
startHeartbeatLoop(): void;
|
|
21
21
|
stopHeartbeatLoop(): void;
|
|
22
22
|
remintRunnerToken(currentToken: string): Promise<RunnerTokenRemint | null>;
|
|
23
|
+
// #1201 F2 — re-evaluate the self-restart safe-point against CURRENT relay state, called
|
|
24
|
+
// immediately before killing the predecessor in a self-restart teardown. Fail-closed: any
|
|
25
|
+
// transport/parse failure resolves to a violation so the caller aborts the kill.
|
|
26
|
+
recheckSelfRestartSafePoint(agentId: string): Promise<{ ok: boolean; reason?: string }>;
|
|
23
27
|
connected: boolean;
|
|
24
28
|
}
|
|
25
29
|
|
|
@@ -269,6 +273,17 @@ export function createRelayClient(config: OrchestratorConfig, probeCache: Provid
|
|
|
269
273
|
return null;
|
|
270
274
|
}
|
|
271
275
|
},
|
|
276
|
+
async recheckSelfRestartSafePoint(agentId: string): Promise<{ ok: boolean; reason?: string }> {
|
|
277
|
+
try {
|
|
278
|
+
const res = await apiCall("POST", `/agents/${encodeURIComponent(agentId)}/self-restart-recheck`, {});
|
|
279
|
+
if (!res.ok) return { ok: false, reason: `self-restart safe-point re-check returned ${res.status}` };
|
|
280
|
+
const body = await res.json().catch(() => null) as { ok?: boolean; reason?: string } | null;
|
|
281
|
+
if (!body || typeof body.ok !== "boolean") return { ok: false, reason: "self-restart safe-point re-check returned an invalid response" };
|
|
282
|
+
return { ok: body.ok, reason: body.reason };
|
|
283
|
+
} catch (error) {
|
|
284
|
+
return { ok: false, reason: `self-restart safe-point re-check unavailable: ${isAbortError(error) ? "timed out" : String(error)}` };
|
|
285
|
+
}
|
|
286
|
+
},
|
|
272
287
|
acquireProviderQuotaLease(orchestratorId: string, input: ProviderQuotaLeaseAcquireInput): Promise<ProviderQuotaLeaseAcquireResult> {
|
|
273
288
|
return http.acquireProviderQuotaLease(orchestratorId, input);
|
|
274
289
|
},
|
package/src/spawn/runtime.ts
CHANGED
|
@@ -147,6 +147,7 @@ export function readRunnerInfo(record: Pick<SessionRecord, "runnerInfoFile" | "a
|
|
|
147
147
|
tmuxSocket: typeof info.tmuxSocket === "string" ? info.tmuxSocket : undefined,
|
|
148
148
|
pid: typeof info.pid === "number" ? info.pid : undefined,
|
|
149
149
|
startedAt: typeof info.startedAt === "number" ? info.startedAt : undefined,
|
|
150
|
+
registeredAt: typeof info.registeredAt === "number" ? info.registeredAt : undefined,
|
|
150
151
|
};
|
|
151
152
|
} catch {
|
|
152
153
|
return null;
|
package/src/spawn/types.ts
CHANGED
|
@@ -64,7 +64,11 @@ export async function populateMergeState(cwd: string, targetRef: string, state:
|
|
|
64
64
|
} else {
|
|
65
65
|
const cherry = await git(["cherry", cherryBase, targetRef], cwd);
|
|
66
66
|
if (cherry.ok) {
|
|
67
|
-
|
|
67
|
+
const unmergedLines = cherry.stdout ? cherry.stdout.split("\n").filter((line) => line.startsWith("+")) : [];
|
|
68
|
+
state.unmergedAhead = unmergedLines.length;
|
|
69
|
+
if (unmergedLines.length) {
|
|
70
|
+
state.unmergedShas = unmergedLines.slice(0, 20).map((line) => line.slice(2).trim().slice(0, 8));
|
|
71
|
+
}
|
|
68
72
|
}
|
|
69
73
|
}
|
|
70
74
|
if (state.unmergedAhead === 0) state.landed = true;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import type { WorkspaceMergeResult } from "agent-relay-sdk";
|
|
4
|
+
import { git } from "../git";
|
|
5
|
+
import { deleteBranchIfSafe, owningRepoRoot } from "./cleanup";
|
|
6
|
+
import { refreshWorkspaceDeps } from "./deps";
|
|
7
|
+
import { throwIfMergeAborted } from "./merge-timeouts";
|
|
8
|
+
import { nextBranchName } from "./names";
|
|
9
|
+
import { shortBranch } from "./parse";
|
|
10
|
+
import type { WorkspaceMergeInput } from "./types";
|
|
11
|
+
|
|
12
|
+
const MAX_PUSH_ATTEMPTS = 3;
|
|
13
|
+
|
|
14
|
+
function result(input: WorkspaceMergeInput, branch: string | undefined, fields: Partial<WorkspaceMergeResult>): WorkspaceMergeResult {
|
|
15
|
+
return {
|
|
16
|
+
workspaceId: input.id,
|
|
17
|
+
strategy: "rebase-ff",
|
|
18
|
+
landMechanism: "plain-git",
|
|
19
|
+
merged: false,
|
|
20
|
+
status: "review_requested",
|
|
21
|
+
...(branch ? { branch } : {}),
|
|
22
|
+
...fields,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function baseBranch(baseRef: string | undefined): string | undefined {
|
|
27
|
+
if (!baseRef) return undefined;
|
|
28
|
+
return baseRef.startsWith("refs/heads/") ? baseRef.slice("refs/heads/".length) : baseRef;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The deliberately small, gateless direct executor. It does not touch Relay's
|
|
33
|
+
* merge lease or integrated-tree gate: each attempt fetches current origin/base,
|
|
34
|
+
* rebases the worker tip, then atomically pushes HEAD to base. A rejected push
|
|
35
|
+
* simply repeats that sequence against the new origin tip.
|
|
36
|
+
*/
|
|
37
|
+
export async function mergeWorkspacePlainGit(input: WorkspaceMergeInput): Promise<WorkspaceMergeResult> {
|
|
38
|
+
if (!input.worktreePath) return result(input, input.branch, { error: "worktreePath required" });
|
|
39
|
+
const worktreePath = resolve(input.worktreePath);
|
|
40
|
+
if (!existsSync(worktreePath)) return result(input, input.branch, { status: "cleaned", error: `worktree does not exist: ${worktreePath}` });
|
|
41
|
+
const signal = input.signal;
|
|
42
|
+
throwIfMergeAborted(signal);
|
|
43
|
+
const branch = shortBranch((await git(["symbolic-ref", "--quiet", "--short", "HEAD"], worktreePath, { signal })).stdout || undefined) ?? input.branch;
|
|
44
|
+
const base = baseBranch(input.baseRef);
|
|
45
|
+
if (!base) return result(input, branch, { error: "no base branch to push" });
|
|
46
|
+
const dirty = await git(["status", "--porcelain"], worktreePath, { signal });
|
|
47
|
+
if (!dirty.ok) return result(input, branch, { error: dirty.stderr || "failed to inspect workspace status" });
|
|
48
|
+
if (dirty.stdout.trim()) return result(input, branch, { error: "worktree has uncommitted changes" });
|
|
49
|
+
|
|
50
|
+
let lastError = "plain-git land failed";
|
|
51
|
+
for (let attempt = 1; attempt <= MAX_PUSH_ATTEMPTS; attempt += 1) {
|
|
52
|
+
throwIfMergeAborted(signal);
|
|
53
|
+
const fetch = await git(["fetch", "origin", base], worktreePath, { signal });
|
|
54
|
+
if (!fetch.ok) return result(input, branch, { error: fetch.stderr || `git fetch origin ${base} failed` });
|
|
55
|
+
const rebase = await git(["rebase", `origin/${base}`], worktreePath, { signal });
|
|
56
|
+
if (!rebase.ok) {
|
|
57
|
+
await git(["rebase", "--abort"], worktreePath, { signal });
|
|
58
|
+
return result(input, branch, { conflict: true, status: "conflict", error: rebase.stderr || `git rebase origin/${base} failed` });
|
|
59
|
+
}
|
|
60
|
+
const head = await git(["rev-parse", "HEAD"], worktreePath, { signal });
|
|
61
|
+
if (!head.ok || !head.stdout.trim()) return result(input, branch, { error: head.stderr || "failed to resolve rebased HEAD" });
|
|
62
|
+
const push = await git(["push", "origin", `HEAD:${base}`], worktreePath, { signal });
|
|
63
|
+
if (!push.ok) {
|
|
64
|
+
lastError = push.stderr || push.stdout || `push to origin/${base} rejected`;
|
|
65
|
+
console.warn(`[orchestrator] plain-git land push rejected workspace=${input.id ?? "(unknown)"} attempt=${attempt}/${MAX_PUSH_ATTEMPTS}; refetching and rebasing`);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const mergedSha = head.stdout.trim();
|
|
69
|
+
const verifyFetch = await git(["fetch", "origin", base], worktreePath, { signal });
|
|
70
|
+
const verified = verifyFetch.ok && (await git(["merge-base", "--is-ancestor", mergedSha, `origin/${base}`], worktreePath, { signal })).ok;
|
|
71
|
+
if (!verified) return result(input, branch, { error: `push reported success but ${mergedSha} is not on origin/${base}` });
|
|
72
|
+
const subject = (await git(["log", "-1", "--format=%s", mergedSha], worktreePath, { signal })).stdout.trim() || undefined;
|
|
73
|
+
const repoRoot = input.repoRoot ? resolve(input.repoRoot) : worktreePath;
|
|
74
|
+
if (input.deleteBranch === false) {
|
|
75
|
+
if (branch) {
|
|
76
|
+
const ownerRepo = await owningRepoRoot(worktreePath, repoRoot);
|
|
77
|
+
const fresh = await nextBranchName(ownerRepo, branch);
|
|
78
|
+
const checkout = await git(["checkout", "-B", fresh, `origin/${base}`], worktreePath, { signal });
|
|
79
|
+
if (checkout.ok) {
|
|
80
|
+
const deleteResult = await deleteBranchIfSafe(ownerRepo, branch, undefined, mergedSha, signal, input.id);
|
|
81
|
+
const depsRefresh = await refreshWorkspaceDeps(ownerRepo, worktreePath);
|
|
82
|
+
return result(input, fresh, { merged: true, status: "active", mergedSha, baseSha: mergedSha, subject, pushed: true, worktreeRemoved: false, newBranch: fresh, ...deleteResult, ...(depsRefresh.refreshed || depsRefresh.stale || depsRefresh.error ? { depsRefresh } : {}), error: undefined });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return result(input, branch, { merged: true, status: "active", mergedSha, baseSha: mergedSha, subject, pushed: true, worktreeRemoved: false, branchDeleted: false, error: undefined });
|
|
86
|
+
}
|
|
87
|
+
const ownerRepo = branch ? await owningRepoRoot(worktreePath, repoRoot) : repoRoot;
|
|
88
|
+
const removed = await git(["worktree", "remove", "--force", worktreePath], ownerRepo, { signal });
|
|
89
|
+
const worktreeRemoved = removed.ok;
|
|
90
|
+
const deleteResult = worktreeRemoved && branch
|
|
91
|
+
? await deleteBranchIfSafe(ownerRepo, branch, undefined, mergedSha, signal, input.id)
|
|
92
|
+
: { branchDeleted: false };
|
|
93
|
+
return result(input, branch, { merged: true, status: "merged", mergedSha, baseSha: mergedSha, subject, pushed: true, worktreeRemoved, ...deleteResult, error: undefined });
|
|
94
|
+
}
|
|
95
|
+
return result(input, branch, { error: `plain-git push rejected after ${MAX_PUSH_ATTEMPTS} attempts: ${lastError}` });
|
|
96
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { WorkspaceMetadata, WorkspaceMode } from "agent-relay-sdk";
|
|
2
|
-
import type { WorkspaceLandGateLevel, WorkspaceMergeResult } from "agent-relay-sdk";
|
|
2
|
+
import type { WorkspaceLandGateLevel, WorkspaceLandMechanism, WorkspaceMergeResult } from "agent-relay-sdk";
|
|
3
3
|
|
|
4
4
|
/** Attach to or branch off an existing managed worktree instead of creating a fresh one (#635). */
|
|
5
5
|
export interface ResumeWorkspaceTarget {
|
|
@@ -47,6 +47,8 @@ export interface WorkspaceMergeInput {
|
|
|
47
47
|
baseRef?: string;
|
|
48
48
|
baseSha?: string;
|
|
49
49
|
strategy?: "pr" | "rebase-ff" | "auto";
|
|
50
|
+
/** Direct-land executor selected by Relay. PR/auto remain managed. */
|
|
51
|
+
landMechanism?: WorkspaceLandMechanism;
|
|
50
52
|
deleteBranch?: boolean;
|
|
51
53
|
/** Push the advanced base to its upstream (origin) after a rebase-ff land.
|
|
52
54
|
* Defaults to true; auto-skipped when base has no upstream. Disable with
|