feat(FN-3019): merge fusion/fn-3019
Commits merged: - feat(FN-3019): complete Step 4 — document gridlock cooldown semantics - fix(FN-3019): normalize legacy custom provider payloads for typecheck - test(FN-3019): complete Step 2 — add gridlock cooldown regression coverage - fix(FN-3019): repair notifier class structure after cooldown refactor - feat(FN-3019): complete Step 1 — add gridlock notification cooldown Files changed: docs/architecture.md | 3 +- docs/settings-reference.md | 4 +- .../app/components/CustomProvidersSection.tsx | 25 ++++++---- .../engine/src/__tests__/gridlock-detector.test.ts | 5 +- packages/engine/src/__tests__/notifier.test.ts | 47 ++++++++++++++++-- packages/engine/src/gridlock-detector.ts | 16 +++++-- packages/engine/src/notifier.ts | 55 ++++++++++++---------- packages/engine/src/project-engine.ts | 1 + 8 files changed, 109 insertions(+), 47 deletions(-) Fusion-Task-Id: FN-3019
This commit is contained in:
@@ -35,6 +35,7 @@ describe("GridlockDetector", () => {
|
||||
let settings: Settings;
|
||||
let scopes: Record<string, string[]>;
|
||||
let onGridlock: ReturnType<typeof vi.fn>;
|
||||
let onGridlockCleared: ReturnType<typeof vi.fn>;
|
||||
let store: TaskStore;
|
||||
let detector: GridlockDetector;
|
||||
|
||||
@@ -43,12 +44,13 @@ describe("GridlockDetector", () => {
|
||||
settings = createSettings();
|
||||
scopes = {};
|
||||
onGridlock = vi.fn();
|
||||
onGridlockCleared = 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 });
|
||||
detector = new GridlockDetector(store, { onGridlock, onGridlockCleared });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -162,6 +164,7 @@ describe("GridlockDetector", () => {
|
||||
await detector.detectGridlock();
|
||||
|
||||
expect(onGridlock).toHaveBeenCalledTimes(2);
|
||||
expect(onGridlockCleared).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("respects paused and recovery-backoff tasks as non-schedulable", async () => {
|
||||
|
||||
@@ -142,6 +142,15 @@ describe("NtfyNotifier", () => {
|
||||
});
|
||||
|
||||
describe("gridlock notifications", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("sends notification when gridlock event is enabled", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["gridlock"] });
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
@@ -186,7 +195,7 @@ describe("NtfyNotifier", () => {
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deduplicates by blocked task set", async () => {
|
||||
it("suppresses repeated gridlock notifications during the 15-minute cooldown even when blocked set changes", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["gridlock"] });
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
notifier = new NtfyNotifier(store);
|
||||
@@ -198,16 +207,44 @@ describe("NtfyNotifier", () => {
|
||||
blockedTaskIds: ["FN-003", "FN-001"],
|
||||
blockingTaskIds: ["FN-002"],
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(5 * 60 * 1000);
|
||||
notifier.notifyGridlock({
|
||||
blockedTaskCount: 2,
|
||||
reasons: { "FN-001": "dependency", "FN-003": "dependency" },
|
||||
blockedTaskIds: ["FN-001", "FN-003"],
|
||||
blockingTaskIds: ["FN-002"],
|
||||
blockedTaskCount: 3,
|
||||
reasons: { "FN-001": "dependency", "FN-003": "dependency", "FN-004": "overlap" },
|
||||
blockedTaskIds: ["FN-001", "FN-003", "FN-004"],
|
||||
blockingTaskIds: ["FN-002", "FN-005"],
|
||||
});
|
||||
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("allows a new gridlock notification immediately after resolution reset", async () => {
|
||||
store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["gridlock"] });
|
||||
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"],
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(60_000);
|
||||
notifier.notifyGridlock(null);
|
||||
notifier.notifyGridlock({
|
||||
blockedTaskCount: 1,
|
||||
reasons: { "FN-009": "overlap" },
|
||||
blockedTaskIds: ["FN-009"],
|
||||
blockingTaskIds: ["FN-010"],
|
||||
});
|
||||
|
||||
await flushAsyncWork();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when enabled", () => {
|
||||
|
||||
Reference in New Issue
Block a user