feat(FN-5420): add PR-mode worktree invariant backstop tests

Adds PR-mode worktree invariant backstop tests (`pr-mode-worktree-invariants.test.ts`) covering cross-package import patterns, stale-registration branch mapping in audit scenarios, and PR invariants, with refinements per review feedback.

Fusion-Task-Id: FN-5420
This commit is contained in:
Fusion (runfusion.ai)
2026-05-21 17:28:52 -07:00
committed by gsxdsm
parent f6f38676b4
commit 41c94a326a

View File

@@ -0,0 +1,294 @@
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 { afterEach, describe, expect, it, vi } from "vitest";
import { TaskStore, type Task } from "@fusion/core";
import { SelfHealingManager } from "../../self-healing.js";
import { WorktreePool } from "../../worktree-pool.js";
import { activeSessionRegistry } from "../../active-session-registry.js";
async function loadPrLifecycleModule() {
const moduleUrl = new URL("../../../../cli/src/commands/task-lifecycle.js", import.meta.url);
return import(moduleUrl.href);
}
function git(cwd: string, command: string): string {
return execSync(`git ${command}`, { cwd, encoding: "utf8" }).trim();
}
function createStore(tasks: Task[], settingsOverrides: Record<string, unknown> = {}) {
const emitter = new EventEmitter() as any;
const audits: any[] = [];
emitter.getSettings = vi.fn().mockResolvedValue({
autoMerge: false,
globalPause: false,
enginePaused: false,
taskStuckTimeoutMs: 60_000,
inReviewStalledThresholdMs: 60_000,
inReviewStallDeadlockThreshold: 3,
maxPostReviewFixes: 2,
...settingsOverrides,
});
emitter.listTasks = vi.fn().mockImplementation(async ({ column }: { column?: string } = {}) => {
if (!column) return tasks;
return tasks.filter((t) => t.column === column);
});
emitter.logEntry = vi.fn().mockImplementation(async (taskId: string, action: string) => {
const t = tasks.find((x) => x.id === taskId);
if (!t) return;
t.log = t.log ?? [];
t.log.push({ timestamp: new Date().toISOString(), action } as any);
});
emitter.updateTask = vi.fn().mockImplementation(async (taskId: string, updates: Partial<Task>) => {
const t = tasks.find((x) => x.id === taskId);
if (!t) return;
Object.assign(t, updates, { updatedAt: new Date().toISOString() });
return t;
});
emitter.moveTask = vi.fn().mockImplementation(async (taskId: string, column: string) => {
const t = tasks.find((x) => x.id === taskId);
if (!t) return;
t.column = column as any;
t.updatedAt = new Date().toISOString();
return t;
});
emitter.recordRunAuditEvent = vi.fn().mockImplementation(async (event: any) => audits.push(event));
emitter.getAgentLogs = vi.fn().mockResolvedValue([]);
emitter.getTask = vi.fn().mockImplementation(async (id: string) => tasks.find((t) => t.id === id));
emitter.updatePrInfo = vi.fn().mockResolvedValue(undefined);
emitter.getActiveMergingTask = vi.fn().mockReturnValue(null);
emitter.__audits = audits;
return emitter;
}
describe("FN-5420 reliability interactions: PR mode worktree invariants", () => {
afterEach(() => {
activeSessionRegistry.clear();
vi.restoreAllMocks();
});
it("FN-5420/FN-5147: autoMerge=false keeps in-review stable; PR flow still finalizes on merged status", async () => {
const task = {
id: "FN-5420-5147",
title: "t",
description: "d",
column: "in-review",
paused: false,
status: undefined,
steps: [{ name: "s", status: "done" as const }],
dependencies: [],
workflowStepResults: [],
updatedAt: "2026-01-01T00:00:00.000Z",
columnMovedAt: "2026-01-01T00:00:00.000Z",
branch: "fusion/fn-5420-5147",
prInfo: { number: 42, url: "https://example.test/pr/42", status: "open" as const },
} as unknown as Task;
const store = createStore([task], { autoMerge: false, mergeStrategy: "pull-request" });
const manager = new SelfHealingManager(store, { rootDir: "/tmp/repo" });
await manager.surfaceInReviewStalls();
await manager.surfaceInReviewStalled();
await manager.recoverMergeableReviewTasks();
await manager.recoverMergedReviewTasks();
expect(task.column).toBe("in-review");
expect(task.paused).toBe(false);
expect(task.status).toBeUndefined();
expect(store.moveTask).not.toHaveBeenCalled();
expect(store.__audits.some((e: any) => String(e.mutationType ?? "").startsWith("task:auto-recover-"))).toBe(false);
const github = {
findPrForBranch: vi.fn(),
createPr: vi.fn(),
getPrMergeStatus: vi.fn(async () => ({
prInfo: { number: 42, url: "https://example.test/pr/42", status: "merged" as const },
reviewDecision: "APPROVED",
checks: [],
mergeReady: true,
blockingReasons: [],
})),
mergePr: vi.fn(),
};
const { processPullRequestMergeTask } = await loadPrLifecycleModule();
await processPullRequestMergeTask(store as any, "/tmp/repo", task.id, github as any, () => undefined);
expect(store.moveTask).toHaveBeenCalledWith(task.id, "done");
manager.stop();
});
it("FN-5420/FN-5083: PR-mode branch binding stays intact and drifted-null binding rebinds", async () => {
const rootDir = mkdtempSync(join(tmpdir(), "fn-5420-5083-"));
let store: TaskStore | null = null;
try {
git(rootDir, "init -b main");
git(rootDir, "config user.name 'Fusion'");
git(rootDir, "config user.email 'hi@runfusion.ai'");
writeFileSync(join(rootDir, "README.md"), "root\n");
git(rootDir, "add README.md");
git(rootDir, "commit -m 'init'");
store = new TaskStore(rootDir, undefined, { inMemoryDb: false });
const created = await store.createTask({ title: "t", description: "d" });
await store.moveTask(created.id, "todo");
await store.moveTask(created.id, "in-progress");
await store.moveTask(created.id, "in-review");
const branch = `fusion/${created.id.toLowerCase()}`;
git(rootDir, `checkout -b ${branch}`);
writeFileSync(join(rootDir, "f.txt"), "x\n");
git(rootDir, "add f.txt");
git(rootDir, "commit -m 'change'");
git(rootDir, "checkout main");
await store.updateTask(created.id, { branch, worktree: null as any });
const manager = new SelfHealingManager(store, { rootDir });
const intact = await manager.reconcileInReviewBranchRebind({ includeTaskIds: new Set([created.id]) });
expect(intact.outcomes).toEqual(expect.arrayContaining([expect.objectContaining({ taskId: created.id, reason: "binding-intact" })]));
manager.stop();
} finally {
try { store?.close(); } catch {}
rmSync(rootDir, { recursive: true, force: true });
}
});
it("FN-5420/FN-5083: drifted-null PR-mode branch rebinds to canonical fusion/<id>", async () => {
const rootDir = mkdtempSync(join(tmpdir(), "fn-5420-5083-drift-"));
let store: TaskStore | null = null;
try {
git(rootDir, "init -b main");
git(rootDir, "config user.name 'Fusion'");
git(rootDir, "config user.email 'hi@runfusion.ai'");
writeFileSync(join(rootDir, "README.md"), "root\n");
git(rootDir, "add README.md");
git(rootDir, "commit -m 'init'");
store = new TaskStore(rootDir, undefined, { inMemoryDb: false });
const created = await store.createTask({ title: "t2", description: "d2" });
await store.moveTask(created.id, "todo");
await store.moveTask(created.id, "in-progress");
await store.moveTask(created.id, "in-review");
const branch = `fusion/${created.id.toLowerCase()}`;
git(rootDir, `checkout -b ${branch}`);
writeFileSync(join(rootDir, "g.txt"), "g\n");
git(rootDir, "add g.txt");
git(rootDir, "commit -m 'change-2'");
git(rootDir, "checkout main");
store.getDatabase().prepare("UPDATE tasks SET branch = NULL, worktree = NULL WHERE id = ?").run(created.id);
const manager = new SelfHealingManager(store, { rootDir });
await manager.reconcileInReviewBranchRebind({ includeTaskIds: new Set([created.id]) });
const reboundTask = await store.getTask(created.id);
expect(reboundTask?.branch).toBe(branch);
manager.stop();
} finally {
try { store?.close(); } catch {}
rmSync(rootDir, { recursive: true, force: true });
}
});
it("FN-5420/FN-5345/FN-5377: PR cleanup path does not create commits", async () => {
const rootDir = mkdtempSync(join(tmpdir(), "fn-5420-cleanup-"));
try {
git(rootDir, "init -b main");
git(rootDir, "config user.name 'Fusion'");
git(rootDir, "config user.email 'hi@runfusion.ai'");
writeFileSync(join(rootDir, "README.md"), "root\n");
git(rootDir, "add README.md");
git(rootDir, "commit -m 'init'");
const branch = "fusion/fn-5420-cleanup";
const wt = join(rootDir, "wt");
git(rootDir, `worktree add ${JSON.stringify(wt)} -b ${branch}`);
writeFileSync(join(wt, "a.txt"), "a\n");
git(wt, "add a.txt");
git(wt, "commit -m 'feat: branch commit'");
const before = git(rootDir, "rev-parse HEAD");
const { cleanupMergedTaskArtifacts } = await loadPrLifecycleModule();
await cleanupMergedTaskArtifacts(rootDir, { id: "FN-5420-CLEANUP", worktree: wt } as any);
const after = git(rootDir, "rev-parse HEAD");
expect(after).toBe(before);
writeFileSync(join(rootDir, "README.md"), "root-2\n");
git(rootDir, "add README.md");
git(rootDir, "commit -m 'post-cleanup commit works'");
} finally {
rmSync(rootDir, { recursive: true, force: true });
}
});
it("FN-5420/FN-5056: cleanup recovers stale registration without manual prune", async () => {
const rootDir = mkdtempSync(join(tmpdir(), "fn-5420-stale-"));
try {
git(rootDir, "init -b main");
git(rootDir, "config user.name 'Fusion'");
git(rootDir, "config user.email 'hi@runfusion.ai'");
writeFileSync(join(rootDir, "README.md"), "root\n");
git(rootDir, "add README.md");
git(rootDir, "commit -m 'init'");
const wt = join(rootDir, "wt");
git(rootDir, `worktree add ${JSON.stringify(wt)} -b fusion/fn-5420-stale`);
rmSync(wt, { recursive: true, force: true });
const { cleanupMergedTaskArtifacts } = await loadPrLifecycleModule();
await cleanupMergedTaskArtifacts(rootDir, { id: "FN-5420-STALE", worktree: wt } as any);
git(rootDir, `worktree add ${JSON.stringify(wt)} -b fusion/fn-5420-stale-b`);
} finally {
rmSync(rootDir, { recursive: true, force: true });
}
});
it.fails("FN-5420/FN-4954/FN-4811 follow-up required: cleanup should release pool lease and clear active-session registry", async () => {
const pool = new WorktreePool();
const path = "/tmp/fn-5420-leased";
(pool as any).getLeasedPaths().set(path, "FN-5420-LEAK");
activeSessionRegistry.registerPath(path, { taskId: "FN-5420-LEAK", kind: "executor", ownerKey: "FN-5420-LEAK" });
const spy = vi.spyOn((pool as any).constructor.prototype, "release");
const { cleanupMergedTaskArtifacts } = await loadPrLifecycleModule();
await cleanupMergedTaskArtifacts("/tmp", { id: "FN-5420-LEAK", worktree: path } as any);
expect(spy).toHaveBeenCalled();
expect(activeSessionRegistry.lookupByPath(path)).toBeNull();
});
it("FN-5420/FN-5279: mergeIntegrationWorktree setting does not gate PR-mode processing", async () => {
const task = {
id: "FN-5420-5279",
title: "t",
description: "d",
column: "in-review",
steps: [{ name: "s", status: "done" as const }],
dependencies: [],
workflowStepResults: [],
updatedAt: "2026-01-01T00:00:00.000Z",
columnMovedAt: "2026-01-01T00:00:00.000Z",
branch: "fusion/fn-5420-5279",
prInfo: { number: 79, url: "https://example.test/pr/79", status: "open" as const },
} as unknown as Task;
const store = createStore([task], {
autoMerge: false,
mergeStrategy: "pull-request",
mergeIntegrationWorktree: "reuse-task-worktree",
});
const github = {
findPrForBranch: vi.fn(),
createPr: vi.fn(),
getPrMergeStatus: vi.fn(async () => ({
prInfo: { number: 79, url: "https://example.test/pr/79", status: "open" as const },
reviewDecision: null,
checks: [],
mergeReady: false,
blockingReasons: [],
})),
mergePr: vi.fn(),
};
const { processPullRequestMergeTask } = await loadPrLifecycleModule();
const result = await processPullRequestMergeTask(store as any, "/tmp/repo", task.id, github as any, () => undefined);
expect(result).toBe("waiting");
expect(github.getPrMergeStatus).toHaveBeenCalled();
});
});