U4 trim: delete the dependency-blocked-todo feature (unreachable in production) and revert 5a2de7d (#2477)

Stacked on #2474. Base is `feature/workflow-vocabulary-u4-dead-code` —
do not merge before it.

Deletes an **entire feature that has never executed in production**, and
reverts `5a2de7d`, which only threaded resolved lifecycle columns
through it.

## Reachability evidence — the whole basis for this

```
surfaceDependencyBlockedTodos          ← in NEITHER sweep registry; no caller in
  └─ getDependencyBlockedTodoReporter()      engine/dashboard/cli — only tests
      └─ engine/dependency-blocked-todo-reporter.ts   ← sole caller of ↓
          └─ core/computeDependencyBlockedTodoReport
```

self-healing owns two name-based sweep registries (`runStartupRecovery`,
58 entries; `runMaintenance`, 76). `surfaceDependencyBlockedTodos` is in
**neither**, so nothing ever invoked the chain below it. Its four tests
passed while proving nothing about production.

## Why delete rather than wire it up

Wiring was the tempting option and is the riskier one. Switching on a
450-line path that has never run — whose tests therefore establish
nothing about its behavior against real data — is a **behavior change
with unquantified blast radius**. This program already refused exactly
that move for the **pool-id sentinel**, a one-line change that would
switch on dormant enforcement across every project. This is the same
class of move at ~450× the size.

Deleting is also the recoverable direction: git keeps the feature, and
it can be resurrected deliberately — with tests that prove it *runs* —
if dependency-blocked reporting is actually wanted.

## The settings keys go with it

`dependencyBlockedTodoReportEnabled` defaulted `true` while driving
nothing. A schema/API-visible switch that lies about what the system
does is worse than no switch. (It had no dashboard UI field — the
dashboard test allowlist already recorded it as *"no UI field"*.) Four
sibling tuning keys are removed with it.

## Against my own earlier work

`5a2de7d` threaded resolved lifecycle roles into
`computeDependencyBlockedTodoReport` and its reporter, answering a
review finding I confirmed as real. **The code was correct; the impact
claim was not**, because the path never executes. Neither the reviewer
nor I checked *reachability* before agreeing the defect mattered — only
correctness. A correction is posted on that thread in #2470.

**Scope limit on that admission:** the same finding also described
*incorrect scheduler ordering*. That half runs through
`buildUnblockWeightMap` in `task-priority.ts`, which is **live** and was
already threading `terminalColumns` (B1, `434b385`). Scheduler ordering
was never affected, before or after.

## What survives

`blocker-fanout.ts` **stays** — it is live via `task-priority.ts`. Only
the plural `holdColumns` option added by `5a2de7d` is reverted, since
the deleted report was its sole consumer. `holdColumn` (singular, from
B1) remains.

## Net

**1,244 deletions / 5 insertions across 15 files** — ~450 production
lines, ~684 test lines, 5 settings keys.

## Verification

- `tsc --noEmit` clean in **core, engine, and dashboard-app**; `pnpm
lint` clean
- merge gate green (299 + 10 + 71)
- self-healing suite: 411 passed, 1 **pre-existing** failure
(`archiveStaleDoneTasks`)
- dashboard settings-descriptions suite green
- `settings-parity.test.ts` has one **pre-existing** failure
(`agentToolOutputMaxChars` overlap) that fails identically with these
changes stashed — unrelated to this deletion

No changeset: `@fusion/core` and `@fusion/engine` are private.

🤖 Generated with [Claude Code](https://claude.com/claude-code)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added quiet-window backlog health diagnostics for stalled items in
review, with repeat-alert suppression.
  * Added default thresholds for backlog-pressure alerts.

* **Changes**
  * Removed dependency-blocked todo reporting and related alerts.
* Removed the dependency-blocked todo enable/disable setting; remaining
tuning options are no longer active.
* Updated the workflow hold classification to use a single todo column.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
gsxdsm
2026-07-27 14:54:32 -07:00
committed by GitHub
parent 923a7c0fc9
commit 710d56b2db
15 changed files with 5 additions and 1269 deletions

View File

@@ -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> = {}): 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"]);
});
});

View File

@@ -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> = {}): 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);
});
});

View File

@@ -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);

View File

@@ -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<string>;
/*
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<string> =
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<string, MutableEntry>();
@@ -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;

View File

@@ -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<DependencyBlockedTodoGroup["ageBucket"], number> = {
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,
},
};
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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).

View File

@@ -493,11 +493,6 @@ const NOT_SURFACED_ALLOWLIST: Record<string, string> = {
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",

View File

@@ -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> = {}): 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<string, string>,
irByWorkflow: Record<string, WorkflowIr>,
): { store: TaskStore; upsertInsight: ReturnType<typeof vi.fn> } {
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<string, string> = {};
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);
});
});

View File

@@ -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> = {}): 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<string, unknown>;
tasks?: Task[];
insightStore?: { upsertInsight: ReturnType<typeof vi.fn>; listInsights: ReturnType<typeof vi.fn> };
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");
});
});

View File

@@ -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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<string, unknown> = {}) {
return {

View File

@@ -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<string, WorkflowIr>();
const rolesByTaskId = new Map<string, { isHold: boolean; isTerminal: boolean }>();
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 };

View File

@@ -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<string, string>();
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<number> {
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.
*