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:
gsxdsm
2026-04-29 07:06:13 -07:00
parent f577b4a4f7
commit 98fb71c202
4 changed files with 644 additions and 11 deletions

View File

@@ -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);
});
});

View File

@@ -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

View File

@@ -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 13 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.

View File

@@ -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 });