diff --git a/packages/engine/src/__tests__/self-healing.test.ts b/packages/engine/src/__tests__/self-healing.test.ts index cb6c01f80..e1bd73350 100644 --- a/packages/engine/src/__tests__/self-healing.test.ts +++ b/packages/engine/src/__tests__/self-healing.test.ts @@ -4870,6 +4870,45 @@ describe("SelfHealingManager", () => { }); }); + describe("surfaceDependencyBlockedTodos", () => { + it("returns 0 when globalPause is enabled", async () => { + const managerWithRecovery = new SelfHealingManager(store, { rootDir: "/tmp/test-project", getProjectId: () => "/tmp/test-project" }); + (store.getSettings as ReturnType).mockResolvedValue({ globalPause: true }); + + expect(await managerWithRecovery.surfaceDependencyBlockedTodos()).toBe(0); + managerWithRecovery.stop(); + }); + + it("returns 0 when dependency-blocked todo reporting is disabled", async () => { + const managerWithRecovery = new SelfHealingManager(store, { rootDir: "/tmp/test-project", getProjectId: () => "/tmp/test-project" }); + (store.getSettings as ReturnType).mockResolvedValue({ dependencyBlockedTodoReportEnabled: false }); + + expect(await managerWithRecovery.surfaceDependencyBlockedTodos()).toBe(0); + managerWithRecovery.stop(); + }); + + it("returns groupCount from reporter", async () => { + const managerWithRecovery = new SelfHealingManager(store, { rootDir: "/tmp/test-project", getProjectId: () => "/tmp/test-project" }); + (store.getSettings as ReturnType).mockResolvedValue({ dependencyBlockedTodoReportEnabled: true }); + const reportSpy = vi.fn().mockResolvedValue({ alerted: true, groupCount: 1 }); + (managerWithRecovery as unknown as { dependencyBlockedTodoReporter: { report: typeof reportSpy } }).dependencyBlockedTodoReporter = { report: reportSpy }; + + expect(await managerWithRecovery.surfaceDependencyBlockedTodos()).toBe(1); + expect(reportSpy).toHaveBeenCalledWith(); + managerWithRecovery.stop(); + }); + + it("returns 0 and logs error when reporter fails", async () => { + const managerWithRecovery = new SelfHealingManager(store, { rootDir: "/tmp/test-project", getProjectId: () => "/tmp/test-project" }); + (store.getSettings as ReturnType).mockResolvedValue({ dependencyBlockedTodoReportEnabled: true }); + const reportSpy = vi.fn().mockRejectedValue(new Error("boom")); + (managerWithRecovery as unknown as { dependencyBlockedTodoReporter: { report: typeof reportSpy } }).dependencyBlockedTodoReporter = { report: reportSpy }; + + expect(await managerWithRecovery.surfaceDependencyBlockedTodos()).toBe(0); + managerWithRecovery.stop(); + }); + }); + describe("surfaceStalePausedReviews", () => { function pausedReviewTask(overrides: Record = {}) { return { diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index 765ae281f..f15b0a25a 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -57,6 +57,7 @@ import { type NtfyNotifier, } from "./notifier.js"; import type { GhostBugDecision } from "./triage-preflight.js"; +import { DependencyBlockedTodoReporter } from "./dependency-blocked-todo-reporter.js"; const log = createLogger("self-healing"); const worktreeMetadataReconcileLog = createLogger("worktree-metadata-reconcile"); @@ -250,6 +251,7 @@ export interface SelfHealingOptions { messageStore?: MessageStore; /** Optional notifier for board-stall unrecovered alerts. */ ntfyNotifier?: Pick; + getProjectId?: () => string; } const APPROVED_TRIAGE_RECOVERY_GRACE_MS = 60_000; @@ -529,6 +531,8 @@ export class SelfHealingManager { private orphanArchivedAcknowledged = new Set(); private finalizeUnprovenWarned = new Set(); private maintenanceTickCounter = 0; + private dependencyBlockedTodoReporter: DependencyBlockedTodoReporter | null = null; + private boardStallWindow: { windowStartMs: number; windowStartBlockedDepth: number; @@ -4041,6 +4045,39 @@ export class SelfHealingManager { } } + private getDependencyBlockedTodoReporter(): DependencyBlockedTodoReporter | null { + if (this.dependencyBlockedTodoReporter) { + return this.dependencyBlockedTodoReporter; + } + const projectId = this.options.getProjectId?.(); + if (!projectId) { + return null; + } + this.dependencyBlockedTodoReporter = new DependencyBlockedTodoReporter({ + store: this.store, + projectId, + now: () => Date.now(), + }); + return this.dependencyBlockedTodoReporter; + } + + async surfaceDependencyBlockedTodos(): Promise { + try { + const settings = await this.store.getSettings(); + if (settings.globalPause || settings.enginePaused) return 0; + if (settings.dependencyBlockedTodoReportEnabled === false) return 0; + + const reporter = this.getDependencyBlockedTodoReporter(); + if (!reporter) return 0; + const result = await reporter.report(); + return result.groupCount ?? 0; + } catch (err: unknown) { + const errorMessage = err instanceof Error ? err.message : String(err); + log.error(`Dependency-blocked todo surfacing failed: ${errorMessage}`); + return 0; + } + } + async surfaceStalePausedReviews(): Promise { try { const settings = await this.store.getSettings();