feat(FN-5284): surface db corruption via health checks, self-healing, and d

- fix(FN-5284): add db corruption audit mutation type
- feat(FN-5284): complete Step 7 — document corruption surfacing
- feat(FN-5284): complete Step 6 — add db corruption banner
- feat(FN-5284): complete Step 5 — extend health payload
- feat(FN-5284): complete Step 3 — surface db corruption in self-healing
- feat(FN-5284): complete Step 2 — add db corruption notification event
- feat(FN-5284): complete Step 1 — expose integrity check errors

Fusion-Task-Id: FN-5284
This commit is contained in:
Fusion (runfusion.ai)
2026-05-20 04:13:43 -07:00
committed by gsxdsm
parent 12a6ee9af1
commit 1b49cbc94d
21 changed files with 712 additions and 11 deletions

View File

@@ -0,0 +1,260 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { EventEmitter } from "node:events";
import type { Settings, TaskStore } from "@fusion/core";
import { SelfHealingManager } from "../self-healing.js";
import type { NotificationService } from "../notification/notification-service.js";
import * as notifierModule from "../notifier.js";
function createMockStore(overrides: Record<string, unknown> = {}): TaskStore & EventEmitter {
const emitter = new EventEmitter();
return Object.assign(emitter, {
getSettings: vi.fn().mockResolvedValue({
maintenanceIntervalMs: 0,
globalPause: false,
enginePaused: false,
ntfyEnabled: true,
ntfyTopic: "fusion-alerts",
ntfyEvents: ["db-corruption-detected"],
} as unknown as Settings),
getDatabaseHealth: vi.fn().mockReturnValue({
healthy: true,
corruptionDetected: false,
corruptionErrors: [],
lastCheckedAt: null,
isRunning: false,
}),
recordRunAuditEvent: vi.fn().mockResolvedValue(undefined),
...overrides,
}) as unknown as TaskStore & EventEmitter;
}
const BATCH1_METHODS = [
"pruneWorktrees",
"cleanupOrphans",
"cleanupOrphanedBranches",
"enforceWorktreeCap",
] as const;
const BATCH2_METHODS = [
"recoverCompletedTasks",
"recoverStrandedCompletedTodoTasks",
"recoverStaleIncompleteReviewTasks",
"recoverReviewTasksWithFailedPreMergeSteps",
"recoverInterruptedMergingTasks",
"recoverDoneTaskMergeMetadata",
"recoverStaleMergingStatus",
"finalizeNoOpReviewTasks",
"reconcileDoneTaskIntegrity",
"reconcileStaleMergerStatus",
"recoverMergeableReviewTasks",
"recoverMergedReviewTasks",
"recoverAlreadyMergedReviewTasks",
"recoverCompletionHandoffLimbo",
"recoverBranchMisboundInReviewTasks",
"recoverForeignOnlyContaminatedInReviewTasks",
"recoverOrphanOnlyScopeViolations",
"recoverStuckMergeDeadlocks",
"recoverMisclassifiedFailures",
"recoverMissingWorktreeReviewFailures",
"recoverNoProgressNoTaskDoneFailures",
"recoverPartialProgressNoTaskDoneFailures",
"recoverOrphanedExecutions",
"recoverApprovedTriageTasks",
"recoverStarvedRefinementTriageTasks",
"recoverOrphanedPlanningTasks",
"recoverGhostReviewTasks",
"recoverOrphanedAgents",
"recoverStaleHeartbeatRuns",
"recoverAgentsRunningOnInactiveTasks",
"recoverDriftedAgentTaskLinks",
"clearStaleBlockedBy",
"autoReboundPausedScopeDecay",
"autoArchiveResolvedMetaTasks",
"autoArchiveStalledMetaTasks",
"runBoardStallAutoRecoverySweep",
"reconcileSelfDefeatingDependencies",
"reclaimPrConflicts",
"reclaimSelfOwnedBranchConflicts",
"reconcileTaskWorktreeMetadata",
"reconcileInReviewBranchRebind",
"reclaimStaleActiveBranches",
"surfaceInReviewStalls",
"surfaceInReviewStalled",
"surfaceStalePausedReviews",
"surfaceStalePausedTodos",
"auditNoCommitsExpectedCandidates",
] as const;
function stubMaintenance(manager: SelfHealingManager) {
for (const method of BATCH1_METHODS) {
vi.spyOn(manager as never, method).mockResolvedValue(0 as never);
}
vi.spyOn(manager as never, "checkpointWal").mockReturnValue(undefined as never);
for (const method of BATCH2_METHODS) {
vi.spyOn(manager as never, method).mockResolvedValue(0 as never);
}
vi.spyOn(manager, "archiveStaleDoneTasks").mockResolvedValue(0);
}
describe("FN-5284: self-healing DB corruption surfacing", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-20T00:00:00.000Z"));
vi.spyOn(notifierModule, "getActiveNotificationService").mockReturnValue(undefined);
vi.spyOn(notifierModule, "sendNtfyNotification").mockResolvedValue(undefined);
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
it("does not dispatch or audit when the database is healthy", async () => {
const store = createMockStore();
const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
stubMaintenance(manager);
await (manager as any).runMaintenance();
expect(notifierModule.getActiveNotificationService).not.toHaveBeenCalled();
expect(notifierModule.sendNtfyNotification).not.toHaveBeenCalled();
expect(store.recordRunAuditEvent).not.toHaveBeenCalled();
});
it("dispatches a notification and records an audit event on first corruption detection", async () => {
const dispatch = vi.fn().mockResolvedValue(undefined);
vi.spyOn(notifierModule, "getActiveNotificationService").mockReturnValue({ dispatch } as unknown as NotificationService);
const store = createMockStore({
getDatabaseHealth: vi.fn().mockReturnValue({
healthy: false,
corruptionDetected: true,
corruptionErrors: ["bad row", "bad index"],
lastCheckedAt: new Date("2026-05-20T00:05:00.000Z"),
isRunning: false,
}),
});
const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
stubMaintenance(manager);
await (manager as any).runMaintenance();
expect(dispatch).toHaveBeenCalledWith("db-corruption-detected", {
event: "db-corruption-detected",
timestamp: "2026-05-20T00:00:00.000Z",
metadata: {
errors: ["bad row", "bad index"],
lastCheckedAt: "2026-05-20T00:05:00.000Z",
},
});
expect(store.recordRunAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
domain: "database",
mutationType: "task:auto-db-corruption-detected",
target: "database",
metadata: expect.objectContaining({
errors: ["bad row", "bad index"],
notificationDispatched: true,
}),
}),
);
});
it("respects the cooldown and avoids duplicate dispatches and audits", async () => {
const dispatch = vi.fn().mockResolvedValue(undefined);
vi.spyOn(notifierModule, "getActiveNotificationService").mockReturnValue({ dispatch } as unknown as NotificationService);
const store = createMockStore({
getDatabaseHealth: vi.fn().mockReturnValue({
healthy: false,
corruptionDetected: true,
corruptionErrors: ["bad row"],
lastCheckedAt: new Date("2026-05-20T00:05:00.000Z"),
isRunning: false,
}),
});
const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
stubMaintenance(manager);
await (manager as any).runMaintenance();
await (manager as any).runMaintenance();
expect(dispatch).toHaveBeenCalledTimes(1);
expect(store.recordRunAuditEvent).toHaveBeenCalledTimes(1);
});
it("re-notifies after corruption clears and is detected again", async () => {
const dispatch = vi.fn().mockResolvedValue(undefined);
vi.spyOn(notifierModule, "getActiveNotificationService").mockReturnValue({ dispatch } as unknown as NotificationService);
const getDatabaseHealth = vi.fn()
.mockReturnValueOnce({
healthy: false,
corruptionDetected: true,
corruptionErrors: ["first error"],
lastCheckedAt: new Date("2026-05-20T00:05:00.000Z"),
isRunning: false,
})
.mockReturnValueOnce({
healthy: true,
corruptionDetected: false,
corruptionErrors: [],
lastCheckedAt: new Date("2026-05-20T00:15:00.000Z"),
isRunning: false,
})
.mockReturnValueOnce({
healthy: false,
corruptionDetected: true,
corruptionErrors: ["second error"],
lastCheckedAt: new Date("2026-05-20T00:25:00.000Z"),
isRunning: false,
});
const store = createMockStore({ getDatabaseHealth });
const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
stubMaintenance(manager);
await (manager as any).runMaintenance();
await (manager as any).runMaintenance();
await (manager as any).runMaintenance();
expect(dispatch).toHaveBeenNthCalledWith(1, "db-corruption-detected", expect.objectContaining({
metadata: expect.objectContaining({ errors: ["first error"] }),
}));
expect(dispatch).toHaveBeenNthCalledWith(2, "db-corruption-detected", expect.objectContaining({
metadata: expect.objectContaining({ errors: ["second error"] }),
}));
expect(dispatch).toHaveBeenCalledTimes(2);
expect(store.recordRunAuditEvent).toHaveBeenCalledTimes(2);
});
it("records an audit even when no notification channel is active", async () => {
const store = createMockStore({
getSettings: vi.fn().mockResolvedValue({
maintenanceIntervalMs: 0,
globalPause: false,
enginePaused: false,
ntfyEnabled: false,
ntfyTopic: "fusion-alerts",
ntfyEvents: ["db-corruption-detected"],
} as unknown as Settings),
getDatabaseHealth: vi.fn().mockReturnValue({
healthy: false,
corruptionDetected: true,
corruptionErrors: ["bad row"],
lastCheckedAt: new Date("2026-05-20T00:05:00.000Z"),
isRunning: false,
}),
});
const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
stubMaintenance(manager);
await (manager as any).runMaintenance();
expect(notifierModule.sendNtfyNotification).not.toHaveBeenCalled();
expect(store.recordRunAuditEvent).toHaveBeenCalledWith(
expect.objectContaining({
mutationType: "task:auto-db-corruption-detected",
metadata: expect.objectContaining({ notificationDispatched: false }),
}),
);
});
});

