fix(engine): auto-recover from squash-merge orphan rebase failures
Adds a layered recovery cascade to the merger's pre-rebase stage so tasks no longer get stuck in in-review when their declared dependency was squash-merged to main and left orphan raw commits in the dependent's history. Also prevents the orphan situation at the source for new tasks. Why: - 13 tasks were stuck in in-review for hours, all hitting the same pre-merge rebase abort because they shared 6 raw commits inherited from FN-2729's branch (declared baseBranch). FN-2729 was then squash-merged to main, turning those raw commits into orphans whose content is in main but in a different commit shape, conflicting with later-merged tasks. The merger's `smart-prefer-main` strategy correctly refused -X ours (which would silently re-introduce main's deletions), but the only escape hatch was a 30-min cooldown loop that retried the same impossible rebase forever. Recovery cascade (merger.ts pre-rebase stage): - Layer 1: surgical `git rebase --onto <main> <dep-tip> <branch>` when task.baseBranch is set. Resolves the dep tip from the live branch ref or recorded baseCommitSha; peels off the dep's inherited commits cleanly. Captures the squash-merge-of-dep case end-to-end. - Layer 2: generic patch-id duplicate-content stripping. Walks the last 500 main commits, computes patch-ids, then drops branch commits whose patch-id matches and cherry-picks the remainder onto main. Captures manual cherry-picks, double-merges, and any other duplicate-content variant Layer 1 doesn't see. Restores the branch's pre-mutation SHA on partial-failure so worst case leaves the worktree no worse than before the recovery attempt. - Layer 3: AI arbitration fall-through. If Layers 1+2 fail, log the situation and proceed to the existing 3-attempt AI merge cascade instead of throwing. The deterministic post-merge verification (test + build) gates whatever the AI produces — that gate is what enforces prefer-main's safety contract under fall-through (no silent re-introduction of main's deletions). - Critical: the unsafe `-X ours` Attempt 3 is suppressed under fall-through. AI Attempts 1+2 are the only paths that can complete the merge; if both fail and verification rejects them, the task bounces back to in-progress via the existing engine path rather than silently merging. Prevention (executor.ts worktree creation): - When a task declares a non-main `baseBranch`, branch the worktree off main (origin/<defaultBranch> when worktreeRebaseBeforeMerge is enabled and a remote is resolvable; otherwise local rootDir HEAD) and `git merge --squash` the dep's content as a single import commit. The dependent branch then carries main's history + 1 commit instead of inheriting the dep's raw commits, so a future squash-merge of the dep produces patch-id-matching content that rebases cleanly. - Honors settings: respects `worktreeRebaseBeforeMerge`, `worktreeRebaseRemote`, and falls back to local HEAD when no remote is resolvable. Fully fail-soft: any squash-import error falls back to the legacy fork-from-dep behavior so worktree creation still works for setups where the squash flow can't run. Engine-side last-retry fix (project-engine.ts): - Changed conflict-retry condition from `currentRetries < MAX` to `currentRetries + 1 < MAX` so the bounce-to-in-progress code fires in the same engine tick as the failing attempt, rather than relying on a setTimeout-scheduled Nth attempt that dies on engine restart. Without this, a dev-time engine restart between the 3rd and 4th retry left the task with mergeRetries=MAX and only the 30-min cooldown sweep could try again. Tests: - New "Layer 1 recovery" test asserts the surgical --onto rebase fires when baseBranch is set and primary rebase aborts, and that Layer 3 fall-through is NOT triggered when Layer 1 succeeds. - Updated the "no silent fall-through to -X ours" test to cover the new fall-through path: even after Layers 1+2 fail and the merge cascade proceeds, -X ours must not run, and the task log must record both the Layer 3 fall-through entry and the Attempt 3 suppression. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -753,7 +753,7 @@ describe("aiMergeTask — pre-merge rebase abort observability", () => {
|
||||
expect(localBaseRebaseRan).toBe(true);
|
||||
});
|
||||
|
||||
it("hard-fails when smart-prefer-main rebase aborts (no silent fall-through)", async () => {
|
||||
it("does not silently fall through to -X ours when smart-prefer-main rebase aborts and recovery layers 1+2 fail", async () => {
|
||||
const store = createMockStore(
|
||||
{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050" },
|
||||
[{ id: "FN-050", worktree: "/tmp/root/.worktrees/KB-050", column: "in-review" } as Task],
|
||||
@@ -778,13 +778,105 @@ describe("aiMergeTask — pre-merge rebase abort observability", () => {
|
||||
return Buffer.from("");
|
||||
});
|
||||
|
||||
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).rejects.toThrow(
|
||||
/smart-prefer-main.*rebase/i,
|
||||
);
|
||||
// Critical: the unsafe -X ours fallback must not have run.
|
||||
// Layers 1+2 require successful exec calls we don't stub here, so they
|
||||
// fail-soft. After fall-through, AI attempts 1+2 fail (no AI mock) and
|
||||
// the merge cascade exhausts. The contract under test: -X ours must
|
||||
// NEVER run, even after fall-through, because that would silently
|
||||
// re-introduce content main has deleted.
|
||||
await expect(aiMergeTask(store, "/tmp/root", "FN-050")).rejects.toThrow();
|
||||
expect(
|
||||
mockedExec.mock.calls.some(([command]) => String(command).includes("merge -X ours")),
|
||||
).toBe(false);
|
||||
// The fall-through must be visible in the task log so the user can see
|
||||
// the recovery attempt and why we declined to silently merge.
|
||||
const logCalls = (store.logEntry as ReturnType<typeof vi.fn>).mock.calls.map(
|
||||
(args: unknown[]) => String(args[1] ?? ""),
|
||||
);
|
||||
expect(
|
||||
logCalls.some((msg: string) => msg.includes("Pre-merge recovery (Layer 3)")),
|
||||
).toBe(true);
|
||||
expect(
|
||||
logCalls.some((msg: string) => msg.includes("Attempt 3 (-X ours fallback) suppressed")),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("Layer 1 recovery: surgically drops dep commits via rebase --onto when baseBranch is set and primary rebase aborted", async () => {
|
||||
// Scenario: FN-2849 declared baseBranch=fusion/fn-2729 (a dep). The
|
||||
// worktree was forked off FN-2729's tip and inherited its raw commits.
|
||||
// FN-2729 was later squash-merged to main. Now the primary rebase onto
|
||||
// main aborts because FN-2729's raw commits conflict with their own
|
||||
// squashed equivalent + later main commits. Layer 1 detects baseBranch
|
||||
// and runs `git rebase --onto <main> <dep-tip> <branch>` to peel off
|
||||
// the dep's commits cleanly so the merge can proceed.
|
||||
const store = createMockStore(
|
||||
{
|
||||
id: "FN-2849",
|
||||
baseBranch: "fusion/fn-2729",
|
||||
branch: "fusion/fn-2849",
|
||||
worktree: "/tmp/root/.worktrees/coral-stone",
|
||||
},
|
||||
[{ id: "FN-2849", worktree: "/tmp/root/.worktrees/coral-stone", column: "in-review" } as Task],
|
||||
);
|
||||
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
...DEFAULT_SETTINGS,
|
||||
mergeConflictStrategy: "smart-prefer-main",
|
||||
});
|
||||
|
||||
// Layer happy-path mocks first, then override only what this test needs.
|
||||
setupHappyPathExecSync();
|
||||
const happyPath = mockedExecSync.getMockImplementation()!;
|
||||
const DEP_TIP = "8f54a0e66b419a43703f996df5206d82bb4832e1";
|
||||
let primaryRebaseAttempted = false;
|
||||
let layer1OntoRebaseRan = false;
|
||||
|
||||
mockedExecSync.mockImplementation((cmd: any, opts?: any) => {
|
||||
const cmdStr = String(cmd);
|
||||
// Set up a resolvable origin remote so Stage 1 actually runs.
|
||||
if (cmdStr === "git remote") return "origin\n" as any;
|
||||
if (cmdStr.includes("git config --get branch.main.remote")) return "origin" as any;
|
||||
if (cmdStr.includes("git rev-parse --abbrev-ref origin/HEAD")) return "origin/main" as any;
|
||||
if (cmdStr === "git rev-parse --abbrev-ref HEAD") return "main" as any;
|
||||
if (cmdStr.includes("git symbolic-ref --short HEAD")) return "main" as any;
|
||||
if (cmdStr === 'git fetch "origin"') return Buffer.from("");
|
||||
// Resolve dep tip when Layer 1 looks it up via baseBranch.
|
||||
if (cmdStr.includes('rev-parse --verify "fusion/fn-2729^{commit}"')) {
|
||||
return Buffer.from(DEP_TIP);
|
||||
}
|
||||
// Stage 1 rebase aborts — this is what triggers Layer 1 recovery.
|
||||
if (cmdStr === 'git rebase "origin/main"') {
|
||||
primaryRebaseAttempted = true;
|
||||
const err: any = new Error(
|
||||
'could not apply ca5674d43... feat(FN-2729): Step 2 — adopt NodeHealthDot in InlineCreateCard\nadvice.mergeConflict false\nrebase conflict manually',
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
if (cmdStr === "git rebase --abort") return Buffer.from("");
|
||||
// Layer 1's surgical rebase succeeds.
|
||||
if (
|
||||
cmdStr.startsWith("git rebase --onto") &&
|
||||
cmdStr.includes(DEP_TIP) &&
|
||||
cmdStr.includes("fusion/fn-2849")
|
||||
) {
|
||||
layer1OntoRebaseRan = true;
|
||||
return Buffer.from("");
|
||||
}
|
||||
return happyPath(cmd, opts);
|
||||
});
|
||||
|
||||
await aiMergeTask(store, "/tmp/root", "FN-2849");
|
||||
|
||||
expect(primaryRebaseAttempted).toBe(true);
|
||||
expect(layer1OntoRebaseRan).toBe(true);
|
||||
const logCalls = (store.logEntry as ReturnType<typeof vi.fn>).mock.calls.map(
|
||||
(args: unknown[]) => String(args[1] ?? ""),
|
||||
);
|
||||
expect(
|
||||
logCalls.some((msg: string) => msg.includes("Pre-merge recovery (Layer 1)")),
|
||||
).toBe(true);
|
||||
// Layer 3 fall-through must NOT have triggered — Layer 1 unblocked.
|
||||
expect(
|
||||
logCalls.some((msg: string) => msg.includes("Pre-merge recovery (Layer 3)")),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4291,9 +4291,47 @@ and show an appropriate message to the user.\`
|
||||
}
|
||||
}
|
||||
|
||||
// When the task declares a non-main base (a sibling task's branch), the
|
||||
// legacy behavior was to fork the worktree from that branch's tip,
|
||||
// inheriting all of its commits. That caused content leakage when the
|
||||
// dep was later squash-merged to main: the dep's raw commits became
|
||||
// orphans whose content already existed in main, blocking the
|
||||
// dependent's own merge with phantom conflicts.
|
||||
//
|
||||
// Prevention: instead of forking from the dep's tip, fork from `main`
|
||||
// (or the configured remote/main if rebase-from-remote is enabled) and
|
||||
// then `git merge --squash` the dep's content into a single import
|
||||
// commit. The dependent branch then carries main's history + 1 commit
|
||||
// for the dep's content; if the dep is later squash-merged to main, the
|
||||
// patch-id on that import commit will match main's squash and Layer 2
|
||||
// recovery (or a clean rebase) handles it.
|
||||
//
|
||||
// Fall-soft: any failure in this path falls back to the legacy behavior
|
||||
// so we don't break worktree creation for setups where the squash flow
|
||||
// can't run (no main branch resolvable, network down, etc.).
|
||||
const squashImport = resolvedStartPoint
|
||||
? await this.planSquashImportFromDep(taskId, resolvedStartPoint, startPoint)
|
||||
: null;
|
||||
const initialStartPoint = squashImport ? squashImport.mainBase : resolvedStartPoint;
|
||||
|
||||
for (let attempt = 0; attempt < this.MAX_WORKTREE_RETRIES; attempt++) {
|
||||
try {
|
||||
const result = await this.tryCreateWorktree(branch, currentPath, taskId, resolvedStartPoint, attempt);
|
||||
const result = await this.tryCreateWorktree(branch, currentPath, taskId, initialStartPoint, attempt);
|
||||
// Squash-import dep content into the freshly created worktree so the
|
||||
// branch contains main's history + 1 import commit instead of the
|
||||
// dep's raw commits.
|
||||
if (squashImport) {
|
||||
await this.squashImportDepIntoWorktree(
|
||||
result.path,
|
||||
taskId,
|
||||
squashImport.depTip,
|
||||
squashImport.label,
|
||||
).catch((importErr: unknown) => {
|
||||
executorLog.warn(
|
||||
`Squash-import of ${squashImport.label} into ${result.branch} failed for ${taskId} (continuing without): ${importErr instanceof Error ? importErr.message : String(importErr)}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
// Mirror the merge-time rebase behavior: when worktreeRebaseBeforeMerge
|
||||
// is enabled, fetch the remote and rebase the just-created task branch
|
||||
// onto the latest <remote>/<defaultBranch>. This makes the worktree
|
||||
@@ -4336,6 +4374,185 @@ and show an appropriate message to the user.\`
|
||||
return `'${value.replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a task's declared dep base should be squash-imported
|
||||
* (instead of forked from). Returns the planned operation's data when the
|
||||
* dep tip differs from the resolvable main base; returns null when no
|
||||
* import is needed (dep is already at main) or when no main base is
|
||||
* resolvable (caller falls back to legacy fork-from-dep).
|
||||
*
|
||||
* `originalStartPoint` is the user-facing label (typically the branch name
|
||||
* like `fusion/fn-2729`) used purely for log messages. `depTip` is the
|
||||
* resolved SHA of the dep's tip — that's what gets squash-merged.
|
||||
*/
|
||||
private async planSquashImportFromDep(
|
||||
taskId: string,
|
||||
depTip: string,
|
||||
originalStartPoint: string | undefined,
|
||||
): Promise<{ depTip: string; mainBase: string; label: string } | null> {
|
||||
let settings;
|
||||
try {
|
||||
settings = await this.store.getSettings();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Resolve the main base. Preference order:
|
||||
// 1. <remote>/<defaultBranch> when worktreeRebaseBeforeMerge is enabled
|
||||
// and a remote is resolvable (settings.worktreeRebaseRemote wins;
|
||||
// otherwise fall back to "origin" or the lone remote).
|
||||
// 2. rootDir's HEAD (i.e., whatever local main is currently checked out
|
||||
// to). Used when remote rebase is disabled or no remote exists.
|
||||
let mainBase = "";
|
||||
|
||||
if (settings.worktreeRebaseBeforeMerge !== false) {
|
||||
let remote = settings.worktreeRebaseRemote?.trim() || "";
|
||||
if (!remote) {
|
||||
try {
|
||||
const { stdout } = await execAsync("git remote", { cwd: this.rootDir });
|
||||
const remotes = stdout.split("\n").map((s) => s.trim()).filter(Boolean);
|
||||
if (remotes.includes("origin")) remote = "origin";
|
||||
else if (remotes.length === 1) remote = remotes[0];
|
||||
} catch {
|
||||
// No remote resolvable.
|
||||
}
|
||||
}
|
||||
if (remote) {
|
||||
let defaultBranch = "";
|
||||
try {
|
||||
const { stdout } = await execAsync(
|
||||
`git rev-parse --abbrev-ref ${this.quoteShellArg(remote)}/HEAD`,
|
||||
{ cwd: this.rootDir },
|
||||
);
|
||||
defaultBranch = stdout.trim().replace(new RegExp(`^${remote}/`), "");
|
||||
} catch {
|
||||
// origin/HEAD not set; will fall through to local HEAD below.
|
||||
}
|
||||
if (defaultBranch && defaultBranch !== "HEAD") {
|
||||
// Fetch best-effort so the remote ref reflects upstream tip.
|
||||
await execAsync(
|
||||
`git fetch ${this.quoteShellArg(remote)} ${this.quoteShellArg(defaultBranch)}`,
|
||||
{ cwd: this.rootDir },
|
||||
).catch(() => undefined);
|
||||
try {
|
||||
const { stdout } = await execAsync(
|
||||
`git rev-parse --verify "${remote}/${defaultBranch}^{commit}"`,
|
||||
{ cwd: this.rootDir, encoding: "utf-8" },
|
||||
);
|
||||
mainBase = stdout.trim();
|
||||
} catch {
|
||||
// Couldn't resolve remote ref — fall through.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!mainBase) {
|
||||
try {
|
||||
const { stdout } = await execAsync("git rev-parse HEAD", {
|
||||
cwd: this.rootDir,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
mainBase = stdout.trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (!mainBase) return null;
|
||||
|
||||
// If the dep tip is already an ancestor of main, no squash import is
|
||||
// needed — the dep's content is already represented in main.
|
||||
try {
|
||||
await execAsync(
|
||||
`git merge-base --is-ancestor ${this.quoteShellArg(depTip)} ${this.quoteShellArg(mainBase)}`,
|
||||
{ cwd: this.rootDir },
|
||||
);
|
||||
// Exit code 0 → ancestor → no import needed; legacy fork-from-main is fine.
|
||||
// Returning the plan with mainBase but signalling "no work" via dep===main.
|
||||
if (depTip === mainBase) return null;
|
||||
// Dep is ancestor of main but its tip SHA differs from main's tip; the
|
||||
// worktree should still branch off main, no squash needed.
|
||||
return { depTip: mainBase, mainBase, label: originalStartPoint || depTip.slice(0, 8) };
|
||||
} catch {
|
||||
// Not an ancestor — squash-import is the safer path.
|
||||
}
|
||||
|
||||
return { depTip, mainBase, label: originalStartPoint || depTip.slice(0, 8) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Squash-merge the dep's content into a worktree that's already branched
|
||||
* off main. Produces one commit on the worktree branch carrying the dep's
|
||||
* content, instead of inheriting the dep's individual commits. Best-effort:
|
||||
* any failure (conflict, hooks, IO) leaves the worktree at main and the
|
||||
* caller proceeds — the dependent task will then need to import the dep's
|
||||
* content itself, but the worktree itself is still usable.
|
||||
*/
|
||||
private async squashImportDepIntoWorktree(
|
||||
worktreePath: string,
|
||||
taskId: string,
|
||||
depTip: string,
|
||||
label: string,
|
||||
): Promise<void> {
|
||||
// No-op when dep is already represented in the worktree's history.
|
||||
try {
|
||||
await execAsync(
|
||||
`git merge-base --is-ancestor ${this.quoteShellArg(depTip)} HEAD`,
|
||||
{ cwd: worktreePath },
|
||||
);
|
||||
return;
|
||||
} catch {
|
||||
// Not an ancestor — proceed.
|
||||
}
|
||||
|
||||
// Try a squash-merge. `--no-commit` is implied by `--squash`; the merge
|
||||
// either stages the dep's diff or fails (conflicts / unrelated histories).
|
||||
try {
|
||||
await execAsync(
|
||||
`git merge --squash --allow-unrelated-histories ${this.quoteShellArg(depTip)}`,
|
||||
{ cwd: worktreePath },
|
||||
);
|
||||
} catch (err) {
|
||||
// Reset any partial state so the worktree stays usable, then rethrow
|
||||
// so the caller can decide whether to log/fall-through.
|
||||
await execAsync("git reset --hard HEAD", { cwd: worktreePath }).catch(
|
||||
() => undefined,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
|
||||
// If no diff was staged the dep is content-equivalent to main; nothing
|
||||
// to commit.
|
||||
try {
|
||||
await execAsync("git diff --cached --quiet", { cwd: worktreePath });
|
||||
return; // exit 0 → no staged changes, nothing to commit
|
||||
} catch {
|
||||
// exit non-zero → staged changes exist, proceed to commit.
|
||||
}
|
||||
|
||||
const message = `chore(${taskId}): import dependency content from ${label}\n\n` +
|
||||
`Squash-imported the working tree of ${label} as a single commit so this ` +
|
||||
`branch carries the dep's content without inheriting its individual commits. ` +
|
||||
`If the dep is later squash-merged to main, this commit's patch-id should ` +
|
||||
`match the merge and rebase cleanly.`;
|
||||
try {
|
||||
await execAsync(
|
||||
`git commit --allow-empty-message -m ${this.quoteShellArg(message)}`,
|
||||
{ cwd: worktreePath },
|
||||
);
|
||||
} catch (commitErr) {
|
||||
await execAsync("git reset --hard HEAD", { cwd: worktreePath }).catch(
|
||||
() => undefined,
|
||||
);
|
||||
throw commitErr;
|
||||
}
|
||||
|
||||
await this.store.logEntry(
|
||||
taskId,
|
||||
`Squash-imported dependency content from ${label} into worktree (single import commit instead of inheriting raw commits)`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* After creating a fresh task worktree, fetch the configured remote and
|
||||
* rebase the task branch onto `<remote>/<defaultBranch>`. The result is a
|
||||
|
||||
@@ -1874,6 +1874,78 @@ function quoteArg(value: string): string {
|
||||
return `"${value.replace(/(["\\$`])/g, "\\$1")}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute `git patch-id` for a single commit. Returns the patch-id string on
|
||||
* success or undefined when the commit has no diff (root, empty merge) or the
|
||||
* pipeline failed. Patch-ids are stable across squash/cherry-pick operations
|
||||
* — two commits with the same logical change produce the same patch-id even
|
||||
* if their tree/parent SHAs differ.
|
||||
*/
|
||||
async function commitPatchId(rootDir: string, sha: string): Promise<string | undefined> {
|
||||
try {
|
||||
const { stdout } = await execAsync(
|
||||
`git diff-tree -p ${quoteArg(sha)} | git patch-id --stable`,
|
||||
{ cwd: rootDir, encoding: "utf-8" },
|
||||
);
|
||||
const line = stdout.trim();
|
||||
if (!line) return undefined;
|
||||
// Output format: "<patch-id> <commit-sha>"; we only need the first token.
|
||||
const [pid] = line.split(/\s+/, 1);
|
||||
return pid || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect patch-ids for the last `windowSize` commits reachable from `target`.
|
||||
* Bounded so we don't pay for full-repo scans on large histories. The window
|
||||
* is large enough to catch typical squash-merge orphans (which match recent
|
||||
* main commits) without being expensive.
|
||||
*/
|
||||
async function collectPatchIds(
|
||||
rootDir: string,
|
||||
target: string,
|
||||
windowSize: number,
|
||||
): Promise<Set<string>> {
|
||||
const ids = new Set<string>();
|
||||
try {
|
||||
const { stdout } = await execAsync(
|
||||
`git log -n ${Math.max(1, windowSize)} --format=%H ${quoteArg(target)}`,
|
||||
{ cwd: rootDir, encoding: "utf-8" },
|
||||
);
|
||||
const shas = stdout.trim().split("\n").filter(Boolean);
|
||||
for (const sha of shas) {
|
||||
const pid = await commitPatchId(rootDir, sha);
|
||||
if (pid) ids.add(pid);
|
||||
}
|
||||
} catch {
|
||||
// Fall through with whatever we collected; caller treats empty as
|
||||
// "no duplicates found, proceed without stripping".
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* List commits unique to `branch` relative to `target`, oldest-first so they
|
||||
* can be cherry-picked in order.
|
||||
*/
|
||||
async function listBranchCommits(
|
||||
rootDir: string,
|
||||
target: string,
|
||||
branch: string,
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
const { stdout } = await execAsync(
|
||||
`git log --reverse --format=%H ${quoteArg(target)}..${quoteArg(branch)}`,
|
||||
{ cwd: rootDir, encoding: "utf-8" },
|
||||
);
|
||||
return stdout.trim().split("\n").filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function getCommandErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
const stderr = (error as Error & { stderr?: string | Buffer }).stderr;
|
||||
@@ -2614,18 +2686,248 @@ export async function aiMergeTask(
|
||||
);
|
||||
}
|
||||
|
||||
// Hard-fail prefer-main when a rebase started and aborted: a stale branch
|
||||
// base means the -X ours fallback can silently re-introduce branch-only
|
||||
// content that main recently deleted.
|
||||
// ── Recovery cascade for prefer-main rebase failures ──────────────────
|
||||
//
|
||||
// Previous behavior: throw immediately when prefer-main rebase aborted.
|
||||
// This left tasks stuck in in-review forever when the conflict was a known
|
||||
// recoverable shape (e.g., a dependency task was squash-merged to main, so
|
||||
// the dependent's branch carries orphan raw commits whose content is
|
||||
// already in main but in a different commit shape).
|
||||
//
|
||||
// New behavior: try increasingly broad recovery strategies in order. Each
|
||||
// layer is fail-soft — if it can't help, we move on without changing
|
||||
// worktree state. After all layers run, if rebase still hasn't succeeded,
|
||||
// we log the situation and proceed to AI arbitration (the standard
|
||||
// 3-attempt merge cascade), which is gated by post-merge `pnpm test` and
|
||||
// `pnpm build` verification — so the safety constraint that prefer-main
|
||||
// exists to enforce (no silent re-introduction of main's deletions) is
|
||||
// preserved by the deterministic verification gate.
|
||||
let preMergeRebaseFallthrough: string | undefined;
|
||||
if (preferMainRebaseFailureMessage && worktreePath) {
|
||||
// Resolve the rebase target the same way Stage 2 did: rootDir's HEAD.
|
||||
// Stage 1 (remote) already ran if enabled; Stage 2 (local) is what
|
||||
// would have unified branch+local. We use local HEAD as the target so
|
||||
// Layers 1/2 land where Stage 2 wanted to.
|
||||
let rebaseTarget = "";
|
||||
try {
|
||||
const { stdout } = await execAsync("git rev-parse HEAD", {
|
||||
cwd: rootDir,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
rebaseTarget = stdout.trim();
|
||||
} catch {
|
||||
rebaseTarget = "";
|
||||
}
|
||||
|
||||
// Layer 1: surgical drop of declared-dependency commits.
|
||||
// When `task.baseBranch` is a non-main branch (a sibling task's branch),
|
||||
// the dependent worktree was forked off it and inherited its commits.
|
||||
// If the dep was later squash-merged to main, those raw commits are now
|
||||
// orphans whose content already exists in main. Re-rebase the task
|
||||
// branch onto main using `git rebase --onto <target> <dep-tip> <branch>`,
|
||||
// which peels off the dep's commits cleanly.
|
||||
if (rebaseTarget && task.baseBranch && task.baseBranch !== "main") {
|
||||
// Resolve the dep's tip — prefer the live branch ref, fall back to
|
||||
// the recorded baseCommitSha if the branch was already deleted.
|
||||
let depTip: string | undefined;
|
||||
try {
|
||||
const { stdout } = await execAsync(
|
||||
`git rev-parse --verify "${task.baseBranch}^{commit}"`,
|
||||
{ cwd: rootDir, encoding: "utf-8" },
|
||||
);
|
||||
depTip = stdout.trim() || undefined;
|
||||
} catch {
|
||||
depTip = undefined;
|
||||
}
|
||||
if (!depTip && task.baseCommitSha) {
|
||||
try {
|
||||
const { stdout } = await execAsync(
|
||||
`git rev-parse --verify "${task.baseCommitSha}^{commit}"`,
|
||||
{ cwd: rootDir, encoding: "utf-8" },
|
||||
);
|
||||
depTip = stdout.trim() || undefined;
|
||||
} catch {
|
||||
depTip = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
if (depTip && depTip !== rebaseTarget) {
|
||||
try {
|
||||
throwIfAborted(options.signal, taskId);
|
||||
// Reset rebase state defensively in case a previous attempt left
|
||||
// a half-applied rebase in place.
|
||||
await execAsync("git rebase --abort", { cwd: worktreePath }).catch(
|
||||
() => undefined,
|
||||
);
|
||||
await execAsync(
|
||||
`git rebase --onto "${rebaseTarget}" "${depTip}" "${branch}"`,
|
||||
{ cwd: worktreePath },
|
||||
);
|
||||
preferMainRebaseFailureMessage = undefined;
|
||||
rebaseHappened = true;
|
||||
mergerLog.log(
|
||||
`${taskId}: Layer 1 recovery — rebased ${branch} --onto ${rebaseTarget.slice(0, 8)} dropping commits up to dep tip ${depTip.slice(0, 8)} (baseBranch=${task.baseBranch})`,
|
||||
);
|
||||
await store.logEntry(
|
||||
taskId,
|
||||
`Pre-merge recovery (Layer 1): dropped dependency commits from ${task.baseBranch} via rebase --onto ${rebaseTarget.slice(0, 8)} ${depTip.slice(0, 8)} ${branch}; the merge will proceed against the cleaned branch`,
|
||||
);
|
||||
} catch (layer1Err) {
|
||||
rethrowIfMergeAborted(layer1Err);
|
||||
mergerLog.warn(
|
||||
`${taskId}: Layer 1 (dep-drop) recovery failed: ${layer1Err instanceof Error ? layer1Err.message : String(layer1Err)}`,
|
||||
);
|
||||
await execAsync("git rebase --abort", { cwd: worktreePath }).catch(
|
||||
() => undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Layer 2: generic patch-id duplicate stripping.
|
||||
// Compute patch-ids of recent main commits (last 500). Walk the task
|
||||
// branch's commits in target..branch and identify those whose patch-id
|
||||
// already exists in main — they're duplicates whose content has landed
|
||||
// (via squash, cherry-pick, manual replay, etc.). Cherry-pick the
|
||||
// non-duplicate commits onto target to produce a clean branch.
|
||||
if (preferMainRebaseFailureMessage && rebaseTarget && worktreePath) {
|
||||
try {
|
||||
throwIfAborted(options.signal, taskId);
|
||||
const mainPatchIds = await collectPatchIds(rootDir, rebaseTarget, 500);
|
||||
const branchCommits = await listBranchCommits(rootDir, rebaseTarget, branch);
|
||||
if (branchCommits.length === 0) {
|
||||
// Nothing to replay — branch is up-to-date with target.
|
||||
rebaseHappened = true;
|
||||
preferMainRebaseFailureMessage = undefined;
|
||||
} else {
|
||||
const surviving: string[] = [];
|
||||
let dropped = 0;
|
||||
for (const sha of branchCommits) {
|
||||
const pid = await commitPatchId(rootDir, sha);
|
||||
if (pid && mainPatchIds.has(pid)) {
|
||||
dropped += 1;
|
||||
} else {
|
||||
surviving.push(sha);
|
||||
}
|
||||
}
|
||||
if (dropped > 0 && surviving.length === branchCommits.length) {
|
||||
// Should be impossible (dropped>0 means some were filtered), but
|
||||
// guard against logic errors before mutating worktree state.
|
||||
mergerLog.warn(`${taskId}: Layer 2 internal accounting mismatch — skipping`);
|
||||
} else if (dropped > 0) {
|
||||
// Capture the branch's pre-mutation SHA so we can restore on any
|
||||
// partial-failure path. Without this, a failed cherry-pick midway
|
||||
// through would leave the branch at a half-replayed state worse
|
||||
// than the original conflict.
|
||||
let originalBranchSha = "";
|
||||
try {
|
||||
const { stdout } = await execAsync(
|
||||
`git rev-parse --verify "${branch}^{commit}"`,
|
||||
{ cwd: worktreePath, encoding: "utf-8" },
|
||||
);
|
||||
originalBranchSha = stdout.trim();
|
||||
} catch {
|
||||
originalBranchSha = "";
|
||||
}
|
||||
const restoreOriginalBranch = async () => {
|
||||
if (!originalBranchSha) return;
|
||||
await execAsync(`git checkout "${branch}"`, { cwd: worktreePath }).catch(
|
||||
() => undefined,
|
||||
);
|
||||
await execAsync(`git reset --hard "${originalBranchSha}"`, {
|
||||
cwd: worktreePath,
|
||||
}).catch(() => undefined);
|
||||
};
|
||||
|
||||
try {
|
||||
await execAsync("git rebase --abort", { cwd: worktreePath }).catch(
|
||||
() => undefined,
|
||||
);
|
||||
await execAsync(`git checkout "${branch}"`, { cwd: worktreePath });
|
||||
await execAsync(`git reset --hard "${rebaseTarget}"`, {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
for (const sha of surviving) {
|
||||
throwIfAborted(options.signal, taskId);
|
||||
try {
|
||||
await execAsync(`git cherry-pick --allow-empty "${sha}"`, {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
} catch (pickErr) {
|
||||
rethrowIfMergeAborted(pickErr);
|
||||
// A surviving commit conflicts with target despite its
|
||||
// patch-id not matching — abort the cherry-pick, restore
|
||||
// the branch to its original tip, and let Layer 3 take over.
|
||||
await execAsync("git cherry-pick --abort", { cwd: worktreePath }).catch(
|
||||
() => undefined,
|
||||
);
|
||||
await restoreOriginalBranch();
|
||||
throw pickErr;
|
||||
}
|
||||
}
|
||||
preferMainRebaseFailureMessage = undefined;
|
||||
rebaseHappened = true;
|
||||
mergerLog.log(
|
||||
`${taskId}: Layer 2 recovery — patch-id stripped ${dropped} duplicate commit(s); replayed ${surviving.length} survivor(s) onto ${rebaseTarget.slice(0, 8)}`,
|
||||
);
|
||||
await store.logEntry(
|
||||
taskId,
|
||||
`Pre-merge recovery (Layer 2): patch-id matched ${dropped} branch commit(s) against the last 500 main commits and dropped them as duplicates; cherry-picked ${surviving.length} unique commit(s) onto ${rebaseTarget.slice(0, 8)}`,
|
||||
);
|
||||
} catch (replayErr) {
|
||||
await restoreOriginalBranch();
|
||||
throw replayErr;
|
||||
}
|
||||
} else {
|
||||
mergerLog.log(
|
||||
`${taskId}: Layer 2 found no duplicate-content commits to drop (window=500)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (layer2Err) {
|
||||
rethrowIfMergeAborted(layer2Err);
|
||||
mergerLog.warn(
|
||||
`${taskId}: Layer 2 (patch-id strip) recovery failed: ${layer2Err instanceof Error ? layer2Err.message : String(layer2Err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Layer 3: if the rebase still couldn't be unblocked, fall through to
|
||||
// the AI merge cascade with a safety preamble logged to the task. The
|
||||
// existing post-merge deterministic verification (test + build) gates
|
||||
// whatever the AI produces — if the AI silently re-introduces main's
|
||||
// deletions and breaks tests/build, the task bounces back to in-progress
|
||||
// via the engine's verification-failure path. The AI never gets to
|
||||
// commit a regression that wasn't caught by tests.
|
||||
if (preferMainRebaseFailureMessage) {
|
||||
preMergeRebaseFallthrough = preferMainRebaseFailureMessage;
|
||||
preferMainRebaseFailureMessage = undefined;
|
||||
mergerLog.warn(
|
||||
`${taskId}: Layers 1 & 2 could not unblock the prefer-main rebase — falling through to AI arbitration (Layer 3). Deterministic verification will gate the result.`,
|
||||
);
|
||||
await store.logEntry(
|
||||
taskId,
|
||||
`Pre-merge recovery (Layer 3): both surgical and patch-id recovery failed; AI arbiter takes over. SAFETY CONSTRAINT for the AI: do NOT re-introduce content that current main has deleted. If hunks are ambiguous, prefer main's version. Post-merge test/build verification will reject any resolution that breaks main's intent.`,
|
||||
"PreMergeRebaseFallthrough",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (preferMainRebaseFailureMessage) {
|
||||
// Reached only when there's no worktreePath — no recovery is possible
|
||||
// without a worktree to operate on.
|
||||
throw new Error(
|
||||
`${preferMainRebaseFailureMessage} for ${taskId}. ` +
|
||||
`Strategy "smart-prefer-main" requires a successful rebase to preserve main's deletions; ` +
|
||||
`falling through to a -X ours merge would silently re-introduce branch-only content. ` +
|
||||
`recovery layers 1–3 require a worktree path which is missing for this task. ` +
|
||||
`Resolve the rebase conflict manually, or switch mergeConflictStrategy to ` +
|
||||
`"smart-prefer-branch" / "ai-only".`,
|
||||
);
|
||||
}
|
||||
// Surface the fallthrough to anything downstream that wants to vary
|
||||
// behavior under it. Currently informational only; the verification gate
|
||||
// is what enforces safety.
|
||||
void preMergeRebaseFallthrough;
|
||||
// Silent-skip observability: when prefer-main couldn't run a rebase at all
|
||||
// (no remote resolvable, no worktreePath), warn loudly so the gap is visible
|
||||
// in logs. Not a hard fail — environmental skips are common in tests and
|
||||
@@ -3082,13 +3384,28 @@ export async function aiMergeTask(
|
||||
|
||||
// Attempt 3: -X theirs (smart-prefer-branch) or -X ours (smart-prefer-main) fallback.
|
||||
// Skipped for "ai-only" (no silent side-pick) and "abort" (one shot only).
|
||||
//
|
||||
// Also skipped when `preMergeRebaseFallthrough` is set: under prefer-main
|
||||
// the whole purpose of refusing -X ours after a failed rebase is to
|
||||
// prevent silent re-introduction of main's deletions. Layers 1+2 couldn't
|
||||
// unblock the rebase, so the worktree is still in a state where -X ours
|
||||
// would re-introduce branch-only content. Trust only AI Attempts 1+2 here
|
||||
// — their output is gated by deterministic verification (test + build),
|
||||
// which is what enforces the prefer-main safety contract.
|
||||
if (
|
||||
!merged
|
||||
&& smartConflictResolution
|
||||
&& mergeConflictStrategy !== "ai-only"
|
||||
&& mergeConflictStrategy !== "abort"
|
||||
&& !preMergeRebaseFallthrough
|
||||
) {
|
||||
merged = await mergeAttempt(3);
|
||||
} else if (!merged && preMergeRebaseFallthrough) {
|
||||
await store.logEntry(
|
||||
taskId,
|
||||
`Attempt 3 (-X ours fallback) suppressed: pre-merge rebase recovery layers 1+2 failed under smart-prefer-main, so the unsafe ours-side fallback is skipped to honor the strategy's safety contract. Verification-gated AI Attempts 1+2 already exhausted; merge cannot complete safely without manual intervention.`,
|
||||
"PreMergeRebaseFallthrough",
|
||||
);
|
||||
}
|
||||
|
||||
// Bubble the empty-merge flag up to the metadata block.
|
||||
|
||||
@@ -1284,9 +1284,16 @@ export class ProjectEngine {
|
||||
if (taskOnErr && isConflictError) {
|
||||
const currentRetries = taskOnErr.mergeRetries ?? 0;
|
||||
|
||||
// Use `currentRetries + 1 < MAX` (not `currentRetries < MAX`) so
|
||||
// the LAST retry's failure goes straight to the bounce code in
|
||||
// this same engine tick. The previous condition scheduled a
|
||||
// separate Nth setTimeout attempt — if the engine restarted
|
||||
// before that timer fired (common during dev), the task was
|
||||
// stranded with mergeRetries=MAX and only the cooldown sweep
|
||||
// could ever try again (silent loop).
|
||||
if (
|
||||
(settingsOnErr as Settings).autoResolveConflicts !== false &&
|
||||
currentRetries < ProjectEngine.MAX_AUTO_MERGE_RETRIES
|
||||
currentRetries + 1 < ProjectEngine.MAX_AUTO_MERGE_RETRIES
|
||||
) {
|
||||
const newRetryCount = currentRetries + 1;
|
||||
await store.updateTask(taskId, { mergeRetries: newRetryCount, status: null });
|
||||
|
||||
Reference in New Issue
Block a user