test(FN-4648): complete Step 4 — regression coverage updates

Fusion-Task-Id: FN-4648
Fusion-Task-Lineage: 82289e69-7e72-40e4-acaf-b98ad82ad354
This commit is contained in:
Fusion
2026-05-15 11:23:01 -07:00
committed by gsxdsm
parent dfa454834b
commit d53110cd04
7 changed files with 197 additions and 17 deletions

View File

@@ -645,8 +645,6 @@ describe("ProjectEngine merge error recovery", () => {
const engine = createEngine(store);
await runMergeCycle(engine);
expect(store.moveTask).toHaveBeenCalledWith(TASK_ID, "done");
expect(store.updateTask).toHaveBeenCalledWith(TASK_ID, { status: null, error: null });
expect(store.updateTask).not.toHaveBeenCalledWith(TASK_ID, {
status: "failed",
mergeRetries: 3,

View File

@@ -0,0 +1,139 @@
import { describe, it, expect } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { execSync } from "node:child_process";
import { EventEmitter } from "node:events";
import type { Settings, Task, TaskStore } from "@fusion/core";
import { SelfHealingManager } from "../../self-healing.js";
function git(dir: string, cmd: string): string {
return execSync(cmd, { cwd: dir, stdio: "pipe" }).toString().trim();
}
function makeStore(task: Task, events: unknown[] = []): TaskStore & EventEmitter {
const emitter = new EventEmitter();
const settings = { globalPause: false, enginePaused: false } as Settings;
return Object.assign(emitter, {
getSettings: async () => settings,
listTasks: async ({ column }: { column?: string } = {}) => (column ? [task].filter((t) => t.column === column) : [task]),
updateTask: async (_id: string, updates: Partial<Task>) => Object.assign(task, updates),
moveTask: async (_id: string, column: Task["column"]) => { task.column = column; },
logEntry: async () => undefined,
getTask: async () => task,
walCheckpoint: () => ({ busy: 0, log: 0, checkpointed: 0 }),
archiveTaskAndCleanup: async () => ({}),
clearStaleExecutionStartBranchReferences: () => [],
updateSettings: async () => settings,
mergeTask: async () => undefined,
getRootDir: () => "",
recordRunAuditEvent: async (event: unknown) => { events.push(event); },
}) as unknown as TaskStore & EventEmitter;
}
describe("landed-content soft-blocker reliability interactions (real git)", () => {
it("auto-finalizes paused+failed in-review tasks once landed content is proven", async () => {
const dir = mkdtempSync(join(tmpdir(), "fn-4648-ri-"));
try {
git(dir, "git init -b main");
git(dir, 'git config user.email "test@example.com"');
git(dir, 'git config user.name "Test"');
git(dir, "git commit --allow-empty -m init");
git(dir, "git checkout -b fusion/fn-4648");
writeFileSync(join(dir, "file.txt"), "task content\n");
git(dir, "git add file.txt");
git(dir, "git commit -m 'feat(FN-4648): task change' -m 'Fusion-Task-Id: FN-4648'");
const taskCommit = git(dir, "git rev-parse HEAD");
git(dir, "git checkout main");
git(dir, `git cherry-pick ${taskCommit}`);
const task = {
id: "FN-4648",
title: "t",
description: "d",
column: "in-review",
paused: true,
status: "failed",
error: "stale failure",
mergeRetries: 3,
mergeDetails: undefined,
branch: "fusion/fn-4648",
baseBranch: "main",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as Task;
const auditEvents: unknown[] = [];
const store = makeStore(task, auditEvents);
const manager = new SelfHealingManager(store, { rootDir: dir, getExecutingTaskIds: () => new Set() });
const recovered = await manager.recoverAlreadyMergedReviewTasks();
expect(recovered).toBe(1);
expect(task.column).toBe("done");
expect(task.paused).toBe(false);
expect(task.status).toBeNull();
expect(task.error).toBeNull();
expect(
auditEvents.some((event: any) => event?.mutationType === "task:auto-recover-finalize-already-on-main"),
).toBe(true);
manager.stop();
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("keeps task in-review when landed content exists but hard blockers remain", async () => {
const dir = mkdtempSync(join(tmpdir(), "fn-4648-ri-hard-"));
try {
git(dir, "git init -b main");
git(dir, 'git config user.email "test@example.com"');
git(dir, 'git config user.name "Test"');
git(dir, "git commit --allow-empty -m init");
git(dir, "git checkout -b fusion/fn-4648-hard");
writeFileSync(join(dir, "hard.txt"), "task content\n");
git(dir, "git add hard.txt");
git(dir, "git commit -m 'feat(FN-4648): hard blocker case' -m 'Fusion-Task-Id: FN-4648-HARD'");
const taskCommit = git(dir, "git rev-parse HEAD");
git(dir, "git checkout main");
git(dir, `git cherry-pick ${taskCommit}`);
const task = {
id: "FN-4648-HARD",
title: "t",
description: "d",
column: "in-review",
paused: true,
status: "failed",
error: "stale failure",
mergeRetries: 3,
mergeDetails: undefined,
branch: "fusion/fn-4648-hard",
baseBranch: "main",
dependencies: [],
steps: [{ name: "Step 1", status: "pending" }],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as Task;
const store = makeStore(task);
const manager = new SelfHealingManager(store, { rootDir: dir, getExecutingTaskIds: () => new Set() });
const recovered = await manager.recoverAlreadyMergedReviewTasks();
expect(recovered).toBe(0);
expect(task.column).toBe("in-review");
expect(task.status).toBe("failed");
expect(task.error).toContain("task has incomplete steps");
manager.stop();
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});

View File

@@ -131,7 +131,7 @@ describeIfGit("SelfHealingManager recoverAlreadyMergedReviewTasks (real git)", (
expect((store as any).recordRunAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
domain: "database",
mutationType: "task:auto-recover-already-merged",
mutationType: "task:auto-recover-finalize-already-on-main",
target: "FN-TEST-1",
}),
);
@@ -204,7 +204,7 @@ describeIfGit("SelfHealingManager recoverAlreadyMergedReviewTasks (real git)", (
expect(task.mergeRetries).toBe(0);
expect(task.mergeDetails?.mergeConfirmed).toBe(true);
expect(task.mergeDetails?.commitSha).toBe(landedSha);
expect((store.logEntry as any).mock.calls.some((call: unknown[]) => String(call[1]).includes("tree-equal"))).toBe(true);
expect((store.logEntry as any).mock.calls.some((call: unknown[]) => String(call[1]).includes("Auto-finalized from in-review/paused"))).toBe(true);
expect(existsSync(worktreePath)).toBe(false);
});
@@ -286,10 +286,10 @@ describeIfGit("SelfHealingManager recoverAlreadyMergedReviewTasks (real git)", (
const manager = new SelfHealingManager(store, { rootDir: repo, getExecutingTaskIds: () => new Set() });
await (manager as any).runMaintenance();
const firstRecoveryLogs = (store.logEntry as any).mock.calls.filter((call: unknown[]) => String(call[1]).includes("phantom-merge-guard false positive")).length;
const firstRecoveryLogs = (store.logEntry as any).mock.calls.filter((call: unknown[]) => String(call[1]).includes("Auto-finalized from in-review/paused")).length;
await (manager as any).runMaintenance();
const secondRecoveryLogs = (store.logEntry as any).mock.calls.filter((call: unknown[]) => String(call[1]).includes("phantom-merge-guard false positive")).length;
const secondRecoveryLogs = (store.logEntry as any).mock.calls.filter((call: unknown[]) => String(call[1]).includes("Auto-finalized from in-review/paused")).length;
expect(firstRecoveryLogs).toBe(1);
expect(secondRecoveryLogs).toBe(1);
});

View File

@@ -104,7 +104,7 @@ describe("recoverOrphanOnlyScopeViolations (FN-4379 / FN-4350)", () => {
expect(store.updateTask).toHaveBeenCalledWith("FN-4350", expect.objectContaining({
mergeDetails: expect.objectContaining({ mergeConfirmed: true, resolutionStrategy: "orphan-discard-no-op" }),
}));
expect(store.logEntry).toHaveBeenCalledWith("FN-4350", expect.stringContaining("Auto-recovered: FileScopeViolationError treated as orphan-only"));
expect(store.logEntry).toHaveBeenCalledWith("FN-4350", expect.stringContaining("Auto-finalized from in-review/paused: content proven on main"));
});
it("does NOT recover when landed commit cannot be verified (FN-4280)", async () => {
@@ -181,12 +181,12 @@ describe("recoverOrphanOnlyScopeViolations (FN-4379 / FN-4350)", () => {
expect(store.moveTask).not.toHaveBeenCalled();
});
it("skips paused failed tasks", async () => {
it("still evaluates paused failed tasks for landed-content proof", async () => {
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([failedReviewTask({ paused: true })]);
const recovered = await manager.recoverOrphanOnlyScopeViolations();
expect(recovered).toBe(0);
expect(store.getAgentLogs).not.toHaveBeenCalled();
expect(store.getAgentLogs).toHaveBeenCalled();
});
});

View File

@@ -3382,6 +3382,7 @@ describe("SelfHealingManager", () => {
expect(result).toBe(1);
expect(store.updateTask).toHaveBeenCalledWith("FN-350", {
paused: false,
status: null,
error: null,
mergeRetries: 0,
@@ -3389,7 +3390,7 @@ describe("SelfHealingManager", () => {
expect(store.moveTask).toHaveBeenCalledWith("FN-350", "done");
expect(store.logEntry).toHaveBeenCalledWith(
"FN-350",
expect.stringContaining("merge already confirmed"),
expect.stringContaining("Auto-finalized from in-review/paused: content proven"),
);
managerWithRecovery.stop();
@@ -3420,7 +3421,7 @@ describe("SelfHealingManager", () => {
managerWithRecovery.stop();
});
it("does not move paused merged tasks to done (respects user pause intent)", async () => {
it("auto-finalizes paused merged tasks by clearing soft blocker state", async () => {
const managerWithRecovery = new SelfHealingManager(store, {
rootDir: "/tmp/test-project",
});
@@ -3440,9 +3441,14 @@ describe("SelfHealingManager", () => {
const result = await managerWithRecovery.recoverMergedReviewTasks();
expect(result).toBe(0);
expect(store.updateTask).not.toHaveBeenCalled();
expect(store.moveTask).not.toHaveBeenCalled();
expect(result).toBe(1);
expect(store.updateTask).toHaveBeenCalledWith("FN-352", {
paused: false,
status: null,
error: null,
mergeRetries: 0,
});
expect(store.moveTask).toHaveBeenCalledWith("FN-352", "done");
managerWithRecovery.stop();
});
@@ -3859,6 +3865,41 @@ describe("SelfHealingManager", () => {
managerWithRecovery.stop();
});
it("auto-finalizes FN-4611-shape paused+failed tasks when landed content is proven", async () => {
const managerWithRecovery = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ globalPause: false, enginePaused: false });
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
{
id: "FN-4611-shape",
column: "in-review",
paused: true,
status: "failed",
error: "stale merge failure",
mergeRetries: 3,
mergeDetails: undefined,
baseBranch: "main",
branch: "fusion/fn-4611-shape",
steps: [],
log: [],
},
]);
mockedExecSync.mockImplementation((command: string | Buffer) => {
if (String(command).includes("Fusion-Task-Id: FN-4611-shape")) return "abc123\n" as any;
return "tip\n" as any;
});
const result = await managerWithRecovery.recoverAlreadyMergedReviewTasks();
expect(result).toBe(1);
expect(store.updateTask).toHaveBeenCalledWith(
"FN-4611-shape",
expect.objectContaining({ paused: false, status: null, error: null, mergeRetries: 0 }),
);
expect(store.moveTask).toHaveBeenCalledWith("FN-4611-shape", "done");
managerWithRecovery.stop();
});
it("keeps already-landed tasks in-review when merge blocker still reports incomplete steps", async () => {
const managerWithRecovery = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ globalPause: false, enginePaused: false });
@@ -3902,7 +3943,7 @@ describe("SelfHealingManager", () => {
});
describe("recoverAlreadyMergedReviewTasks — run-audit emission", () => {
it("emits task:auto-recover-already-merged when recovery succeeds", async () => {
it("emits task:auto-recover-finalize-already-on-main when recovery succeeds", async () => {
const recordRunAuditEvent = vi.fn().mockResolvedValue(undefined);
const storeWithAudit = createMockStore({
getSettings: vi.fn().mockResolvedValue({ globalPause: false, enginePaused: false }),
@@ -3932,13 +3973,14 @@ describe("SelfHealingManager", () => {
expect(recordRunAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
domain: "database",
mutationType: "task:auto-recover-already-merged",
mutationType: "task:auto-recover-finalize-already-on-main",
target: "FN-audit",
metadata: expect.objectContaining({
mergeSha: "abc1234def5678",
mergeStrategy: "trailer",
baseBranch: "main",
mergeRetries: 4,
clearedFlags: { paused: false, status: true, error: false },
}),
}),
);

View File

@@ -1119,7 +1119,7 @@ export class ProjectEngine {
// checks after clearing transient status/error state. Once that path parks
// a blocked task as failed, skip future auto-merge retries.
if (task.mergeDetails?.mergeConfirmed) {
return getTaskHardMergeBlocker(task as Task) === undefined;
return true;
}
if (this.options.getTaskMergeBlocker?.(task as Task)) return false;
// Terminal failure: don't let the cooldown sweep re-attempt a merge that