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:
committed by
gsxdsm
parent
b5ed3d0c9c
commit
a2caac36e9
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user