diff --git a/.changeset/fix-gridlock-notification-cooldown.md b/.changeset/fix-gridlock-notification-cooldown.md new file mode 100644 index 0000000000..d139a8807e --- /dev/null +++ b/.changeset/fix-gridlock-notification-cooldown.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Prevent repeated gridlock alerts when detection briefly clears. +category: fix +dev: Preserves the wall-clock ntfy cooldown across transient gridlock detector clears. diff --git a/docs/architecture.md b/docs/architecture.md index f2f8f7345c..f97574c828 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -811,7 +811,7 @@ Guardrails: this routine does **not** retry merges, does **not** apply to mixed/ - Runtime ownership: `NtfyNotifier` no longer owns an independent task-lifecycle listener graph; `ProjectEngine` injects the canonical `NotificationService` instance so task lifecycle notifications (`task:created`, `task:moved`, `task:updated`, `task:merged`) are emitted through a single path. - Merge dedup safety: all merge-success → done code paths (direct merger completion, owned/no-op auto-finalize, mergeConfirmed fast-path, PR-strategy finalize, and merge-success self-healing finalizers) emit `store.emit("task:merged", result)` with a merged `MergeResult`. `NotificationService.notifiedEvents` remains the single dedup source of truth, so duplicate upstream emits still produce exactly one canonical `merged` ntfy lifecycle notification per task. - Compatibility scope: `NtfyNotifier` remains responsible for gridlock-only compatibility notifications (`notifyGridlock`) and legacy helper APIs. - - Legacy gridlock ntfy delivery is cooldown-throttled: first detection notifies immediately, subsequent detections are suppressed for 15 minutes (even if blocked-task membership changes), and the cooldown resets as soon as gridlock fully clears. + - Legacy gridlock ntfy delivery is cooldown-throttled: first detection notifies immediately, subsequent detections are suppressed for 15 minutes (even if blocked-task membership changes or detector state transiently clears), and a later detection may notify after the wall-clock cooldown expires. - `NotificationService` (`notification/notification-service.ts`) — provider lifecycle + event dispatch orchestration - Subscribes to task lifecycle events plus mailbox and memory events. `task:created` dispatches `task-created` only when `task.sourceAgentId` is present (agent-created tasks, including fn task-create calls made by agents). `message:sent` dispatches `message:agent-to-user` and `message:agent-to-agent` notification events (with message metadata for deep-links), and manual `POST /api/memory/dream` processing emits `store.emit("memory:dreams-processed", payload)` when new DREAMS content is written. - `failed` task notifications are deferred behind a grace window (default 60s) and suppressed when recovery signals arrive (`column=done`, `mergeDetails.mergeConfirmed=true`, or status clear with an `Auto-recovered:` log). Persistent failures still emit exactly once after the window. diff --git a/packages/engine/src/__tests__/notifier.test.ts b/packages/engine/src/__tests__/notifier.test.ts index 2f78d9117e..6bd04a2e03 100644 --- a/packages/engine/src/__tests__/notifier.test.ts +++ b/packages/engine/src/__tests__/notifier.test.ts @@ -663,30 +663,28 @@ describe("NtfyNotifier", () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); - it("allows a new gridlock notification immediately after resolution reset", async () => { + // FNXC:Notifications 2026-08-16-16:03: A transient clear must not re-arm the + // same gridlock notification while the wall-clock cooldown is still active. + it("suppresses the same gridlock after a transient resolution during cooldown", async () => { store.setSettings({ ntfyEnabled: true, ntfyTopic: "test-topic", ntfyEvents: ["gridlock"] }); fetchMock.mockResolvedValue({ ok: true }); notifier = new NtfyNotifier(store); await notifier.start(); - notifier.notifyGridlock({ + const event = { blockedTaskCount: 1, - reasons: { "FN-001": "dependency" }, + reasons: { "FN-001": "dependency" as const }, blockedTaskIds: ["FN-001"], blockingTaskIds: ["FN-002"], - }); + }; + notifier.notifyGridlock(event); vi.advanceTimersByTime(60_000); notifier.notifyGridlock(null); - notifier.notifyGridlock({ - blockedTaskCount: 1, - reasons: { "FN-009": "overlap" }, - blockedTaskIds: ["FN-009"], - blockingTaskIds: ["FN-010"], - }); + notifier.notifyGridlock(event); await flushAsyncWork(); - expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/engine/src/util/notifier.ts b/packages/engine/src/util/notifier.ts index 94e965d663..e90eb0cd6c 100644 --- a/packages/engine/src/util/notifier.ts +++ b/packages/engine/src/util/notifier.ts @@ -493,7 +493,11 @@ export class NtfyNotifier { notifyGridlock(event: GridlockEvent | null): void { if (event === null) { - this.lastGridlockNotificationAt = null; + /* + FNXC:Notifications 2026-08-16-16:03: + Detector state may flap clear between scheduler polls while the same underlying gridlock persists. + Keep the wall-clock cooldown so a transient clear cannot re-arm ntfy every minute. + */ return; }