Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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/<base> (gated on a
// recorded PR) and re-runs the SAME evidence detector against origin/<base>.
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);
});
58 changes: 57 additions & 1 deletion packages/engine/src/self-healing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<base>` 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<string | null> {
// 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.
}
Comment on lines +1992 to +1994

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Require fresh fetch proof

When git fetch origin <base> fails, this branch still falls through and returns any existing origin/<base> ref. If that local tracking ref is stale but contains an old owned squash commit, the recovery path can prove against stale evidence, move the failed review task to done, mark mergeConfirmed: true, and remove the worktree even though the current remote base was never checked. Return null on fetch failure so destructive recovery only runs after a successful refresh.

Suggested change
} catch {
// Swallow: fall through to the existing remote-tracking ref if present.
}
} catch {
return null;
}

try {
Comment on lines +1987 to +1995

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Stale Remote Ref Still Proves

When git fetch fails, this helper still returns an existing origin/<base> ref. That lets the new recovery path run the destructive finalize detector against a remote-tracking ref that was not refreshed, so an offline/auth failure can still move a failed in-review task to done, remove its worktree, and unblock dependents based on stale evidence.

Suggested change
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 {
try {
await execAsync(`git fetch origin ${shellQuote(baseBranch)}`, {
cwd: this.options.rootDir,
timeout: 60_000,
});
} catch {
return null;
}
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,
Expand Down Expand Up @@ -8635,14 +8668,37 @@ export class SelfHealingManager {
}
}

const landed = await this.findAlreadyMergedTaskCommit({
let landed = await this.findAlreadyMergedTaskCommit({
taskId: task.id,
lineageId: task.lineageId,
repoDir: this.options.rootDir,
baseBranch,
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 = {
Expand Down
Loading