feat(FN-5488): merge fusion/fn-5488

This commit is contained in:
gsxdsm
2026-05-22 10:37:21 -07:00
parent 301e0a050f
commit ba066c88a3
6 changed files with 187 additions and 14 deletions

View File

@@ -0,0 +1,146 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Settings, Task, TaskStore } from "@fusion/core";
import { SelfHealingManager } from "../self-healing.js";
function makeTask(id: string, overrides: Partial<Task> = {}): Task {
return {
id,
title: id,
description: id,
column: "todo",
status: null,
paused: false,
blockedBy: null,
overlapBlockedBy: null,
dependencies: [],
steps: [],
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
} as Task;
}
describe("SelfHealingManager in-review merge stall deadlock recovery (FN-5488)", () => {
let tasks: Map<string, Task>;
let store: TaskStore;
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-22T12:00:00.000Z"));
tasks = new Map();
store = {
getSettings: vi.fn().mockResolvedValue({
globalPause: false,
enginePaused: false,
} as Settings),
listTasks: vi.fn().mockImplementation(async (opts?: { column?: Task["column"]; includeArchived?: boolean }) => {
const all = [...tasks.values()];
if (!opts?.column) return all;
return all.filter((task) => task.column === opts.column);
}),
updateTask: vi.fn().mockImplementation(async (id: string, patch: Partial<Task>) => {
const current = tasks.get(id);
if (!current) throw new Error(`Task ${id} missing`);
tasks.set(id, { ...current, ...patch });
}),
logEntry: vi.fn().mockResolvedValue(undefined),
} as unknown as TaskStore;
});
afterEach(() => {
vi.useRealTimers();
});
it("clears blockedBy for failed in-review blocker with exhausted merge retries", async () => {
tasks.set("FN-5217", makeTask("FN-5217", {
column: "in-review",
status: "failed",
mergeRetries: 3,
updatedAt: "2026-05-20T00:00:00.000Z",
}));
tasks.set("FN-5119", makeTask("FN-5119", {
column: "todo",
blockedBy: "FN-5217",
status: "queued",
dependencies: ["FN-5217"],
}));
tasks.set("FN-5361", makeTask("FN-5361", {
column: "todo",
}));
tasks.set("FN-5449", makeTask("FN-5449", {
column: "todo",
blockedBy: "FN-5217",
status: "queued",
dependencies: ["FN-5361", "FN-5217"],
}));
const manager = new SelfHealingManager(store, { rootDir: "/tmp/test" });
const recovered = await manager.clearStaleBlockedBy();
expect(recovered).toBe(2);
expect(tasks.get("FN-5119")?.blockedBy).toBeNull();
expect(tasks.get("FN-5119")?.status).toBeNull();
expect(tasks.get("FN-5449")?.blockedBy).toBe("FN-5361");
expect(tasks.get("FN-5449")?.status).toBe("queued");
});
it("treats fresh unbacked in-review merging status as stale for fanout", async () => {
tasks.set("FN-5485", makeTask("FN-5485", {
column: "in-review",
status: "merging",
updatedAt: "2026-05-22T11:58:50.000Z",
}));
tasks.set("FN-5486", makeTask("FN-5486", {
column: "todo",
blockedBy: "FN-5485",
status: "queued",
dependencies: ["FN-5485"],
}));
const manager = new SelfHealingManager(store, {
rootDir: "/tmp/test",
staleMergingStatusMinAgeMs: 5 * 60_000,
staleMergingFanoutMinAgeMs: 15 * 60_000,
getActiveMergeTaskId: () => null,
});
const recovered = await manager.clearStaleBlockedBy();
expect(recovered).toBe(1);
expect(tasks.get("FN-5486")?.blockedBy).toBeNull();
expect(tasks.get("FN-5486")?.status).toBeNull();
});
it("preserves overlapBlockedBy when active overlap blocker exists", async () => {
tasks.set("FN-5485", makeTask("FN-5485", {
column: "in-review",
status: "merging",
updatedAt: "2026-05-22T11:58:50.000Z",
}));
tasks.set("FN-OV", makeTask("FN-OV", {
column: "in-progress",
}));
tasks.set("FN-DEP", makeTask("FN-DEP", {
column: "todo",
blockedBy: "FN-5485",
overlapBlockedBy: "FN-OV",
status: "queued",
dependencies: ["FN-5485"],
}));
const manager = new SelfHealingManager(store, {
rootDir: "/tmp/test",
staleMergingStatusMinAgeMs: 5 * 60_000,
staleMergingFanoutMinAgeMs: 15 * 60_000,
getActiveMergeTaskId: () => null,
});
await manager.clearStaleBlockedBy();
expect(tasks.get("FN-DEP")?.blockedBy).toBeNull();
expect(tasks.get("FN-DEP")?.status).toBe("queued");
expect(tasks.get("FN-DEP")?.overlapBlockedBy).toBe("FN-OV");
});
});

View File

@@ -616,7 +616,7 @@ describe("SelfHealingManager", () => {
await manager.runStartupRecovery();
expect(store.updateTask).toHaveBeenCalledWith("A", { blockedBy: null, overlapBlockedBy: null, status: null });
expect(store.logEntry).toHaveBeenCalledWith("A", expect.stringContaining("Auto-recovered: cleared stale blockedBy"));
expect(store.logEntry).toHaveBeenCalledWith("A", expect.stringContaining("Auto-recovered (FN-5488): cleared stale blockedBy"));
});
it("runStartupRecovery skips while enginePaused is active", async () => {
@@ -5950,8 +5950,8 @@ describe("clearStaleBlockedBy", () => {
expect(recovered).toBe(1);
expect(store.updateTask).toHaveBeenCalledWith("A", { blockedBy: null, overlapBlockedBy: null, status: null });
expect(store.logEntry).toHaveBeenCalledWith("A", expect.stringContaining(`blocker ${blockerId}`));
expect(store.logEntry).toHaveBeenCalledWith("A", expect.stringContaining("stale for"));
expect(store.logEntry).toHaveBeenCalledWith("A", expect.stringContaining(`blocker=${blockerId}`));
expect(store.logEntry).toHaveBeenCalledWith("A", expect.stringContaining("reason=unbacked-merging"));
manager.stop();
vi.useRealTimers();
});
@@ -5964,7 +5964,7 @@ describe("clearStaleBlockedBy", () => {
column: "in-review",
paused: false,
status: "merging",
updatedAt: "2026-01-01T00:00:01.000Z",
updatedAt: "2026-01-01T00:09:31.000Z",
});
mockSweepTasks(store, { todo: [taskA], inReview: [taskB], all: [taskA, taskB] });