agent-relay-orchestrator 0.119.3 → 0.119.7
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 +3 -3
- package/src/control.ts +65 -27
- package/src/git.ts +1 -0
- package/src/process.ts +32 -10
- package/src/relay.ts +8 -0
- package/src/spawn/command.ts +9 -2
- package/src/workspace-probe/cleanup.ts +11 -2
- package/src/workspace-probe/git-state.ts +6 -6
- package/src/workspace-probe/merge-timeouts.ts +27 -3
- package/src/workspace-probe/merge.ts +128 -102
- package/src/workspace-probe/types.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-orchestrator",
|
|
3
|
-
"version": "0.119.
|
|
3
|
+
"version": "0.119.7",
|
|
4
4
|
"description": "Agent Relay orchestrator — manages agent lifecycle across hosts",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
"test": "bun test"
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
|
-
"agent-relay-providers": "0.104.
|
|
20
|
-
"agent-relay-sdk": "0.2.
|
|
19
|
+
"agent-relay-providers": "0.104.3",
|
|
20
|
+
"agent-relay-sdk": "0.2.105",
|
|
21
21
|
"callmux": "0.23.0"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
package/src/control.ts
CHANGED
|
@@ -32,6 +32,7 @@ interface ControlHandler {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
const DEFAULT_MAX_CONCURRENT_SPAWNS = 2;
|
|
35
|
+
const TERMINAL_COMMAND_STATUSES = new Set(["succeeded", "failed", "timed_out", "rejected", "canceled"]);
|
|
35
36
|
|
|
36
37
|
// #930/#880 — per-host spawn concurrency cap. Bursts of spawns racing worktree
|
|
37
38
|
// materialization + port churn during the registration window are what surface
|
|
@@ -95,6 +96,8 @@ interface ControlHandlerDeps {
|
|
|
95
96
|
waitForRegistration?: WaitForRegistration;
|
|
96
97
|
// Override the fail-safe registration timeout, bypassing the env parse (tests).
|
|
97
98
|
registrationTimeoutMs?: number;
|
|
99
|
+
// Injectable stop dispatch — production uses tmux/system process teardown.
|
|
100
|
+
stopSession?: typeof stopSession;
|
|
98
101
|
}
|
|
99
102
|
|
|
100
103
|
export function createControlHandler(
|
|
@@ -104,6 +107,7 @@ export function createControlHandler(
|
|
|
104
107
|
): ControlHandler {
|
|
105
108
|
let managedAgents: ManagedAgentReport[] = [];
|
|
106
109
|
const dispatchSpawn = deps.spawnAgent ?? spawnAgent;
|
|
110
|
+
const dispatchStop = deps.stopSession ?? stopSession;
|
|
107
111
|
// In-memory counter is correct here: a slot only needs to live within this
|
|
108
112
|
// orchestrator process, and a crash resets in-flight spawns anyway (#881 Q1).
|
|
109
113
|
const spawnSlots = new Semaphore(deps.maxConcurrentSpawns ?? resolveMaxConcurrentSpawns());
|
|
@@ -114,14 +118,38 @@ export function createControlHandler(
|
|
|
114
118
|
// registration window. Tracked so tests/shutdown can observe and settle them.
|
|
115
119
|
const backgroundSpawns = new Set<Promise<void>>();
|
|
116
120
|
|
|
117
|
-
async function
|
|
118
|
-
const opts = spawnOptionsFromControl(ctrl, config);
|
|
121
|
+
async function spawnManagedAgent(opts: SpawnOptions, action = "Spawned"): Promise<ManagedAgentReport> {
|
|
119
122
|
const agent = await dispatchSpawn(opts, config);
|
|
120
123
|
managedAgents.push(agent);
|
|
121
|
-
console.error(`[orchestrator]
|
|
124
|
+
console.error(`[orchestrator] ${action} ${opts.provider} agent: ${agent.tmuxSession}`);
|
|
122
125
|
return agent;
|
|
123
126
|
}
|
|
124
127
|
|
|
128
|
+
async function spawnAndWaitForRegistration(opts: SpawnOptions, action = "Spawned"): Promise<ManagedAgentReport> {
|
|
129
|
+
const agent = await spawnManagedAgent(opts, action);
|
|
130
|
+
const registered = await waitForRegistration(agent, registrationTimeoutMs);
|
|
131
|
+
if (!registered) {
|
|
132
|
+
// Not a spawn failure — the process launched; it just didn't register in
|
|
133
|
+
// time. Release the slot (fail-safe) so a slow/never-registering child
|
|
134
|
+
// can't wedge capacity, but still treat the launch as successful.
|
|
135
|
+
console.error(`[orchestrator] spawn ${agent.tmuxSession} did not register within ${registrationTimeoutMs}ms; releasing slot (fail-safe)`);
|
|
136
|
+
}
|
|
137
|
+
return agent;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function markSpawnCommandFailed(command: RelayCommand, error: unknown): Promise<void> {
|
|
141
|
+
console.error(`[orchestrator] spawn dispatch failed: ${errMessage(error)}`);
|
|
142
|
+
// #930 — the `failed` status write can itself reject during a relay blip. Wrap it so
|
|
143
|
+
// a failed status write can't re-throw out of the backgrounded task (belt-and-suspenders
|
|
144
|
+
// with the `.catch` in dispatchSpawnCommand). The command may strand non-terminal until
|
|
145
|
+
// the TTL sweep / watchdog reconciles it, but the task settles cleanly regardless.
|
|
146
|
+
try {
|
|
147
|
+
await relay.updateCommand(command.id, "failed", undefined, errMessage(error));
|
|
148
|
+
} catch (statusError) {
|
|
149
|
+
console.error(`[orchestrator] failed to mark spawn command failed: ${errMessage(statusError)}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
125
153
|
// #930/#880 — dispatch a spawn command concurrently, up to the per-host cap.
|
|
126
154
|
//
|
|
127
155
|
// The command is CLAIMED (status → accepted) synchronously here, BEFORE we
|
|
@@ -151,28 +179,31 @@ export function createControlHandler(
|
|
|
151
179
|
async function runSpawnToRegistration(command: RelayCommand): Promise<void> {
|
|
152
180
|
// Blocks FIFO until a slot frees — this is what bounds concurrent spawns to N.
|
|
153
181
|
await spawnSlots.acquire();
|
|
182
|
+
let launched = false;
|
|
154
183
|
try {
|
|
184
|
+
const current = await relay.getCommand(command.id);
|
|
185
|
+
if (!current || TERMINAL_COMMAND_STATUSES.has(current.status)) {
|
|
186
|
+
console.error(`[orchestrator] skipping spawn command ${command.id}: ${current ? `status is ${current.status}` : "command not found"}`);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
155
189
|
await relay.updateCommand(command.id, "running");
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
console.error(`[orchestrator] spawn ${agent.tmuxSession} did not register within ${registrationTimeoutMs}ms; releasing slot (fail-safe)`);
|
|
190
|
+
try {
|
|
191
|
+
await spawnAndWaitForRegistration(spawnOptionsFromControl(command.params, config));
|
|
192
|
+
launched = true;
|
|
193
|
+
} catch (error) {
|
|
194
|
+
await markSpawnCommandFailed(command, error);
|
|
195
|
+
return;
|
|
163
196
|
}
|
|
197
|
+
// #956 — from this point the child process launched. A relay blip while
|
|
198
|
+
// recording success is a settle failure, not a spawn failure; do not flip
|
|
199
|
+
// the command to failed and notify the parent while the child may register.
|
|
164
200
|
await relay.updateCommand(command.id, "succeeded", { managedAgents });
|
|
165
201
|
await relay.updateManagedAgents(managedAgents);
|
|
166
202
|
} catch (error) {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
// the TTL sweep / watchdog reconciles it, but the task settles cleanly regardless.
|
|
172
|
-
try {
|
|
173
|
-
await relay.updateCommand(command.id, "failed", undefined, errMessage(error));
|
|
174
|
-
} catch (statusError) {
|
|
175
|
-
console.error(`[orchestrator] failed to mark spawn command failed: ${errMessage(statusError)}`);
|
|
203
|
+
if (launched) {
|
|
204
|
+
console.error(`[orchestrator] spawn command settle failed after launch: ${errMessage(error)}`);
|
|
205
|
+
} else {
|
|
206
|
+
await markSpawnCommandFailed(command, error);
|
|
176
207
|
}
|
|
177
208
|
} finally {
|
|
178
209
|
spawnSlots.release();
|
|
@@ -192,9 +223,12 @@ export function createControlHandler(
|
|
|
192
223
|
if (!session) {
|
|
193
224
|
let restarted: ManagedAgentReport | undefined;
|
|
194
225
|
if (restart && restartSpawn) {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
226
|
+
await spawnSlots.acquire();
|
|
227
|
+
try {
|
|
228
|
+
restarted = await spawnAndWaitForRegistration(spawnOptionsFromRestartSource(restartSpawn, config), "Restarted");
|
|
229
|
+
} finally {
|
|
230
|
+
spawnSlots.release();
|
|
231
|
+
}
|
|
198
232
|
}
|
|
199
233
|
return {
|
|
200
234
|
stopped: false,
|
|
@@ -206,7 +240,7 @@ export function createControlHandler(
|
|
|
206
240
|
spawnRequestId: ctrl.spawnRequestId,
|
|
207
241
|
};
|
|
208
242
|
}
|
|
209
|
-
const result = await
|
|
243
|
+
const result = await dispatchStop(session, config, typeof ctrl.reason === "string" ? ctrl.reason : restart ? "restart" : "shutdown", ctrl.graceful !== false, shutdownTimeoutMs(ctrl));
|
|
210
244
|
managedAgents = managedAgents.filter((agent) => (agent.sessionName ?? agent.tmuxSession) !== session);
|
|
211
245
|
// A managed restart carries a fresh spawnRequestId in restartSpawn — keep it.
|
|
212
246
|
// Falling back to the live agent's params would reuse the stale id and break
|
|
@@ -214,9 +248,12 @@ export function createControlHandler(
|
|
|
214
248
|
const restartSource = (restartSpawn ?? (current ? { ...current, spawnRequestId: undefined } : undefined)) as Record<string, any> | undefined;
|
|
215
249
|
let restarted: ManagedAgentReport | undefined;
|
|
216
250
|
if (restart && restartSource) {
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
251
|
+
await spawnSlots.acquire();
|
|
252
|
+
try {
|
|
253
|
+
restarted = await spawnAndWaitForRegistration(spawnOptionsFromRestartSource(restartSource, config), "Restarted");
|
|
254
|
+
} finally {
|
|
255
|
+
spawnSlots.release();
|
|
256
|
+
}
|
|
220
257
|
}
|
|
221
258
|
return {
|
|
222
259
|
...result,
|
|
@@ -270,7 +307,7 @@ export function createControlHandler(
|
|
|
270
307
|
} else if (command.type === "workspace.merge") {
|
|
271
308
|
let result: WorkspaceMergeResult;
|
|
272
309
|
try {
|
|
273
|
-
result = await withMergePhaseTimeout("total", () => mergeWorkspace({
|
|
310
|
+
result = await withMergePhaseTimeout("total", (signal) => mergeWorkspace({
|
|
274
311
|
id: typeof command.params.workspaceId === "string" ? command.params.workspaceId : undefined,
|
|
275
312
|
repoRoot: typeof command.params.repoRoot === "string" ? command.params.repoRoot : undefined,
|
|
276
313
|
worktreePath: typeof command.params.worktreePath === "string" ? command.params.worktreePath : undefined,
|
|
@@ -289,6 +326,7 @@ export function createControlHandler(
|
|
|
289
326
|
subject: typeof command.params.prLanded.subject === "string" ? command.params.prLanded.subject : undefined,
|
|
290
327
|
}
|
|
291
328
|
: undefined,
|
|
329
|
+
signal,
|
|
292
330
|
}));
|
|
293
331
|
} catch (err) {
|
|
294
332
|
const branch = typeof command.params.branch === "string" ? command.params.branch : undefined;
|
package/src/git.ts
CHANGED
|
@@ -10,6 +10,7 @@ type GitResult = Pick<ExecResult, "ok" | "stdout" | "stderr" | "exitCode" | "tim
|
|
|
10
10
|
interface GitOptions {
|
|
11
11
|
timeoutMs?: number;
|
|
12
12
|
timeoutLabel?: string;
|
|
13
|
+
signal?: AbortSignal;
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
/** Run `git -C cwd <args>` and capture trimmed stdout/stderr; never throws. */
|
package/src/process.ts
CHANGED
|
@@ -16,6 +16,7 @@ interface ExecOptions {
|
|
|
16
16
|
timeoutMs?: number;
|
|
17
17
|
timeoutLabel?: string;
|
|
18
18
|
streamDrainGraceMs?: number;
|
|
19
|
+
signal?: AbortSignal;
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
const DEFAULT_STREAM_DRAIN_GRACE_MS = 1_000;
|
|
@@ -65,7 +66,18 @@ function timeoutMessage(cmd: string[], options: ExecOptions): string {
|
|
|
65
66
|
return `${label} timed out after ${options.timeoutMs}ms`;
|
|
66
67
|
}
|
|
67
68
|
|
|
69
|
+
function abortMessage(cmd: string[], options: ExecOptions): string {
|
|
70
|
+
const reason = options.signal?.reason;
|
|
71
|
+
if (reason instanceof Error && reason.message) return reason.message;
|
|
72
|
+
if (typeof reason === "string" && reason) return reason;
|
|
73
|
+
const label = options.timeoutLabel ?? cmd.join(" ");
|
|
74
|
+
return `${label} aborted`;
|
|
75
|
+
}
|
|
76
|
+
|
|
68
77
|
export async function execProcess(cmd: string[], options: ExecOptions = {}): Promise<ExecResult> {
|
|
78
|
+
if (options.signal?.aborted) {
|
|
79
|
+
return { ok: false, exitCode: null, stdout: "", stderr: abortMessage(cmd, options) };
|
|
80
|
+
}
|
|
69
81
|
const proc = Bun.spawn(cmd, {
|
|
70
82
|
cwd: options.cwd,
|
|
71
83
|
env: options.env,
|
|
@@ -76,27 +88,34 @@ export async function execProcess(cmd: string[], options: ExecOptions = {}): Pro
|
|
|
76
88
|
const stdoutCapture = options.stdout === "ignore" ? captureStream(undefined) : captureStream(proc.stdout);
|
|
77
89
|
const stderrCapture = options.stderr === "ignore" ? captureStream(undefined) : captureStream(proc.stderr);
|
|
78
90
|
let timedOut = false;
|
|
91
|
+
let aborted = false;
|
|
79
92
|
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
80
93
|
let killTimeout: ReturnType<typeof setTimeout> | undefined;
|
|
81
94
|
let timeoutResolve: ((value: null) => void) | undefined;
|
|
82
95
|
const timeoutPromise = new Promise<null>((resolve) => { timeoutResolve = resolve; });
|
|
96
|
+
const abortProcess = () => {
|
|
97
|
+
aborted = true;
|
|
98
|
+
try { proc.kill("SIGTERM"); } catch {}
|
|
99
|
+
killTimeout = setTimeout(() => {
|
|
100
|
+
try { proc.kill("SIGKILL"); } catch {}
|
|
101
|
+
}, 1_000);
|
|
102
|
+
killTimeout.unref?.();
|
|
103
|
+
timeoutResolve?.(null);
|
|
104
|
+
};
|
|
105
|
+
options.signal?.addEventListener("abort", abortProcess, { once: true });
|
|
83
106
|
if (options.timeoutMs && options.timeoutMs > 0) {
|
|
84
107
|
timeout = setTimeout(() => {
|
|
85
108
|
timedOut = true;
|
|
86
|
-
|
|
87
|
-
killTimeout = setTimeout(() => {
|
|
88
|
-
try { proc.kill("SIGKILL"); } catch {}
|
|
89
|
-
}, 1_000);
|
|
90
|
-
killTimeout.unref?.();
|
|
91
|
-
timeoutResolve?.(null);
|
|
109
|
+
abortProcess();
|
|
92
110
|
}, options.timeoutMs);
|
|
93
111
|
timeout.unref?.();
|
|
94
112
|
}
|
|
95
113
|
|
|
96
|
-
const exitCode = await (timeout ? Promise.race([proc.exited, timeoutPromise]) : proc.exited);
|
|
114
|
+
const exitCode = await (timeout || options.signal ? Promise.race([proc.exited, timeoutPromise]) : proc.exited);
|
|
115
|
+
options.signal?.removeEventListener("abort", abortProcess);
|
|
97
116
|
if (timeout) clearTimeout(timeout);
|
|
98
|
-
if (!timedOut && killTimeout) clearTimeout(killTimeout);
|
|
99
|
-
if (timedOut) {
|
|
117
|
+
if (!timedOut && !aborted && killTimeout) clearTimeout(killTimeout);
|
|
118
|
+
if (timedOut || aborted) {
|
|
100
119
|
stdoutCapture.cancel();
|
|
101
120
|
stderrCapture.cancel();
|
|
102
121
|
}
|
|
@@ -111,9 +130,12 @@ export async function execProcess(cmd: string[], options: ExecOptions = {}): Pro
|
|
|
111
130
|
if (timedOut) {
|
|
112
131
|
const msg = timeoutMessage(cmd, options);
|
|
113
132
|
stderr = stderr ? `${stderr}\n${msg}` : msg;
|
|
133
|
+
} else if (aborted) {
|
|
134
|
+
const msg = abortMessage(cmd, options);
|
|
135
|
+
stderr = stderr ? `${stderr}\n${msg}` : msg;
|
|
114
136
|
}
|
|
115
137
|
return {
|
|
116
|
-
ok: !timedOut && exitCode === 0,
|
|
138
|
+
ok: !timedOut && !aborted && exitCode === 0,
|
|
117
139
|
exitCode,
|
|
118
140
|
stdout: options.trimStdout === false ? stdout : stdout.trim(),
|
|
119
141
|
stderr: options.trimStderr === false ? stderr : stderr.trim(),
|
package/src/relay.ts
CHANGED
|
@@ -10,6 +10,7 @@ export interface RelayClient {
|
|
|
10
10
|
heartbeat(): Promise<void>;
|
|
11
11
|
updateManagedAgents(agents: ManagedAgentReport[], exitedAgents?: ManagedSessionExitDiagnostics[]): Promise<void>;
|
|
12
12
|
pollCommands(): Promise<RelayCommand[]>;
|
|
13
|
+
getCommand(commandId: string): Promise<RelayCommand | null>;
|
|
13
14
|
updateCommand(commandId: string, status: string, result?: Record<string, unknown>, error?: string): Promise<boolean>;
|
|
14
15
|
acquireProviderQuotaLease(orchestratorId: string, input: ProviderQuotaLeaseAcquireInput): Promise<ProviderQuotaLeaseAcquireResult>;
|
|
15
16
|
releaseProviderQuotaLease(orchestratorId: string, input: ProviderQuotaLeaseAcquireInput & { leaseToken: string }): Promise<{ released: boolean }>;
|
|
@@ -219,6 +220,12 @@ export function createRelayClient(config: OrchestratorConfig, probeCache: Provid
|
|
|
219
220
|
return await res.json() as RelayCommand[];
|
|
220
221
|
}
|
|
221
222
|
|
|
223
|
+
async function getCommand(commandId: string): Promise<RelayCommand | null> {
|
|
224
|
+
const res = await apiCall("GET", `/commands/${encodeURIComponent(commandId)}`);
|
|
225
|
+
if (!res.ok) return null;
|
|
226
|
+
return await res.json() as RelayCommand;
|
|
227
|
+
}
|
|
228
|
+
|
|
222
229
|
async function updateCommand(commandId: string, status: string, result?: Record<string, unknown>, error?: string): Promise<boolean> {
|
|
223
230
|
const res = await apiCall("PATCH", `/commands/${encodeURIComponent(commandId)}`, {
|
|
224
231
|
status,
|
|
@@ -245,6 +252,7 @@ export function createRelayClient(config: OrchestratorConfig, probeCache: Provid
|
|
|
245
252
|
heartbeat,
|
|
246
253
|
updateManagedAgents,
|
|
247
254
|
pollCommands,
|
|
255
|
+
getCommand,
|
|
248
256
|
updateCommand,
|
|
249
257
|
setApiUrl(url: string) { apiUrl = url; },
|
|
250
258
|
startHeartbeatLoop,
|
package/src/spawn/command.ts
CHANGED
|
@@ -66,7 +66,7 @@ export function buildEnv(opts: SpawnOptions & { label: string; agentId: string }
|
|
|
66
66
|
.filter((v, i, a) => a.indexOf(v) === i)
|
|
67
67
|
.join(":");
|
|
68
68
|
|
|
69
|
-
return {
|
|
69
|
+
return sanitizeWorkspaceEnv({
|
|
70
70
|
...process.env as Record<string, string>,
|
|
71
71
|
...(config.token ? { AGENT_RELAY_TOKEN: config.token } : {}),
|
|
72
72
|
...config.env,
|
|
@@ -101,7 +101,7 @@ export function buildEnv(opts: SpawnOptions & { label: string; agentId: string }
|
|
|
101
101
|
...(opts.workspace ? { AGENT_RELAY_WORKSPACE_JSON: JSON.stringify(opts.workspace) } : {}),
|
|
102
102
|
...(opts.automationId ? { AGENT_RELAY_AUTOMATION_ID: opts.automationId } : {}),
|
|
103
103
|
...(opts.automationRunId ? { AGENT_RELAY_AUTOMATION_RUN_ID: opts.automationRunId } : {}),
|
|
104
|
-
};
|
|
104
|
+
}, opts.workspaceMode);
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
function agentProfileEnv(profile: Record<string, unknown> | undefined): Record<string, string> {
|
|
@@ -109,3 +109,10 @@ function agentProfileEnv(profile: Record<string, unknown> | undefined): Record<s
|
|
|
109
109
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
|
110
110
|
return Object.fromEntries(Object.entries(raw).filter((entry): entry is [string, string] => typeof entry[1] === "string"));
|
|
111
111
|
}
|
|
112
|
+
|
|
113
|
+
function sanitizeWorkspaceEnv(env: Record<string, string>, workspaceMode: string | undefined): Record<string, string> {
|
|
114
|
+
if (workspaceMode !== "isolated") return env;
|
|
115
|
+
const isolatedEnv = { ...env };
|
|
116
|
+
delete isolatedEnv.PUBLIC_DIR;
|
|
117
|
+
return isolatedEnv;
|
|
118
|
+
}
|
|
@@ -13,7 +13,7 @@ export async function pruneWorktrees(input: { repoRoot?: string }): Promise<{ re
|
|
|
13
13
|
// repoRoot points at the PARENT worktree (maybe deleted), not the real checkout (#278/#307);
|
|
14
14
|
// every linked worktree shares the main `.git` (--git-common-dir), so derive the owner from
|
|
15
15
|
// the worktree itself, falling back to `fallback` only when it can't be interrogated.
|
|
16
|
-
async function owningRepoRoot(worktreePath: string, fallback: string): Promise<string> {
|
|
16
|
+
export async function owningRepoRoot(worktreePath: string, fallback: string): Promise<string> {
|
|
17
17
|
const res = await git(["rev-parse", "--git-common-dir"], worktreePath);
|
|
18
18
|
if (!res.ok || !res.stdout) return fallback;
|
|
19
19
|
let commonDir = res.stdout;
|
|
@@ -56,7 +56,7 @@ export async function cleanupWorkspace(workspace: { repoRoot?: string; worktreeP
|
|
|
56
56
|
return { workspaceId: workspace.id, removed: true, worktreePath: path, branchDeleted, ...(branchPreservedReason ? { branchPreservedReason } : {}), containerRemoved };
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
async function branchSafeToDelete(repo: string, branch: string, baseRef?: string, baseSha?: string): Promise<{ safe: boolean; reason?: string }> {
|
|
59
|
+
export async function branchSafeToDelete(repo: string, branch: string, baseRef?: string, baseSha?: string): Promise<{ safe: boolean; reason?: string }> {
|
|
60
60
|
const branchRef = await resolveCommit(repo, branch) ? branch : await resolveCommit(repo, `refs/heads/${branch}`) ? `refs/heads/${branch}` : undefined;
|
|
61
61
|
if (!branchRef) return { safe: true };
|
|
62
62
|
|
|
@@ -79,6 +79,15 @@ async function branchSafeToDelete(repo: string, branch: string, baseRef?: string
|
|
|
79
79
|
: { safe: false, reason: `${unmergedAhead} unlanded commit(s)` };
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
export async function deleteBranchIfSafe(repo: string, branch: string, baseRef?: string, baseSha?: string, signal?: AbortSignal): Promise<{ branchDeleted: boolean; branchPreservedReason?: string }> {
|
|
83
|
+
if (signal?.aborted) throw signal.reason instanceof Error ? signal.reason : new Error("workspace merge aborted");
|
|
84
|
+
const safety = await branchSafeToDelete(repo, branch, baseRef, baseSha);
|
|
85
|
+
if (signal?.aborted) throw signal.reason instanceof Error ? signal.reason : new Error("workspace merge aborted");
|
|
86
|
+
if (!safety.safe) return { branchDeleted: false, ...(safety.reason ? { branchPreservedReason: safety.reason } : {}) };
|
|
87
|
+
const branchDeleted = (await git(["branch", "-D", branch], repo, { signal })).ok;
|
|
88
|
+
return branchDeleted ? { branchDeleted } : { branchDeleted, branchPreservedReason: "branch delete failed" };
|
|
89
|
+
}
|
|
90
|
+
|
|
82
91
|
async function resolveCleanupBase(repo: string, baseRef?: string, baseSha?: string): Promise<string | undefined> {
|
|
83
92
|
for (const candidate of [baseRef, baseSha, "main", "master"]) {
|
|
84
93
|
if (candidate && await resolveCommit(repo, candidate)) return candidate;
|
|
@@ -75,8 +75,8 @@ export async function populateMergeState(cwd: string, targetRef: string, state:
|
|
|
75
75
|
/** Remote-tracking branch a local base tracks (e.g. `main` -> `origin/main`),
|
|
76
76
|
* or undefined when base has no upstream or is a bare sha. Squash PRs land on
|
|
77
77
|
* the remote, so the upstream is the truthful "has this merged?" reference. */
|
|
78
|
-
export async function upstreamRef(worktreePath: string, base: string): Promise<string | undefined> {
|
|
79
|
-
const res = await git(["rev-parse", "--abbrev-ref", `${base}@{upstream}`], worktreePath);
|
|
78
|
+
export async function upstreamRef(worktreePath: string, base: string, signal?: AbortSignal): Promise<string | undefined> {
|
|
79
|
+
const res = await git(["rev-parse", "--abbrev-ref", `${base}@{upstream}`], worktreePath, { signal });
|
|
80
80
|
return res.ok && res.stdout ? res.stdout : undefined;
|
|
81
81
|
}
|
|
82
82
|
|
|
@@ -88,15 +88,15 @@ export async function upstreamRef(worktreePath: string, base: string): Promise<s
|
|
|
88
88
|
* base. Falls back to the local base ref when there is no upstream (no remote) or the
|
|
89
89
|
* upstream ref can't be resolved — the caller still verifies it contains the merge SHA.
|
|
90
90
|
*/
|
|
91
|
-
export async function syncBaseFromOrigin(worktreePath: string, base: string | undefined): Promise<string | undefined> {
|
|
91
|
+
export async function syncBaseFromOrigin(worktreePath: string, base: string | undefined, signal?: AbortSignal): Promise<string | undefined> {
|
|
92
92
|
if (!base) return undefined;
|
|
93
|
-
const upstream = await upstreamRef(worktreePath, base);
|
|
93
|
+
const upstream = await upstreamRef(worktreePath, base, signal);
|
|
94
94
|
if (!upstream) return base;
|
|
95
95
|
const slash = upstream.indexOf("/");
|
|
96
96
|
const remote = slash > 0 ? upstream.slice(0, slash) : undefined;
|
|
97
97
|
const remoteBranch = slash > 0 ? upstream.slice(slash + 1) : base;
|
|
98
|
-
if (remote) await git(["fetch", remote, remoteBranch], worktreePath); // best-effort freshness
|
|
99
|
-
return (await git(["rev-parse", "--verify", "--quiet", upstream], worktreePath)).ok ? upstream : base;
|
|
98
|
+
if (remote) await git(["fetch", remote, remoteBranch], worktreePath, { signal }); // best-effort freshness
|
|
99
|
+
return (await git(["rev-parse", "--verify", "--quiet", upstream], worktreePath, { signal })).ok ? upstream : base;
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
async function resolveBaseRef(worktreePath: string, baseRef?: string, baseSha?: string): Promise<string | undefined> {
|
|
@@ -26,20 +26,44 @@ export function mergePhaseTimeoutMs(phase: MergePhase): number {
|
|
|
26
26
|
?? MERGE_PHASE_TIMEOUTS_MS[phase];
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
function abortError(signal: AbortSignal): Error {
|
|
30
|
+
const reason = signal.reason;
|
|
31
|
+
if (reason instanceof Error) return reason;
|
|
32
|
+
if (typeof reason === "string" && reason) return new Error(reason);
|
|
33
|
+
return new Error("workspace merge aborted");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function throwIfMergeAborted(signal?: AbortSignal): void {
|
|
37
|
+
if (signal?.aborted) throw abortError(signal);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function withMergePhaseTimeout<T>(
|
|
41
|
+
phase: MergePhase,
|
|
42
|
+
run: (signal: AbortSignal) => Promise<T>,
|
|
43
|
+
options: { signal?: AbortSignal } = {},
|
|
44
|
+
): Promise<T> {
|
|
30
45
|
const timeoutMs = mergePhaseTimeoutMs(phase);
|
|
46
|
+
const controller = new AbortController();
|
|
31
47
|
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
32
|
-
const
|
|
48
|
+
const abortFromParent = () => controller.abort(abortError(options.signal!));
|
|
49
|
+
if (options.signal?.aborted) controller.abort(abortError(options.signal));
|
|
50
|
+
else options.signal?.addEventListener("abort", abortFromParent, { once: true });
|
|
51
|
+
const work = run(controller.signal);
|
|
33
52
|
work.catch(() => {});
|
|
34
53
|
try {
|
|
35
54
|
return await Promise.race([
|
|
36
55
|
work,
|
|
37
56
|
new Promise<T>((_, reject) => {
|
|
38
|
-
timeout = setTimeout(() =>
|
|
57
|
+
timeout = setTimeout(() => {
|
|
58
|
+
const error = new Error(`workspace merge phase "${phase}" timed out after ${timeoutMs}ms`);
|
|
59
|
+
controller.abort(error);
|
|
60
|
+
reject(error);
|
|
61
|
+
}, timeoutMs);
|
|
39
62
|
timeout.unref?.();
|
|
40
63
|
}),
|
|
41
64
|
]);
|
|
42
65
|
} finally {
|
|
43
66
|
if (timeout) clearTimeout(timeout);
|
|
67
|
+
options.signal?.removeEventListener("abort", abortFromParent);
|
|
44
68
|
}
|
|
45
69
|
}
|
|
@@ -4,27 +4,32 @@ import { errMessage, type BaseWorktreeSyncResult, type WorkspaceMergePreview, ty
|
|
|
4
4
|
import { git, gitRaw } from "../git";
|
|
5
5
|
import { execProcess } from "../process";
|
|
6
6
|
import { prMergedState } from "../workspace-pr";
|
|
7
|
+
import { deleteBranchIfSafe, owningRepoRoot } from "./cleanup";
|
|
7
8
|
import { refreshWorkspaceDeps } from "./deps";
|
|
8
9
|
import { type LandGatesResult } from "./land-gates-runner";
|
|
9
10
|
import { populateMergeState, resolveBranchRef, syncBaseFromOrigin, workspaceGitState } from "./git-state";
|
|
10
|
-
import { type MergePhase, mergePhaseTimeoutMs, withMergePhaseTimeout } from "./merge-timeouts";
|
|
11
|
+
import { type MergePhase, mergePhaseTimeoutMs, throwIfMergeAborted, withMergePhaseTimeout } from "./merge-timeouts";
|
|
11
12
|
import { nextBranchName } from "./names";
|
|
12
13
|
import { parseWorktrees, shortBranch } from "./parse";
|
|
13
14
|
import { json, resolveRequestedPath } from "./request";
|
|
14
15
|
import type { WorkspaceMergeInput } from "./types";
|
|
15
16
|
import { workspacePushEnabled } from "../config";
|
|
16
17
|
|
|
17
|
-
async function mergeGit(args: string[], cwd: string, phase: MergePhase, timeoutLabel?: string): ReturnType<typeof git> {
|
|
18
|
+
async function mergeGit(args: string[], cwd: string, phase: MergePhase, timeoutLabel?: string, signal?: AbortSignal): ReturnType<typeof git> {
|
|
19
|
+
throwIfMergeAborted(signal);
|
|
18
20
|
return git(args, cwd, {
|
|
19
21
|
timeoutMs: mergePhaseTimeoutMs(phase),
|
|
20
22
|
timeoutLabel: timeoutLabel ?? `workspace merge ${phase} git ${args.join(" ")}`,
|
|
23
|
+
signal,
|
|
21
24
|
});
|
|
22
25
|
}
|
|
23
26
|
|
|
24
|
-
async function mergeGitRaw(args: string[], cwd: string, phase: MergePhase, timeoutLabel?: string): ReturnType<typeof gitRaw> {
|
|
27
|
+
async function mergeGitRaw(args: string[], cwd: string, phase: MergePhase, timeoutLabel?: string, signal?: AbortSignal): ReturnType<typeof gitRaw> {
|
|
28
|
+
throwIfMergeAborted(signal);
|
|
25
29
|
return gitRaw(args, cwd, {
|
|
26
30
|
timeoutMs: mergePhaseTimeoutMs(phase),
|
|
27
31
|
timeoutLabel: timeoutLabel ?? `workspace merge ${phase} git ${args.join(" ")}`,
|
|
32
|
+
signal,
|
|
28
33
|
});
|
|
29
34
|
}
|
|
30
35
|
|
|
@@ -33,8 +38,8 @@ function gitError(result: Awaited<ReturnType<typeof git>>, fallback: string): st
|
|
|
33
38
|
}
|
|
34
39
|
|
|
35
40
|
/** Behind-count of HEAD relative to `base`, from inside `worktreePath`. */
|
|
36
|
-
async function countBehind(worktreePath: string, base: string): Promise<{ behind: number } | { error: string }> {
|
|
37
|
-
const counts = await mergeGit(["rev-list", "--left-right", "--count", `${base}...HEAD`], worktreePath, "rebase", "workspace merge count behind integration base");
|
|
41
|
+
async function countBehind(worktreePath: string, base: string, signal?: AbortSignal): Promise<{ behind: number } | { error: string }> {
|
|
42
|
+
const counts = await mergeGit(["rev-list", "--left-right", "--count", `${base}...HEAD`], worktreePath, "rebase", "workspace merge count behind integration base", signal);
|
|
38
43
|
if (!counts.ok || !counts.stdout) return { error: gitError(counts, "failed to count branch behind integration base") };
|
|
39
44
|
const behind = Number(counts.stdout.split(/\s+/)[0]);
|
|
40
45
|
return Number.isFinite(behind) ? { behind } : { error: "git rev-list produced an invalid behind count" };
|
|
@@ -75,12 +80,12 @@ async function baseBranchName(worktreePath: string, baseRef?: string): Promise<s
|
|
|
75
80
|
}
|
|
76
81
|
|
|
77
82
|
/** Locate the worktree (if any) that currently has `branch` checked out. */
|
|
78
|
-
async function worktreeForBranch(repoRoot: string, branch: string): Promise<{ path: string; dirty: boolean } | undefined> {
|
|
79
|
-
const list = await mergeGit(["worktree", "list", "--porcelain"], repoRoot, "rebase", "workspace merge list worktrees");
|
|
83
|
+
async function worktreeForBranch(repoRoot: string, branch: string, signal?: AbortSignal): Promise<{ path: string; dirty: boolean } | undefined> {
|
|
84
|
+
const list = await mergeGit(["worktree", "list", "--porcelain"], repoRoot, "rebase", "workspace merge list worktrees", signal);
|
|
80
85
|
if (!list.ok) return undefined;
|
|
81
86
|
const match = parseWorktrees(list.stdout).find((worktree) => worktree.branch === branch);
|
|
82
87
|
if (!match) return undefined;
|
|
83
|
-
const status = await mergeGit(["status", "--porcelain"], match.path, "rebase", `workspace merge status ${branch} worktree
|
|
88
|
+
const status = await mergeGit(["status", "--porcelain"], match.path, "rebase", `workspace merge status ${branch} worktree`, signal);
|
|
84
89
|
return { path: match.path, dirty: status.ok ? status.stdout.length > 0 : true };
|
|
85
90
|
}
|
|
86
91
|
|
|
@@ -88,8 +93,8 @@ function splitNul(stdout: string): string[] {
|
|
|
88
93
|
return stdout.split("\0").filter(Boolean);
|
|
89
94
|
}
|
|
90
95
|
|
|
91
|
-
async function dirtyPathSet(worktreePath: string): Promise<Set<string>> {
|
|
92
|
-
const status = await mergeGitRaw(["status", "--porcelain=v1", "-z", "--untracked-files=all"], worktreePath, "rebase", "workspace merge dirty path scan");
|
|
96
|
+
async function dirtyPathSet(worktreePath: string, signal?: AbortSignal): Promise<Set<string>> {
|
|
97
|
+
const status = await mergeGitRaw(["status", "--porcelain=v1", "-z", "--untracked-files=all"], worktreePath, "rebase", "workspace merge dirty path scan", signal);
|
|
93
98
|
if (!status.ok) return new Set<string>();
|
|
94
99
|
const paths = new Set<string>();
|
|
95
100
|
const entries = splitNul(status.stdout);
|
|
@@ -104,26 +109,26 @@ async function dirtyPathSet(worktreePath: string): Promise<Set<string>> {
|
|
|
104
109
|
return paths;
|
|
105
110
|
}
|
|
106
111
|
|
|
107
|
-
async function changedPathList(worktreePath: string, oldBaseTip: string, newBaseTip: string): Promise<string[]> {
|
|
108
|
-
const diff = await mergeGitRaw(["diff", "--name-only", "-z", oldBaseTip, newBaseTip], worktreePath, "rebase", "workspace merge changed path scan");
|
|
112
|
+
async function changedPathList(worktreePath: string, oldBaseTip: string, newBaseTip: string, signal?: AbortSignal): Promise<string[]> {
|
|
113
|
+
const diff = await mergeGitRaw(["diff", "--name-only", "-z", oldBaseTip, newBaseTip], worktreePath, "rebase", "workspace merge changed path scan", signal);
|
|
109
114
|
return diff.ok ? splitNul(diff.stdout) : [];
|
|
110
115
|
}
|
|
111
116
|
|
|
112
|
-
async function restorePathsFromHead(worktreePath: string, paths: string[]): Promise<boolean> {
|
|
117
|
+
async function restorePathsFromHead(worktreePath: string, paths: string[], signal?: AbortSignal): Promise<boolean> {
|
|
113
118
|
if (paths.length === 0) return true;
|
|
114
|
-
return (await mergeGit(["restore", "--staged", "--worktree", "--", ...paths], worktreePath, "rebase", "workspace merge restore clean landed paths")).ok;
|
|
119
|
+
return (await mergeGit(["restore", "--staged", "--worktree", "--", ...paths], worktreePath, "rebase", "workspace merge restore clean landed paths", signal)).ok;
|
|
115
120
|
}
|
|
116
121
|
|
|
117
|
-
async function resetIndexPathsToHead(worktreePath: string, paths: string[]): Promise<boolean> {
|
|
122
|
+
async function resetIndexPathsToHead(worktreePath: string, paths: string[], signal?: AbortSignal): Promise<boolean> {
|
|
118
123
|
if (paths.length === 0) return true;
|
|
119
|
-
return (await mergeGit(["reset", "-q", "HEAD", "--", ...paths], worktreePath, "rebase", "workspace merge reset preserved paths")).ok;
|
|
124
|
+
return (await mergeGit(["reset", "-q", "HEAD", "--", ...paths], worktreePath, "rebase", "workspace merge reset preserved paths", signal)).ok;
|
|
120
125
|
}
|
|
121
126
|
|
|
122
127
|
/** Landed paths whose working copy still differs from the advanced HEAD (ground truth, not
|
|
123
128
|
* exit codes): the checkout is mixed for these — reads/builds/publishes see STALE files. */
|
|
124
|
-
async function pathsDifferingFromHead(worktreePath: string, paths: string[]): Promise<string[]> {
|
|
129
|
+
async function pathsDifferingFromHead(worktreePath: string, paths: string[], signal?: AbortSignal): Promise<string[]> {
|
|
125
130
|
if (paths.length === 0) return [];
|
|
126
|
-
const diff = await mergeGitRaw(["diff", "--name-only", "-z", "HEAD", "--", ...paths], worktreePath, "rebase", "workspace merge verify base worktree sync");
|
|
131
|
+
const diff = await mergeGitRaw(["diff", "--name-only", "-z", "HEAD", "--", ...paths], worktreePath, "rebase", "workspace merge verify base worktree sync", signal);
|
|
127
132
|
// A failed diff can't prove the checkout is clean — treat every landed path as suspect.
|
|
128
133
|
return diff.ok ? splitNul(diff.stdout) : [...paths];
|
|
129
134
|
}
|
|
@@ -165,24 +170,25 @@ async function syncDirtyBaseWorktreeAfterRefAdvance(
|
|
|
165
170
|
oldBaseTip: string,
|
|
166
171
|
newBaseTip: string,
|
|
167
172
|
dirtyBefore: Set<string> | undefined,
|
|
173
|
+
signal?: AbortSignal,
|
|
168
174
|
): Promise<BaseWorktreeSyncResult> {
|
|
169
175
|
if (!baseWorktree?.dirty || !dirtyBefore) return RECONCILED;
|
|
170
|
-
const readTree = await mergeGit(["read-tree", "-m", "-u", oldBaseTip, newBaseTip], baseWorktree.path, "rebase", "workspace merge sync dirty base worktree");
|
|
176
|
+
const readTree = await mergeGit(["read-tree", "-m", "-u", oldBaseTip, newBaseTip], baseWorktree.path, "rebase", "workspace merge sync dirty base worktree", signal);
|
|
171
177
|
if (readTree.ok) return RECONCILED;
|
|
172
178
|
|
|
173
|
-
const changedPaths = await changedPathList(baseWorktree.path, oldBaseTip, newBaseTip);
|
|
179
|
+
const changedPaths = await changedPathList(baseWorktree.path, oldBaseTip, newBaseTip, signal);
|
|
174
180
|
const cleanChangedPaths = changedPaths.filter((path) => !dirtyBefore.has(path));
|
|
175
181
|
const dirtyChangedPaths = changedPaths.filter((path) => dirtyBefore.has(path));
|
|
176
182
|
// Clean landed paths carry no human WIP — force them to HEAD (restores content AND mode).
|
|
177
|
-
await restorePathsFromHead(baseWorktree.path, cleanChangedPaths);
|
|
183
|
+
await restorePathsFromHead(baseWorktree.path, cleanChangedPaths, signal);
|
|
178
184
|
// Landed paths the human also edited — preserve the WIP, normalize the index to HEAD so the
|
|
179
185
|
// difference surfaces as an unstaged modification (not a staged reverse diff). #681.
|
|
180
|
-
await resetIndexPathsToHead(baseWorktree.path, dirtyChangedPaths);
|
|
186
|
+
await resetIndexPathsToHead(baseWorktree.path, dirtyChangedPaths, signal);
|
|
181
187
|
|
|
182
188
|
// Ground-truth check (#824): re-derive which landed paths are STILL out of sync with the
|
|
183
189
|
// advanced HEAD rather than trusting the restore/reset exit codes. A clean landed path left
|
|
184
190
|
// stale here is the silent strand that shipped a stale file into the #823 bootstrap publish.
|
|
185
|
-
const unsyncedPaths = await pathsDifferingFromHead(baseWorktree.path, changedPaths);
|
|
191
|
+
const unsyncedPaths = await pathsDifferingFromHead(baseWorktree.path, changedPaths, signal);
|
|
186
192
|
if (unsyncedPaths.length === 0) return RECONCILED;
|
|
187
193
|
const preservedWipPaths = unsyncedPaths.filter((path) => dirtyBefore.has(path));
|
|
188
194
|
return {
|
|
@@ -365,6 +371,8 @@ function validPreviewStrategy(strategy: string | null): "pr" | "rebase-ff" | "au
|
|
|
365
371
|
*/
|
|
366
372
|
export async function mergeWorkspace(input: WorkspaceMergeInput): Promise<WorkspaceMergeResult> {
|
|
367
373
|
if (!input.worktreePath) return { strategy: "rebase-ff", merged: false, status: "review_requested", error: "worktreePath required", workspaceId: input.id };
|
|
374
|
+
const signal = input.signal;
|
|
375
|
+
throwIfMergeAborted(signal);
|
|
368
376
|
const worktreePath = resolve(input.worktreePath);
|
|
369
377
|
const repoRoot = input.repoRoot ? resolve(input.repoRoot) : worktreePath;
|
|
370
378
|
console.error(`[orchestrator] workspace.merge prep-start workspace=${input.id ?? "(unknown)"} worktree=${worktreePath} repo=${repoRoot}`);
|
|
@@ -372,14 +380,15 @@ export async function mergeWorkspace(input: WorkspaceMergeInput): Promise<Worksp
|
|
|
372
380
|
// DB-recorded branch only when the live probe fails (detached HEAD, missing worktree, etc.).
|
|
373
381
|
// This fixes #232: a stale DB branch value (non-null mismatch) would pass through the
|
|
374
382
|
// `input.branch ?? ...` guard unchanged and cause git to attempt merging a non-existent ref.
|
|
375
|
-
const liveBranch = shortBranch((await mergeGit(["symbolic-ref", "--quiet", "--short", "HEAD"], worktreePath, "prep", "workspace merge resolve live branch")).stdout || undefined);
|
|
383
|
+
const liveBranch = shortBranch((await mergeGit(["symbolic-ref", "--quiet", "--short", "HEAD"], worktreePath, "prep", "workspace merge resolve live branch", signal)).stdout || undefined);
|
|
376
384
|
const branch = liveBranch ?? input.branch;
|
|
377
385
|
let preview: WorkspaceMergePreview;
|
|
378
386
|
try {
|
|
379
|
-
preview = await withMergePhaseTimeout("preview", () => previewWorkspaceMerge({ worktreePath, baseRef: input.baseRef, baseSha: input.baseSha, strategy: input.strategy }));
|
|
387
|
+
preview = await withMergePhaseTimeout("preview", () => previewWorkspaceMerge({ worktreePath, baseRef: input.baseRef, baseSha: input.baseSha, strategy: input.strategy }), { signal });
|
|
380
388
|
} catch (err) {
|
|
381
389
|
return { workspaceId: input.id, strategy: "rebase-ff", merged: false, status: "review_requested", branch, error: errMessage(err) };
|
|
382
390
|
}
|
|
391
|
+
throwIfMergeAborted(signal);
|
|
383
392
|
const strategy = preview.strategy;
|
|
384
393
|
const head = (field: Partial<WorkspaceMergeResult>): WorkspaceMergeResult => ({ workspaceId: input.id, strategy, merged: false, status: "review_requested", branch, baseRef: preview.baseRef, ...field });
|
|
385
394
|
|
|
@@ -400,22 +409,23 @@ export async function mergeWorkspace(input: WorkspaceMergeInput): Promise<Worksp
|
|
|
400
409
|
// hasn't propagated the PR merge yet, fall through rather than reset onto a base
|
|
401
410
|
// missing the landed work — never reset on an unverified ground-truth/refs mismatch.
|
|
402
411
|
if (input.prLanded?.sha && (preview.dirtyCount ?? 0) === 0) {
|
|
403
|
-
const startRef = await syncBaseFromOrigin(worktreePath, preview.baseRef);
|
|
404
|
-
|
|
412
|
+
const startRef = await syncBaseFromOrigin(worktreePath, preview.baseRef, signal);
|
|
413
|
+
throwIfMergeAborted(signal);
|
|
414
|
+
if (startRef && (await mergeGit(["merge-base", "--is-ancestor", input.prLanded.sha, startRef], worktreePath, "rebase", "workspace merge verify PR landed sha on base", signal)).ok) {
|
|
405
415
|
const landedPreview: WorkspaceMergePreview = { ...preview, noop: true, prMerged: true, prMergeSha: input.prLanded.sha, reason: "PR merged on remote" };
|
|
406
|
-
return await resolveNoopMerge(input, worktreePath, repoRoot, branch, landedPreview, head, startRef);
|
|
416
|
+
return await resolveNoopMerge(input, worktreePath, repoRoot, branch, landedPreview, head, startRef, signal);
|
|
407
417
|
}
|
|
408
418
|
}
|
|
409
419
|
// Nothing to land (ahead=0, clean): the branch tree is already in base. Resolve it
|
|
410
420
|
// to a terminal state so it leaves the steward queue instead of looping forever in
|
|
411
421
|
// review_requested (#230). Reclaim the spent worktree/branch when the owner is gone.
|
|
412
|
-
if (preview.noop) return await resolveNoopMerge(input, worktreePath, repoRoot, branch, preview, head);
|
|
422
|
+
if (preview.noop) return await resolveNoopMerge(input, worktreePath, repoRoot, branch, preview, head, undefined, signal);
|
|
413
423
|
if (preview.reason) return head({ status: "review_requested", error: preview.reason });
|
|
414
424
|
if (preview.conflict) return head({ conflict: true, status: "conflict", error: "merge would conflict with base" });
|
|
415
425
|
|
|
416
|
-
if (strategy === "pr") return await mergePr(input, worktreePath, branch, preview, head);
|
|
426
|
+
if (strategy === "pr") return await mergePr(input, worktreePath, branch, preview, head, signal);
|
|
417
427
|
console.error(`[orchestrator] workspace.merge rebase-start workspace=${input.id ?? "(unknown)"} branch=${branch ?? "(unknown)"} base=${preview.baseRef ?? "(unknown)"}`);
|
|
418
|
-
return await mergeRebaseFf(input, worktreePath, repoRoot, branch, preview, head);
|
|
428
|
+
return await mergeRebaseFf(input, worktreePath, repoRoot, branch, preview, head, signal);
|
|
419
429
|
}
|
|
420
430
|
|
|
421
431
|
/**
|
|
@@ -440,27 +450,31 @@ async function resolveNoopMerge(
|
|
|
440
450
|
// the upstream tip (e.g. origin/main) so the live owner continues off UPDATED base rather
|
|
441
451
|
// than the worktree's stale local base.
|
|
442
452
|
startRef?: string,
|
|
453
|
+
signal?: AbortSignal,
|
|
443
454
|
): Promise<WorkspaceMergeResult> {
|
|
455
|
+
throwIfMergeAborted(signal);
|
|
456
|
+
const ownerRepo = branch ? await owningRepoRoot(worktreePath, repoRoot) : repoRoot;
|
|
444
457
|
// Live owner (#327): recycle-to-continue instead of bricking the session.
|
|
445
458
|
if (input.deleteBranch === false) {
|
|
446
459
|
const base = preview.baseRef;
|
|
447
460
|
if (base && branch) {
|
|
448
|
-
const fresh = await nextBranchName(
|
|
461
|
+
const fresh = await nextBranchName(ownerRepo, branch);
|
|
449
462
|
// #478 — cut from the FETCHED upstream tip so even a plain noop recycle (no
|
|
450
463
|
// startRef) advances to origin/main, not the stale local base. The PR-land
|
|
451
464
|
// recycle (#423) already passes startRef (the verified upstream sha); this
|
|
452
465
|
// makes the rebase-ff noop path equally multi-host-correct.
|
|
453
|
-
const start = startRef ?? await syncBaseFromOrigin(worktreePath, base) ?? base;
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
const
|
|
457
|
-
const
|
|
466
|
+
const start = startRef ?? await syncBaseFromOrigin(worktreePath, base, signal) ?? base;
|
|
467
|
+
throwIfMergeAborted(signal);
|
|
468
|
+
if ((await mergeGit(["checkout", "-B", fresh, start], worktreePath, "rebase", `workspace merge noop recycle worktree to ${fresh}`, signal)).ok) {
|
|
469
|
+
const baseSha = (await mergeGit(["rev-parse", start], worktreePath, "rebase", `workspace merge resolve noop recycle start ${start}`, signal)).stdout || undefined;
|
|
470
|
+
const deleteResult = await deleteBranchIfSafe(ownerRepo, branch, undefined, baseSha ?? start, signal);
|
|
458
471
|
// Recycled onto base, which may declare deps the symlinked node_modules lacks (#51).
|
|
459
|
-
const depsRefresh = await refreshWorkspaceDeps(
|
|
472
|
+
const depsRefresh = await refreshWorkspaceDeps(ownerRepo, worktreePath);
|
|
473
|
+
throwIfMergeAborted(signal);
|
|
460
474
|
const reportDeps = depsRefresh.refreshed || depsRefresh.stale || depsRefresh.error;
|
|
461
475
|
// merged:false → no `branch.landed` notice (nothing landed); newBranch makes the
|
|
462
476
|
// relay repoint the row and return it to `active` rather than terminal `merged`.
|
|
463
|
-
return head({ merged: false, noop: true, status: "active", baseSha, worktreeRemoved: false, branch: fresh, newBranch: fresh,
|
|
477
|
+
return head({ merged: false, noop: true, status: "active", baseSha, worktreeRemoved: false, branch: fresh, newBranch: fresh, ...deleteResult, ...(reportDeps ? { depsRefresh } : {}), error: undefined });
|
|
464
478
|
}
|
|
465
479
|
}
|
|
466
480
|
// No base or checkout failed — stay live on the current branch, don't strand at `merged`.
|
|
@@ -468,10 +482,11 @@ async function resolveNoopMerge(
|
|
|
468
482
|
}
|
|
469
483
|
// Owner is gone — reclaim the spent worktree/branch and go terminal.
|
|
470
484
|
if (branch) {
|
|
471
|
-
|
|
485
|
+
throwIfMergeAborted(signal);
|
|
486
|
+
const removed = await mergeGit(["worktree", "remove", "--force", worktreePath], ownerRepo, "cleanup", "workspace merge remove noop worktree", signal);
|
|
472
487
|
const worktreeRemoved = removed.ok;
|
|
473
|
-
const
|
|
474
|
-
return head({ status: "merged", noop: true, worktreeRemoved,
|
|
488
|
+
const deleteResult = worktreeRemoved ? await deleteBranchIfSafe(ownerRepo, branch, preview.baseRef, undefined, signal) : { branchDeleted: false };
|
|
489
|
+
return head({ status: "merged", noop: true, worktreeRemoved, ...deleteResult, error: undefined });
|
|
475
490
|
}
|
|
476
491
|
return head({ status: "merged", noop: true, error: undefined });
|
|
477
492
|
}
|
|
@@ -482,19 +497,22 @@ async function mergePr(
|
|
|
482
497
|
branch: string | undefined,
|
|
483
498
|
preview: WorkspaceMergePreview,
|
|
484
499
|
head: (field: Partial<WorkspaceMergeResult>) => WorkspaceMergeResult,
|
|
500
|
+
signal?: AbortSignal,
|
|
485
501
|
): Promise<WorkspaceMergeResult> {
|
|
486
502
|
if (!branch) return head({ status: "review_requested", error: "cannot determine branch to push" });
|
|
487
503
|
const base = preview.baseRef;
|
|
488
|
-
|
|
504
|
+
throwIfMergeAborted(signal);
|
|
505
|
+
const push = await mergeGit(["push", "-u", "origin", branch], worktreePath, "rebase", `workspace merge push ${branch} to origin`, signal);
|
|
489
506
|
if (!push.ok) return head({ status: "review_requested", error: push.stderr || "git push failed" });
|
|
490
507
|
|
|
491
|
-
const title = input.prTitle || (await
|
|
508
|
+
const title = input.prTitle || (await mergeGit(["log", "-1", "--format=%s"], worktreePath, "rebase", "workspace merge read PR title", signal)).stdout || `Merge ${branch}`;
|
|
492
509
|
const body = input.prBody || `Automated PR for agent workspace branch \`${branch}\`.`;
|
|
493
510
|
const args = ["pr", "create", "--head", branch, "--title", title, "--body", body];
|
|
494
511
|
if (base) args.push("--base", base);
|
|
495
512
|
// Pass process.env explicitly so runtime env mutations (e.g. test PATH injection)
|
|
496
513
|
// are visible to the child process. Bun's default is the startup-time env snapshot.
|
|
497
|
-
|
|
514
|
+
throwIfMergeAborted(signal);
|
|
515
|
+
const proc = await execProcess(["gh", ...args], { cwd: worktreePath, env: process.env, signal });
|
|
498
516
|
const stdout = proc.stdout;
|
|
499
517
|
if (!proc.ok) {
|
|
500
518
|
return head({ status: "review_requested", error: proc.stderr || "gh pr create failed" });
|
|
@@ -518,7 +536,8 @@ async function mergePr(
|
|
|
518
536
|
// Never throw — if arming fails (repo has auto-merge disabled) we still return
|
|
519
537
|
// merge_planned so the relay reconcile scan can finalize when the PR merges.
|
|
520
538
|
const mergeTarget = prUrl ?? branch!;
|
|
521
|
-
|
|
539
|
+
throwIfMergeAborted(signal);
|
|
540
|
+
const armProc = await execProcess(["gh", "pr", "merge", mergeTarget, "--auto", "--merge"], { cwd: worktreePath, env: process.env, signal });
|
|
522
541
|
if (!armProc.ok) {
|
|
523
542
|
// Arm failed (e.g. repo has auto-merge disabled) — return merge_planned so the
|
|
524
543
|
// reconcile scan still finalizes when the PR is merged by a human.
|
|
@@ -550,35 +569,28 @@ function landMergeMessage(branch: string | undefined, subject: string | undefine
|
|
|
550
569
|
// moves (#902/#903) and, via {@link recordNoFfMerge}, to advance base when no working tree is
|
|
551
570
|
// available. Pure object creation: no ref is touched, so a caller can throw it away freely.
|
|
552
571
|
async function synthesizeNoFfMerge(
|
|
553
|
-
repoRoot: string,
|
|
554
|
-
|
|
555
|
-
branchSha: string,
|
|
556
|
-
message: string,
|
|
557
|
-
timeoutMs?: number,
|
|
572
|
+
repoRoot: string, baseSha: string, branchSha: string, message: string,
|
|
573
|
+
timeoutMs?: number, signal?: AbortSignal,
|
|
558
574
|
): Promise<{ ok: true; mergeSha: string } | { ok: false; conflict?: boolean; error: string }> {
|
|
559
|
-
const tree = await git(["merge-tree", "--write-tree", baseSha, branchSha], repoRoot, { timeoutMs, timeoutLabel: "workspace merge synthesize merge-tree" });
|
|
575
|
+
const tree = await git(["merge-tree", "--write-tree", baseSha, branchSha], repoRoot, { timeoutMs, timeoutLabel: "workspace merge synthesize merge-tree", signal });
|
|
560
576
|
if (!tree.ok) return { ok: false, conflict: true, error: tree.stdout || tree.stderr || "merge conflict computing tree" };
|
|
561
577
|
const treeOid = tree.stdout.split("\n")[0]?.trim();
|
|
562
578
|
if (!treeOid) return { ok: false, error: "merge-tree produced no tree oid" };
|
|
563
579
|
const commit = await git(
|
|
564
580
|
["-c", `user.name=${LAND_COMMITTER.name}`, "-c", `user.email=${LAND_COMMITTER.email}`, "commit-tree", treeOid, "-p", baseSha, "-p", branchSha, "-m", message],
|
|
565
581
|
repoRoot,
|
|
566
|
-
{ timeoutMs, timeoutLabel: "workspace merge synthesize commit-tree" },
|
|
582
|
+
{ timeoutMs, timeoutLabel: "workspace merge synthesize commit-tree", signal },
|
|
567
583
|
);
|
|
568
584
|
if (!commit.ok || !commit.stdout) return { ok: false, error: commit.stderr || "commit-tree failed" };
|
|
569
585
|
return { ok: true, mergeSha: commit.stdout };
|
|
570
586
|
}
|
|
571
587
|
|
|
572
588
|
async function recordNoFfMerge(
|
|
573
|
-
repoRoot: string,
|
|
574
|
-
base: string,
|
|
575
|
-
baseSha: string,
|
|
576
|
-
branchSha: string,
|
|
577
|
-
message: string,
|
|
589
|
+
repoRoot: string, base: string, baseSha: string, branchSha: string, message: string, signal?: AbortSignal,
|
|
578
590
|
): Promise<{ ok: true; mergeSha: string } | { ok: false; conflict?: boolean; error: string }> {
|
|
579
|
-
const synth = await synthesizeNoFfMerge(repoRoot, baseSha, branchSha, message, mergePhaseTimeoutMs("synthesize"));
|
|
591
|
+
const synth = await synthesizeNoFfMerge(repoRoot, baseSha, branchSha, message, mergePhaseTimeoutMs("synthesize"), signal);
|
|
580
592
|
if (!synth.ok) return synth;
|
|
581
|
-
const update = await mergeGit(["update-ref", `refs/heads/${base}`, synth.mergeSha, baseSha], repoRoot, "rebase", `workspace merge advance ${base} to synthesized merge
|
|
593
|
+
const update = await mergeGit(["update-ref", `refs/heads/${base}`, synth.mergeSha, baseSha], repoRoot, "rebase", `workspace merge advance ${base} to synthesized merge`, signal);
|
|
582
594
|
if (!update.ok) return { ok: false, error: update.stderr || "failed to advance base ref" };
|
|
583
595
|
return { ok: true, mergeSha: synth.mergeSha };
|
|
584
596
|
}
|
|
@@ -623,26 +635,28 @@ async function syncLocalBaseToUpstream(
|
|
|
623
635
|
worktreePath: string,
|
|
624
636
|
base: string,
|
|
625
637
|
upstream: string,
|
|
638
|
+
signal?: AbortSignal,
|
|
626
639
|
): Promise<{ ok: true; baseSync?: BaseWorktreeSyncResult } | { ok: false; error: string }> {
|
|
627
|
-
const upstreamSha = (await mergeGit(["rev-parse", "--verify", upstream], worktreePath, "rebase", `workspace merge resolve ${upstream}
|
|
640
|
+
const upstreamSha = (await mergeGit(["rev-parse", "--verify", upstream], worktreePath, "rebase", `workspace merge resolve ${upstream}`, signal)).stdout;
|
|
628
641
|
if (!upstreamSha) return { ok: false, error: `cannot resolve ${upstream} to sync ${base}` };
|
|
629
|
-
const baseWorktree = await worktreeForBranch(repoRoot, base);
|
|
642
|
+
const baseWorktree = await worktreeForBranch(repoRoot, base, signal);
|
|
630
643
|
// Sync IN the base worktree only when it's clean — that keeps its working tree consistent
|
|
631
644
|
// with the advanced ref (the pristine home-repo checkout). When the base worktree is dirty
|
|
632
645
|
// (#644: a human's WIP in the shared checkout) we must NOT refuse and stall the whole repo's
|
|
633
646
|
// lands: advance the ref directly with update-ref, then best-effort sync the checked-out
|
|
634
647
|
// index/worktree forward for paths that are not human-modified (#681).
|
|
635
648
|
if (baseWorktree && !baseWorktree.dirty) {
|
|
636
|
-
const ff = await mergeGit(["merge", "--ff-only", upstream], baseWorktree.path, "rebase", `workspace merge fast-forward ${base} to ${upstream}
|
|
649
|
+
const ff = await mergeGit(["merge", "--ff-only", upstream], baseWorktree.path, "rebase", `workspace merge fast-forward ${base} to ${upstream}`, signal);
|
|
637
650
|
if (!ff.ok) return { ok: false, error: ff.stderr || `failed to fast-forward ${base} to ${upstream}` };
|
|
638
651
|
return { ok: true };
|
|
639
652
|
}
|
|
640
|
-
const oldBaseTip = (await mergeGit(["rev-parse", base], repoRoot, "rebase", `workspace merge resolve ${base} before upstream sync
|
|
641
|
-
const dirtyBefore = baseWorktree?.dirty ? await dirtyPathSet(baseWorktree.path) : undefined;
|
|
653
|
+
const oldBaseTip = (await mergeGit(["rev-parse", base], repoRoot, "rebase", `workspace merge resolve ${base} before upstream sync`, signal)).stdout;
|
|
654
|
+
const dirtyBefore = baseWorktree?.dirty ? await dirtyPathSet(baseWorktree.path, signal) : undefined;
|
|
642
655
|
const updateArgs = oldBaseTip ? ["update-ref", `refs/heads/${base}`, upstreamSha, oldBaseTip] : ["update-ref", `refs/heads/${base}`, upstreamSha];
|
|
643
|
-
|
|
656
|
+
throwIfMergeAborted(signal);
|
|
657
|
+
const update = await mergeGit(updateArgs, repoRoot, "rebase", `workspace merge update ${base} to ${upstream}`, signal);
|
|
644
658
|
if (!update.ok) return { ok: false, error: update.stderr || `failed to advance ${base} to ${upstream}` };
|
|
645
|
-
const baseSync = oldBaseTip ? await syncDirtyBaseWorktreeAfterRefAdvance(base, baseWorktree, oldBaseTip, upstreamSha, dirtyBefore) : undefined;
|
|
659
|
+
const baseSync = oldBaseTip ? await syncDirtyBaseWorktreeAfterRefAdvance(base, baseWorktree, oldBaseTip, upstreamSha, dirtyBefore, signal) : undefined;
|
|
646
660
|
return { ok: true, baseSync };
|
|
647
661
|
}
|
|
648
662
|
|
|
@@ -653,7 +667,9 @@ async function mergeRebaseFf(
|
|
|
653
667
|
branch: string | undefined,
|
|
654
668
|
preview: WorkspaceMergePreview,
|
|
655
669
|
head: (field: Partial<WorkspaceMergeResult>) => WorkspaceMergeResult,
|
|
670
|
+
signal?: AbortSignal,
|
|
656
671
|
): Promise<WorkspaceMergeResult> {
|
|
672
|
+
throwIfMergeAborted(signal);
|
|
657
673
|
const base = preview.baseRef;
|
|
658
674
|
if (!base) return head({ status: "review_requested", error: "no base branch to merge into" });
|
|
659
675
|
if (!branch) return head({ status: "review_requested", error: "cannot determine agent branch" });
|
|
@@ -676,7 +692,7 @@ async function mergeRebaseFf(
|
|
|
676
692
|
// points (the upstream sync below and the final land). A mixed state from either is surfaced
|
|
677
693
|
// loudly rather than swallowed as a log warning (#824).
|
|
678
694
|
let baseSync: BaseWorktreeSyncResult | undefined;
|
|
679
|
-
const upstreamResult = await mergeGit(["rev-parse", "--abbrev-ref", `${base}@{upstream}`], worktreePath, "rebase", `workspace merge resolve upstream for ${base}
|
|
695
|
+
const upstreamResult = await mergeGit(["rev-parse", "--abbrev-ref", `${base}@{upstream}`], worktreePath, "rebase", `workspace merge resolve upstream for ${base}`, signal);
|
|
680
696
|
const upstream = upstreamResult.ok && upstreamResult.stdout ? upstreamResult.stdout : undefined;
|
|
681
697
|
const slash = upstream ? upstream.indexOf("/") : -1;
|
|
682
698
|
const remote = slash > 0 ? upstream!.slice(0, slash) : undefined; // remote of a `remote/branch` upstream
|
|
@@ -686,38 +702,38 @@ async function mergeRebaseFf(
|
|
|
686
702
|
// gives them new SHAs and breaks traceability (the branch.landed SHA must exist on
|
|
687
703
|
// base verbatim). headSha is the preserved landed commit; when base has advanced we
|
|
688
704
|
// tie the branch in with a no-ff merge so the agent's commits keep their identity.
|
|
689
|
-
const headResult = await mergeGit(["rev-parse", "HEAD"], worktreePath, "rebase", "workspace merge resolve workspace HEAD before gates");
|
|
705
|
+
const headResult = await mergeGit(["rev-parse", "HEAD"], worktreePath, "rebase", "workspace merge resolve workspace HEAD before gates", signal);
|
|
690
706
|
const headSha = headResult.stdout;
|
|
691
707
|
if (!headResult.ok || !headSha) return head({ status: "review_requested", error: gitError(headResult, "failed to resolve workspace HEAD before gates") });
|
|
692
708
|
// Subject of the landed commit for the relay's branch.landed notice (#239). Best-effort:
|
|
693
709
|
// an empty/failed read just omits it from the message body.
|
|
694
|
-
const landedSubject = (await mergeGit(["log", "-1", "--format=%s", headSha], worktreePath, "rebase", "workspace merge read landed commit subject")).stdout || undefined;
|
|
710
|
+
const landedSubject = (await mergeGit(["log", "-1", "--format=%s", headSha], worktreePath, "rebase", "workspace merge read landed commit subject", signal)).stdout || undefined;
|
|
695
711
|
|
|
696
712
|
// Resolve the SHA the work will integrate onto WITHOUT moving the ref. Origin-ahead is
|
|
697
713
|
// the common concurrent case (#638): we still fetch fresh origin to compute the real merge,
|
|
698
714
|
// but defer the local-base sync until the final base-ref mutation block below.
|
|
699
|
-
const integrationBaseResult = await mergeGit(["rev-parse", base], repoRoot, "rebase", `workspace merge resolve integration base ${base}
|
|
715
|
+
const integrationBaseResult = await mergeGit(["rev-parse", base], repoRoot, "rebase", `workspace merge resolve integration base ${base}`, signal);
|
|
700
716
|
let integrationBaseSha = integrationBaseResult.stdout;
|
|
701
717
|
if (!integrationBaseResult.ok || !integrationBaseSha) return head({ status: "review_requested", error: gitError(integrationBaseResult, `failed to resolve integration base ${base}`) });
|
|
702
718
|
let needSync = false;
|
|
703
719
|
if (upstream && remote && pushEnabled) {
|
|
704
720
|
try {
|
|
705
|
-
const fetch = await withMergePhaseTimeout("fetch", () => git(
|
|
721
|
+
const fetch = await withMergePhaseTimeout("fetch", (fetchSignal) => git(
|
|
706
722
|
["fetch", remote, base],
|
|
707
723
|
worktreePath,
|
|
708
|
-
{ timeoutMs: mergePhaseTimeoutMs("fetch"), timeoutLabel: `workspace merge fetch ${remote}/${base}
|
|
709
|
-
));
|
|
724
|
+
{ timeoutMs: mergePhaseTimeoutMs("fetch"), timeoutLabel: `workspace merge fetch ${remote}/${base}`, signal: fetchSignal },
|
|
725
|
+
), { signal });
|
|
710
726
|
if (!fetch.ok && fetch.timedOut) return head({ status: "review_requested", error: fetch.stderr || `fetch ${remote}/${base} timed out` });
|
|
711
727
|
} catch (err) {
|
|
712
728
|
return head({ status: "review_requested", error: errMessage(err) });
|
|
713
729
|
}
|
|
714
|
-
if (!(await mergeGit(["merge-base", "--is-ancestor", upstream, base], worktreePath, "rebase", `workspace merge compare ${upstream} to ${base}
|
|
730
|
+
if (!(await mergeGit(["merge-base", "--is-ancestor", upstream, base], worktreePath, "rebase", `workspace merge compare ${upstream} to ${base}`, signal)).ok) {
|
|
715
731
|
// Origin moved ahead. Sync-then-land iff local base is cleanly behind (ancestor
|
|
716
732
|
// of upstream); otherwise it's genuine divergence — refuse without mutating.
|
|
717
|
-
if (!(await mergeGit(["merge-base", "--is-ancestor", base, upstream], worktreePath, "rebase", `workspace merge compare ${base} to ${upstream}
|
|
733
|
+
if (!(await mergeGit(["merge-base", "--is-ancestor", base, upstream], worktreePath, "rebase", `workspace merge compare ${base} to ${upstream}`, signal)).ok) {
|
|
718
734
|
return head({ status: "review_requested", error: `local ${base} has diverged from ${upstream} (commits not on origin); sync before landing` });
|
|
719
735
|
}
|
|
720
|
-
const upstreamSha = (await mergeGit(["rev-parse", "--verify", upstream], worktreePath, "rebase", `workspace merge resolve ${upstream} for integration
|
|
736
|
+
const upstreamSha = (await mergeGit(["rev-parse", "--verify", upstream], worktreePath, "rebase", `workspace merge resolve ${upstream} for integration`, signal)).stdout;
|
|
721
737
|
if (!upstreamSha) return head({ status: "review_requested", error: `cannot resolve ${upstream} to sync ${base}` });
|
|
722
738
|
// Integrate onto fresh origin (the tree that will land), but DON'T advance local base yet.
|
|
723
739
|
integrationBaseSha = upstreamSha;
|
|
@@ -726,7 +742,7 @@ async function mergeRebaseFf(
|
|
|
726
742
|
}
|
|
727
743
|
|
|
728
744
|
// Behind relative to the TRUE integration base (origin-ahead ⟹ behind>0 ⟹ a real no-ff merge).
|
|
729
|
-
const behindResult = await countBehind(worktreePath, integrationBaseSha);
|
|
745
|
+
const behindResult = await countBehind(worktreePath, integrationBaseSha, signal);
|
|
730
746
|
if ("error" in behindResult) return head({ status: "review_requested", error: behindResult.error });
|
|
731
747
|
const behind = behindResult.behind;
|
|
732
748
|
|
|
@@ -735,6 +751,7 @@ async function mergeRebaseFf(
|
|
|
735
751
|
// before base-ref mutation so the rest of the historical land ordering stays intact, but no
|
|
736
752
|
// gate subprocess or detached gate worktree can be launched at land time.
|
|
737
753
|
const gateRun = await runLandGatesOnIntegratedTree(repoRoot, worktreePath, behind, integrationBaseSha, headSha, landMergeMessage(branch, landedSubject));
|
|
754
|
+
throwIfMergeAborted(signal);
|
|
738
755
|
if ("abort" in gateRun) {
|
|
739
756
|
return gateRun.abort.conflict
|
|
740
757
|
? head({ conflict: true, status: "conflict", error: gateRun.abort.error })
|
|
@@ -750,7 +767,9 @@ async function mergeRebaseFf(
|
|
|
750
767
|
// upstream first (#638); this preserves the pre-existing land ordering while gate execution
|
|
751
768
|
// remains permanently disabled.
|
|
752
769
|
if (needSync) {
|
|
753
|
-
|
|
770
|
+
throwIfMergeAborted(signal);
|
|
771
|
+
const synced = await syncLocalBaseToUpstream(repoRoot, worktreePath, base, upstream!, signal);
|
|
772
|
+
throwIfMergeAborted(signal);
|
|
754
773
|
if (!synced.ok) return head({ status: "review_requested", error: synced.error });
|
|
755
774
|
baseSync = mergeBaseSyncResults(baseSync, synced.baseSync);
|
|
756
775
|
}
|
|
@@ -763,11 +782,12 @@ async function mergeRebaseFf(
|
|
|
763
782
|
// land: fall through to ref-plumbing (update-ref / synthesized no-ff merge) below, then
|
|
764
783
|
// best-effort sync clean landed paths in the dirty checkout while preserving WIP (#681).
|
|
765
784
|
let baseTip = headSha;
|
|
766
|
-
|
|
767
|
-
const
|
|
785
|
+
throwIfMergeAborted(signal);
|
|
786
|
+
const baseWorktree = await worktreeForBranch(repoRoot, base, signal);
|
|
787
|
+
const dirtyBasePathsBefore = baseWorktree?.dirty ? await dirtyPathSet(baseWorktree.path, signal) : undefined;
|
|
768
788
|
if (baseWorktree && !baseWorktree.dirty) {
|
|
769
789
|
if (behind === 0) {
|
|
770
|
-
const ff = await mergeGit(["merge", "--ff-only", branch], baseWorktree.path, "rebase", `workspace merge fast-forward ${base} to ${branch}
|
|
790
|
+
const ff = await mergeGit(["merge", "--ff-only", branch], baseWorktree.path, "rebase", `workspace merge fast-forward ${base} to ${branch}`, signal);
|
|
771
791
|
if (!ff.ok) return head({ status: "review_requested", error: ff.stderr || "fast-forward into base failed" });
|
|
772
792
|
} else {
|
|
773
793
|
const merge = await mergeGit(
|
|
@@ -775,28 +795,31 @@ async function mergeRebaseFf(
|
|
|
775
795
|
baseWorktree.path,
|
|
776
796
|
"rebase",
|
|
777
797
|
`workspace merge no-ff ${branch} into ${base}`,
|
|
798
|
+
signal,
|
|
778
799
|
);
|
|
779
800
|
if (!merge.ok) {
|
|
780
|
-
await mergeGit(["merge", "--abort"], baseWorktree.path, "rebase", `workspace merge abort failed no-ff ${branch}
|
|
801
|
+
await mergeGit(["merge", "--abort"], baseWorktree.path, "rebase", `workspace merge abort failed no-ff ${branch}`, signal);
|
|
781
802
|
return head({ conflict: true, status: "conflict", error: merge.stderr || "merge into base failed" });
|
|
782
803
|
}
|
|
783
|
-
baseTip = (await mergeGit(["rev-parse", "HEAD"], baseWorktree.path, "rebase", `workspace merge resolve ${base} after no-ff
|
|
804
|
+
baseTip = (await mergeGit(["rev-parse", "HEAD"], baseWorktree.path, "rebase", `workspace merge resolve ${base} after no-ff`, signal)).stdout;
|
|
784
805
|
}
|
|
785
806
|
} else if (behind === 0) {
|
|
786
|
-
const oldBaseTip = (await mergeGit(["rev-parse", base], repoRoot, "rebase", `workspace merge resolve ${base} before fast-forward ref update
|
|
807
|
+
const oldBaseTip = (await mergeGit(["rev-parse", base], repoRoot, "rebase", `workspace merge resolve ${base} before fast-forward ref update`, signal)).stdout;
|
|
808
|
+
throwIfMergeAborted(signal);
|
|
787
809
|
const update = oldBaseTip
|
|
788
|
-
? await mergeGit(["update-ref", `refs/heads/${base}`, headSha, oldBaseTip], repoRoot, "rebase", `workspace merge update ${base} fast-forward
|
|
789
|
-
: await mergeGit(["update-ref", `refs/heads/${base}`, headSha], repoRoot, "rebase", `workspace merge create/update ${base} fast-forward
|
|
810
|
+
? await mergeGit(["update-ref", `refs/heads/${base}`, headSha, oldBaseTip], repoRoot, "rebase", `workspace merge update ${base} fast-forward`, signal)
|
|
811
|
+
: await mergeGit(["update-ref", `refs/heads/${base}`, headSha], repoRoot, "rebase", `workspace merge create/update ${base} fast-forward`, signal);
|
|
790
812
|
if (!update.ok) return head({ status: "review_requested", error: update.stderr || "failed to advance base ref" });
|
|
791
|
-
if (oldBaseTip) baseSync = mergeBaseSyncResults(baseSync, await syncDirtyBaseWorktreeAfterRefAdvance(base, baseWorktree, oldBaseTip, headSha, dirtyBasePathsBefore));
|
|
813
|
+
if (oldBaseTip) baseSync = mergeBaseSyncResults(baseSync, await syncDirtyBaseWorktreeAfterRefAdvance(base, baseWorktree, oldBaseTip, headSha, dirtyBasePathsBefore, signal));
|
|
792
814
|
} else {
|
|
793
|
-
const baseShaResult = await mergeGit(["rev-parse", base], repoRoot, "rebase", `workspace merge resolve ${base} before no-ff ref update
|
|
815
|
+
const baseShaResult = await mergeGit(["rev-parse", base], repoRoot, "rebase", `workspace merge resolve ${base} before no-ff ref update`, signal);
|
|
794
816
|
const baseSha = baseShaResult.stdout;
|
|
795
817
|
if (!baseShaResult.ok || !baseSha) return head({ status: "review_requested", error: gitError(baseShaResult, `failed to resolve ${base} before no-ff ref update`) });
|
|
796
|
-
|
|
818
|
+
throwIfMergeAborted(signal);
|
|
819
|
+
const merged = await recordNoFfMerge(repoRoot, base, baseSha, headSha, landMergeMessage(branch, landedSubject), signal);
|
|
797
820
|
if (!merged.ok) return head(merged.conflict ? { conflict: true, status: "conflict", error: merged.error } : { status: "review_requested", error: merged.error });
|
|
798
821
|
baseTip = merged.mergeSha;
|
|
799
|
-
baseSync = mergeBaseSyncResults(baseSync, await syncDirtyBaseWorktreeAfterRefAdvance(base, baseWorktree, baseSha, baseTip, dirtyBasePathsBefore));
|
|
822
|
+
baseSync = mergeBaseSyncResults(baseSync, await syncDirtyBaseWorktreeAfterRefAdvance(base, baseWorktree, baseSha, baseTip, dirtyBasePathsBefore, signal));
|
|
800
823
|
}
|
|
801
824
|
|
|
802
825
|
// #824 — a dirty base checkout left out of sync with the advanced HEAD is a MIXED state:
|
|
@@ -812,7 +835,8 @@ async function mergeRebaseFf(
|
|
|
812
835
|
// means origin raced us — surface it instead of claiming an unpublished land.
|
|
813
836
|
let pushed = false;
|
|
814
837
|
if (upstream && remote && pushEnabled) {
|
|
815
|
-
|
|
838
|
+
throwIfMergeAborted(signal);
|
|
839
|
+
const push = await mergeGit(["push", remote, `${base}:${base}`], worktreePath, "rebase", `workspace merge push ${base} to ${remote}`, signal);
|
|
816
840
|
if (!push.ok) return head({ status: "review_requested", mergedSha: headSha, error: push.stderr || `git push to ${remote}/${base} failed` });
|
|
817
841
|
pushed = true;
|
|
818
842
|
}
|
|
@@ -824,25 +848,27 @@ async function mergeRebaseFf(
|
|
|
824
848
|
// state in the same CWD; status returns to `active`. An absent owner gets the
|
|
825
849
|
// worktree reclaimed.
|
|
826
850
|
const deleteBranch = input.deleteBranch !== false;
|
|
851
|
+
const ownerRepo = await owningRepoRoot(worktreePath, repoRoot);
|
|
827
852
|
if (!deleteBranch) {
|
|
828
|
-
const fresh = await nextBranchName(
|
|
829
|
-
|
|
853
|
+
const fresh = await nextBranchName(ownerRepo, branch);
|
|
854
|
+
throwIfMergeAborted(signal);
|
|
855
|
+
const switched = await mergeGit(["checkout", "-B", fresh, base], worktreePath, "rebase", `workspace merge recycle worktree to ${fresh}`, signal);
|
|
830
856
|
if (switched.ok) {
|
|
831
|
-
|
|
832
|
-
// as the no-ff merge's second parent) — drop the litter.
|
|
833
|
-
const oldDeleted = (await mergeGit(["branch", "-D", branch], repoRoot, "cleanup", `workspace merge delete landed branch ${branch}`)).ok;
|
|
857
|
+
const deleteResult = await deleteBranchIfSafe(ownerRepo, branch, undefined, baseTip, signal);
|
|
834
858
|
// The worktree just moved onto the advanced base, which may declare deps the
|
|
835
859
|
// shared (symlinked) node_modules lacks. Re-provision so the recycled session
|
|
836
860
|
// continues from a buildable state (issue #51). No-op when nothing is stale.
|
|
837
|
-
const depsRefresh = await refreshWorkspaceDeps(
|
|
861
|
+
const depsRefresh = await refreshWorkspaceDeps(ownerRepo, worktreePath);
|
|
862
|
+
throwIfMergeAborted(signal);
|
|
838
863
|
const reportDeps = depsRefresh.refreshed || depsRefresh.stale || depsRefresh.error;
|
|
839
|
-
return head({ merged: true, status: "active", mergedSha: headSha, baseSha: baseTip, subject: landedSubject, worktreeRemoved: false, branch: fresh, newBranch: fresh,
|
|
864
|
+
return head({ merged: true, status: "active", mergedSha: headSha, baseSha: baseTip, subject: landedSubject, worktreeRemoved: false, branch: fresh, newBranch: fresh, ...deleteResult, pushed, ...(reportDeps ? { depsRefresh } : {}), ...baseWorktreeSyncField, ...gateWarningsField, error: undefined });
|
|
840
865
|
}
|
|
841
866
|
// Recycle failed — keep the existing branch. Still landed, still active.
|
|
842
867
|
return head({ merged: true, status: "active", mergedSha: headSha, baseSha: baseTip, subject: landedSubject, worktreeRemoved: false, branchDeleted: false, pushed, ...baseWorktreeSyncField, ...gateWarningsField, error: undefined });
|
|
843
868
|
}
|
|
844
|
-
|
|
869
|
+
throwIfMergeAborted(signal);
|
|
870
|
+
const removed = await mergeGit(["worktree", "remove", "--force", worktreePath], ownerRepo, "cleanup", "workspace merge remove landed worktree", signal);
|
|
845
871
|
const worktreeRemoved = removed.ok;
|
|
846
|
-
const
|
|
847
|
-
return head({ merged: true, status: "merged", mergedSha: headSha, baseSha: baseTip, subject: landedSubject, worktreeRemoved,
|
|
872
|
+
const deleteResult = worktreeRemoved ? await deleteBranchIfSafe(ownerRepo, branch, undefined, baseTip, signal) : { branchDeleted: false };
|
|
873
|
+
return head({ merged: true, status: "merged", mergedSha: headSha, baseSha: baseTip, subject: landedSubject, worktreeRemoved, ...deleteResult, pushed, ...baseWorktreeSyncField, ...gateWarningsField, error: undefined });
|
|
848
874
|
}
|
|
@@ -65,4 +65,5 @@ export interface WorkspaceMergeInput {
|
|
|
65
65
|
* otherwise loop via review_requested. prMerged is monotonic and was read from THIS host's
|
|
66
66
|
* own `checkPr` scan, so trusting it is safe; we still refuse on a dirty tree. */
|
|
67
67
|
prLanded?: { sha?: string; subject?: string };
|
|
68
|
+
signal?: AbortSignal;
|
|
68
69
|
}
|