test(FN-4307): add recovery-notification integration coverage

Fusion-Task-Id: FN-4307
Fusion-Task-Lineage: 00140a5a-af93-4905-8570-32d7ecb8a874
This commit is contained in:
Fusion
2026-05-13 15:50:09 -07:00
committed by gsxdsm
parent 425926b0b4
commit cd7779c3e0
3 changed files with 83 additions and 1 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Suppress transient auto-merge failure surfacing: `failed` notifications now wait through a grace window and are dropped when self-healing confirms recovery, while persistent failures still notify. Non-conflict auto-merge failures are now logged to task history instead of persisting a hard-failure user comment.

View File

@@ -649,6 +649,7 @@ Guardrails: this routine does **not** retry merges, does **not** apply to mixed/
- 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), and the cooldown resets as soon as gridlock fully clears.
- `NotificationService` (`notification/notification-service.ts`) — provider lifecycle + event dispatch orchestration - `NotificationService` (`notification/notification-service.ts`) — provider lifecycle + event dispatch orchestration
- Subscribes to task lifecycle events plus mailbox and memory events. `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. - Subscribes to task lifecycle events plus mailbox and memory events. `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.
- `NotificationProvider` interface (`@fusion/core` `notification/provider.ts`) — pluggable provider contract - `NotificationProvider` interface (`@fusion/core` `notification/provider.ts`) — pluggable provider contract
- Built-in providers: `NtfyNotificationProvider` (`notification/ntfy-provider.ts`), `WebhookNotificationProvider` (`notification/webhook-provider.ts`) - Built-in providers: `NtfyNotificationProvider` (`notification/ntfy-provider.ts`), `WebhookNotificationProvider` (`notification/webhook-provider.ts`)
- `AgentReflection` (`agent-reflection.ts`) — reflection extraction and persistence - `AgentReflection` (`agent-reflection.ts`) — reflection extraction and persistence

View File

