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

This commit is contained in:
gsxdsm
2026-04-28 08:04:00 -07:00
parent 26a14698c6
commit 54f4fd540e
11 changed files with 488 additions and 6 deletions

View File

@@ -0,0 +1,194 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { Settings, Task, TaskStore } from "@fusion/core";
import { GridlockDetector } from "../gridlock-detector.js";
function createTask(id: string, overrides: Partial<Task> = {}): Task {
return {
id,
title: id,
description: "desc",
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
log: [],
...overrides,
};
}
function createSettings(overrides: Partial<Settings> = {}): Settings {
return {
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
groupOverlappingFiles: true,
autoMerge: true,
overlapIgnorePaths: [],
...overrides,
} as Settings;
}
describe("GridlockDetector", () => {
let tasks: Task[];
let settings: Settings;
let scopes: Record<string, string[]>;
let onGridlock: ReturnType<typeof vi.fn>;
let store: TaskStore;
let detector: GridlockDetector;
beforeEach(() => {
tasks = [];
settings = createSettings();
scopes = {};
onGridlock = vi.fn();
store = {
listTasks: vi.fn(async () => tasks),
getSettings: vi.fn(async () => settings),
parseFileScopeFromPrompt: vi.fn(async (taskId: string) => scopes[taskId] ?? []),
} as unknown as TaskStore;
detector = new GridlockDetector(store, { onGridlock });
});
afterEach(() => {
detector.stop();
});
it("detects gridlock when all todo tasks are blocked by dependencies", async () => {
tasks = [
createTask("FN-1", { column: "todo", dependencies: ["FN-10"] }),
createTask("FN-2", { column: "todo", dependencies: ["FN-11"] }),
createTask("FN-3", { column: "in-progress" }),
createTask("FN-10", { column: "in-progress" }),
createTask("FN-11", { column: "in-progress" }),
];
const event = await detector.detectGridlock();
expect(event).not.toBeNull();
expect(event?.blockedTaskIds).toEqual(["FN-1", "FN-2"]);
expect(event?.reasons).toEqual({ "FN-1": "dependency", "FN-2": "dependency" });
expect(event?.blockingTaskIds).toEqual(["FN-10", "FN-11"]);
expect(onGridlock).toHaveBeenCalledTimes(1);
});
it("detects gridlock when all todo tasks are blocked by file overlap", async () => {
tasks = [
createTask("FN-1", { column: "todo" }),
createTask("FN-2", { column: "todo" }),
createTask("FN-9", { column: "in-progress" }),
];
scopes = {
"FN-1": ["packages/core/src/a.ts"],
"FN-2": ["packages/core/src/b.ts"],
"FN-9": ["packages/core/src/*"],
};
const event = await detector.detectGridlock();
expect(event?.blockedTaskIds).toEqual(["FN-1", "FN-2"]);
expect(event?.reasons).toEqual({ "FN-1": "overlap", "FN-2": "overlap" });
expect(event?.blockingTaskIds).toEqual(["FN-9"]);
});
it("does not detect gridlock when there are no schedulable tasks", async () => {
tasks = [createTask("FN-1", { column: "todo", paused: true }), createTask("FN-2", { column: "in-progress" })];
const event = await detector.detectGridlock();
expect(event).toBeNull();
expect(onGridlock).not.toHaveBeenCalled();
});
it("does not detect gridlock when at least one todo task is unblocked", async () => {
tasks = [
createTask("FN-1", { column: "todo", dependencies: ["FN-10"] }),
createTask("FN-2", { column: "todo" }),
createTask("FN-3", { column: "in-progress" }),
createTask("FN-10", { column: "todo" }),
];
const event = await detector.detectGridlock();
expect(event).toBeNull();
});
it("deduplicates same blocked task set", async () => {
tasks = [
createTask("FN-1", { column: "todo", dependencies: ["FN-10"] }),
createTask("FN-2", { column: "in-progress" }),
createTask("FN-10", { column: "in-progress" }),
];
await detector.detectGridlock();
await detector.detectGridlock();
expect(onGridlock).toHaveBeenCalledTimes(1);
});
it("fires again when blocked task set changes", async () => {
tasks = [
createTask("FN-1", { column: "todo", dependencies: ["FN-10"] }),
createTask("FN-2", { column: "in-progress" }),
createTask("FN-10", { column: "in-progress" }),
];
await detector.detectGridlock();
tasks = [
...tasks,
createTask("FN-3", { column: "todo", dependencies: ["FN-10"] }),
];
await detector.detectGridlock();
expect(onGridlock).toHaveBeenCalledTimes(2);
});
it("resets dedup after resolution", async () => {
tasks = [
createTask("FN-1", { column: "todo", dependencies: ["FN-10"] }),
createTask("FN-2", { column: "in-progress" }),
createTask("FN-10", { column: "in-progress" }),
];
await detector.detectGridlock();
tasks = [createTask("FN-1", { column: "todo" }), createTask("FN-2", { column: "in-progress" }), createTask("FN-10", { column: "done" })];
await detector.detectGridlock();
tasks = [
createTask("FN-1", { column: "todo", dependencies: ["FN-10"] }),
createTask("FN-2", { column: "in-progress" }),
createTask("FN-10", { column: "in-progress" }),
];
await detector.detectGridlock();
expect(onGridlock).toHaveBeenCalledTimes(2);
});
it("respects paused and recovery-backoff tasks as non-schedulable", async () => {
const future = new Date(Date.now() + 60_000).toISOString();
tasks = [
createTask("FN-1", { column: "todo", paused: true, dependencies: ["FN-9"] }),
createTask("FN-2", { column: "todo", nextRecoveryAt: future, dependencies: ["FN-9"] }),
createTask("FN-3", { column: "in-progress" }),
createTask("FN-9", { column: "todo" }),
];
const event = await detector.detectGridlock();
expect(event).toBeNull();
});
it("respects overlap ignore paths from settings", async () => {
settings = createSettings({ overlapIgnorePaths: ["docs/"] });
tasks = [
createTask("FN-1", { column: "todo" }),
createTask("FN-2", { column: "in-progress" }),
];
scopes = {
"FN-1": ["docs/readme.md"],
"FN-2": ["docs/"],
};
const event = await detector.detectGridlock();
expect(event).toBeNull();
});
});

