diff --git a/packages/core/src/__tests__/dependency-blocked-todo-report-renamed-columns.test.ts b/packages/core/src/__tests__/dependency-blocked-todo-report-renamed-columns.test.ts deleted file mode 100644 index f83ec38fa5..0000000000 --- a/packages/core/src/__tests__/dependency-blocked-todo-report-renamed-columns.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -/* -FNXC:WorkflowLifecycleColumns 2026-07-28-02:40 (PR #2470 review, P1): - -`computeBlockerFanoutMap` accepts resolved `terminalColumns` / `holdColumn`, but -`computeDependencyBlockedTodoReport` called it with NEITHER — so the fan-out fell -back to the legacy {done, archived} / "todo" defaults even for a workflow that -renames them. That is the "convertible rather than converted" defect: the module -takes the roles, the caller never passes them, and end-to-end the bug is still -live. - -Concretely, for a workflow whose terminal column is `published` and whose hold -column is `queued`: - - a FINISHED blocker in `published` counted as ACTIVE, so it kept appearing as - a live blocker in the report; - - dependents resting in `queued` were not counted as blocked todos at all - (`activeTodoCount` keyed on the literal "todo"), so genuinely-blocked work - was invisible to the report. - -The two errors point in OPPOSITE directions — over-reporting dead blockers while -under-reporting real ones — which is why both are asserted separately rather -than through a single aggregate count. - -The report also had two literals of its OWN beyond the unthreaded call: its -`todoTaskIds` filter and its `blocker.column === "done" || "archived"` skip. -Threading the fan-out alone would have left those, so a renamed workflow would -still report nothing. All three now read the same resolved roles. - -Written against the unthreaded implementation and observed FAILING first. -*/ -import { describe, expect, it } from "vitest"; - -import { - computeDependencyBlockedTodoReport, - type DependencyBlockedTodoReportContext, -} from "../dependency-blocked-todo-report.js"; -import type { Task } from "../types.js"; - -const NOW = Date.parse("2026-05-01T12:00:00.000Z"); -/** Old enough to be a "stale" blocker, so age bucketing never masks a miss. */ -const MOVED_AT = new Date(NOW - 5 * 60 * 60_000).toISOString(); - -function task(over: Partial = {}): Task { - return { - id: "FN-1", - title: "t", - description: "", - column: "todo", - dependencies: [], - steps: [], - currentStep: 0, - log: [], - createdAt: MOVED_AT, - updatedAt: MOVED_AT, - columnMovedAt: MOVED_AT, - ...over, - } as Task; -} - -/** - * One blocker plus two dependents resting in the hold column, expressed under a - * caller-supplied vocabulary. The SHAPE is identical across vocabularies, so any - * difference in the report is attributable to a surviving literal. - */ -function board(names: { hold: string; terminal: string }, blockerColumn: string): Task[] { - return [ - task({ id: "BLOCKER", column: blockerColumn }), - task({ id: "DEP-1", column: names.hold, dependencies: ["BLOCKER"] }), - task({ id: "DEP-2", column: names.hold, dependencies: ["BLOCKER"] }), - ]; -} - -const DEFAULT_NAMES = { hold: "todo", terminal: "done" }; -/* Neither renamed id collides with a legacy literal. */ -const RENAMED = { hold: "queued", terminal: "published" }; -const RENAMED_ROLES: DependencyBlockedTodoReportContext = { - now: NOW, - holdColumn: RENAMED.hold, - terminalColumns: [RENAMED.terminal, "retired"], -}; - -describe("dependency-blocked-todo report under a renamed column vocabulary", () => { - it("counts dependents resting in a RENAMED hold column as blocked todos", async () => { - // Blocker is live (in the wip column), dependents wait in the renamed hold. - const report = computeDependencyBlockedTodoReport(board(RENAMED, "building"), 0, RENAMED_ROLES); - - expect(report.totalBlockedTodoCount).toBe(2); - expect(report.uniqueBlockerCount).toBe(1); - expect(report.groups[0]?.blockerId).toBe("BLOCKER"); - expect(report.groups[0]?.blockedTodoIds).toEqual(["DEP-1", "DEP-2"]); - }); - - it("drops a blocker that already reached a RENAMED terminal column", async () => { - /* The opposite-direction error: a finished blocker kept being reported as a - live one because `published` is not in the legacy terminal set. */ - const report = computeDependencyBlockedTodoReport( - board(RENAMED, RENAMED.terminal), - 0, - RENAMED_ROLES, - ); - - expect(report.groups).toEqual([]); - expect(report.totalBlockedTodoCount).toBe(0); - }); - - it("honors a SECOND declared terminal column, not just the first", async () => { - /* terminalColumns is a set, not a single id — a fix that only handled the - primary terminal column would pass the test above and fail here. */ - const report = computeDependencyBlockedTodoReport(board(RENAMED, "retired"), 0, RENAMED_ROLES); - - expect(report.groups).toEqual([]); - }); - - it("is byte-identical for the builtin vocabulary when roles are omitted (regression floor)", async () => { - const live = computeDependencyBlockedTodoReport(board(DEFAULT_NAMES, "in-progress"), 0, { now: NOW }); - expect(live.totalBlockedTodoCount).toBe(2); - expect(live.groups[0]?.blockedTodoIds).toEqual(["DEP-1", "DEP-2"]); - - const finished = computeDependencyBlockedTodoReport(board(DEFAULT_NAMES, "done"), 0, { now: NOW }); - expect(finished.groups).toEqual([]); - - const archived = computeDependencyBlockedTodoReport(board(DEFAULT_NAMES, "archived"), 0, { now: NOW }); - expect(archived.groups).toEqual([]); - }); - - it("still counts a legacy-named board correctly when roles ARE supplied explicitly", async () => { - /* Supplying the legacy ids explicitly must behave exactly like omitting - them — otherwise threading the caller would itself change behavior for - builtin:coding. */ - const report = computeDependencyBlockedTodoReport(board(DEFAULT_NAMES, "in-progress"), 0, { - now: NOW, - holdColumn: "todo", - terminalColumns: ["done", "archived"], - }); - - expect(report.totalBlockedTodoCount).toBe(2); - expect(report.groups[0]?.blockedTodoIds).toEqual(["DEP-1", "DEP-2"]); - }); -}); diff --git a/packages/core/src/__tests__/dependency-blocked-todo-report.test.ts b/packages/core/src/__tests__/dependency-blocked-todo-report.test.ts deleted file mode 100644 index f1cebd871a..0000000000 --- a/packages/core/src/__tests__/dependency-blocked-todo-report.test.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - computeDependencyBlockedTodoReport, - DEFAULT_DEPENDENCY_BLOCKED_TODO_FRESH_MS, -} from "../dependency-blocked-todo-report.js"; -import type { Task } from "../types.js"; - -const MAX_AUTO_MERGE_RETRIES = 3; -const NOW_ISO = "2026-01-01T12:00:00.000Z"; -const NOW_MS = Date.parse(NOW_ISO); - -function createTask(id: string, column: Task["column"], overrides: Partial = {}): Task { - return { - id, - description: id, - column, - dependencies: [], - steps: [], - currentStep: 0, - log: [], - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - ...overrides, - }; -} - -describe("computeDependencyBlockedTodoReport", () => { - it("returns an empty report for empty input", () => { - const report = computeDependencyBlockedTodoReport([], MAX_AUTO_MERGE_RETRIES, { now: NOW_MS }); - expect(report.totalBlockedTodoCount).toBe(0); - expect(report.uniqueBlockerCount).toBe(0); - expect(report.groups).toEqual([]); - }); - - it("reports dependency-linked todo tasks under one blocker", () => { - const tasks = [ - createTask("FN-5090", "in-progress"), - createTask("FN-5034", "todo", { dependencies: ["FN-5090"] }), - createTask("FN-5085", "todo", { dependencies: ["FN-5090"] }), - createTask("FN-5089", "todo", { dependencies: ["FN-5090"] }), - ]; - - const report = computeDependencyBlockedTodoReport(tasks, MAX_AUTO_MERGE_RETRIES, { now: NOW_MS }); - expect(report.uniqueBlockerCount).toBe(1); - expect(report.groups[0]).toMatchObject({ - blockerId: "FN-5090", - blockedTodoCount: 3, - viaDependencies: ["FN-5034", "FN-5085", "FN-5089"], - viaBlockedBy: [], - blockedTodoIds: ["FN-5034", "FN-5085", "FN-5089"], - }); - }); - - it("dedupes todos across dependencies and blockedBy overlap", () => { - const tasks = [ - createTask("B", "in-progress"), - createTask("T1", "todo", { dependencies: ["B"] }), - createTask("T2", "todo", { blockedBy: "B", dependencies: ["B"] }), - ]; - - const report = computeDependencyBlockedTodoReport(tasks, MAX_AUTO_MERGE_RETRIES, { now: NOW_MS }); - expect(report.groups[0]?.viaDependencies).toEqual(["T1", "T2"]); - expect(report.groups[0]?.viaBlockedBy).toEqual(["T2"]); - expect(report.groups[0]?.blockedTodoIds).toEqual(["T1", "T2"]); - expect(report.groups[0]?.blockedTodoCount).toBe(2); - }); - - it("classifies age buckets as fresh, aging, and stale", () => { - const tasks = [ - createTask("fresh", "in-progress", { columnMovedAt: "2026-01-01T11:55:00.000Z" }), - createTask("aging", "in-progress", { columnMovedAt: "2026-01-01T11:00:00.000Z" }), - createTask("stale", "in-progress", { columnMovedAt: "2026-01-01T06:00:00.000Z" }), - createTask("tf", "todo", { dependencies: ["fresh"] }), - createTask("ta", "todo", { dependencies: ["aging"] }), - createTask("ts", "todo", { dependencies: ["stale"] }), - ]; - - const report = computeDependencyBlockedTodoReport(tasks, MAX_AUTO_MERGE_RETRIES, { now: NOW_MS }); - const byId = new Map(report.groups.map((group) => [group.blockerId, group.ageBucket])); - expect(byId.get("fresh")).toBe("fresh"); - expect(byId.get("aging")).toBe("aging"); - expect(byId.get("stale")).toBe("stale"); - }); - - it("sorts by age bucket priority then count then age then id", () => { - const tasks = [ - createTask("A-stale-older", "in-progress", { columnMovedAt: "2026-01-01T03:00:00.000Z" }), - createTask("B-stale-more", "in-progress", { columnMovedAt: "2026-01-01T05:00:00.000Z" }), - createTask("C-aging", "in-progress", { columnMovedAt: "2026-01-01T11:00:00.000Z" }), - createTask("D-fresh", "in-progress", { columnMovedAt: "2026-01-01T11:50:00.000Z" }), - createTask("t1", "todo", { dependencies: ["A-stale-older"] }), - createTask("t2", "todo", { dependencies: ["B-stale-more"] }), - createTask("t3", "todo", { dependencies: ["B-stale-more"] }), - createTask("t4", "todo", { dependencies: ["C-aging"] }), - createTask("t5", "todo", { dependencies: ["D-fresh"] }), - ]; - - const report = computeDependencyBlockedTodoReport(tasks, MAX_AUTO_MERGE_RETRIES, { now: NOW_MS }); - expect(report.groups.map((group) => group.blockerId)).toEqual(["B-stale-more", "A-stale-older", "C-aging", "D-fresh"]); - }); - - it("applies minBlockedTodoCount and maxGroups limits", () => { - const tasks = [ - createTask("B1", "in-progress"), - createTask("B2", "in-progress"), - createTask("T1", "todo", { dependencies: ["B1"] }), - createTask("T2", "todo", { dependencies: ["B1"] }), - createTask("T3", "todo", { dependencies: ["B2"] }), - ]; - - const filtered = computeDependencyBlockedTodoReport(tasks, MAX_AUTO_MERGE_RETRIES, { - now: NOW_MS, - minBlockedTodoCount: 2, - }); - expect(filtered.groups.map((group) => group.blockerId)).toEqual(["B1"]); - - const capped = computeDependencyBlockedTodoReport(tasks, MAX_AUTO_MERGE_RETRIES, { - now: NOW_MS, - maxGroups: 1, - }); - expect(capped.groups).toHaveLength(1); - }); - - it("excludes done and archived blockers", () => { - const tasks = [ - createTask("done-blocker", "done"), - createTask("archived-blocker", "archived"), - createTask("td", "todo", { dependencies: ["done-blocker"] }), - createTask("ta", "todo", { dependencies: ["archived-blocker"] }), - ]; - - const report = computeDependencyBlockedTodoReport(tasks, MAX_AUTO_MERGE_RETRIES, { now: NOW_MS }); - expect(report.groups).toEqual([]); - }); - - it("sanitizes invalid thresholds", () => { - const tasks = [ - createTask("B", "in-progress", { columnMovedAt: "2026-01-01T11:40:00.000Z" }), - createTask("T", "todo", { dependencies: ["B"] }), - ]; - - const report = computeDependencyBlockedTodoReport(tasks, MAX_AUTO_MERGE_RETRIES, { - now: NOW_MS, - freshAgeMs: -100, - staleAgeMs: 10, - minBlockedTodoCount: 0, - maxGroups: 0, - }); - - expect(report.thresholds.freshMs).toBe(DEFAULT_DEPENDENCY_BLOCKED_TODO_FRESH_MS); - expect(report.thresholds.staleMs).toBe(DEFAULT_DEPENDENCY_BLOCKED_TODO_FRESH_MS + 1); - expect(report.thresholds.minBlockedTodoCount).toBe(1); - expect(report.groups).toHaveLength(1); - }); - - it("reproduces FN-5091 motivating fixture", () => { - const tasks = [ - createTask("FN-5090", "in-progress"), - createTask("FN-5034", "todo", { dependencies: ["FN-5090"] }), - createTask("FN-5085", "todo", { dependencies: ["FN-5090"] }), - createTask("FN-5089", "todo", { dependencies: ["FN-5090"] }), - ]; - - const report = computeDependencyBlockedTodoReport(tasks, MAX_AUTO_MERGE_RETRIES, { now: NOW_MS }); - expect(report.groups).toHaveLength(1); - expect(report.groups[0]?.blockerId).toBe("FN-5090"); - expect(report.groups[0]?.blockedTodoCount).toBe(3); - expect(report.groups[0]?.blockedTodoIds).toEqual(["FN-5034", "FN-5085", "FN-5089"]); - expect(report.observedAt).toBe(NOW_ISO); - }); -}); diff --git a/packages/core/src/__tests__/settings-parity.test.ts b/packages/core/src/__tests__/settings-parity.test.ts index 6ca33c0197..24cc10c15b 100644 --- a/packages/core/src/__tests__/settings-parity.test.ts +++ b/packages/core/src/__tests__/settings-parity.test.ts @@ -447,32 +447,6 @@ describe("settings key parity", () => { expect(isGlobalSettingsKey("backlogPressureAlertCooldownMs")).toBe(false); }); - it("keeps dependency-blocked todo report settings project-scoped with documented defaults", () => { - expect(DEFAULT_PROJECT_SETTINGS.dependencyBlockedTodoReportEnabled).toBe(true); - expect(DEFAULT_PROJECT_SETTINGS.dependencyBlockedTodoFreshAgeMs).toBe(30 * 60_000); - expect(DEFAULT_PROJECT_SETTINGS.dependencyBlockedTodoStaleAgeMs).toBe(4 * 60 * 60_000); - expect(DEFAULT_PROJECT_SETTINGS.dependencyBlockedTodoMinCount).toBe(1); - expect(DEFAULT_PROJECT_SETTINGS.dependencyBlockedTodoReportCooldownMs).toBe(6 * 60 * 60_000); - - expect(PROJECT_SETTINGS_KEYS).toContain("dependencyBlockedTodoReportEnabled"); - expect(PROJECT_SETTINGS_KEYS).toContain("dependencyBlockedTodoFreshAgeMs"); - expect(PROJECT_SETTINGS_KEYS).toContain("dependencyBlockedTodoStaleAgeMs"); - expect(PROJECT_SETTINGS_KEYS).toContain("dependencyBlockedTodoMinCount"); - expect(PROJECT_SETTINGS_KEYS).toContain("dependencyBlockedTodoReportCooldownMs"); - - expect(isProjectSettingsKey("dependencyBlockedTodoReportEnabled")).toBe(true); - expect(isProjectSettingsKey("dependencyBlockedTodoFreshAgeMs")).toBe(true); - expect(isProjectSettingsKey("dependencyBlockedTodoStaleAgeMs")).toBe(true); - expect(isProjectSettingsKey("dependencyBlockedTodoMinCount")).toBe(true); - expect(isProjectSettingsKey("dependencyBlockedTodoReportCooldownMs")).toBe(true); - - expect(isGlobalSettingsKey("dependencyBlockedTodoReportEnabled")).toBe(false); - expect(isGlobalSettingsKey("dependencyBlockedTodoFreshAgeMs")).toBe(false); - expect(isGlobalSettingsKey("dependencyBlockedTodoStaleAgeMs")).toBe(false); - expect(isGlobalSettingsKey("dependencyBlockedTodoMinCount")).toBe(false); - expect(isGlobalSettingsKey("dependencyBlockedTodoReportCooldownMs")).toBe(false); - }); - it("keeps github tracking keys in expected scopes with documented defaults", () => { expect(DEFAULT_PROJECT_SETTINGS.githubTrackingEnabledByDefault).toBe(false); expect(DEFAULT_PROJECT_SETTINGS.sessionAdvisorEnabledByDefault).toBe(false); diff --git a/packages/core/src/blocker-fanout.ts b/packages/core/src/blocker-fanout.ts index 2d590eadf8..764f421f23 100644 --- a/packages/core/src/blocker-fanout.ts +++ b/packages/core/src/blocker-fanout.ts @@ -46,16 +46,6 @@ export interface ComputeBlockerFanoutOptions { * built-in coding workflow gives it. Defaults to `"todo"`. */ holdColumn?: string; /* - FNXC:WorkflowLifecycleColumns 2026-07-28-03:05 (PR #2470 review, P1): - PLURAL form, for callers computing over a board that spans MORE THAN ONE - workflow. `holdColumn` assumes a single vocabulary, which is wrong for the - board-wide backlog-health reporter: a project running two workflows has two - hold columns, and collapsing them to one silently drops every card held by the - other. Takes precedence over `holdColumn` when supplied; when neither is given - the legacy `"todo"` applies, so existing callers are byte-identical. - */ - holdColumns?: ReadonlySet; - /* FNXC:WorkflowLifecycleColumns 2026-07-28-17:50 (PR #2479 review, P1): PER-TASK classification, and the only correct option on a multi-workflow board. @@ -68,7 +58,7 @@ export interface ComputeBlockerFanoutOptions { Supplying `classify` resolves each task against its own workflow, which makes that misclassification impossible by construction. It takes precedence over - `terminalColumns`/`holdColumn(s)`; those remain for single-vocabulary callers + `terminalColumns`/`holdColumn`; those remain for single-vocabulary callers (task-priority's unblock weighting) and as the legacy default. */ classify?: (task: Task) => { isHold: boolean; isTerminal: boolean }; @@ -118,10 +108,7 @@ export function computeBlockerFanoutMap( options.staleHighFanoutAgeThresholdMs ?? STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS; const terminalColumns = options.terminalColumns ?? DEFAULT_TERMINAL_COLUMNS; - /* Plural wins; else the singular; else the legacy id. One resolved set so the - two spellings cannot disagree downstream. */ - const holdColumns: ReadonlySet = - options.holdColumns ?? new Set([options.holdColumn ?? "todo"]); + const holdColumn = options.holdColumn ?? "todo"; const taskById = new Map(tasks.map((task) => [task.id, task])); const fanout = new Map(); @@ -146,12 +133,12 @@ export function computeBlockerFanoutMap( for (const task of tasks) { /* Per-task classification wins when supplied (PR #2479 P1); otherwise fall back - to the board-wide sets. Active is by EXCLUSION — not terminal — never by - enumeration. + to the board-wide set / single hold column. Active is by EXCLUSION — not + terminal — never by enumeration. */ const roles = options.classify?.(task); const active = roles ? !roles.isTerminal : !terminalColumns.has(task.column); - const isTodo = roles ? roles.isHold : holdColumns.has(task.column); + const isTodo = roles ? roles.isHold : task.column === holdColumn; for (const depId of task.dependencies ?? []) { if (!depId) continue; diff --git a/packages/core/src/dependency-blocked-todo-report.ts b/packages/core/src/dependency-blocked-todo-report.ts deleted file mode 100644 index f8b3073d57..0000000000 --- a/packages/core/src/dependency-blocked-todo-report.ts +++ /dev/null @@ -1,202 +0,0 @@ -import { computeBlockerFanoutMap } from "./blocker-fanout.js"; -import type { Task } from "./types.js"; - -export type DependencyBlockedTodoCode = "dependency-blocked-todo"; - -export interface DependencyBlockedTodoGroup { - blockerId: string; - blockerColumn: Task["column"] | "unknown"; - blockerTitle?: string; - blockedTodoIds: string[]; - blockedTodoCount: number; - blockingAgeMs: number; - ageBucket: "fresh" | "aging" | "stale"; - viaDependencies: string[]; - viaBlockedBy: string[]; -} - -export interface DependencyBlockedTodoReport { - observedAt: string; - totalBlockedTodoCount: number; - uniqueBlockerCount: number; - groups: DependencyBlockedTodoGroup[]; - thresholds: { - freshMs: number; - staleMs: number; - minBlockedTodoCount: number; - }; -} - -export interface DependencyBlockedTodoReportContext { - now?: number; - freshAgeMs?: number; - staleAgeMs?: number; - minBlockedTodoCount?: number; - maxGroups?: number; - /* - FNXC:WorkflowLifecycleColumns 2026-07-28-02:40 (PR #2470 review, P1): - The task's resolved lifecycle roles. `computeBlockerFanoutMap` already accepted - these, but this report called it with NEITHER — so a renamed workflow silently - fell back to the legacy sets. The failures pointed in opposite directions: a - FINISHED blocker in a renamed terminal column counted as ACTIVE (over-reporting - dead blockers), while dependents in a renamed hold column were not counted as - blocked todos at all (under-reporting real ones). - - Both default to the legacy values, so a caller that cannot resolve a workflow - is byte-identical. - */ - /** Columns that end a task's life (`complete` + `archived` roles). */ - terminalColumns?: readonly string[]; - /** The capacity-wait column whose residents are the report's "blocked todos". */ - holdColumn?: string; - /* - PLURAL form. This report runs BOARD-WIDE, and a board may span more than one - workflow — so there can be more than one hold column and collapsing them to one - silently drops every card held by the other workflows. The engine reporter - passes the union across the workflows actually present on the board. - */ - holdColumns?: readonly string[]; - /* - FNXC:WorkflowLifecycleColumns 2026-07-28-17:50 (PR #2479 review, P1): - PER-TASK classification against each task's OWN workflow — the only correct - option for this report, which is board-wide and therefore multi-workflow. - Board-wide sets assume a column id means the same thing in every workflow; when - two workflows reuse an id for different roles, a union marks that column both - held and terminal and misclassifies every card in it. Takes precedence over the - set-shaped options, which remain as the single-vocabulary/legacy fallback. - */ - classifyTask?: (task: Task) => { isHold: boolean; isTerminal: boolean }; -} - -/* Legacy role ids — the builtin coding workflow's names, used when a caller - cannot resolve the task's workflow. */ -const DEFAULT_REPORT_TERMINAL_COLUMNS: readonly string[] = ["done", "archived"]; -const DEFAULT_REPORT_HOLD_COLUMN = "todo"; - -export const DEFAULT_DEPENDENCY_BLOCKED_TODO_FRESH_MS = 30 * 60_000; -export const DEFAULT_DEPENDENCY_BLOCKED_TODO_STALE_MS = 4 * 60 * 60_000; -export const DEFAULT_DEPENDENCY_BLOCKED_TODO_MIN_COUNT = 1; -export const DEFAULT_DEPENDENCY_BLOCKED_TODO_MAX_GROUPS = 10; - -const AGE_BUCKET_PRIORITY: Record = { - stale: 0, - aging: 1, - fresh: 2, -}; - -function sanitizeContext(context: DependencyBlockedTodoReportContext | undefined) { - const now = context?.now ?? Date.now(); - const freshMs = Number.isFinite(context?.freshAgeMs) && (context?.freshAgeMs ?? 0) > 0 - ? Math.floor(context!.freshAgeMs as number) - : DEFAULT_DEPENDENCY_BLOCKED_TODO_FRESH_MS; - const requestedStaleMs = Number.isFinite(context?.staleAgeMs) && (context?.staleAgeMs ?? 0) > 0 - ? Math.floor(context!.staleAgeMs as number) - : DEFAULT_DEPENDENCY_BLOCKED_TODO_STALE_MS; - const staleMs = requestedStaleMs <= freshMs ? freshMs + 1 : requestedStaleMs; - const minBlockedTodoCount = Number.isFinite(context?.minBlockedTodoCount) - ? Math.max(1, Math.floor(context!.minBlockedTodoCount as number)) - : DEFAULT_DEPENDENCY_BLOCKED_TODO_MIN_COUNT; - const maxGroups = Number.isFinite(context?.maxGroups) - ? Math.max(1, Math.floor(context!.maxGroups as number)) - : DEFAULT_DEPENDENCY_BLOCKED_TODO_MAX_GROUPS; - - return { now, freshMs, staleMs, minBlockedTodoCount, maxGroups }; -} - -export function computeDependencyBlockedTodoReport( - tasks: Task[], - maxAutoMergeRetries: number, - context: DependencyBlockedTodoReportContext = {}, -): DependencyBlockedTodoReport { - const { now, freshMs, staleMs, minBlockedTodoCount, maxGroups } = sanitizeContext(context); - /* - FNXC:WorkflowLifecycleColumns 2026-07-28-02:40 (PR #2470 review, P1): - Thread the resolved roles into the fan-out AND into this function's own two - literals below. Threading only the fan-out would leave the `todoTaskIds` filter - and the terminal-blocker skip on legacy ids, so a renamed workflow would still - report nothing — a fix that looks complete and changes no outcome. - */ - const terminalColumns = context.terminalColumns ?? DEFAULT_REPORT_TERMINAL_COLUMNS; - const holdColumns = new Set( - context.holdColumns ?? [context.holdColumn ?? DEFAULT_REPORT_HOLD_COLUMN], - ); - const terminalColumnSet = new Set(terminalColumns); - - const classify = context.classifyTask; - const blockerFanout = computeBlockerFanoutMap(tasks, maxAutoMergeRetries, { - nowMs: now, - terminalColumns: terminalColumnSet, - holdColumns, - classify, - }); - /* Held-ness is per task when a classifier is supplied; the set is the legacy - single-vocabulary fallback. */ - const isHold = (task: Task): boolean => - classify ? classify(task).isHold : holdColumns.has(task.column); - const isTerminal = (task: Task): boolean => - classify ? classify(task).isTerminal : terminalColumnSet.has(task.column); - const todoTaskIds = new Set(tasks.filter(isHold).map((task) => task.id)); - const taskById = new Map(tasks.map((task) => [task.id, task])); - - const groups: DependencyBlockedTodoGroup[] = []; - - for (const [blockerId, entry] of blockerFanout.entries()) { - if (entry.activeTodoCount < minBlockedTodoCount) { - continue; - } - - const blocker = taskById.get(blockerId); - // Terminal by the blocker's OWN workflow, never by a board-wide union. - if (!blocker || isTerminal(blocker)) { - continue; - } - - const viaDependencies = [...new Set(entry.dependencyDependentIds.filter((id) => todoTaskIds.has(id)))].sort(); - const viaBlockedBy = [...new Set(entry.overlapBlockedDependentIds.filter((id) => todoTaskIds.has(id)))].sort(); - const blockedTodoIds = [...new Set([...viaDependencies, ...viaBlockedBy])].sort(); - const blockedTodoCount = blockedTodoIds.length; - if (blockedTodoCount < minBlockedTodoCount) { - continue; - } - - const anchor = Date.parse(blocker.columnMovedAt ?? blocker.updatedAt); - const blockingAgeMs = Number.isFinite(anchor) ? Math.max(0, now - anchor) : 0; - const ageBucket: DependencyBlockedTodoGroup["ageBucket"] = - blockingAgeMs < freshMs ? "fresh" : blockingAgeMs >= staleMs ? "stale" : "aging"; - - groups.push({ - blockerId, - blockerColumn: blocker.column ?? "unknown", - blockerTitle: blocker.title, - blockedTodoIds, - blockedTodoCount, - blockingAgeMs, - ageBucket, - viaDependencies, - viaBlockedBy, - }); - } - - groups.sort((a, b) => { - const agePriority = AGE_BUCKET_PRIORITY[a.ageBucket] - AGE_BUCKET_PRIORITY[b.ageBucket]; - if (agePriority !== 0) return agePriority; - if (a.blockedTodoCount !== b.blockedTodoCount) return b.blockedTodoCount - a.blockedTodoCount; - if (a.blockingAgeMs !== b.blockingAgeMs) return b.blockingAgeMs - a.blockingAgeMs; - return a.blockerId.localeCompare(b.blockerId); - }); - - const keptGroups = groups.slice(0, maxGroups); - const totalBlockedTodoCount = new Set(keptGroups.flatMap((group) => group.blockedTodoIds)).size; - - return { - observedAt: new Date(now).toISOString(), - totalBlockedTodoCount, - uniqueBlockerCount: keptGroups.length, - groups: keptGroups, - thresholds: { - freshMs, - staleMs, - minBlockedTodoCount, - }, - }; -} diff --git a/packages/core/src/index.gate.ts b/packages/core/src/index.gate.ts index f3e39c79b1..3544e8a97f 100644 --- a/packages/core/src/index.gate.ts +++ b/packages/core/src/index.gate.ts @@ -981,19 +981,6 @@ export { DEFAULT_CAPACITY_RISK_TODO_THRESHOLD, } from "./capacity.js"; export type { CapacityRiskSignal } from "./capacity.js"; -export { - computeDependencyBlockedTodoReport, - DEFAULT_DEPENDENCY_BLOCKED_TODO_FRESH_MS, - DEFAULT_DEPENDENCY_BLOCKED_TODO_STALE_MS, - DEFAULT_DEPENDENCY_BLOCKED_TODO_MIN_COUNT, - DEFAULT_DEPENDENCY_BLOCKED_TODO_MAX_GROUPS, -} from "./dependency-blocked-todo-report.js"; -export type { - DependencyBlockedTodoCode, - DependencyBlockedTodoGroup, - DependencyBlockedTodoReport, - DependencyBlockedTodoReportContext, -} from "./dependency-blocked-todo-report.js"; export { getPrimaryPrInfo, taskHasManualOpenPullRequest } from "./task-helpers.js"; export { getTaskMergeBlocker, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 750e773e2f..571b29d3cd 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1104,19 +1104,6 @@ export { DEFAULT_CAPACITY_RISK_TODO_THRESHOLD, } from "./capacity.js"; export type { CapacityRiskSignal } from "./capacity.js"; -export { - computeDependencyBlockedTodoReport, - DEFAULT_DEPENDENCY_BLOCKED_TODO_FRESH_MS, - DEFAULT_DEPENDENCY_BLOCKED_TODO_STALE_MS, - DEFAULT_DEPENDENCY_BLOCKED_TODO_MIN_COUNT, - DEFAULT_DEPENDENCY_BLOCKED_TODO_MAX_GROUPS, -} from "./dependency-blocked-todo-report.js"; -export type { - DependencyBlockedTodoCode, - DependencyBlockedTodoGroup, - DependencyBlockedTodoReport, - DependencyBlockedTodoReportContext, -} from "./dependency-blocked-todo-report.js"; export { getPrimaryPrInfo, taskHasManualOpenPullRequest } from "./task-helpers.js"; export { getTaskMergeBlocker, diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index 30b4711c2b..8f8ce8b899 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -637,11 +637,6 @@ export const DEFAULT_PROJECT_SETTINGS = { backlogPressureRatioThreshold: 10, backlogPressureMinTodoCount: 5, backlogPressureAlertCooldownMs: 24 * 60 * 60_000, - dependencyBlockedTodoReportEnabled: true, - dependencyBlockedTodoFreshAgeMs: 30 * 60_000, - dependencyBlockedTodoStaleAgeMs: 4 * 60 * 60_000, - dependencyBlockedTodoMinCount: 1, - dependencyBlockedTodoReportCooldownMs: 6 * 60 * 60_000, staleHighFanoutBlockerAgeThresholdMs: 2 * 60 * 60 * 1000, staleInProgressWarningMs: 4 * 60 * 60_000, staleInProgressCriticalMs: 24 * 60 * 60_000, diff --git a/packages/core/src/types/settings-scope.ts b/packages/core/src/types/settings-scope.ts index 8440aea565..f8543f15a8 100644 --- a/packages/core/src/types/settings-scope.ts +++ b/packages/core/src/types/settings-scope.ts @@ -1837,19 +1837,14 @@ export interface ProjectSettings { * Default: 24 * 60 * 60_000. */ backlogPressureAlertCooldownMs?: number; /** Enables dependency-blocked todo backlog-health reporting. Default: true. */ - dependencyBlockedTodoReportEnabled?: boolean; /** Blocker age in milliseconds below which dependency-blocked todo groups are fresh. * Default: 30 * 60_000 (30 minutes). */ - dependencyBlockedTodoFreshAgeMs?: number; /** Blocker age in milliseconds at or above which dependency-blocked todo groups are stale. * Default: 4 * 60 * 60_000 (4 hours). */ - dependencyBlockedTodoStaleAgeMs?: number; /** Minimum dependency-blocked todo count required to include a blocker group. * Default: 1. */ - dependencyBlockedTodoMinCount?: number; /** Minimum cooldown in milliseconds between dependency-blocked todo insight emissions. * Default: 6 * 60 * 60_000. */ - dependencyBlockedTodoReportCooldownMs?: number; /** TTL in milliseconds for persisted AI planning/subtask/mission interview sessions. * Sessions older than this cutoff are expired by the dashboard session cleanup loop. * Valid range: 600000 (10 minutes) to 2592000000 (30 days). diff --git a/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx b/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx index 8ef761a078..3e87333ee8 100644 --- a/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx +++ b/packages/dashboard/app/components/settings/sections/__tests__/settings-default-descriptions.test.tsx @@ -493,11 +493,6 @@ const NOT_SURFACED_ALLOWLIST: Record = { backlogPressureRatioThreshold: "internal reliability tuning constant, no UI field", backlogPressureMinTodoCount: "internal reliability tuning constant, no UI field", backlogPressureAlertCooldownMs: "internal reliability tuning constant, no UI field", - dependencyBlockedTodoReportEnabled: "internal reliability tuning constant, no UI field", - dependencyBlockedTodoFreshAgeMs: "internal reliability tuning constant, no UI field", - dependencyBlockedTodoStaleAgeMs: "internal reliability tuning constant, no UI field", - dependencyBlockedTodoMinCount: "internal reliability tuning constant, no UI field", - dependencyBlockedTodoReportCooldownMs: "internal reliability tuning constant, no UI field", staleInProgressWarningMs: "internal reliability tuning constant, no UI field", staleInProgressCriticalMs: "internal reliability tuning constant, no UI field", staleInReviewWarningMs: "internal reliability tuning constant, no UI field", diff --git a/packages/engine/src/__tests__/dependency-blocked-todo-reporter-renamed-columns.test.ts b/packages/engine/src/__tests__/dependency-blocked-todo-reporter-renamed-columns.test.ts deleted file mode 100644 index 1fbd3a9b70..0000000000 --- a/packages/engine/src/__tests__/dependency-blocked-todo-reporter-renamed-columns.test.ts +++ /dev/null @@ -1,202 +0,0 @@ -/* -FNXC:WorkflowLifecycleColumns 2026-07-28-03:20 (PR #2470 review, P1): - -End-to-end half of the "convertible rather than converted" fix. B1 gave -`computeBlockerFanoutMap` resolved `terminalColumns`/`holdColumn` parameters, but -this reporter — the only production caller of the report — passed NEITHER, so a -renamed workflow still fell through to the legacy {done,archived}/"todo" sets. -Fixing the module without the caller changes no observable behavior, which is -precisely the defect Greptile flagged. - -Asserted here at the REPORTER level rather than the pure-function level, because -that is where the bug actually lived: the pure-function tests were already green -before this fix. - -Also covers the board-wide multi-workflow case. This report runs over the WHOLE -board, so a project running two workflows has two hold columns; the reporter -therefore passes a UNION of roles, not one vocabulary. A single-vocabulary fix -would pass the renamed test below and silently drop every card belonging to the -other workflow. -*/ -import { describe, expect, it, vi } from "vitest"; -import type { Task, TaskStore, WorkflowIr } from "@fusion/core"; - -import { DependencyBlockedTodoReporter } from "../dependency-blocked-todo-reporter.js"; - -const NOW = Date.parse("2026-05-18T12:00:00.000Z"); -/** Old enough to bucket as "stale", so significance gating never masks a miss. */ -const MOVED_AT = new Date(NOW - 5 * 60 * 60_000).toISOString(); - -function task(over: Partial = {}): Task { - return { - id: "FN-1", - title: "t", - description: "test", - column: "todo", - dependencies: [], - steps: [], - currentStep: 0, - paused: false, - blockedBy: "", - overlapBlockedBy: "", - log: [], - createdAt: MOVED_AT, - updatedAt: MOVED_AT, - columnMovedAt: MOVED_AT, - ...over, - } as Task; -} - -function ir(id: string, names: { hold: string; wip: string; complete: string }): WorkflowIr { - return { - version: "v2", - id, - nodes: [], - edges: [], - columns: [ - { id: names.hold, label: "Hold", traits: [{ trait: "hold", config: { release: "capacity" } }] }, - { id: names.wip, label: "Wip", traits: [{ trait: "wip", config: { limitSetting: "maxConcurrent" } }] }, - { id: names.complete, label: "Complete", traits: [{ trait: "complete" }] }, - ], - } as unknown as WorkflowIr; -} - -const RENAMED = { hold: "queued", wip: "building", complete: "published" }; -const DEFAULTS = { hold: "todo", wip: "in-progress", complete: "done" }; - -/** - * @param workflowByTask taskId → workflow id, so a board can span workflows. - * @param irByWorkflow workflow id → its IR. - */ -function createStore( - tasks: Task[], - workflowByTask: Record, - irByWorkflow: Record, -): { store: TaskStore; upsertInsight: ReturnType } { - const upsertInsight = vi.fn().mockResolvedValue(undefined); - const store = { - getSettings: vi.fn().mockResolvedValue({ maxAutoMergeRetries: 3 }), - listTasks: vi.fn().mockResolvedValue(tasks), - logEntry: vi.fn().mockResolvedValue(undefined), - getInsightStore: vi.fn(() => ({ upsertInsight, listInsights: vi.fn().mockResolvedValue([]) })), - getTaskWorkflowSelectionAsync: vi.fn(async (id: string) => ({ - workflowId: workflowByTask[id] ?? "wf-default", - stepIds: [], - })), - getTaskWorkflowSelection: vi.fn((id: string) => ({ - workflowId: workflowByTask[id] ?? "wf-default", - stepIds: [], - })), - getWorkflowDefinition: vi.fn(async (id: string) => - irByWorkflow[id] ? { ir: irByWorkflow[id] } : null, - ), - } as unknown as TaskStore; - return { store, upsertInsight }; -} - -function reporter(store: TaskStore) { - return new DependencyBlockedTodoReporter({ - store, - projectId: "p1", - logger: { warn: vi.fn(), error: vi.fn() }, - now: () => NOW, - }); -} - -/** Blocker + 3 dependents held in `hold`, enough to clear the significance gate. */ -function blockedBoard(hold: string, blockerColumn: string): Task[] { - return [ - task({ id: "BLOCKER", column: blockerColumn }), - task({ id: "DEP-1", column: hold, dependencies: ["BLOCKER"] }), - task({ id: "DEP-2", column: hold, dependencies: ["BLOCKER"] }), - task({ id: "DEP-3", column: hold, dependencies: ["BLOCKER"] }), - ]; -} - -describe("DependencyBlockedTodoReporter under a renamed column vocabulary", () => { - it("reports cards blocked in a RENAMED hold column", async () => { - /* The under-reporting half: before the fix, `queued` residents were not - counted as blocked todos at all, so the reporter alerted on nothing. */ - const tasks = blockedBoard(RENAMED.hold, RENAMED.wip); - const workflows = Object.fromEntries(tasks.map((t) => [t.id, "wf-renamed"])); - const { store, upsertInsight } = createStore(tasks, workflows, { - "wf-renamed": ir("wf-renamed", RENAMED), - }); - - const result = await reporter(store).report(); - - expect(result.alerted).toBe(true); - expect(result.groupCount).toBe(1); - const payload = JSON.parse(upsertInsight.mock.calls[0][1].content); - expect(payload.totalBlockedTodoCount).toBe(3); - expect(payload.groups[0].blockedTodoIds).toEqual(["DEP-1", "DEP-2", "DEP-3"]); - }); - - it("does NOT report a blocker that already reached a RENAMED terminal column", async () => { - /* The over-reporting half, in the opposite direction: `published` is not in - the legacy terminal set, so a finished blocker looked live. */ - const tasks = blockedBoard(RENAMED.hold, RENAMED.complete); - const workflows = Object.fromEntries(tasks.map((t) => [t.id, "wf-renamed"])); - const { store } = createStore(tasks, workflows, { "wf-renamed": ir("wf-renamed", RENAMED) }); - - const result = await reporter(store).report(); - - expect(result.alerted).toBe(false); - expect(result.reason).toBe("no-blocked-groups"); - }); - - it("covers BOTH workflows on a board that mixes a renamed and a builtin one", async () => { - /* The union case. A single-vocabulary fix reports one group and silently - drops the other workflow's blocked cards entirely. */ - const tasks = [ - ...blockedBoard(RENAMED.hold, RENAMED.wip), - ...blockedBoard(DEFAULTS.hold, DEFAULTS.wip).map((t) => - task({ ...t, id: `L-${t.id}`, dependencies: t.dependencies?.length ? ["L-BLOCKER"] : [] }), - ), - ]; - const workflows: Record = {}; - for (const t of tasks) workflows[t.id] = t.id.startsWith("L-") ? "wf-default" : "wf-renamed"; - - const { store, upsertInsight } = createStore(tasks, workflows, { - "wf-renamed": ir("wf-renamed", RENAMED), - "wf-default": ir("wf-default", DEFAULTS), - }); - - const result = await reporter(store).report(); - - expect(result.alerted).toBe(true); - expect(result.groupCount).toBe(2); - const payload = JSON.parse(upsertInsight.mock.calls[0][1].content); - expect(payload.groups.map((g: { blockerId: string }) => g.blockerId).sort()).toEqual([ - "BLOCKER", - "L-BLOCKER", - ]); - expect(payload.totalBlockedTodoCount).toBe(6); - }); - - it("still reports a builtin-only board identically (regression floor)", async () => { - const tasks = blockedBoard(DEFAULTS.hold, DEFAULTS.wip); - const workflows = Object.fromEntries(tasks.map((t) => [t.id, "wf-default"])); - const { store, upsertInsight } = createStore(tasks, workflows, { - "wf-default": ir("wf-default", DEFAULTS), - }); - - const result = await reporter(store).report(); - - expect(result.alerted).toBe(true); - const payload = JSON.parse(upsertInsight.mock.calls[0][1].content); - expect(payload.groups[0].blockedTodoIds).toEqual(["DEP-1", "DEP-2", "DEP-3"]); - }); - - it("degrades to the legacy sets when no workflow resolves", async () => { - /* Conservative fallback: an unresolvable board must behave exactly as it did - before this threading rather than dropping columns from the union. */ - const tasks = blockedBoard(DEFAULTS.hold, DEFAULTS.wip); - const { store } = createStore(tasks, {}, {}); - - const result = await reporter(store).report(); - - expect(result.alerted).toBe(true); - expect(result.groupCount).toBe(1); - }); -}); diff --git a/packages/engine/src/__tests__/dependency-blocked-todo-reporter.test.ts b/packages/engine/src/__tests__/dependency-blocked-todo-reporter.test.ts deleted file mode 100644 index 49cc614a82..0000000000 --- a/packages/engine/src/__tests__/dependency-blocked-todo-reporter.test.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { Task, TaskStore } from "@fusion/core"; -import { - DependencyBlockedTodoReporter, - DEPENDENCY_BLOCKED_TODO_TITLE_PREFIX, -} from "../dependency-blocked-todo-reporter.js"; - -/* -FNXC:PgMigrationQuarantine 2026-07-18-04:15: -VAL-REMOVAL-005 reporters await the PostgreSQL-shaped insight-store contract. -Keep mock reads promise-based so cooldown and payload assertions exercise the -same asynchronous collaborator boundary as production. -*/ - -function createTask(overrides: Partial = {}): Task { - return { - id: "FN-1", - description: "test", - title: "Test task", - column: "todo", - dependencies: [], - steps: [], - currentStep: 0, - paused: false, - status: undefined, - blockedBy: "", - overlapBlockedBy: "", - log: [], - createdAt: "2026-05-18T00:00:00.000Z", - updatedAt: "2026-05-18T00:00:00.000Z", - ...overrides, - } as Task; -} - -function createStore(params: { - settings?: Record; - tasks?: Task[]; - insightStore?: { upsertInsight: ReturnType; listInsights: ReturnType }; - throwInsightStore?: boolean; -}): TaskStore { - return { - getSettings: vi.fn().mockResolvedValue(params.settings ?? {}), - listTasks: vi.fn().mockResolvedValue(params.tasks ?? []), - getInsightStore: vi.fn().mockImplementation(() => { - if (params.throwInsightStore) throw new Error("missing insight store"); - return params.insightStore; - }), - logEntry: vi.fn().mockResolvedValue(undefined), - } as unknown as TaskStore; -} - -describe("DependencyBlockedTodoReporter", () => { - const logger = { warn: vi.fn(), error: vi.fn() }; - const now = Date.parse("2026-05-18T12:00:00.000Z"); - - beforeEach(() => vi.clearAllMocks()); - - it("no-ops when disabled", async () => { - const store = createStore({ settings: { dependencyBlockedTodoReportEnabled: false } }); - const reporter = new DependencyBlockedTodoReporter({ store, projectId: "/tmp/project", logger, now: () => now }); - await expect(reporter.report()).resolves.toEqual({ alerted: false, reason: "disabled" }); - expect(store.logEntry).not.toHaveBeenCalled(); - }); - - it("rejects invalid config", async () => { - const store = createStore({ settings: { dependencyBlockedTodoFreshAgeMs: -1 } }); - const reporter = new DependencyBlockedTodoReporter({ store, projectId: "/tmp/project", logger, now: () => now }); - await expect(reporter.report()).resolves.toEqual({ alerted: false, reason: "invalid-config" }); - expect(logger.warn).toHaveBeenCalled(); - }); - - it("returns no-blocked-groups when no dependency-blocked todos exist", async () => { - const store = createStore({ tasks: [createTask({ id: "FN-0", column: "in-progress" })] }); - const reporter = new DependencyBlockedTodoReporter({ store, projectId: "/tmp/project", logger, now: () => now }); - await expect(reporter.report()).resolves.toEqual({ alerted: false, reason: "no-blocked-groups" }); - }); - - it("suppresses single fresh group noise", async () => { - const tasks = [ - createTask({ id: "FN-B", column: "in-progress", columnMovedAt: "2026-05-18T11:58:00.000Z" }), - createTask({ id: "FN-T1", dependencies: ["FN-B"] }), - ]; - const store = createStore({ tasks }); - const reporter = new DependencyBlockedTodoReporter({ store, projectId: "/tmp/project", logger, now: () => now }); - await expect(reporter.report()).resolves.toEqual({ alerted: false, reason: "below-significance" }); - }); - - it("emits stale grouped insight payload", async () => { - const tasks = [ - createTask({ id: "FN-5090", column: "in-progress", title: "Blocker", columnMovedAt: "2026-05-18T05:00:00.000Z" }), - createTask({ id: "FN-5034", dependencies: ["FN-5090"] }), - createTask({ id: "FN-5085", dependencies: ["FN-5090"] }), - createTask({ id: "FN-5089", dependencies: ["FN-5090"] }), - ]; - const insightStore = { upsertInsight: vi.fn(), listInsights: vi.fn().mockResolvedValue([]) }; - const store = createStore({ tasks, insightStore }); - const reporter = new DependencyBlockedTodoReporter({ store, projectId: "/tmp/project", logger, now: () => now }); - - const result = await reporter.report(); - expect(result).toEqual({ alerted: true, groupCount: 1 }); - expect(insightStore.upsertInsight).toHaveBeenCalledTimes(1); - const payload = insightStore.upsertInsight.mock.calls[0][1]; - expect(payload.title).toBe(`${DEPENDENCY_BLOCKED_TODO_TITLE_PREFIX} 2026-05-18`); - expect(payload.category).toBe("workflow"); - expect(payload.provenance.relatedEntityIds).toEqual(["FN-5090"]); - const content = JSON.parse(payload.content); - expect(content.groups).toHaveLength(1); - expect(content.groups[0]).toMatchObject({ blockerId: "FN-5090", blockedTodoCount: 3, blockerTitle: "Blocker" }); - }); - - it("suppresses under cooldown", async () => { - const tasks = [ - createTask({ id: "FN-5090", column: "in-progress", columnMovedAt: "2026-05-18T05:00:00.000Z" }), - createTask({ id: "FN-5034", dependencies: ["FN-5090"] }), - createTask({ id: "FN-5085", dependencies: ["FN-5090"] }), - createTask({ id: "FN-5089", dependencies: ["FN-5090"] }), - ]; - const insightStore = { - upsertInsight: vi.fn(), - listInsights: vi.fn().mockResolvedValue([{ title: `${DEPENDENCY_BLOCKED_TODO_TITLE_PREFIX} 2026-05-18`, updatedAt: "2026-05-18T11:59:30.000Z" }]), - }; - const store = createStore({ tasks, insightStore, settings: { dependencyBlockedTodoReportCooldownMs: 60_000 } }); - const reporter = new DependencyBlockedTodoReporter({ store, projectId: "/tmp/project", logger, now: () => now }); - await expect(reporter.report()).resolves.toEqual({ alerted: false, reason: "cooldown" }); - }); - - it("re-emits outside cooldown", async () => { - const tasks = [ - createTask({ id: "FN-5090", column: "in-progress", columnMovedAt: "2026-05-18T05:00:00.000Z" }), - createTask({ id: "FN-5034", dependencies: ["FN-5090"] }), - createTask({ id: "FN-5085", dependencies: ["FN-5090"] }), - createTask({ id: "FN-5089", dependencies: ["FN-5090"] }), - ]; - const insightStore = { - upsertInsight: vi.fn(), - listInsights: vi.fn().mockResolvedValue([{ title: `${DEPENDENCY_BLOCKED_TODO_TITLE_PREFIX} 2026-05-17`, updatedAt: "2026-05-18T11:58:00.000Z" }]), - }; - const store = createStore({ tasks, insightStore, settings: { dependencyBlockedTodoReportCooldownMs: 60_000 } }); - const reporter = new DependencyBlockedTodoReporter({ store, projectId: "/tmp/project", logger, now: () => now }); - await expect(reporter.report()).resolves.toEqual({ alerted: true, groupCount: 1 }); - expect(insightStore.upsertInsight).toHaveBeenCalledTimes(1); - }); - - it("falls back to logEntry when insight store unavailable", async () => { - const tasks = [ - createTask({ id: "FN-5090", column: "in-progress", columnMovedAt: "2026-05-18T05:00:00.000Z" }), - createTask({ id: "FN-5034", dependencies: ["FN-5090"] }), - createTask({ id: "FN-5085", dependencies: ["FN-5090"] }), - createTask({ id: "FN-5089", dependencies: ["FN-5090"] }), - ]; - const store = createStore({ tasks, throwInsightStore: true }); - const reporter = new DependencyBlockedTodoReporter({ store, projectId: "/tmp/project", logger, now: () => now }); - - await expect(reporter.report()).resolves.toEqual({ alerted: true, groupCount: 1 }); - expect(store.logEntry).toHaveBeenCalledWith("FN-5090", expect.stringContaining("[dependency-blocked-todo]")); - expect(logger.warn).toHaveBeenCalled(); - }); - - it("honors injected now for deterministic age bucket", async () => { - const tasks = [ - createTask({ id: "FN-5090", column: "in-progress", columnMovedAt: "2026-05-18T11:20:00.000Z" }), - createTask({ id: "FN-5034", dependencies: ["FN-5090"] }), - createTask({ id: "FN-5085", dependencies: ["FN-5090"] }), - createTask({ id: "FN-5089", dependencies: ["FN-5090"] }), - ]; - const insightStore = { upsertInsight: vi.fn(), listInsights: vi.fn().mockResolvedValue([]) }; - const store = createStore({ tasks, insightStore }); - const reporter = new DependencyBlockedTodoReporter({ store, projectId: "/tmp/project", logger, now: () => Date.parse("2026-05-18T12:00:00.000Z") }); - await reporter.report(); - const content = JSON.parse(insightStore.upsertInsight.mock.calls[0][1].content); - expect(content.groups[0].ageBucket).toBe("aging"); - }); -}); diff --git a/packages/engine/src/__tests__/self-healing.test.ts b/packages/engine/src/__tests__/self-healing.test.ts index 5c459ba41c..1b3c336679 100644 --- a/packages/engine/src/__tests__/self-healing.test.ts +++ b/packages/engine/src/__tests__/self-healing.test.ts @@ -7667,45 +7667,6 @@ 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("surfaceInReviewStalled", () => { function inReviewTask(overrides: Record = {}) { return { diff --git a/packages/engine/src/dependency-blocked-todo-reporter.ts b/packages/engine/src/dependency-blocked-todo-reporter.ts deleted file mode 100644 index 4d67ae8183..0000000000 --- a/packages/engine/src/dependency-blocked-todo-reporter.ts +++ /dev/null @@ -1,224 +0,0 @@ -import { - computeDependencyBlockedTodoReport, - computeInsightFingerprint, - resolveLifecycleColumns, - resolveWorkflowIrForTask, - DEFAULT_DEPENDENCY_BLOCKED_TODO_MAX_GROUPS, - type Task, - type TaskStore, - type WorkflowIr, -} from "@fusion/core"; -import { createLogger } from "./logger.js"; - -const reporterLog = createLogger("dependency-blocked-todo"); -const TITLE_PREFIX = "Backlog health: dependency-blocked todos"; - -type DependencyBlockedTodoReporterLogger = { - warn: (message: string, ...args: unknown[]) => void; - error?: (message: string, ...args: unknown[]) => void; -}; - -interface DependencyBlockedTodoReporterOptions { - store: TaskStore; - projectId: string; - logger?: DependencyBlockedTodoReporterLogger; - now?: () => number; -} - -export class DependencyBlockedTodoReporter { - private readonly store: TaskStore; - private readonly projectId: string; - private readonly logger: DependencyBlockedTodoReporterLogger; - private readonly now: () => number; - - constructor(options: DependencyBlockedTodoReporterOptions) { - this.store = options.store; - this.projectId = options.projectId; - this.logger = options.logger ?? reporterLog; - this.now = options.now ?? (() => Date.now()); - } - - /* - FNXC:WorkflowLifecycleColumns 2026-07-28-17:50 (PR #2479 review, P1 + P2): - Classify every task against ITS OWN workflow. - - This replaces a board-wide UNION of roles, which was wrong in the way this whole - program is about: a column id means something only RELATIVE TO ITS WORKFLOW. If - one workflow calls `done` its hold column and another calls `done` terminal, a - union marks that column BOTH, so dependents count as held while the blocker - beside them is discarded as finished — from a single ambiguous id. Resolving per - task makes that impossible by construction instead of detectable afterwards. - - It also fixes the sibling P2 as a side effect rather than needing its own memo - layer: ONE caller-owned `irCache` is shared across the whole pass, so - workflow-definition and prompt-override reads scale with the number of - WORKFLOWS, not the number of cards. - - Fail-soft per task: a card whose workflow will not resolve falls back to the - legacy roles, so one bad workflow degrades that card to today's behavior instead - of breaking the report. - */ - private async buildTaskLifecycleClassifier( - tasks: readonly Task[], - ): Promise<(task: Task) => { isHold: boolean; isTerminal: boolean }> { - const irCache = new Map(); - const rolesByTaskId = new Map(); - - for (const task of tasks) { - try { - const lifecycle = resolveLifecycleColumns(await resolveWorkflowIrForTask(this.store, task.id, irCache)); - if (!lifecycle) continue; - rolesByTaskId.set(task.id, { - isHold: lifecycle.hold !== undefined && task.column === lifecycle.hold, - isTerminal: - (lifecycle.complete !== undefined && task.column === lifecycle.complete) || - (lifecycle.archived !== undefined && task.column === lifecycle.archived), - }); - } catch { - // Leave unmapped: the legacy fallback below applies to this card only. - } - } - - return (task: Task) => - rolesByTaskId.get(task.id) ?? { - isHold: task.column === "todo", - isTerminal: task.column === "done" || task.column === "archived", - }; - } - - async report(): Promise<{ alerted: boolean; reason?: string; groupCount?: number }> { - try { - const settings = await this.store.getSettings(); - if (settings.dependencyBlockedTodoReportEnabled === false) { - return { alerted: false, reason: "disabled" }; - } - - const freshAgeMs = settings.dependencyBlockedTodoFreshAgeMs ?? 30 * 60_000; - const staleAgeMs = settings.dependencyBlockedTodoStaleAgeMs ?? 4 * 60 * 60_000; - const minBlockedTodoCount = settings.dependencyBlockedTodoMinCount ?? 1; - const cooldownMs = settings.dependencyBlockedTodoReportCooldownMs ?? 6 * 60 * 60_000; - if ( - !Number.isFinite(freshAgeMs) || - freshAgeMs <= 0 || - !Number.isFinite(staleAgeMs) || - staleAgeMs <= 0 || - !Number.isFinite(minBlockedTodoCount) || - minBlockedTodoCount <= 0 || - !Number.isFinite(cooldownMs) || - cooldownMs < 0 - ) { - this.logger.warn("[dependency-blocked-todo] invalid config: thresholds must be valid finite values"); - return { alerted: false, reason: "invalid-config" }; - } - - const configuredMaxAutoMergeRetries = Number(settings.maxAutoMergeRetries); - const maxAutoMergeRetries = Number.isFinite(configuredMaxAutoMergeRetries) - ? configuredMaxAutoMergeRetries - : 3; - const tasks = await this.store.listTasks({ slim: true, includeArchived: false }); - const taskById = new Map(tasks.map((task) => [task.id, task])); - const nowMs = this.now(); - const classifyTask = await this.buildTaskLifecycleClassifier(tasks); - const report = computeDependencyBlockedTodoReport(tasks, maxAutoMergeRetries, { - now: nowMs, - freshAgeMs, - staleAgeMs, - minBlockedTodoCount, - maxGroups: DEFAULT_DEPENDENCY_BLOCKED_TODO_MAX_GROUPS, - classifyTask, - }); - - if (report.uniqueBlockerCount === 0) { - return { alerted: false, reason: "no-blocked-groups" }; - } - - const hasAgingOrStale = report.groups.some((group) => group.ageBucket !== "fresh"); - if (!hasAgingOrStale && report.totalBlockedTodoCount < 3) { - return { alerted: false, reason: "below-significance" }; - } - - const detectedAt = new Date(nowMs).toISOString(); - const title = `${TITLE_PREFIX} ${detectedAt.slice(0, 10)}`; - const contentPayload = { - observedAt: report.observedAt, - totalBlockedTodoCount: report.totalBlockedTodoCount, - uniqueBlockerCount: report.uniqueBlockerCount, - thresholds: report.thresholds, - groups: report.groups.map((group) => ({ - blockerId: group.blockerId, - blockerColumn: group.blockerColumn, - blockerTitle: taskById.get(group.blockerId)?.title, - blockedTodoCount: group.blockedTodoCount, - ageBucket: group.ageBucket, - blockingAgeMs: group.blockingAgeMs, - blockedTodoIds: group.blockedTodoIds.slice(0, 10), - viaDependencies: group.viaDependencies.slice(0, 10), - viaBlockedBy: group.viaBlockedBy.slice(0, 10), - })), - }; - const content = JSON.stringify(contentPayload); - - let insightStore; - try { - if (!this.projectId) throw new Error("empty projectId"); - // FNXC:PostgresInsights 2026-07-14-17:25: Both store implementations - // share an awaitable API; backend mode must emit durable insights. - insightStore = this.store.getInsightStore(); - } catch (error) { - await this.store.logEntry(report.groups[0].blockerId, `[dependency-blocked-todo] ${content}`); - this.logger.warn("[dependency-blocked-todo] insight store unavailable; logged fallback payload", error); - this.logger.warn( - `[dependency-blocked-todo] alert: groups=${report.uniqueBlockerCount} blockedTodos=${report.totalBlockedTodoCount} blockers=${report.groups - .slice(0, 3) - .map((group) => group.blockerId) - .join(",")}`, - ); - return { alerted: true, groupCount: report.uniqueBlockerCount }; - } - - if (cooldownMs > 0) { - const insights = await insightStore.listInsights({ - projectId: this.projectId, - category: "workflow", - status: "generated", - limit: 10, - }); - const latest = [...insights] - .filter((insight) => insight.title.startsWith(TITLE_PREFIX)) - .sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt))[0]; - if (latest) { - const updatedAtMs = Date.parse(latest.updatedAt); - if (Number.isFinite(updatedAtMs) && nowMs - updatedAtMs < cooldownMs) { - return { alerted: false, reason: "cooldown" }; - } - } - } - - await insightStore.upsertInsight(this.projectId, { - title, - content, - category: "workflow", - fingerprint: computeInsightFingerprint(title, "workflow"), - provenance: { - trigger: "schedule", - description: "Dependency-blocked Todo grouping (generated by dependency-blocked-todo-reporter)", - relatedEntityIds: report.groups.map((group) => group.blockerId), - metadata: { generator: "dependency-blocked-todo-reporter" }, - }, - }); - - this.logger.warn( - `[dependency-blocked-todo] alert: groups=${report.uniqueBlockerCount} blockedTodos=${report.totalBlockedTodoCount} blockers=${report.groups - .slice(0, 3) - .map((group) => group.blockerId) - .join(",")}`, - ); - return { alerted: true, groupCount: report.uniqueBlockerCount }; - } catch (error) { - this.logger.error?.("[dependency-blocked-todo] reporter failed", error); - return { alerted: false, reason: "error" }; - } - } -} - -export { TITLE_PREFIX as DEPENDENCY_BLOCKED_TODO_TITLE_PREFIX }; diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index f37ffb347f..013dc26c56 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -96,7 +96,6 @@ import { type NtfyNotifier, } from "./notifier.js"; import type { GhostBugDecision } from "./triage-preflight.js"; -import { DependencyBlockedTodoReporter } from "./dependency-blocked-todo-reporter.js"; import { filterPathsByIgnoreList, getUnmetSchedulingDependencies, isCoordinationOnlyTask, pathsOverlap, shouldHoldActiveFileScopeLease } from "./scheduler.js"; import { evaluateParkedAgentTaskLink, PARKED_AGENT_LINK_FRESH_RUN_MS } from "./task-agent-sync.js"; import { describeSelfHealingNoActionWedge } from "./notification/task-wedge-notification.js"; @@ -895,7 +894,6 @@ export class SelfHealingManager { private preservedQueuedOverlapLogged = new Map(); private maintenanceTickCounter = 0; private readonly processBootStartedAt = Date.now(); - private dependencyBlockedTodoReporter: DependencyBlockedTodoReporter | null = null; private lastDbCorruptionNotifiedAt: number | null = null; private boardStallWindow: { @@ -8029,39 +8027,6 @@ 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; - } - } - /** * Surface quiet-window backlog-health diagnostics for unpaused in-review tasks. *