From 353845a6b8873bde0800271055aa9e58088418a3 Mon Sep 17 00:00:00 2001 From: "Fusion (runfusion.ai)" Date: Mon, 18 May 2026 15:17:14 -0700 Subject: [PATCH] =?UTF-8?q?feat(FN-5034):=20complete=20Step=205=20?= =?UTF-8?q?=E2=80=94=20add=20stale=20paused=20todo=20self-healing=20surfac?= =?UTF-8?q?ing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fusion-Task-Id: FN-5034 Fusion-Task-Lineage: 1e315656-30d8-41d2-9802-0d9e155f0ba8 --- .../engine/src/__tests__/self-healing.test.ts | 92 +++++++++++++++++++ packages/engine/src/self-healing.ts | 48 ++++++++++ 2 files changed, 140 insertions(+) diff --git a/packages/engine/src/__tests__/self-healing.test.ts b/packages/engine/src/__tests__/self-healing.test.ts index fb97ab93f..cb6c01f80 100644 --- a/packages/engine/src/__tests__/self-healing.test.ts +++ b/packages/engine/src/__tests__/self-healing.test.ts @@ -523,6 +523,7 @@ describe("SelfHealingManager", () => { const clearStaleBlockedBy = vi.spyOn(manager, "clearStaleBlockedBy").mockResolvedValue(1); const surfaceInReviewStalls = vi.spyOn(manager, "surfaceInReviewStalls").mockResolvedValue(1); const surfaceStalePausedReviews = vi.spyOn(manager, "surfaceStalePausedReviews").mockResolvedValue(1); + const surfaceStalePausedTodos = vi.spyOn(manager, "surfaceStalePausedTodos").mockResolvedValue(1); await manager.runStartupRecovery(); @@ -538,6 +539,7 @@ describe("SelfHealingManager", () => { expect(clearStaleBlockedBy).toHaveBeenCalledTimes(1); expect(surfaceInReviewStalls).toHaveBeenCalledTimes(1); expect(surfaceStalePausedReviews).toHaveBeenCalledTimes(1); + expect(surfaceStalePausedTodos).toHaveBeenCalledTimes(1); }); it("runStartupRecovery clears stale blockedBy rows", async () => { @@ -4960,6 +4962,96 @@ describe("SelfHealingManager", () => { }); }); + describe("surfaceStalePausedTodos", () => { + function pausedTodoTask(overrides: Record = {}) { + return { + id: "FN-5034", + column: "todo", + paused: true, + pausedReason: "manual-hold", + columnMovedAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + log: [], + ...overrides, + }; + } + + it("logs for stale paused todo tasks", async () => { + vi.setSystemTime(new Date("2026-01-02T01:00:00.000Z")); + const managerWithRecovery = new SelfHealingManager(store, { rootDir: "/tmp/test-project" }); + (store.getSettings as ReturnType).mockResolvedValue({ stalePausedTodoThresholdMs: 24 * 60 * 60_000 }); + (store.listTasks as ReturnType).mockResolvedValue([pausedTodoTask()]); + + expect(await managerWithRecovery.surfaceStalePausedTodos()).toBe(1); + expect(store.logEntry).toHaveBeenCalledWith( + "FN-5034", + expect.stringContaining("Stale paused todo surfaced [stale-paused-todo]: paused"), + ); + expect(store.logEntry).toHaveBeenCalledWith( + "FN-5034", + expect.stringContaining("disposition options — unpause, move to triage, archive, or create follow-up task"), + ); + managerWithRecovery.stop(); + }); + + it("skips under threshold and for unpaused/non-todo tasks", async () => { + vi.setSystemTime(new Date("2026-01-01T00:10:00.000Z")); + const managerWithRecovery = new SelfHealingManager(store, { rootDir: "/tmp/test-project" }); + (store.getSettings as ReturnType).mockResolvedValue({ stalePausedTodoThresholdMs: 24 * 60 * 60_000 }); + (store.listTasks as ReturnType).mockResolvedValue([ + pausedTodoTask(), + pausedTodoTask({ id: "FN-UP", paused: false }), + pausedTodoTask({ id: "FN-IR", column: "in-review" }), + ]); + + expect(await managerWithRecovery.surfaceStalePausedTodos()).toBe(0); + expect(store.logEntry).not.toHaveBeenCalled(); + managerWithRecovery.stop(); + }); + + it("returns zero while paused or when threshold is disabled", async () => { + vi.setSystemTime(new Date("2026-01-02T01:00:00.000Z")); + const managerWithRecovery = new SelfHealingManager(store, { rootDir: "/tmp/test-project" }); + (store.getSettings as ReturnType) + .mockResolvedValueOnce({ stalePausedTodoThresholdMs: 24 * 60 * 60_000, globalPause: true }) + .mockResolvedValueOnce({ stalePausedTodoThresholdMs: 24 * 60 * 60_000, enginePaused: true }) + .mockResolvedValueOnce({ stalePausedTodoThresholdMs: 0 }); + + expect(await managerWithRecovery.surfaceStalePausedTodos()).toBe(0); + expect(await managerWithRecovery.surfaceStalePausedTodos()).toBe(0); + expect(await managerWithRecovery.surfaceStalePausedTodos()).toBe(0); + expect(store.logEntry).not.toHaveBeenCalled(); + managerWithRecovery.stop(); + }); + + it("dedupes within threshold window and re-emits after window", async () => { + vi.setSystemTime(new Date("2026-01-02T01:00:00.000Z")); + const managerWithRecovery = new SelfHealingManager(store, { rootDir: "/tmp/test-project" }); + (store.getSettings as ReturnType).mockResolvedValue({ stalePausedTodoThresholdMs: 24 * 60 * 60_000 }); + (store.listTasks as ReturnType) + .mockResolvedValueOnce([ + pausedTodoTask({ + log: [{ + timestamp: "2026-01-01T12:00:00.000Z", + action: "Stale paused todo surfaced [stale-paused-todo]: recent", + }], + }), + ]) + .mockResolvedValueOnce([ + pausedTodoTask({ + log: [{ + timestamp: "2025-12-30T00:00:00.000Z", + action: "Stale paused todo surfaced [stale-paused-todo]: old", + }], + }), + ]); + + expect(await managerWithRecovery.surfaceStalePausedTodos()).toBe(0); + expect(await managerWithRecovery.surfaceStalePausedTodos()).toBe(1); + managerWithRecovery.stop(); + }); + }); + describe("recoverGhostReviewTasks", () => { it("preserves failed in-review tasks so actionable merge failures are not ghost-retried", async () => { const managerWithRecovery = new SelfHealingManager(store, { diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index 0a16a1c37..765ae281f 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -662,6 +662,7 @@ export class SelfHealingManager { { name: "reclaim-stale-active-branches", fn: () => this.reclaimStaleActiveBranches().then(() => undefined) }, { name: "surface-in-review-stalls", fn: () => this.surfaceInReviewStalls().then(() => undefined) }, { name: "surface-stale-paused-reviews", fn: () => this.surfaceStalePausedReviews().then(() => undefined) }, + { name: "surface-stale-paused-todos", fn: () => this.surfaceStalePausedTodos().then(() => undefined) }, { name: "audit-no-commits-expected-candidates", fn: () => this.auditNoCommitsExpectedCandidates().then(() => undefined) }, ]; @@ -1248,6 +1249,7 @@ export class SelfHealingManager { { name: "reclaim-stale-active-branches", fn: () => this.reclaimStaleActiveBranches() }, { name: "surface-in-review-stalls", fn: () => this.surfaceInReviewStalls() }, { name: "surface-stale-paused-reviews", fn: () => this.surfaceStalePausedReviews() }, + { name: "surface-stale-paused-todos", fn: () => this.surfaceStalePausedTodos() }, { name: "audit-no-commits-expected-candidates", fn: () => this.auditNoCommitsExpectedCandidates() }, ]; for (const fn of batch2Fns) { @@ -4085,6 +4087,52 @@ export class SelfHealingManager { } } + async surfaceStalePausedTodos(): Promise { + try { + const settings = await this.store.getSettings(); + if (settings.globalPause || settings.enginePaused) return 0; + + const cycleStartMs = Date.now(); + const thresholdMs = settings.stalePausedTodoThresholdMs; + if (!thresholdMs || thresholdMs <= 0) return 0; + + const tasks = await this.store.listTasks({ column: "todo", slim: false }); + let surfaced = 0; + + for (const task of tasks) { + if (task.paused !== true) continue; + const signal = getStalePausedTodoSignal(task, { now: cycleStartMs, thresholdMs }); + if (!signal) continue; + if (Date.parse(task.updatedAt) >= cycleStartMs) continue; + + const previous = [...(task.log ?? [])] + .reverse() + .find((entry) => entry.action.startsWith("Stale paused todo surfaced [")); + if (previous) { + const parsed = /^Stale paused todo surfaced \[([^\]]+)\]/.exec(previous.action); + const previousCode = parsed?.[1]; + const previousAt = Date.parse(previous.timestamp); + if (Number.isFinite(previousAt) && previousAt >= cycleStartMs - thresholdMs && previousCode === signal.code) { + continue; + } + } + + const hours = (signal.ageMs / 3_600_000).toFixed(1); + await this.store.logEntry( + task.id, + `Stale paused todo surfaced [${signal.code}]: paused ${hours}h beyond ${(thresholdMs / 3_600_000).toFixed(1)}h threshold; disposition options — unpause, move to triage, archive, or create follow-up task. pausedReason=${signal.pausedReason ?? "none"}`, + ); + surfaced += 1; + } + + return surfaced; + } catch (err: unknown) { + const errorMessage = err instanceof Error ? err.message : String(err); + log.error(`Stale paused todo surfacing failed: ${errorMessage}`); + return 0; + } + } + async recoverGhostReviewTasks(): Promise { try { const settings = await this.store.getSettings();