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:
Fusion
2026-04-30 00:18:41 -07:00
committed by gsxdsm
parent 493ca91d63
commit 42e8e462fc
8 changed files with 110 additions and 48 deletions

View File

@@ -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 () => {

View File

@@ -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", () => {

View File

@@ -15,6 +15,7 @@ export interface GridlockDetectorOptions {
pollIntervalMs?: number;
missionStore?: MissionStore;
onGridlock?: (event: GridlockEvent) => void;
onGridlockCleared?: () => void;
}
export class GridlockDetector {
@@ -22,6 +23,7 @@ export class GridlockDetector {
private readonly pollIntervalMs: number;
private readonly missionStore?: MissionStore;
private readonly onGridlock?: (event: GridlockEvent) => void;
private readonly onGridlockCleared?: () => void;
private lastGridlockKey: string | null = null;
constructor(
@@ -31,6 +33,7 @@ export class GridlockDetector {
this.pollIntervalMs = options.pollIntervalMs ?? 30_000;
this.missionStore = options.missionStore;
this.onGridlock = options.onGridlock;
this.onGridlockCleared = options.onGridlockCleared;
}
start(): void {
@@ -65,13 +68,13 @@ export class GridlockDetector {
});
if (schedulable.length === 0) {
this.lastGridlockKey = null;
this.clearGridlockState();
return null;
}
const active = tasks.filter((task) => task.column === "in-progress" || (task.column === "in-review" && Boolean(task.worktree)));
if (active.length === 0) {
this.lastGridlockKey = null;
this.clearGridlockState();
return null;
}
@@ -117,7 +120,7 @@ export class GridlockDetector {
const blockedTaskIds = Object.keys(reasons).sort();
if (blockedTaskIds.length !== schedulable.length) {
this.lastGridlockKey = null;
this.clearGridlockState();
return null;
}
@@ -138,6 +141,13 @@ export class GridlockDetector {
return event;
}
private clearGridlockState(): void {
if (this.lastGridlockKey !== null) {
this.lastGridlockKey = null;
this.onGridlockCleared?.();
}
}
private isMissionBlocked(task: Task): boolean {
if (!this.missionStore || !task.sliceId) return false;
try {

View File

@@ -13,6 +13,7 @@ export interface NtfyNotifierOptions {
export type NtfyNotificationPriority = "low" | "default" | "high" | "urgent";
const DEFAULT_NTFY_BASE_URL = "https://ntfy.sh";
const GRIDLOCK_NOTIFICATION_COOLDOWN_MS = 15 * 60 * 1000;
export const DEFAULT_NTFY_EVENTS: readonly NtfyNotificationEvent[] = [
"in-review",
@@ -176,8 +177,8 @@ export class NtfyNotifier {
private ntfyBaseUrl: string;
private readonly defaultNtfyBaseUrl: string;
private readonly projectId?: string;
private notifiedEvents: Set<string> = new Set();
private abortController: AbortController | null = null;
private lastGridlockNotificationAt: number | null = null;
constructor(
private store: NtfyNotifierStore,
@@ -230,10 +231,23 @@ export class NtfyNotifier {
this.ntfyBaseUrl = resolveNtfyBaseUrl(settings.ntfyBaseUrl, this.defaultNtfyBaseUrl);
}
notifyGridlock(event: GridlockEvent): void {
notifyGridlock(event: GridlockEvent | null): void {
if (event === null) {
this.lastGridlockNotificationAt = null;
return;
}
if (!this.config.enabled || !this.config.topic || !this.isEventEnabled("gridlock")) return;
const blockedTasks = event.blockedTaskIds.sort();
const now = Date.now();
if (
this.lastGridlockNotificationAt !== null
&& now - this.lastGridlockNotificationAt < GRIDLOCK_NOTIFICATION_COOLDOWN_MS
) {
return;
}
const blockedTasks = [...event.blockedTaskIds].sort();
const reasonSummary = Object.values(event.reasons).reduce((acc, reason) => {
acc[reason] = (acc[reason] ?? 0) + 1;
return acc;
@@ -248,35 +262,24 @@ export class NtfyNotifier {
projectId: this.projectId,
});
const dedupKey = `gridlock:${blockedTasks.join(",")}`;
this.maybeNotifyByKey(dedupKey, () =>
sendNtfyNotification({
ntfyBaseUrl: this.ntfyBaseUrl,
topic: this.config.topic!,
title: "Pipeline gridlocked",
message: `${event.blockedTaskCount} todo tasks are blocked (${reasons.join(", ")}). Blocked: ${blockedTasks.join(", ")}. Blocking: ${event.blockingTaskIds.join(", ") || "none"}.`,
priority: "high",
clickUrl,
signal: this.abortController?.signal,
}),
);
this.lastGridlockNotificationAt = now;
sendNtfyNotification({
ntfyBaseUrl: this.ntfyBaseUrl,
topic: this.config.topic!,
title: "Pipeline gridlocked",
message: `${event.blockedTaskCount} todo tasks are blocked (${reasons.join(", ")}). Blocked: ${blockedTasks.join(", ")}. Blocking: ${event.blockingTaskIds.join(", ") || "none"}.`,
priority: "high",
clickUrl,
signal: this.abortController?.signal,
}).catch(() => {
// sendNtfyNotification already logs; notifier must stay best-effort
});
}
private isEventEnabled(event: AnyNotificationEvent): boolean {
return isNtfyEventEnabled(this.config.events, event);
}
private maybeNotifyByKey(key: string, notifyFn: () => Promise<void>): void {
if (this.notifiedEvents.has(key)) {
return;
}
this.notifiedEvents.add(key);
notifyFn().catch(() => {
// sendNtfyNotification already logs; notifier must stay best-effort
});
}
getConfig(): NtfyConfig {
return { ...this.config, events: [...this.config.events] };
}

View File

@@ -282,6 +282,7 @@ export class ProjectEngine {
this.gridlockDetector = new GridlockDetector(store, {
onGridlock: (event) => this.notifier?.notifyGridlock(event),
onGridlockCleared: () => this.notifier?.notifyGridlock(null),
});
this.gridlockDetector.start();