View File

@@ -29,6 +29,7 @@ export const DEFAULT_NTFY_EVENTS: readonly NtfyNotificationEvent[] = [
"planning-awaiting-input",
"gridlock",
"board-stall-unrecovered",
"db-corruption-detected",
"fallback-used",
"token-budget",
"message:agent-to-user",

View File

@@ -254,6 +254,8 @@ export type DatabaseMutationType =
| "task:auto-board-stall-broken"
/** Metadata: { holderIds: string[], followerCount: number, windowMs: number, ntfyDispatched: boolean } */
| "task:auto-board-stall-unrecovered"
/** Metadata: { errors: string[], lastCheckedAt: string | null, notificationDispatched: boolean } */
| "task:auto-db-corruption-detected"
| "task:in-review-stall-deadlock-disposed"
| "task:finalize-unproven-blocked"
| "task:integrity-reconcile-modified-files"

View File

@@ -30,7 +30,7 @@ import { existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } f
import { isAbsolute, join, relative, resolve } from "node:path";
import { IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX, IN_REVIEW_STALL_LOG_PREFIX, countRecentIdenticalStallEntries, detectSelfDefeatingDependency, getInReviewStalledSignal, getInReviewStallReason, getPrimaryPrInfo, getStalePausedReviewSignal, getStalePausedTodoSignal, getTaskHardMergeBlocker, getTaskMergeBlocker, isEphemeralAgent, type AgentStore, type ChatStore, type MessageStore, type TaskStore, type Settings, type Task, type MergeDetails, type TaskPriority, type MergeResult } from "@fusion/core";
import type { MeshLeaseManager } from "./mesh-lease-manager.js";
import { createLogger } from "./logger.js";
import { createLogger, schedulerLog } from "./logger.js";
import { RemovalReason, getRegisteredWorktreeBranchMap, getRegisteredWorktreePaths, isUsableTaskWorktree, removeWorktree, resolveWorktreeBackend, scanIdleWorktrees, scanOrphanedBranches } from "./worktree-pool.js";
import {
classifyMissingWorktreeSessionStartFailure,
@@ -51,6 +51,7 @@ import type { OwnedLandedClassification } from "./merger.js";
import { recoverForeignOnlyContamination } from "./recovery/foreign-only-contamination.js";
import {
buildNtfyClickUrl,
getActiveNotificationService,
isNtfyEventEnabled,
resolveNtfyEvents,
sendNtfyNotification,
@@ -64,6 +65,7 @@ const worktreeMetadataReconcileLog = createLogger("worktree-metadata-reconcile")
const execAsync = promisify(exec);
const DONE_TASK_INTEGRITY_SWEEP_LIMIT = 50;
const BOARD_STALL_NOTIFICATION_COOLDOWN_MS = 60 * 60_000;
const DB_CORRUPTION_NOTIFICATION_COOLDOWN_MS = 60 * 60 * 1000;
export const STALE_ACTIVE_BRANCH_EXECUTION_GRACE_MS = 10 * 60_000;
export const COMPLETION_HANDOFF_LIMBO_GRACE_MS = 5 * 60_000;
export const MAX_COMPLETION_HANDOFF_LIMBO_RECOVERIES = 3;
@@ -534,6 +536,7 @@ export class SelfHealingManager {
private maintenanceTickCounter = 0;
private readonly processBootStartedAt = Date.now();
private dependencyBlockedTodoReporter: DependencyBlockedTodoReporter | null = null;
private lastDbCorruptionNotifiedAt: number | null = null;
private boardStallWindow: {
windowStartMs: number;
@@ -1317,6 +1320,7 @@ export class SelfHealingManager {
{ name: "surface-in-review-stalled", fn: () => this.surfaceInReviewStalled() },
{ name: "surface-stale-paused-reviews", fn: () => this.surfaceStalePausedReviews() },
{ name: "surface-stale-paused-todos", fn: () => this.surfaceStalePausedTodos() },
{ name: "surface-db-corruption", fn: () => this.surfaceDbCorruption() },
{ name: "audit-no-commits-expected-candidates", fn: () => this.auditNoCommitsExpectedCandidates() },
];
for (const fn of batch2Fns) {
@@ -3199,6 +3203,78 @@ export class SelfHealingManager {
return { holders: [], recovered: 0, unrecovered: false };
}
private async surfaceDbCorruption(): Promise<void> {
const health = this.store.getDatabaseHealth();
if (!health.corruptionDetected) {
this.lastDbCorruptionNotifiedAt = null;
return;
}
const now = Date.now();
if (
this.lastDbCorruptionNotifiedAt !== null
&& now - this.lastDbCorruptionNotifiedAt < DB_CORRUPTION_NOTIFICATION_COOLDOWN_MS
) {
return;
}
const settings = await this.store.getSettings();
const errors = health.corruptionErrors.slice(0, 5);
let notificationDispatched = false;
try {
const notificationService = getActiveNotificationService();
if (notificationService) {
await notificationService.dispatch("db-corruption-detected", {
event: "db-corruption-detected",
timestamp: new Date().toISOString(),
metadata: {
errors,
lastCheckedAt: health.lastCheckedAt?.toISOString() ?? null,
},
});
notificationDispatched = true;
} else {
const enabled = Boolean(settings.ntfyEnabled && settings.ntfyTopic);
const events = resolveNtfyEvents(settings.ntfyEvents);
if (enabled && isNtfyEventEnabled(events, "db-corruption-detected")) {
const clickUrl = buildNtfyClickUrl({ dashboardHost: settings.ntfyDashboardHost });
await sendNtfyNotification({
ntfyBaseUrl: settings.ntfyBaseUrl,
ntfyAccessToken: settings.ntfyAccessToken,
topic: settings.ntfyTopic!,
title: "Database corruption detected",
message: `Background SQLite integrity check detected corruption. Errors: ${errors.join(" | ") || "unknown"}.`,
priority: "urgent",
clickUrl,
});
notificationDispatched = true;
}
}
} catch (error: unknown) {
schedulerLog.log(
`Failed to dispatch db-corruption-detected notification: ${error instanceof Error ? error.message : String(error)}`,
);
}
const auditor = createRunAuditor(this.store, {
runId: generateSyntheticRunId("fn5284-db-corruption", "global"),
agentId: "self-healing",
phase: "db-corruption-detected",
});
await auditor.database({
type: "task:auto-db-corruption-detected",
target: "database",
metadata: {
errors,
lastCheckedAt: health.lastCheckedAt?.toISOString() ?? null,
notificationDispatched,
},
});
this.lastDbCorruptionNotifiedAt = now;
}
async clearStaleBlockedBy(): Promise<number> {
try {
const settings = await this.store.getSettings();