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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-orchestrator",
3
- "version": "0.127.11",
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.124",
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
- // #930/#880 the orchestrator observes a child registering through its runner
105
- // info file: the runner writes `controlUrl` immediately after binding its local
106
- // control/MCP server (runner-core writeRunnerInfoFile, right after startMcpProxy),
107
- // which is exactly #880's contended ws-bind window. So the file's presence is the
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
- // Not a spawn failure — the process launched; it just didn't register in
172
- // time. Release the slot (fail-safe) so a slow/never-registering child
173
- // can't wedge capacity, but still treat the launch as successful.
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
- result = await withMergePhaseTimeout("total", (signal) => mergeWorkspace({
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
- signal,
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
  },
@@ -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;
@@ -136,6 +136,7 @@ export interface RunnerInfo {
136
136
  tmuxSocket?: string;
137
137
  pid?: number;
138
138
  startedAt?: number;
139
+ registeredAt?: number;
139
140
  }
140
141
 
141
142
  export interface TerminalAttachSpec {
@@ -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
- state.unmergedAhead = cherry.stdout ? cherry.stdout.split("\n").filter((line) => line.startsWith("+")).length : 0;
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;
@@ -3,6 +3,7 @@ export * from "./deps";
3
3
  export * from "./idle-refresh";
4
4
  export * from "./git-state";
5
5
  export * from "./merge";
6
+ export * from "./plain-git";
6
7
  export * from "./names";
7
8
  export * from "./parse";
8
9
  export * from "./probe";
@@ -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