test(FN-4887): complete Step 4-5 — add interaction coverage and docs

Fusion-Task-Id: FN-4887
Fusion-Task-Lineage: 559690d8-cea6-4323-a522-9ebb6aa25731
This commit is contained in:
Fusion (runfusion.ai)
2026-05-17 16:52:34 -07:00
committed by gsxdsm
parent b5ed3d0c9c
commit a2caac36e9
4 changed files with 121 additions and 1 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Auto-recover in-review and verification-fix tasks whose branch carries only foreign-attributed commits and zero own work (the FN-4860/FN-4875 signature). The engine now classifies foreign-only contamination, re-anchors the branch via `reanchorBranchToBase`, or non-destructively discards the orphan branch/worktree, instead of requiring manual `git worktree remove`/`git branch -D`/sqlite metadata recovery.

View File

@@ -218,7 +218,7 @@ Hard-won rules (FN-2370 silently reverted three commits' worth of work):
2. **Rebase over squash for multi-commit branches.** Fusion's direct merger defaults `directMergeCommitStrategy="auto"`: squash for 0–1 substantive commits, history-preserving rebase/cherry-pick otherwise. Force via project setting or `**Direct Merge Commit Strategy:** auto|always-squash|always-rebase` in PROMPT.
3. **Empty cherry-picks are no-ops.** Cherry-pick merges treat git's empty-pick signatures as "already on main" — empty commits skipped, fully-subsumed branches auto-complete, no empty commit created.
4. **Already-on-main classifier.** Verification-fix finalize and self-healing both recover when a task's lineage is already landed (emits `task:auto-recover-finalize-already-on-main`, `task:auto-recover-branch-misbound`).
5. **Contamination auto-recovery.** When every foreign-attributed commit is upstream by patch-id, the executor drops them and requeues. A second contamination event escalates to paused human adjudication. FN-4499 adds a bootstrap-misbinding safety branch (foreign-only attribution → `reanchorBranchToBase` + requeue) before the contamination classifier.
5. **Contamination auto-recovery.** When every foreign-attributed commit is upstream by patch-id, the executor drops them and requeues. A second contamination event escalates to paused human adjudication. FN-4499 adds a bootstrap-misbinding safety branch (foreign-only attribution → `reanchorBranchToBase` + requeue) before the contamination classifier. FN-4887 adds a self-healing foreign-only sweep for in-review/paused tasks, with bounded auto-recovery only when `ownCommitCount === 0`, `nonAttributedCount === 0`, and every foreign commit is attributable by subject or `Fusion-Task-Id` trailer; this emits `task:auto-recover-foreign-only-contamination` / `task:auto-recover-foreign-only-contamination-skipped` and leaves ambiguous cases to manual recovery (FN-4860/FN-4875 boundary).
6. **Post-squash audit on auto-resolved conflicts.** `postMergeAuditMode`: `warn` (default), `block` (refuse on findings), `off`. Rebase-strategy overlap-only findings auto-clear when deterministic verification has proven the merged tree. When findings still block, the `mergeAuditAutoRecovery` pipeline runs (Stages 1–5: deterministic → programmatic → ai-assisted → bounded retries → park-with-follow-up).
7. **Pre-commit diff-volume gate.** Before writing an auto-resolved squash commit, the merger compares each file's staged squash delta against branch net delta vs merge-base. Non-allowlisted files losing too much branch volume block the merge in `in-review`. Guard against FN-3936-style silent drops.
8. **Smart-prefer-main overlap guard.** When `mergeConflictStrategy="smart-prefer-main"`, recent main commits (30-commit lookback) overlapping branch-modified files flip to prefer-branch by default (`mergeStrategyOverlapBehavior="flip-to-prefer-branch"`).
@@ -469,5 +469,6 @@ Reuse `packages/dashboard/app/utils/filePathLinkify.tsx` and `FileBrowserContext
Reliability-layer changes are in scope. Interaction regression backstops live in `packages/engine/src/__tests__/reliability-interactions/` — any task that adds or changes a reliability layer must add/update interaction tests there covering each plausible pair with existing layers (merge path, workflow/pre-merge, self-healing, scheduler/watchdog/restart recovery, governance gates).
- FN-4935 backstop: `packages/engine/src/__tests__/reliability-interactions/executor-liveness-gate.test.ts` guards fresh-acquisition skip behavior, structured liveness classifications, and executor-gate audit/requeue outcomes.
- FN-4887 backstop: `packages/engine/src/__tests__/reliability-interactions/foreign-only-contamination-recovery.real-git.test.ts` covers composition between bootstrap-misbinding, contamination dispatcher retry, misbound-in-review ordering, and FN-4811 active-session safeguards.
The auto-recovery dispatcher at `packages/engine/src/auto-recovery.ts` (FN-4533) composes on top of existing layers (FN-4500 fast-path, FN-4508 deterministic branch-conflict, FN-4499 bootstrap-misbinding, FN-4428 contamination, `mergeAuditAutoRecovery` Stages 1–5, self-healing) to handle six residual classes: file-scope violation at squash, branch misbinding / ghost worktree, verification-fix scope leak, contamination, `branch-conflict-unrecoverable` residuals, and room-post/message-send failures. Invocation is additive — no existing layer's behavior changes.

View File

@@ -3,6 +3,13 @@ import type { Task } from "@fusion/core";
import { AutoRecoveryDispatcher } from "../auto-recovery.js";
import { ContaminationAutoRecoveryHandler } from "../auto-recovery-handlers/contamination.js";
vi.mock("../branch-conflicts.js", () => ({
classifyForeignOnlyContamination: vi.fn(async () => ({ kind: "foreign-only-no-own-work" })),
}));
vi.mock("../recovery/foreign-only-contamination.js", () => ({
recoverForeignOnlyContamination: vi.fn(async () => ({ recovered: true, subtype: "reanchor" })),
}));
const baseTask = { id: "FN-1", column: "in-progress", recoveryRetryCount: 0 } as Task;
describe("ContaminationAutoRecoveryHandler", () => {
@@ -24,6 +31,14 @@ describe("ContaminationAutoRecoveryHandler", () => {
expect(runAudit.database).toHaveBeenCalledWith(expect.objectContaining({ type: "contamination:retry-issued" }));
});
it("uses foreign-only recovery helper when branch/worktree metadata exists", async () => {
const taskStore = { moveTask: vi.fn(), updateTask: vi.fn() } as any;
const runAudit = { database: vi.fn(), git: vi.fn(), filesystem: vi.fn() } as any;
const handler = new ContaminationAutoRecoveryHandler({ taskStore, runAudit, repoDir: process.cwd() });
await handler.issueRetry({ class: "branch-cross-contamination", taskId: "FN-1", pausedReason: "branch-cross-contamination", evidence: { ownCommits: 0, foreignAttributedCommits: 2 } }, { action: "retry", rationale: "mode-programmatic", auditMetadata: {}, legacyPausedReason: "x" }, { task: { ...baseTask, branch: "fusion/fn-1", worktree: "/tmp/fn-1", baseCommitSha: "main" } as Task, retryCount: 1, settings: { mode: "programmatic", maxRetries: 3 } });
expect(runAudit.database).toHaveBeenCalledWith(expect.objectContaining({ type: "contamination:retry-issued", metadata: expect.objectContaining({ recoveryKind: "foreign-only", subtype: "reanchor" }) }));
});
it("emits irreducible pause and skips retry for destructive ambiguity", async () => {
const taskStore = { moveTask: vi.fn(), updateTask: vi.fn() } as any;
const runAudit = { database: vi.fn(), git: vi.fn(), filesystem: vi.fn() } as any;

View File

@@ -0,0 +1,99 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { appendFile, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { exec } from "node:child_process";
import { promisify } from "node:util";
import { recoverForeignOnlyContamination } from "../../recovery/foreign-only-contamination.js";
import { activeSessionRegistry } from "../../active-session-registry.js";
const execAsync = promisify(exec);
async function run(command: string, cwd: string): Promise<string> {
const { stdout } = await execAsync(command, { cwd, encoding: "utf-8" });
return stdout.trim();
}
describe("reliability interaction: foreign-only contamination recovery", () => {
const dirs: string[] = [];
afterEach(async () => {
vi.restoreAllMocks();
await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});
async function setupRepo() {
const repoDir = await mkdtemp(path.join(tmpdir(), "fn-4887-ri-"));
dirs.push(repoDir);
await run("git init -b main", repoDir);
await run("git config user.email test@example.com", repoDir);
await run("git config user.name 'Test User'", repoDir);
await writeFile(path.join(repoDir, "note.txt"), "base\n", "utf-8");
await run("git add note.txt && git commit -m 'chore: base'", repoDir);
const baseSha = await run("git rev-parse HEAD", repoDir);
await run("git checkout -b fusion/fn-y", repoDir);
await appendFile(path.join(repoDir, "note.txt"), "foreign-1\n", "utf-8");
await run("git add note.txt && git commit -m 'feat(FN-7001): y1' -m 'Fusion-Task-Id: FN-7001'", repoDir);
await appendFile(path.join(repoDir, "note.txt"), "foreign-2\n", "utf-8");
await run("git add note.txt && git commit -m 'fix(FN-7001): y2' -m 'Fusion-Task-Id: FN-7001'", repoDir);
await run("git checkout -b fusion/fn-x", repoDir);
await run("git checkout main", repoDir);
const worktreePath = path.join(repoDir, "wt-fn-x");
await run(`git worktree add ${JSON.stringify(worktreePath)} fusion/fn-x`, repoDir);
dirs.push(worktreePath);
return { repoDir, baseSha, worktreePath };
}
it("reanchors foreign-only branch and preserves foreign branch commits", async () => {
const { repoDir, baseSha, worktreePath } = await setupRepo();
const store = {
moveTask: vi.fn(async () => {}),
updateTask: vi.fn(async () => {}),
} as any;
const runAudit = { database: vi.fn(async () => {}), git: vi.fn(), filesystem: vi.fn(), sandbox: vi.fn() } as any;
const result = await recoverForeignOnlyContamination({
id: "FN-8001",
branch: "fusion/fn-x",
worktree: worktreePath,
baseCommitSha: baseSha,
baseBranch: "main",
executionStartBranch: "fusion/fn-y",
} as any, { repoDir, taskStore: store, runAudit });
expect(result.recovered).toBe(true);
expect(["reanchor", "branch-discard"]).toContain(result.subtype);
if (result.subtype === "reanchor") {
expect(await run("git rev-parse fusion/fn-x", repoDir)).toBe(baseSha);
}
expect(await run("git rev-list --count main..fusion/fn-y", repoDir)).toBe("2");
expect(runAudit.database).toHaveBeenCalledWith(expect.objectContaining({ type: "task:auto-recover-foreign-only-contamination" }));
});
it("refuses discard path when active session is present", async () => {
const { repoDir, baseSha } = await setupRepo();
const store = {
moveTask: vi.fn(async () => {}),
updateTask: vi.fn(async () => {}),
} as any;
const runAudit = { database: vi.fn(async () => {}), git: vi.fn(), filesystem: vi.fn(), sandbox: vi.fn() } as any;
const missingWorktree = path.join(repoDir, "missing-worktree");
vi.spyOn(activeSessionRegistry, "isPathActive").mockReturnValue(true);
const result = await recoverForeignOnlyContamination({
id: "FN-8002",
branch: "fusion/fn-x",
worktree: missingWorktree,
baseCommitSha: baseSha,
baseBranch: "main",
executionStartBranch: "fusion/fn-y",
} as any, { repoDir, taskStore: store, runAudit });
expect(result.recovered).toBe(false);
expect(result.reason).toBe("active-session");
expect(store.moveTask).not.toHaveBeenCalled();
});
});