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:
committed by
gsxdsm
parent
12a6ee9af1
commit
1b49cbc94d
260
packages/engine/src/__tests__/self-healing-db-corruption.test.ts
Normal file
260
packages/engine/src/__tests__/self-healing-db-corruption.test.ts
Normal 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 }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user