agent-relay-orchestrator 0.91.2 → 0.91.4
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 -2
- package/src/git.ts +14 -0
- package/src/shared-callmux.ts +46 -4
- package/src/terminal-stream.ts +6 -0
- package/src/workspace-probe/merge.ts +90 -9
- package/vendor/callmux/bin/callmux.js +47579 -0
- package/vendor/callmux/package.json +11 -0
- package/vendor/callmux/schema.json +868 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-orchestrator",
|
|
3
|
-
"version": "0.91.
|
|
3
|
+
"version": "0.91.4",
|
|
4
4
|
"description": "Agent Relay orchestrator — manages agent lifecycle across hosts",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
"src/**/*.ts",
|
|
11
11
|
"!src/**/*.test.ts",
|
|
12
|
+
"vendor/**",
|
|
12
13
|
"README.md"
|
|
13
14
|
],
|
|
14
15
|
"scripts": {
|
|
@@ -16,7 +17,7 @@
|
|
|
16
17
|
"test": "bun test"
|
|
17
18
|
},
|
|
18
19
|
"dependencies": {
|
|
19
|
-
"agent-relay-sdk": "0.2.
|
|
20
|
+
"agent-relay-sdk": "0.2.71"
|
|
20
21
|
},
|
|
21
22
|
"devDependencies": {
|
|
22
23
|
"@types/bun": "latest",
|
package/src/git.ts
CHANGED
|
@@ -22,6 +22,20 @@ export function git(args: string[], cwd: string): GitResult {
|
|
|
22
22
|
};
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/** Run `git -C cwd <args>` and preserve stdout exactly for path-safe parsers. */
|
|
26
|
+
export function gitRaw(args: string[], cwd: string): GitResult {
|
|
27
|
+
const proc = Bun.spawnSync(["git", "-C", cwd, ...args], {
|
|
28
|
+
stdin: "ignore",
|
|
29
|
+
stdout: "pipe",
|
|
30
|
+
stderr: "pipe",
|
|
31
|
+
});
|
|
32
|
+
return {
|
|
33
|
+
ok: proc.exitCode === 0,
|
|
34
|
+
stdout: proc.stdout.toString(),
|
|
35
|
+
stderr: proc.stderr.toString().trim(),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
25
39
|
/** Like {@link git} but throws on a non-zero exit, returning stdout on success. */
|
|
26
40
|
export function requireGit(args: string[], cwd: string): string {
|
|
27
41
|
const result = git(args, cwd);
|
package/src/shared-callmux.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
-
import { dirname, isAbsolute, join } from "node:path";
|
|
3
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
4
4
|
import { errMessage, isRecord } from "agent-relay-sdk";
|
|
5
5
|
import type { OrchestratorConfig } from "./config";
|
|
6
6
|
import { agentRelayHome } from "./config";
|
|
@@ -54,6 +54,15 @@ export interface SharedCallmuxSupervisorDeps {
|
|
|
54
54
|
setTimeout(fn: () => void, ms: number): Timer;
|
|
55
55
|
clearTimeout(timer: Timer): void;
|
|
56
56
|
log(message: string): void;
|
|
57
|
+
report(snapshot: SharedCallmuxHealthSnapshot): void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface SharedCallmuxHealthSnapshot {
|
|
61
|
+
state: "disabled" | "missing" | "starting" | "running" | "unhealthy" | "restarting" | "stopped";
|
|
62
|
+
url: string;
|
|
63
|
+
command?: string;
|
|
64
|
+
reason?: string;
|
|
65
|
+
pid?: number;
|
|
57
66
|
}
|
|
58
67
|
|
|
59
68
|
export function sharedCallmuxOptionsFromEnv(env: Record<string, string | undefined> = process.env): SharedCallmuxOptions {
|
|
@@ -63,13 +72,13 @@ export function sharedCallmuxOptionsFromEnv(env: Record<string, string | undefin
|
|
|
63
72
|
const port = numberEnv(env[SHARED_CALLMUX_PORT_ENV]) ?? parsed?.port ?? DEFAULT_SHARED_CALLMUX_PORT;
|
|
64
73
|
const url = explicitUrl ?? `http://${host}:${port}/mcp`;
|
|
65
74
|
return {
|
|
66
|
-
command: env[SHARED_CALLMUX_COMMAND_ENV] || "callmux",
|
|
75
|
+
command: env[SHARED_CALLMUX_COMMAND_ENV] || bundledCallmuxCommand() || "callmux",
|
|
67
76
|
host,
|
|
68
77
|
port,
|
|
69
78
|
url,
|
|
70
79
|
configPath: env[SHARED_CALLMUX_CONFIG_ENV] || join(agentRelayHome(), "callmux", "shared-listener.json"),
|
|
71
80
|
sourceConfigPath: env[SHARED_CALLMUX_SOURCE_CONFIG_ENV] || env.CALLMUX_CONFIG || join(homedir(), ".config", "callmux", "config.json"),
|
|
72
|
-
enabled: env[SHARED_CALLMUX_ENABLE_ENV]
|
|
81
|
+
enabled: !envOff(env[SHARED_CALLMUX_ENABLE_ENV]),
|
|
73
82
|
};
|
|
74
83
|
}
|
|
75
84
|
|
|
@@ -91,6 +100,8 @@ export function writeSharedCallmuxConfig(opts: Pick<SharedCallmuxOptions, "confi
|
|
|
91
100
|
maxConcurrency: numberFromRecord(source, "maxConcurrency") ?? 20,
|
|
92
101
|
callTimeoutMs: numberFromRecord(source, "callTimeoutMs") ?? 180_000,
|
|
93
102
|
outputFormat: stringFromRecord(source, "outputFormat") ?? "auto",
|
|
103
|
+
// Relay workers consume only proxied tokenlean+github tools; suppress callmux meta-tools.
|
|
104
|
+
exposeMetaTools: false,
|
|
94
105
|
};
|
|
95
106
|
mkdirSync(dirname(opts.configPath), { recursive: true });
|
|
96
107
|
writeFileSync(opts.configPath, JSON.stringify(generated, null, 2) + "\n", { mode: 0o600 });
|
|
@@ -115,14 +126,17 @@ export class SharedCallmuxSupervisor {
|
|
|
115
126
|
|
|
116
127
|
start(): void {
|
|
117
128
|
if (!this.opts.enabled) {
|
|
118
|
-
this.deps.log("[orchestrator] shared callmux listener disabled
|
|
129
|
+
this.deps.log("[orchestrator] shared callmux listener disabled by AGENT_RELAY_SHARED_CALLMUX_ENABLE=0");
|
|
130
|
+
this.report("disabled", "global kill-switch");
|
|
119
131
|
return;
|
|
120
132
|
}
|
|
121
133
|
this.deps.log(`[orchestrator] Shared callmux listener: ${this.opts.url}`);
|
|
122
134
|
if (!this.deps.which(this.opts.command)) {
|
|
123
135
|
this.deps.log("[orchestrator] shared callmux not found — shared listener dormant");
|
|
136
|
+
this.report("missing", "command not found");
|
|
124
137
|
return;
|
|
125
138
|
}
|
|
139
|
+
this.report("starting");
|
|
126
140
|
this.spawn();
|
|
127
141
|
this.healthTimer = this.deps.setInterval(() => {
|
|
128
142
|
void this.checkHealth();
|
|
@@ -153,9 +167,11 @@ export class SharedCallmuxSupervisor {
|
|
|
153
167
|
}
|
|
154
168
|
if (ok) {
|
|
155
169
|
this.backoffMs = this.timing.restartBaseMs ?? 1_000;
|
|
170
|
+
this.report("running", undefined, this.proc?.pid);
|
|
156
171
|
return true;
|
|
157
172
|
}
|
|
158
173
|
this.deps.log(`[orchestrator] Shared callmux readiness failed at ${readyUrl}; restarting`);
|
|
174
|
+
this.report("unhealthy", "readiness failed", this.proc?.pid);
|
|
159
175
|
this.restart("readiness failed");
|
|
160
176
|
return false;
|
|
161
177
|
}
|
|
@@ -174,20 +190,24 @@ export class SharedCallmuxSupervisor {
|
|
|
174
190
|
proc = this.deps.spawn(this.opts.command, args, { env, cwd: homedir() });
|
|
175
191
|
} catch (err) {
|
|
176
192
|
this.deps.log(`[orchestrator] Shared callmux listener failed to start: ${errMessage(err)}; scheduling restart`);
|
|
193
|
+
this.report("restarting", errMessage(err));
|
|
177
194
|
this.scheduleRestart();
|
|
178
195
|
return;
|
|
179
196
|
}
|
|
180
197
|
this.proc = proc;
|
|
181
198
|
this.deps.log(`[orchestrator] Started shared callmux listener pid=${proc.pid ?? "unknown"}`);
|
|
199
|
+
this.report("running", undefined, proc.pid);
|
|
182
200
|
proc.exited.then((code) => {
|
|
183
201
|
if (this.proc !== proc || this.stopping) return;
|
|
184
202
|
this.proc = null;
|
|
185
203
|
this.deps.log(`[orchestrator] Shared callmux listener exited (${code ?? "signal"}); scheduling restart`);
|
|
204
|
+
this.report("restarting", `exited ${code ?? "signal"}`);
|
|
186
205
|
this.scheduleRestart();
|
|
187
206
|
}).catch((err) => {
|
|
188
207
|
if (this.proc !== proc || this.stopping) return;
|
|
189
208
|
this.proc = null;
|
|
190
209
|
this.deps.log(`[orchestrator] Shared callmux listener exit watcher failed: ${err}`);
|
|
210
|
+
this.report("restarting", errMessage(err));
|
|
191
211
|
this.scheduleRestart();
|
|
192
212
|
});
|
|
193
213
|
}
|
|
@@ -198,9 +218,20 @@ export class SharedCallmuxSupervisor {
|
|
|
198
218
|
this.proc.kill("SIGTERM");
|
|
199
219
|
this.proc = null;
|
|
200
220
|
}
|
|
221
|
+
this.report("restarting", reason);
|
|
201
222
|
this.scheduleRestart();
|
|
202
223
|
}
|
|
203
224
|
|
|
225
|
+
private report(state: SharedCallmuxHealthSnapshot["state"], reason?: string, pid?: number): void {
|
|
226
|
+
this.deps.report({
|
|
227
|
+
state,
|
|
228
|
+
url: this.opts.url,
|
|
229
|
+
command: this.opts.command,
|
|
230
|
+
...(reason ? { reason } : {}),
|
|
231
|
+
...(pid ? { pid } : {}),
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
204
235
|
private scheduleRestart(): void {
|
|
205
236
|
if (this.stopping || this.restartTimer) return;
|
|
206
237
|
const delay = this.backoffMs;
|
|
@@ -235,15 +266,26 @@ function defaultDeps(): SharedCallmuxSupervisorDeps {
|
|
|
235
266
|
setTimeout: (fn, ms) => setTimeout(fn, ms),
|
|
236
267
|
clearTimeout: (timer) => clearTimeout(timer),
|
|
237
268
|
log: (message) => console.error(message),
|
|
269
|
+
report: (snapshot) => console.error(`[orchestrator] shared callmux status ${snapshot.state}${snapshot.reason ? `: ${snapshot.reason}` : ""}`),
|
|
238
270
|
};
|
|
239
271
|
}
|
|
240
272
|
|
|
273
|
+
export function bundledCallmuxCommand(): string | null {
|
|
274
|
+
const candidate = resolve(import.meta.dir, "../vendor/callmux/bin/callmux.js");
|
|
275
|
+
return existsSync(candidate) ? candidate : null;
|
|
276
|
+
}
|
|
277
|
+
|
|
241
278
|
function resolveCommand(command: string): string | null {
|
|
242
279
|
if (isAbsolute(command)) return existsSync(command) ? command : null;
|
|
243
280
|
if (command.includes("/")) return existsSync(command) ? command : null;
|
|
244
281
|
return Bun.which(command);
|
|
245
282
|
}
|
|
246
283
|
|
|
284
|
+
function envOff(value: string | undefined): boolean {
|
|
285
|
+
if (value === undefined || value === null || value === "") return false;
|
|
286
|
+
return ["0", "false", "off", "no"].includes(value.trim().toLowerCase());
|
|
287
|
+
}
|
|
288
|
+
|
|
247
289
|
function readJsonObject(path: string): Record<string, unknown> {
|
|
248
290
|
if (!existsSync(path)) return {};
|
|
249
291
|
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
package/src/terminal-stream.ts
CHANGED
|
@@ -603,6 +603,12 @@ class SessionStream {
|
|
|
603
603
|
// can't re-apply on top of it (the duplicate-text race). %output that arrived AFTER the
|
|
604
604
|
// capture stayed in `pending` and applies on top of the repaint via the trailing flush.
|
|
605
605
|
this.drainPreCaptureDeltas();
|
|
606
|
+
if (!forceAbort && this.broadcastState !== "ground") {
|
|
607
|
+
this.resyncDirty = true;
|
|
608
|
+
this.flush(); // let the sequence tail complete normally; retry with a fresh capture
|
|
609
|
+
if (!this.resyncTimer) this.resyncTimer = setTimeout(() => void this.doResync(), RESYNC_GROUND_RETRY_MS);
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
606
612
|
if (!repaint || this.closed || this.subscribers.size === 0) {
|
|
607
613
|
this.flush(); // release any post-capture deltas normally (no repaint to inject)
|
|
608
614
|
return;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
3
|
import type { WorkspaceMergePreview, WorkspaceMergeResult } from "agent-relay-sdk";
|
|
4
|
-
import { git } from "../git";
|
|
4
|
+
import { git, gitRaw } from "../git";
|
|
5
5
|
import { prMergedState } from "../workspace-pr";
|
|
6
6
|
import { refreshWorkspaceDeps } from "./deps";
|
|
7
7
|
import { populateMergeState, resolveBranchRef, syncBaseFromOrigin, upstreamRef, workspaceGitState } from "./git-state";
|
|
@@ -65,6 +65,79 @@ function worktreeForBranch(repoRoot: string, branch: string): { path: string; di
|
|
|
65
65
|
return { path: match.path, dirty: status.ok ? status.stdout.length > 0 : true };
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
function splitNul(stdout: string): string[] {
|
|
69
|
+
return stdout.split("\0").filter(Boolean);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function dirtyPathSet(worktreePath: string): Set<string> {
|
|
73
|
+
const status = gitRaw(["status", "--porcelain=v1", "-z", "--untracked-files=all"], worktreePath);
|
|
74
|
+
if (!status.ok) return new Set<string>();
|
|
75
|
+
const paths = new Set<string>();
|
|
76
|
+
const entries = splitNul(status.stdout);
|
|
77
|
+
for (let i = 0; i < entries.length; i += 1) {
|
|
78
|
+
const entry = entries[i] ?? "";
|
|
79
|
+
if (entry.length < 4) continue;
|
|
80
|
+
const code = entry.slice(0, 2);
|
|
81
|
+
const path = entry.slice(3);
|
|
82
|
+
paths.add(path);
|
|
83
|
+
if (code[0] === "R" || code[0] === "C") i += 1;
|
|
84
|
+
}
|
|
85
|
+
return paths;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function changedPathList(worktreePath: string, oldBaseTip: string, newBaseTip: string): string[] {
|
|
89
|
+
const diff = gitRaw(["diff", "--name-only", "-z", oldBaseTip, newBaseTip], worktreePath);
|
|
90
|
+
return diff.ok ? splitNul(diff.stdout) : [];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function restorePathsFromHead(worktreePath: string, paths: string[]): boolean {
|
|
94
|
+
if (paths.length === 0) return true;
|
|
95
|
+
return git(["restore", "--staged", "--worktree", "--", ...paths], worktreePath).ok;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function resetIndexPathsToHead(worktreePath: string, paths: string[]): boolean {
|
|
99
|
+
if (paths.length === 0) return true;
|
|
100
|
+
return git(["reset", "-q", "HEAD", "--", ...paths], worktreePath).ok;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function logBaseWorktreeSyncWarning(base: string, worktreePath: string, message: string): void {
|
|
104
|
+
console.error(`[orchestrator] Warning: advanced ${base} but could not fully sync dirty base worktree ${worktreePath}: ${message}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* After a dirty checked-out base is advanced with ref plumbing, bring the checkout forward
|
|
109
|
+
* without clobbering human WIP. `read-tree -m -u old new` is the primary mechanism: it
|
|
110
|
+
* updates clean paths in the index and worktree and refuses before overwriting local edits.
|
|
111
|
+
* If it refuses, preserve the land by leaving the ref advanced, restore only paths that were
|
|
112
|
+
* clean before the ref move, and normalize conflicting landed paths to unstaged edits.
|
|
113
|
+
*/
|
|
114
|
+
function syncDirtyBaseWorktreeAfterRefAdvance(
|
|
115
|
+
base: string,
|
|
116
|
+
baseWorktree: { path: string; dirty: boolean } | undefined,
|
|
117
|
+
oldBaseTip: string,
|
|
118
|
+
newBaseTip: string,
|
|
119
|
+
dirtyBefore: Set<string> | undefined,
|
|
120
|
+
): void {
|
|
121
|
+
if (!baseWorktree?.dirty || !dirtyBefore) return;
|
|
122
|
+
const readTree = git(["read-tree", "-m", "-u", oldBaseTip, newBaseTip], baseWorktree.path);
|
|
123
|
+
if (readTree.ok) return;
|
|
124
|
+
|
|
125
|
+
const changedPaths = changedPathList(baseWorktree.path, oldBaseTip, newBaseTip);
|
|
126
|
+
const cleanChangedPaths = changedPaths.filter((path) => !dirtyBefore.has(path));
|
|
127
|
+
const dirtyChangedPaths = changedPaths.filter((path) => dirtyBefore.has(path));
|
|
128
|
+
const restoredClean = restorePathsFromHead(baseWorktree.path, cleanChangedPaths);
|
|
129
|
+
const resetDirtyIndex = resetIndexPathsToHead(baseWorktree.path, dirtyChangedPaths);
|
|
130
|
+
logBaseWorktreeSyncWarning(
|
|
131
|
+
base,
|
|
132
|
+
baseWorktree.path,
|
|
133
|
+
[
|
|
134
|
+
readTree.stderr || "read-tree refused local changes",
|
|
135
|
+
restoredClean ? undefined : "failed to restore clean landed paths",
|
|
136
|
+
resetDirtyIndex ? undefined : "failed to reset conflicting path index",
|
|
137
|
+
].filter(Boolean).join("; "),
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
68
141
|
// Populate a preview's PR fields from `gh` ground truth; returns whether the PR is merged
|
|
69
142
|
// (#168/#304). One home for the PR-landing rule so the worktree- and repoRoot-based previews
|
|
70
143
|
// can't drift. On a merge it stamps the merge SHA so the relay can finalize a parked pr land.
|
|
@@ -441,16 +514,19 @@ function syncLocalBaseToUpstream(
|
|
|
441
514
|
// Sync IN the base worktree only when it's clean — that keeps its working tree consistent
|
|
442
515
|
// with the advanced ref (the pristine home-repo checkout). When the base worktree is dirty
|
|
443
516
|
// (#644: a human's WIP in the shared checkout) we must NOT refuse and stall the whole repo's
|
|
444
|
-
// lands: advance the ref directly with update-ref
|
|
445
|
-
//
|
|
446
|
-
// exactly as-is (the ref moves underneath; their files on disk are untouched).
|
|
517
|
+
// lands: advance the ref directly with update-ref, then best-effort sync the checked-out
|
|
518
|
+
// index/worktree forward for paths that are not human-modified (#681).
|
|
447
519
|
if (baseWorktree && !baseWorktree.dirty) {
|
|
448
520
|
const ff = git(["merge", "--ff-only", upstream], baseWorktree.path);
|
|
449
521
|
if (!ff.ok) return { ok: false, error: ff.stderr || `failed to fast-forward ${base} to ${upstream}` };
|
|
450
522
|
return { ok: true };
|
|
451
523
|
}
|
|
452
|
-
const
|
|
524
|
+
const oldBaseTip = git(["rev-parse", base], repoRoot).stdout;
|
|
525
|
+
const dirtyBefore = baseWorktree?.dirty ? dirtyPathSet(baseWorktree.path) : undefined;
|
|
526
|
+
const updateArgs = oldBaseTip ? ["update-ref", `refs/heads/${base}`, upstreamSha, oldBaseTip] : ["update-ref", `refs/heads/${base}`, upstreamSha];
|
|
527
|
+
const update = git(updateArgs, repoRoot);
|
|
453
528
|
if (!update.ok) return { ok: false, error: update.stderr || `failed to advance ${base} to ${upstream}` };
|
|
529
|
+
if (oldBaseTip) syncDirtyBaseWorktreeAfterRefAdvance(base, baseWorktree, oldBaseTip, upstreamSha, dirtyBefore);
|
|
454
530
|
return { ok: true };
|
|
455
531
|
}
|
|
456
532
|
|
|
@@ -512,11 +588,11 @@ function mergeRebaseFf(
|
|
|
512
588
|
// only when it exists AND is clean — that keeps its working tree consistent with the
|
|
513
589
|
// advanced ref (the pristine home-repo checkout). When the base worktree is dirty
|
|
514
590
|
// (#644: a human's WIP in the shared checkout) we must NOT refuse and stall every lane's
|
|
515
|
-
// land: fall through to ref-plumbing (update-ref / synthesized no-ff merge) below,
|
|
516
|
-
//
|
|
517
|
-
// uncommitted work is left exactly as-is — the ref moves underneath, their files untouched.
|
|
591
|
+
// land: fall through to ref-plumbing (update-ref / synthesized no-ff merge) below, then
|
|
592
|
+
// best-effort sync clean landed paths in the dirty checkout while preserving WIP (#681).
|
|
518
593
|
let baseTip = headSha;
|
|
519
594
|
const baseWorktree = worktreeForBranch(repoRoot, base);
|
|
595
|
+
const dirtyBasePathsBefore = baseWorktree?.dirty ? dirtyPathSet(baseWorktree.path) : undefined;
|
|
520
596
|
if (baseWorktree && !baseWorktree.dirty) {
|
|
521
597
|
if (behind === 0) {
|
|
522
598
|
const ff = git(["merge", "--ff-only", branch], baseWorktree.path);
|
|
@@ -533,13 +609,18 @@ function mergeRebaseFf(
|
|
|
533
609
|
baseTip = git(["rev-parse", "HEAD"], baseWorktree.path).stdout;
|
|
534
610
|
}
|
|
535
611
|
} else if (behind === 0) {
|
|
536
|
-
const
|
|
612
|
+
const oldBaseTip = git(["rev-parse", base], repoRoot).stdout;
|
|
613
|
+
const update = oldBaseTip
|
|
614
|
+
? git(["update-ref", `refs/heads/${base}`, headSha, oldBaseTip], repoRoot)
|
|
615
|
+
: git(["update-ref", `refs/heads/${base}`, headSha], repoRoot);
|
|
537
616
|
if (!update.ok) return head({ status: "review_requested", error: update.stderr || "failed to advance base ref" });
|
|
617
|
+
if (oldBaseTip) syncDirtyBaseWorktreeAfterRefAdvance(base, baseWorktree, oldBaseTip, headSha, dirtyBasePathsBefore);
|
|
538
618
|
} else {
|
|
539
619
|
const baseSha = git(["rev-parse", base], repoRoot).stdout;
|
|
540
620
|
const merged = recordNoFfMerge(repoRoot, base, baseSha, headSha, landMergeMessage(branch, landedSubject));
|
|
541
621
|
if (!merged.ok) return head(merged.conflict ? { conflict: true, status: "conflict", error: merged.error } : { status: "review_requested", error: merged.error });
|
|
542
622
|
baseTip = merged.mergeSha;
|
|
623
|
+
syncDirtyBaseWorktreeAfterRefAdvance(base, baseWorktree, baseSha, baseTip, dirtyBasePathsBefore);
|
|
543
624
|
}
|
|
544
625
|
|
|
545
626
|
// Publish the advanced base so local and origin converge (#190). We verified
|