View File

@@ -66,6 +66,7 @@ describe("Ntfy notifier helpers", () => {
it("includes planning-awaiting-input in default events", () => {
expect(DEFAULT_NTFY_EVENTS).toContain("planning-awaiting-input");
expect(resolveNtfyEvents(undefined)).toContain("planning-awaiting-input");
expect(DEFAULT_NTFY_EVENTS).toContain("gridlock");
});
it("checks planning-awaiting-input event enablement", () => {
@@ -139,6 +140,75 @@ describe("NtfyNotifier", () => {
});
});
describe("gridlock notifications", () => {
it("sends notification when gridlock event is enabled", async () => {
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["gridlock"] });
fetchMock.mockResolvedValue({ ok: true });
notifier = new NtfyNotifier(store, { projectId: "proj-1" });
await notifier.start();
notifier.notifyGridlock({
blockedTaskCount: 2,
reasons: { "FN-001": "dependency", "FN-003": "overlap" },
blockedTaskIds: ["FN-001", "FN-003"],
blockingTaskIds: ["FN-002"],
});
await flushAsyncWork();
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
"https://ntfy.sh/test-topic",
expect.objectContaining({
method: "POST",
headers: expect.objectContaining({
Title: "Pipeline gridlocked",
Priority: "high",
}),
}),
);
});
it("skips notification when gridlock event is disabled", async () => {
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["failed"] });
fetchMock.mockResolvedValue({ ok: true });
notifier = new NtfyNotifier(store);
await notifier.start();
notifier.notifyGridlock({
blockedTaskCount: 1,
reasons: { "FN-001": "dependency" },
blockedTaskIds: ["FN-001"],
blockingTaskIds: ["FN-002"],
});
await flushAsyncWork();
expect(fetchMock).not.toHaveBeenCalled();
});
it("deduplicates by blocked task set", async () => {
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["gridlock"] });
fetchMock.mockResolvedValue({ ok: true });
notifier = new NtfyNotifier(store);
await notifier.start();
notifier.notifyGridlock({
blockedTaskCount: 2,
reasons: { "FN-001": "dependency", "FN-003": "dependency" },
blockedTaskIds: ["FN-003", "FN-001"],
blockingTaskIds: ["FN-002"],
});
notifier.notifyGridlock({
blockedTaskCount: 2,
reasons: { "FN-001": "dependency", "FN-003": "dependency" },
blockedTaskIds: ["FN-001", "FN-003"],
blockingTaskIds: ["FN-002"],
});
await flushAsyncWork();
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
describe("when enabled", () => {
beforeEach(() => {
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic" });