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:
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user