@@ -66,15 +66,17 @@ const { selfHealingLoggerMock } = vi.hoisted(() => ({
vi.mock("../logger.js", () => ({ vi.mock("../logger.js", () => ({
createLogger: vi.fn((_name: string) => selfHealingLoggerMock), createLogger: vi.fn((_name: string) => selfHealingLoggerMock),
schedulerLog: { log: vi.fn(), warn: vi.fn(), error: vi.fn() },
})); }));
import { SelfHealingManager, isBranchAheadOfBase } from "../self-healing.js"; import { SelfHealingManager, isBranchAheadOfBase } from "../self-healing.js";
import type { TaskStore, Settings, Task, AgentStore, Agent } from "@fusion/core"; import type { TaskStore, Settings, Task, AgentStore, Agent, NotificationProvider } from "@fusion/core";
import { EventEmitter } from "node:events"; import { EventEmitter } from "node:events";
import { execSync } from "node:child_process"; import { execSync } from "node:child_process";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { scanOrphanedBranches } from "../worktree-pool.js"; import { scanOrphanedBranches } from "../worktree-pool.js";
import { createLogger } from "../logger.js"; import { createLogger } from "../logger.js";
import { NotificationService } from "../notification/notification-service.js";
const mockedExecSync = vi.mocked(execSync); const mockedExecSync = vi.mocked(execSync);
const mockedExistsSync = vi.mocked(existsSync); const mockedExistsSync = vi.mocked(existsSync);
@@ -3549,6 +3551,80 @@ describe("SelfHealingManager", () => {
managerWithRecovery.stop(); managerWithRecovery.stop();
}); });
it("suppresses transient failed notification when already-merged sweep recovers to done", async () => {
const now = new Date().toISOString();
const tasks = new Map<string, Task>([["FN-1", { id: "FN-1", column: "in-review", paused: false, status: "failed", mergeRetries: 3, mergeDetails: undefined, baseBranch: "main", branch: "fusion/fn-1", worktree: "/tmp/wt", dependencies: [], steps: [], currentStep: 0, description: "x", log: [], createdAt: now, updatedAt: now } as Task]]);
const eventedStore = createMockStore({
getSettings: vi.fn().mockResolvedValue({ globalPause: false, enginePaused: false, ntfyEnabled: true, ntfyTopic: "topic", failureNotificationMode: "sticky-only", failureNotificationDelayMs: 50 }),
listTasks: vi.fn().mockImplementation(async () => Array.from(tasks.values())),
getTask: vi.fn().mockImplementation(async (id: string) => tasks.get(id)),
});
(eventedStore.updateTask as ReturnType<typeof vi.fn>).mockImplementation(async (id: string, patch: Partial<Task>) => {
const next = { ...(tasks.get(id) as Task), ...patch } as Task;
tasks.set(id, next);
(eventedStore as unknown as EventEmitter).emit("task:updated", next);
return next;
});
(eventedStore.moveTask as ReturnType<typeof vi.fn>).mockImplementation(async (id: string, to: any) => {
const current = tasks.get(id) as Task;
const next = { ...current, column: to } as Task;
tasks.set(id, next);
(eventedStore as unknown as EventEmitter).emit("task:moved", { task: next, from: current.column, to });
});
const managerWithRecovery = new SelfHealingManager(eventedStore, { rootDir: "/tmp/test-project" });
const sendNotification = vi.fn(async () => ({ success: true, providerId: "mock" }));
const provider: NotificationProvider = { getProviderId: () => "mock", isEventSupported: () => true, sendNotification };
const notificationService = new NotificationService(eventedStore as any);
notificationService.registerProvider(provider);
await notificationService.start();
(eventedStore.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([tasks.get("FN-1")]);
mockedExecSync.mockImplementation((command: string | Buffer) => {
if (String(command).includes("Fusion-Task-Id: FN-1")) return "abc123\n" as any;
return "tip\n" as any;
});
mockedExistsSync.mockReturnValue(false);
(eventedStore as unknown as EventEmitter).emit("task:updated", tasks.get("FN-1"));
await managerWithRecovery.recoverAlreadyMergedReviewTasks();
await vi.advanceTimersByTimeAsync(60);
expect(sendNotification).not.toHaveBeenCalledWith("failed", expect.anything());
expect(tasks.get("FN-1")?.column).toBe("done");
expect(tasks.get("FN-1")?.mergeDetails?.mergeConfirmed).toBe(true);
await notificationService.stop();
managerWithRecovery.stop();
});
it("keeps failed notification when already-merged sweep finds no landed commit", async () => {
const now = new Date().toISOString();
const tasks = new Map<string, Task>([["FN-1", { id: "FN-1", column: "in-review", paused: false, status: "failed", mergeRetries: 3, mergeDetails: undefined, baseBranch: "main", branch: "fusion/fn-1", worktree: "/tmp/wt", dependencies: [], steps: [], currentStep: 0, description: "x", log: [], createdAt: now, updatedAt: now } as Task]]);
const eventedStore = createMockStore({
getSettings: vi.fn().mockResolvedValue({ globalPause: false, enginePaused: false, ntfyEnabled: true, ntfyTopic: "topic", failureNotificationMode: "sticky-only", failureNotificationDelayMs: 50 }),
listTasks: vi.fn().mockImplementation(async () => Array.from(tasks.values())),
getTask: vi.fn().mockImplementation(async (id: string) => tasks.get(id)),
});
const managerWithRecovery = new SelfHealingManager(eventedStore, { rootDir: "/tmp/test-project" });
const sendNotification = vi.fn(async () => ({ success: true, providerId: "mock" }));
const provider: NotificationProvider = { getProviderId: () => "mock", isEventSupported: () => true, sendNotification };
const notificationService = new NotificationService(eventedStore as any);
notificationService.registerProvider(provider);
await notificationService.start();
mockedExecSync.mockImplementation(() => {
throw new Error("missing branch");
});
(eventedStore as unknown as EventEmitter).emit("task:updated", tasks.get("FN-1"));
await managerWithRecovery.recoverAlreadyMergedReviewTasks();
await vi.advanceTimersByTimeAsync(60);
expect(sendNotification).toHaveBeenCalledWith("failed", expect.objectContaining({ taskId: "FN-1" }));
await notificationService.stop();
managerWithRecovery.stop();
});
it("isolates per-task failures and still recovers later candidates", async () => { it("isolates per-task failures and still recovers later candidates", async () => {
const managerWithRecovery = new SelfHealingManager(store, { rootDir: "/tmp/test-project" }); const managerWithRecovery = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
(store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ globalPause: false, enginePaused: false }); (store.getSettings as ReturnType<typeof vi.fn>).mockResolvedValue({ globalPause: false, enginePaused: false });