From 405a6ea02d4245a2b8641c34e8503b98848739c6 Mon Sep 17 00:00:00 2001 From: "Fusion (runfusion.ai)" Date: Tue, 19 May 2026 09:12:19 -0700 Subject: [PATCH] =?UTF-8?q?test(FN-5147):=20complete=20Step=205=20?= =?UTF-8?q?=E2=80=94=20add=20autoMerge-off=20in-review=20reliability=20bac?= =?UTF-8?q?kstop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fusion-Task-Id: FN-5147 Fusion-Task-Lineage: 2a0e5bd4-b2ea-498c-95ef-85126819e824 --- .../in-review-automerge-off.test.ts | 189 ++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 packages/engine/src/__tests__/reliability-interactions/in-review-automerge-off.test.ts diff --git a/packages/engine/src/__tests__/reliability-interactions/in-review-automerge-off.test.ts b/packages/engine/src/__tests__/reliability-interactions/in-review-automerge-off.test.ts new file mode 100644 index 000000000..66a2e56e5 --- /dev/null +++ b/packages/engine/src/__tests__/reliability-interactions/in-review-automerge-off.test.ts @@ -0,0 +1,189 @@ +import { EventEmitter } from "node:events"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { Task, TaskStore } from "@fusion/core"; +import { SelfHealingManager } from "../../self-healing.js"; + +function makeTask(overrides: Partial = {}): Task { + return { + id: "FN-5147-RI", + title: "t", + description: "d", + column: "in-review", + paused: false, + status: null, + error: null, + steps: [{ id: "1", title: "s", status: "done" as const }], + workflowStepResults: [], + dependencies: [], + log: [], + updatedAt: "2026-01-01T00:00:00.000Z", + columnMovedAt: "2026-01-01T00:00:00.000Z", + ...overrides, + } as Task; +} + +function createStore(tasks: Task[], settingsOverrides: Record = {}): TaskStore & EventEmitter { + const emitter = new EventEmitter() as TaskStore & EventEmitter; + const audits: any[] = []; + (emitter as any).__audits = audits; + (emitter as any).getSettings = vi.fn().mockResolvedValue({ + autoMerge: false, + globalPause: false, + enginePaused: false, + taskStuckTimeoutMs: 60_000, + inReviewStalledThresholdMs: 60_000, + inReviewStallDeadlockThreshold: 3, + maxPostReviewFixes: 2, + ...settingsOverrides, + }); + (emitter as any).listTasks = vi.fn().mockImplementation(async ({ column }: { column?: string } = {}) => { + if (!column) return tasks; + return tasks.filter((t) => t.column === column); + }); + (emitter as any).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(Date.now()).toISOString(), action } as any); + }); + (emitter as any).updateTask = vi.fn().mockImplementation(async (taskId: string, updates: Partial) => { + const t = tasks.find((x) => x.id === taskId); + if (!t) return; + Object.assign(t, updates, { updatedAt: new Date(Date.now()).toISOString() }); + return t; + }); + (emitter as any).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(Date.now()).toISOString(); + return t; + }); + (emitter as any).recordRunAuditEvent = vi.fn().mockImplementation(async (event: any) => { + audits.push(event); + }); + (emitter as any).getAgentLogs = vi.fn().mockResolvedValue([]); + (emitter as any).getTask = vi.fn().mockImplementation(async (id: string) => tasks.find((t) => t.id === id)); + return emitter; +} + +describe("FN-5147 reliability interactions: in-review autoMerge off", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it("long-quiet in-review remains unchanged across startup + maintenance", async () => { + const task = makeTask({ id: "FN-5147-Q1", steps: [{ id: "1", title: "s", status: "done" as const }] }); + const store = createStore([task], { taskStuckTimeoutMs: 1_000, inReviewStalledThresholdMs: 1_000 }); + const manager = new SelfHealingManager(store, { rootDir: "/tmp/repo" }); + + vi.setSystemTime(new Date("2026-01-01T05:00:00.000Z")); + await manager.runStartupRecovery(); + await (manager as any).runMaintenance(); + + expect(task.column).toBe("in-review"); + expect(task.paused).toBe(false); + expect(task.status).toBeNull(); + expect(task.taskDoneRetryCount).toBeUndefined(); + expect(task.mergeRetries).toBeUndefined(); + expect((store.moveTask as any).mock.calls.length).toBe(0); + expect((task.log ?? []).some((entry: any) => /Auto-recovered|Auto-revived|Auto-retry|kicked back to todo|in-review-stall-deadlock/.test(entry.action))).toBe(false); + manager.stop(); + }); + + it("stale transient merging status does not requeue/finalize with autoMerge off", async () => { + const task = makeTask({ id: "FN-5147-Q2", status: "merging" as any, error: "x" }); + const store = createStore([task], { taskStuckTimeoutMs: 1_000 }); + const manager = new SelfHealingManager(store, { rootDir: "/tmp/repo" }); + + vi.setSystemTime(new Date("2026-01-01T05:00:00.000Z")); + await (manager as any).runMaintenance(); + + expect(task.column).toBe("in-review"); + expect((store.moveTask as any).mock.calls.length).toBe(0); + manager.stop(); + }); + + it("partial-progress no-task-done failure stays in-review", async () => { + const task = makeTask({ + id: "FN-5147-Q3", + status: "failed", + error: "Agent exited without calling fn_task_done", + taskDoneRetryCount: 1, + steps: [{ id: "1", title: "s1", status: "done" as const }, { id: "2", title: "s2", status: "pending" as any }], + }); + const store = createStore([task]); + const manager = new SelfHealingManager(store, { rootDir: "/tmp/repo" }); + + await manager.recoverPartialProgressNoTaskDoneFailures(); + expect(task.column).toBe("in-review"); + expect(task.taskDoneRetryCount).toBe(1); + expect((store.moveTask as any).mock.calls.length).toBe(0); + manager.stop(); + }); + + it("incomplete in-review task is not moved by stale-incomplete sweep", async () => { + const task = makeTask({ + id: "FN-5147-Q4", + status: null, + steps: [{ id: "1", title: "s1", status: "pending" as any }], + }); + const store = createStore([task], { taskStuckTimeoutMs: 1_000 }); + const manager = new SelfHealingManager(store, { rootDir: "/tmp/repo" }); + + vi.setSystemTime(new Date("2026-01-01T05:00:00.000Z")); + await manager.recoverStaleIncompleteReviewTasks(); + expect(task.column).toBe("in-review"); + expect((store.moveTask as any).mock.calls.length).toBe(0); + manager.stop(); + }); + + it("unusable-worktree review failure is not requeued", async () => { + const task = makeTask({ + id: "FN-5147-Q5", + status: "failed", + error: "Failed to create worktree after 3 attempts: missing worktree", + worktree: "/tmp/missing", + steps: [{ id: "1", title: "s1", status: "done" as const }, { id: "2", title: "s2", status: "pending" as any }], + }); + const store = createStore([task]); + const manager = new SelfHealingManager(store, { rootDir: "/tmp/repo" }); + + await manager.recoverMissingWorktreeReviewFailures(); + expect(task.column).toBe("in-review"); + expect((store.moveTask as any).mock.calls.length).toBe(0); + manager.stop(); + }); + + it("reason-driven stall signals are fully suppressed", async () => { + const task = makeTask({ + id: "FN-5147-Q6", + status: "failed", + error: "Failed to create worktree after 3 attempts: branch conflict", + worktree: "/tmp/missing", + branch: "fusion/fn-5147-q6", + }); + const store = createStore([task], { taskStuckTimeoutMs: 1_000, inReviewStallDeadlockThreshold: 1 }); + const manager = new SelfHealingManager(store, { rootDir: "/tmp/repo" }); + + for (let i = 0; i < 4; i++) { + await manager.surfaceInReviewStalls(); + await manager.surfaceInReviewStalled(); + } + + expect(task.inReviewStall).toBeUndefined(); + expect((task.log ?? []).some((entry: any) => entry.action.includes("Stall surfaced") || entry.action.includes("in-review-stall-deadlock"))).toBe(false); + expect((store as any).__audits.some((e: any) => e.mutationType === "task:in-review-stall-deadlock-disposed")).toBe(false); + manager.stop(); + }); + + it("direct stall detectors return 0 and emit no logs", async () => { + const task = makeTask({ id: "FN-5147-Q7", status: "failed", error: "x" }); + const store = createStore([task], { taskStuckTimeoutMs: 1_000, inReviewStalledThresholdMs: 1_000 }); + const manager = new SelfHealingManager(store, { rootDir: "/tmp/repo" }); + + expect(await manager.surfaceInReviewStalls()).toBe(0); + expect(await manager.surfaceInReviewStalled()).toBe(0); + expect((task.log ?? []).length).toBe(0); + manager.stop(); + }); +});