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 = {