From 18115553d663130393a4062a2f9c121f08676384 Mon Sep 17 00:00:00 2001 From: Fusion Date: Fri, 15 May 2026 09:29:23 -0700 Subject: [PATCH] =?UTF-8?q?feat(FN-4608):=20complete=20Step=201=20?= =?UTF-8?q?=E2=80=94=20persist=20reliability=20reset=20baseline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fusion-Task-Id: FN-4608 Fusion-Task-Lineage: 6cabc509-38a1-4cb8-be6f-51c53d5753ff --- packages/core/src/types.ts | 4 ++ .../dashboard/src/__tests__/server.test.ts | 42 +++++++++++++++ packages/dashboard/src/server.ts | 54 ++++++++++++++----- 3 files changed, 86 insertions(+), 14 deletions(-) diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 20878b921..41eaa2223 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -2539,6 +2539,10 @@ export interface ProjectSettings { mergeAuditAutoRecovery?: MergeAuditAutoRecoveryMode; /** Dispatcher-level reliability recovery policy (FN-4533/FN-4534). */ autoRecovery?: AutoRecoverySettings; + /** Optional ISO-8601 timestamp baseline for reliability metrics. + * When set, reliability windows are floored at this instant so historical + * events before the reset are excluded from aggregates (but not deleted). */ + reliabilityStatsResetAt?: string; /** Wall-clock timeout (ms) for a single pre-merge workflow step's AI call. * When a step exceeds this, the session is aborted and the executor is * given one shot to retry with the configured fallback model before the diff --git a/packages/dashboard/src/__tests__/server.test.ts b/packages/dashboard/src/__tests__/server.test.ts index 999b87f57..982208a03 100644 --- a/packages/dashboard/src/__tests__/server.test.ts +++ b/packages/dashboard/src/__tests__/server.test.ts @@ -515,8 +515,10 @@ describe("createServer health and headless mode", () => { expect(res.body).toMatchObject({ windowDays: 7, generatedAt: expect.any(String), + resetAt: null, headline: { inReviewFailureRate7d: 1 }, perDay: expect.any(Array), + perDayNonEmpty: expect.any(Array), duration: { p50Ms: null, p95Ms: null, sampleCount: 0, reason: "insufficient-samples" }, mergeAttempts: { mean: 1, max: 1, histogram: { "1": 1 } }, }); @@ -591,6 +593,46 @@ describe("createServer health and headless mode", () => { vi.useRealTimers(); }); + it("returns resetAt and floors reliability windows to reset baseline", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-05-13T12:00:00.000Z")); + + const store = createMockStore({ + getSettings: vi.fn().mockResolvedValue({ reliabilityStatsResetAt: "2026-05-12T00:00:00.000Z" }), + getActivityLog: vi.fn().mockResolvedValue([]), + getRunAuditEvents: vi.fn().mockReturnValue([]), + }); + const app = createServer(store); + + const res = await GET(app, "/api/health/reliability?windowDays=7"); + + expect(res.status).toBe(200); + expect(res.body.resetAt).toBe("2026-05-12T00:00:00.000Z"); + expect(store.getActivityLog).toHaveBeenCalledWith(expect.objectContaining({ since: "2026-05-12T00:00:00.000Z" })); + expect(store.getRunAuditEvents).toHaveBeenCalledWith( + expect.objectContaining({ startTime: "2026-05-12T00:00:00.000Z", endTime: "2026-05-13T12:00:00.000Z" }), + ); + + vi.useRealTimers(); + }); + + it("sets reliability reset baseline via /api/health/reliability/reset", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-05-13T12:00:00.000Z")); + + const updateSettings = vi.fn().mockResolvedValue({}); + const store = createMockStore({ updateSettings }); + const app = createServer(store); + + const res = await REQUEST(app, "POST", "/api/health/reliability/reset"); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ resetAt: "2026-05-13T12:00:00.000Z" }); + expect(updateSettings).toHaveBeenCalledWith({ reliabilityStatsResetAt: "2026-05-13T12:00:00.000Z" }); + + vi.useRealTimers(); + }); + it("rejects invalid windowDays values", async () => { const app = createServer(createMockStore({ getActivityLog: vi.fn().mockResolvedValue([]), diff --git a/packages/dashboard/src/server.ts b/packages/dashboard/src/server.ts index 153123383..b31ba41a5 100644 --- a/packages/dashboard/src/server.ts +++ b/packages/dashboard/src/server.ts @@ -1121,9 +1121,14 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT return; } + const settings = await store.getSettings(); + const resetAt = typeof settings.reliabilityStatsResetAt === "string" ? settings.reliabilityStatsResetAt : null; + const nowMs = Date.now(); const windowStartMs = nowMs - parsedWindowDays * 86_400_000; - const startIso = new Date(windowStartMs).toISOString(); + const resetAtMs = resetAt ? Date.parse(resetAt) : Number.NaN; + const effectiveStartMs = Number.isFinite(resetAtMs) ? Math.max(windowStartMs, resetAtMs) : windowStartMs; + const startIso = new Date(effectiveStartMs).toISOString(); const endIso = new Date(nowMs).toISOString(); const [runAuditEvents, activityLog] = await Promise.all([ @@ -1131,13 +1136,13 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT store.getActivityLog({ since: startIso, limit: 50_000 }), ]); - const enteredByDay = tasksEnteredInReviewPerDay(activityLog, windowStartMs, nowMs); - const bouncedByDay = tasksBouncedToInProgressPerDay(activityLog, windowStartMs, nowMs); - const postMergeByDay = postMergeAuditFailuresPerDay(runAuditEvents, windowStartMs, nowMs); - const fileScopeByDay = fileScopeInvariantFailuresPerDay(runAuditEvents, windowStartMs, nowMs); - const recoveriesByDay = recoverAlreadyMergedReviewTasksRecoveriesPerDay(runAuditEvents, windowStartMs, nowMs); - const duration = inReviewDurationMetrics(activityLog, windowStartMs, nowMs); - const mergeAttempts = mergeAttemptsPerMergedTask(runAuditEvents, activityLog, windowStartMs, nowMs); + const enteredByDay = tasksEnteredInReviewPerDay(activityLog, effectiveStartMs, nowMs); + const bouncedByDay = tasksBouncedToInProgressPerDay(activityLog, effectiveStartMs, nowMs); + const postMergeByDay = postMergeAuditFailuresPerDay(runAuditEvents, effectiveStartMs, nowMs); + const fileScopeByDay = fileScopeInvariantFailuresPerDay(runAuditEvents, effectiveStartMs, nowMs); + const recoveriesByDay = recoverAlreadyMergedReviewTasksRecoveriesPerDay(runAuditEvents, effectiveStartMs, nowMs); + const duration = inReviewDurationMetrics(activityLog, effectiveStartMs, nowMs); + const mergeAttempts = mergeAttemptsPerMergedTask(runAuditEvents, activityLog, effectiveStartMs, nowMs); const headline = inReviewFailureRate7d(enteredByDay, bouncedByDay, nowMs); const perDay: Array<{ @@ -1147,22 +1152,35 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT postMergeAuditFailures: { block: number; warn: number; off: number } | null; fileScopeInvariantFailures: number | null; recoverAlreadyMergedReviewTasksRecoveries: number | null; + hasSamples: boolean; }> = []; - const dayCursor = new Date(startIso); + const dayCursor = new Date(effectiveStartMs); const dayEnd = new Date(endIso); dayCursor.setUTCHours(0, 0, 0, 0); dayEnd.setUTCHours(0, 0, 0, 0); while (dayCursor.getTime() <= dayEnd.getTime()) { const day = dayCursor.toISOString().slice(0, 10); + const tasksEnteredInReview = enteredByDay[day] ?? 0; + const tasksBouncedToInProgress = bouncedByDay[day] ?? 0; + const postMergeAuditFailures = postMergeByDay.value ? (postMergeByDay.value[day] ?? { block: 0, warn: 0, off: 0 }) : null; + const fileScopeInvariantFailures = fileScopeByDay.value ? (fileScopeByDay.value[day] ?? 0) : null; + const recoverAlreadyMergedReviewTasksRecoveries = recoveriesByDay.value ? (recoveriesByDay.value[day] ?? 0) : null; + const hasSamples = tasksEnteredInReview > 0 + || tasksBouncedToInProgress > 0 + || (postMergeAuditFailures ? postMergeAuditFailures.block + postMergeAuditFailures.warn + postMergeAuditFailures.off > 0 : false) + || (typeof fileScopeInvariantFailures === "number" && fileScopeInvariantFailures > 0) + || (typeof recoverAlreadyMergedReviewTasksRecoveries === "number" && recoverAlreadyMergedReviewTasksRecoveries > 0); + perDay.push({ date: day, - tasksEnteredInReview: enteredByDay[day] ?? 0, - tasksBouncedToInProgress: bouncedByDay[day] ?? 0, - postMergeAuditFailures: postMergeByDay.value ? (postMergeByDay.value[day] ?? { block: 0, warn: 0, off: 0 }) : null, - fileScopeInvariantFailures: fileScopeByDay.value ? (fileScopeByDay.value[day] ?? 0) : null, - recoverAlreadyMergedReviewTasksRecoveries: recoveriesByDay.value ? (recoveriesByDay.value[day] ?? 0) : null, + tasksEnteredInReview, + tasksBouncedToInProgress, + postMergeAuditFailures, + fileScopeInvariantFailures, + recoverAlreadyMergedReviewTasksRecoveries, + hasSamples, }); dayCursor.setUTCDate(dayCursor.getUTCDate() + 1); } @@ -1170,11 +1188,13 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT res.json({ windowDays: parsedWindowDays, generatedAt: new Date(nowMs).toISOString(), + resetAt, headline: { inReviewFailureRate7d: headline.value, ...(headline.reason ? { reason: headline.reason } : {}), }, perDay, + perDayNonEmpty: perDay.filter((row) => row.hasSamples), duration: { p50Ms: duration.p50Ms, p95Ms: duration.p95Ms, @@ -1190,6 +1210,12 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT }); }); + app.post("/api/health/reliability/reset", async (_req, res) => { + const resetAt = new Date().toISOString(); + await store.updateSettings({ reliabilityStatsResetAt: resetAt }); + res.json({ resetAt }); + }); + app.post("/api/health/refresh", (_req, res) => { const report = store.refreshTaskIdIntegrityReport(); const database = store.getDatabaseHealth();