From 15c2c831a85575f04a210282ee308615abb9b80d Mon Sep 17 00:00:00 2001 From: fusion-merge-train Date: Sun, 5 Jul 2026 20:41:20 +0200 Subject: [PATCH] fix(engine): self-heal failed in-review cards whose PR merged on the remote MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A transient error at merge time can flag an in-review card `failed` even when its PR actually squash-merged on the remote. `recoverAlreadyMergedReviewTasks` only ran the already-merged evidence detector against the LOCAL base ref, so when this process never fetched the merge, the owned commit was absent locally, the detector returned null, and the card held its file-scope lease forever. Fetch-then-prove: when a failed candidate has a recorded PR and the local base yields no owned commit, best-effort `git fetch origin ` and re-run the SAME evidence detector against `origin/`. The owned-commit proof and every foreign-ownership guard inside the detector remain the sole finalize gate, so this only un-wedges a genuinely-merged task — it never phantom-finalizes on unproven state. Gated on a recorded PR (no PR ⇒ nothing merged remotely ⇒ no fetch); fail-closed on fetch error. Co-Authored-By: Claude Opus 4.8 --- ...lf-healing-already-merged.real-git.test.ts | 103 ++++++++++++++++++ packages/engine/src/self-healing.ts | 58 +++++++++- 2 files changed, 160 insertions(+), 1 deletion(-) diff --git a/packages/engine/src/__tests__/self-healing-already-merged.real-git.test.ts b/packages/engine/src/__tests__/self-healing-already-merged.real-git.test.ts index e2adf0f50..7b4039310 100644 --- a/packages/engine/src/__tests__/self-healing-already-merged.real-git.test.ts +++ b/packages/engine/src/__tests__/self-healing-already-merged.real-git.test.ts @@ -467,4 +467,107 @@ describeIfGit("SelfHealingManager recoverAlreadyMergedReviewTasks (real git)", ( await enginePausedManager.recoverAlreadyMergedReviewTasks(); expect(enginePausedStore.listTasks).not.toHaveBeenCalled(); }, 20_000); + + // PR3: the local base ref can be stale. When a PR squash-merged on the remote + // but this process never fetched, the owned commit is absent from the LOCAL + // base branch, so the detector finds nothing and the failed card holds its + // file-scope lease forever. Recovery now fetches origin/ (gated on a + // recorded PR) and re-runs the SAME evidence detector against origin/. + function setupRepoWithRemote(): { repo: string; remote: string } { + const remoteParent = mkdtempSync(path.join(os.tmpdir(), "fn-stale-remote-")); + repos.push(remoteParent); + const remote = path.join(remoteParent, "origin.git"); + execSync(`git init --bare -b main ${JSON.stringify(remote)}`, { stdio: ["pipe", "pipe", "pipe"] }); + const repo = setupRepo(); + git(repo, `git remote add origin ${JSON.stringify(remote)}`); + git(repo, "git push origin main"); + return { repo, remote }; + } + + // Clone the bare remote, run `mutate` in it, push main back. Advances the + // remote's main WITHOUT touching the primary repo's local main / origin/main + // tracking ref — i.e. it manufactures a genuinely stale local base. + function landOnRemoteMain(remote: string, mutate: (clone: string) => void): string { + const cloneParent = mkdtempSync(path.join(os.tmpdir(), "fn-stale-clone-")); + repos.push(cloneParent); + const clone = path.join(cloneParent, "clone"); + execSync(`git clone ${JSON.stringify(remote)} ${JSON.stringify(clone)}`, { stdio: ["pipe", "pipe", "pipe"] }); + git(clone, 'git config user.email "test@example.com"'); + git(clone, 'git config user.name "Test"'); + mutate(clone); + git(clone, "git push origin main"); + return git(clone, "git rev-parse HEAD"); + } + + it("fetch-then-prove: finalizes a failed card whose PR merged on the remote (stale local base)", async () => { + const { repo, remote } = setupRepoWithRemote(); + + // Task branch tip lives locally (owned by nothing foreign). + git(repo, "git checkout -b fusion/fn-stale"); + mkdirSync(path.join(repo, "src"), { recursive: true }); + writeFileSync(path.join(repo, "src", "stale.txt"), "work\n", "utf-8"); + git(repo, "git add src/stale.txt && git commit -m 'task work'"); + git(repo, "git checkout main"); + + const worktreePath = path.join(repo, ".worktrees", "fn-stale"); + mkdirSync(path.dirname(worktreePath), { recursive: true }); + git(repo, `git worktree add ${JSON.stringify(worktreePath)} fusion/fn-stale`); + + // Simulate the merge-train squash landing on the remote — never fetched locally. + const landedSha = landOnRemoteMain(remote, (clone) => { + git(clone, "git commit --allow-empty -m 'feat: landed' -m 'Fusion-Task-Id: FN-STALE'"); + }); + + // Local base is stale: neither main nor origin/main has the owned commit yet. + expect(git(repo, "git rev-parse origin/main")).not.toBe(landedSha); + + const tasks: TaskMap = new Map([ + ["FN-STALE", makeTask({ id: "FN-STALE", column: "in-review", status: "failed", mergeRetries: 3, paused: false, baseBranch: "main", branch: "fusion/fn-stale", worktree: worktreePath, prInfo: { number: 77 } as any })], + ]); + const store = createStore(tasks); + const manager = new SelfHealingManager(store, { rootDir: repo, getExecutingTaskIds: () => new Set() }); + + await (manager as any).recoverAlreadyMergedReviewTasks(); + + // Fetch advanced origin/main, and the detector proved the owned commit against it. + expect(git(repo, "git rev-parse origin/main")).toBe(landedSha); + const task = tasks.get("FN-STALE")!; + expect(task.column).toBe("done"); + expect(task.status).toBeNull(); + expect(task.mergeRetries).toBe(0); + expect(task.mergeDetails?.commitSha).toBe(landedSha); + expect(task.mergeDetails?.mergeConfirmed).toBe(true); + expect(existsSync(worktreePath)).toBe(false); + }, 30_000); + + it("fetch-then-prove: does NOT heal when the remote base carries only a foreign commit", async () => { + const { repo, remote } = setupRepoWithRemote(); + + git(repo, "git checkout -b fusion/fn-guard"); + mkdirSync(path.join(repo, "src"), { recursive: true }); + writeFileSync(path.join(repo, "src", "guard.txt"), "work\n", "utf-8"); + git(repo, "git add src/guard.txt && git commit -m 'task work'"); + git(repo, "git checkout main"); + + // Remote base advances, but the landed commit is owned by a DIFFERENT task. + const foreignSha = landOnRemoteMain(remote, (clone) => { + git(clone, "git commit --allow-empty -m 'feat: other' -m 'Fusion-Task-Id: FN-OTHER'"); + }); + + const tasks: TaskMap = new Map([ + ["FN-GUARD", makeTask({ id: "FN-GUARD", column: "in-review", status: "failed", mergeRetries: 3, paused: false, baseBranch: "main", branch: "fusion/fn-guard", prInfo: { number: 88 } as any })], + ]); + const store = createStore(tasks); + const manager = new SelfHealingManager(store, { rootDir: repo, getExecutingTaskIds: () => new Set() }); + + await (manager as any).recoverAlreadyMergedReviewTasks(); + + // The fetch still ran (proves the guard, not a missing fetch), but no owned + // commit exists → the card is left untouched, never phantom-finalized. + expect(git(repo, "git rev-parse origin/main")).toBe(foreignSha); + const task = tasks.get("FN-GUARD")!; + expect(task.column).toBe("in-review"); + expect(task.status).toBe("failed"); + expect(task.mergeDetails?.mergeConfirmed).not.toBe(true); + }, 30_000); }); diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index b4a1c7e42..551925513 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -1970,6 +1970,39 @@ export class SelfHealingManager { return findAlreadyMergedTaskCommit(input); } + /** + * Best-effort refresh of the remote-tracking base ref so the already-merged + * evidence detector can see a squash that landed on the remote after this + * process last fetched. Returns the `origin/` ref to re-run the detector + * against, or null when there is nothing fresher to prove against. + * + * Fail-closed: a fetch error (offline / auth / no remote) is swallowed and we + * still attempt to resolve the (possibly stale) remote-tracking ref; if even + * that is absent we return null and the caller leaves the card untouched. + */ + private async refreshRemoteBaseRef(baseBranch: string): Promise { + // Already a remote ref — nothing local to refresh. + if (baseBranch.startsWith("origin/")) return null; + const remoteRef = `origin/${baseBranch}`; + try { + await execAsync(`git fetch origin ${shellQuote(baseBranch)}`, { + cwd: this.options.rootDir, + timeout: 60_000, + }); + } catch { + // Swallow: fall through to the existing remote-tracking ref if present. + } + try { + await execAsync(`git rev-parse --verify ${shellQuote(remoteRef)}`, { + cwd: this.options.rootDir, + timeout: 30_000, + }); + return remoteRef; + } catch { + return null; + } + } + private async readCommitTaskOwnership(sha: string, taskId: string, lineageId?: string) { const { stdout } = await execAsync(`git show -s --format=%s%x1f%b ${shellQuote(sha)}`, { cwd: this.options.rootDir, @@ -8635,7 +8668,7 @@ export class SelfHealingManager { } } - const landed = await this.findAlreadyMergedTaskCommit({ + let landed = await this.findAlreadyMergedTaskCommit({ taskId: task.id, lineageId: task.lineageId, repoDir: this.options.rootDir, @@ -8643,6 +8676,29 @@ export class SelfHealingManager { taskBranch: task.branch, baseCommitSha: task.baseCommitSha, }); + if (!landed && getPrimaryPrInfo(task)) { + // Fetch-then-prove: the LOCAL base ref can be stale. When a PR merged + // on the remote (human / merge-train squash) but this process never + // fetched, the owned commit is absent from the local base branch, so + // the detector finds nothing and the failed card holds its file-scope + // lease forever. Best-effort refresh the remote-tracking base ref and + // re-run the SAME evidence detector against it. The owned-commit proof + // (and every foreign-ownership guard inside the detector) still gates + // the heal, so this only un-wedges a genuinely-merged task — it never + // phantom-finalizes on unproven state. Gated on a recorded PR: no PR + // ⇒ nothing could have merged remotely ⇒ no fetch. + const refreshedBaseRef = await this.refreshRemoteBaseRef(baseBranch); + if (refreshedBaseRef) { + landed = await this.findAlreadyMergedTaskCommit({ + taskId: task.id, + lineageId: task.lineageId, + repoDir: this.options.rootDir, + baseBranch: refreshedBaseRef, + taskBranch: task.branch, + baseCommitSha: task.baseCommitSha, + }); + } + } if (!landed) continue; const mergeDetails: MergeDetails = {