From 65f4e8533e2be32c6f74640ac66f07360e60b787 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 30 Jul 2026 23:41:14 -0700 Subject: [PATCH] fix(dashboard): blocker fan-out classified every board against the LEGACY lanes (finished cards shown as blockers; escalation never fired) (#2990) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dashboard's `computeBlockerFanoutMap` wrapper called core with **no lane answers at all**: ```ts return computeBlockerFanoutMapCore(tasks, MAX_AUTO_MERGE_RETRIES, { staleHighFanoutAgeThresholdMs: options.staleHighFanoutAgeThresholdMs, }); // no terminalColumns, no reviewColumns, no holdColumn, no classify ``` So every fan-out surface classified against `todo` / `in-review` / `done` regardless of what the operator named their columns. Core defines **active by exclusion — not terminal** — so on a renamed board a **finished** card never became terminal and stayed an active blocker forever. The Executor bar's highest-overlap blocker and the task modal's blocking-dependents list both kept naming work that had already landed. **Escalation was worse.** `shouldEscalate` requires the blocker to sit in an escalation lane (wip ∪ review), which unresolved means `in-progress`/`in-review` only — so a stale blocker holding up many cards **never escalated**. The fan-out numbers themselves stayed correct, which is what makes it easy to miss: the metric says there is a problem and the mechanism that acts on it is switched off. ## Shape **Per task, not a board-wide union** — the reason `blocker-fanout.ts` documents on `classify`: an id means something only relative to its own workflow, and this board renders several at once. `Board` builds the index exactly as `App.tsx` already does for the footer (`footerColumnFlagsByTaskId`): task → its own workflow → that workflow's entry for the column the card rests in. **Escalation = wip ∪ review**, mirroring `scheduler.ts`'s own construction. The two must agree — the scheduler decides a blocker escalates and the dashboard is where an operator sees it. **An empty trait map means "not resolved yet", not "nothing is terminal."** The pre-load window and the remote-node case keep the documented legacy default rather than fabricated lifecycle state. ## Reverted | case | reverted | |---|---| | a finished card in a renamed completion lane is not an active blocker | **fails** | | a stale high-fan-out blocker in a renamed wip lane escalates | **fails** | | unresolved traits stay byte-identical | passes either way — that is why it is there | ## Two notes - The hook call had to move below `useBoardWorkflows` in `Board` (it was at line 206, the workflows at ~390). `blockerFanoutMap` is consumed only in JSX, so the hook order change is unconditional and stable. - The unresolved-card fallbacks are hoisted into three named helpers with `DELIBERATE-LITERAL` markers on the **declarations** — the census reads markers from leading comments, so an inline one attaches to the wrong node and is silently ignored. Census baseline re-recorded in the same commit (debt did not increase; markers moved 5 sites out of the guard count). ## Not done `ExecutorStatusBar` and `TaskDetailModal` call the wrapper directly and still pass no traits. `ExecutorStatusBar` already receives `columnFlagsByTaskId` so it is a one-liner; `TaskDetailModal` has no trait index in scope and needs one threaded. Left out to keep this reviewable — the ratchet keeps both visible. ## Verification dashboard app suite **1919 passed (140 files)** · `pnpm test:gate` 161 + 13 + 487 + 71 · lint · census `--strict` · lane-wiring · fnxc-dates · changesets — green. --------- Co-authored-by: Claude Opus 5 (1M context) --- .changeset/blocker-fanout-dashboard-lanes.md | 7 + packages/dashboard/app/components/Board.tsx | 28 +++- .../app/components/ExecutorStatusBar.tsx | 9 +- .../__tests__/ExecutorStatusBar.test.tsx | 33 ++++ ...oard-blocker-fanout-renamed-lanes.test.tsx | 150 ++++++++++++++++++ .../hooks/__tests__/useBlockerFanout.test.ts | 84 ++++++++++ .../dashboard/app/hooks/useBlockerFanout.ts | 109 ++++++++++++- .../lib/lifecycle-column-census-baseline.json | 5 + 8 files changed, 419 insertions(+), 6 deletions(-) create mode 100644 .changeset/blocker-fanout-dashboard-lanes.md create mode 100644 packages/dashboard/app/components/__tests__/board-blocker-fanout-renamed-lanes.test.tsx diff --git a/.changeset/blocker-fanout-dashboard-lanes.md b/.changeset/blocker-fanout-dashboard-lanes.md new file mode 100644 index 0000000000..9d6c9dd94a --- /dev/null +++ b/.changeset/blocker-fanout-dashboard-lanes.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Blocker fan-out on the board now uses your own column names, so finished cards stop being shown as blockers. +category: fix +dev: The dashboard `computeBlockerFanoutMap` wrapper forwards per-task `classify`/`escalationClassify`/`reviewColumns` derived from each task's own workflow traits; `Board` builds the index the way `App.tsx` already does for the footer. diff --git a/packages/dashboard/app/components/Board.tsx b/packages/dashboard/app/components/Board.tsx index b18f37cbb7..ccaab555f6 100644 --- a/packages/dashboard/app/components/Board.tsx +++ b/packages/dashboard/app/components/Board.tsx @@ -7,7 +7,7 @@ import type { ToastType } from "../hooks/useToast"; import { useState, useMemo, useEffect, useCallback, useRef } from "react"; import { createPortal } from "react-dom"; import { promoteTask, type ModelInfo, type BoardWorkflowsPayload, type BoardWorkflowColumn, type RevertTaskOptions, type RevertTaskResult } from "../api"; -import { useBlockerFanout } from "../hooks/useBlockerFanout"; +import { useBlockerFanout, type BlockerFanoutColumnFlags } from "../hooks/useBlockerFanout"; import { useColumnScrollSnap } from "../hooks/useColumnScrollSnap"; import { MOBILE_MEDIA_QUERY, useViewportMode } from "../hooks/useViewportMode"; import { recordResumeEvent } from "../utils/resumeInstrumentation"; @@ -203,9 +203,6 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o return document.getElementById("header-workflow-slot"); }); const viewportMode = useViewportMode(); - const blockerFanoutMap = useBlockerFanout(tasks, { - staleHighFanoutAgeThresholdMs: staleHighFanoutBlockerAgeThresholdMs, - }); // Normalized search-active signal: trimmed and non-empty const isSearchActive = searchQuery.trim() !== ""; useEffect(() => { @@ -380,6 +377,29 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o } = useBoardWorkflows({ projectId }); const draggingTaskIdRef = useRef(null); + /* + FNXC:WorkflowResolvedColumns 2026-07-30-23:15 (the board's fan-out read the LEGACY lanes): + Resolve each card's traits through its OWN workflow — the construction `App.tsx` already uses for + the footer index — so the fan-out map classifies against the operator's column names. Without it + core fell back to `todo`/`in-review`/`done`, and since "active" is defined by exclusion, a finished + card in a renamed completion lane stayed an active blocker forever. + */ + const blockerFanoutColumnFlagsByTaskId = useMemo(() => { + const index = new Map(); + if (!boardWorkflows) return index; + const workflowsById = new Map(boardWorkflows.workflows.map((workflow) => [workflow.id, workflow])); + for (const task of tasks) { + const workflow = workflowsById.get(boardWorkflows.taskWorkflowIds[task.id] ?? boardWorkflows.defaultWorkflowId); + const flags = workflow?.columns.find((column) => column.id === task.column)?.flags; + if (flags) index.set(task.id, flags); + } + return index; + }, [boardWorkflows, tasks]); + const blockerFanoutMap = useBlockerFanout(tasks, { + staleHighFanoutAgeThresholdMs: staleHighFanoutBlockerAgeThresholdMs, + columnFlagsByTaskId: blockerFanoutColumnFlagsByTaskId, + }); + const handlePromote = useCallback(async (taskId: string, options?: { force?: boolean }) => { // `force` only ever arrives from Column's confirmed unplanned-for-execution override. await promoteTask(taskId, projectId, options); diff --git a/packages/dashboard/app/components/ExecutorStatusBar.tsx b/packages/dashboard/app/components/ExecutorStatusBar.tsx index 96404b2c4e..15ff39861d 100644 --- a/packages/dashboard/app/components/ExecutorStatusBar.tsx +++ b/packages/dashboard/app/components/ExecutorStatusBar.tsx @@ -222,6 +222,13 @@ export function ExecutorStatusBar({ tasks, projectId, columnFlagsByTaskId: suppl const fanoutMap = computeBlockerFanoutMap(tasks, { staleHighFanoutAgeThresholdMs: staleHighFanoutBlockerAgeThresholdMs ?? STALE_HIGH_FANOUT_BLOCKER_AGE_THRESHOLD_MS, + /* + FNXC:WorkflowResolvedColumns 2026-07-30-23:55: + The same per-task trait index this bar already takes for its stuck/running counts. Without it + the fan-out classified against `todo`/`in-review`/`done`, so on a renamed board the overlap + bottleneck this segment exists to surface was never detected at all. + */ + columnFlagsByTaskId, }); const candidates = Array.from(fanoutMap.entries()) .map(([blockerId, entry]) => ({ blockerId, entry })) @@ -235,7 +242,7 @@ export function ExecutorStatusBar({ tasks, projectId, columnFlagsByTaskId: suppl }); return candidates[0] ?? null; - }, [tasks, staleHighFanoutBlockerAgeThresholdMs]); + }, [tasks, staleHighFanoutBlockerAgeThresholdMs, columnFlagsByTaskId]); const StateIcon = stateDisplay.icon; diff --git a/packages/dashboard/app/components/__tests__/ExecutorStatusBar.test.tsx b/packages/dashboard/app/components/__tests__/ExecutorStatusBar.test.tsx index bf7abc0f6c..a3e9a74a99 100644 --- a/packages/dashboard/app/components/__tests__/ExecutorStatusBar.test.tsx +++ b/packages/dashboard/app/components/__tests__/ExecutorStatusBar.test.tsx @@ -232,6 +232,39 @@ describe("ExecutorStatusBar", () => { expect(statusBar).toHaveTextContent("FN-002 · 5 todo"); }); + it("shows the overlap bottleneck on a RENAMED board", () => { + /* + FNXC:WorkflowResolvedColumns 2026-07-30-23:58: + The board above renamed and nothing else. Without resolved traits the fan-out counts + `overlapBlockedTodoCount` against the literal `todo`, which no card is in, so the segment this + test asserts never rendered — the bottleneck existed and the bar stayed silent about it. + */ + const tasks = [ + makeTask("FN-010", "building", { columnMovedAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z" }), + makeTask("FN-101", "drafting", { blockedBy: "FN-010" }), + makeTask("FN-102", "drafting", { blockedBy: "FN-010" }), + makeTask("FN-103", "drafting", { blockedBy: "FN-010" }), + makeTask("FN-104", "drafting", { blockedBy: "FN-010" }), + makeTask("FN-105", "drafting", { blockedBy: "FN-010" }), + ]; + const columnFlagsByTaskId = new Map(tasks.map((task) => [ + task.id, + task.column === "building" ? { countsTowardWip: true } : { hold: true }, + ])); + + render( + , + ); + + const statusBar = screen.getByRole("status"); + expect(statusBar).toHaveTextContent("Overlap queue"); + expect(statusBar).toHaveTextContent("FN-010 · 5 todo"); + }); + it("does not show overlap queue summary for ordinary chains below threshold", () => { const tasks = [ makeTask("FN-500", "in-progress"), diff --git a/packages/dashboard/app/components/__tests__/board-blocker-fanout-renamed-lanes.test.tsx b/packages/dashboard/app/components/__tests__/board-blocker-fanout-renamed-lanes.test.tsx new file mode 100644 index 0000000000..5f4aea8b48 --- /dev/null +++ b/packages/dashboard/app/components/__tests__/board-blocker-fanout-renamed-lanes.test.tsx @@ -0,0 +1,150 @@ +/* +FNXC:WorkflowResolvedColumns 2026-07-30-23:50: +THE CARD FAN-OUT BADGES READ LEGACY LANE IDS ON A RENAMED BOARD. + +`computeBlockerFanoutMap` in core takes four lane options. The dashboard wrapper +(`hooks/useBlockerFanout.ts`) declared and forwarded only `staleHighFanoutAgeThresholdMs`, so core +fell back to `holdColumn: "todo"`, `LEGACY_TERMINAL_COLUMNS` and `BLOCKER_ESCALATION_COLUMNS` for +every dashboard caller. On a board whose lanes are renamed none of those match, so `activeTodoCount` +— the "blocking N tasks" count on the card — comes back ZERO while real cards sit blocked. + +WHY THIS TEST DRIVES `Board` RATHER THAN THE HOOK. Testing the wrapper would prove the wrapper +forwards what it is given and say nothing about whether anything gives it — the exact producer/ +consumer split that this program's learnings doc records as its fifth failure shape, where a +converted consumer with an unconverted producer passed every instrument. The defect here IS the +producer: `Board` had no supplier for these options. So the assertion runs through the real Board, +the real per-task workflow metadata, and core's real computation. + +The cases are DIFFERENTIAL: identical task graphs under two vocabularies whose roles are the same and +only the ids differ. `drafting` collides with no legacy id, so a surviving `"todo"` cannot pass by +luck. +*/ + +import type React from "react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, waitFor } from "@testing-library/react"; +import type { Task } from "@fusion/core"; +import { Board } from "../Board"; +import type { BoardWorkflowsPayload } from "../../api"; +import type { BlockerFanoutEntry } from "../../hooks/useBlockerFanout"; + +const fetchBoardWorkflowsMock = vi.fn(); + +vi.mock("../../api", () => ({ + fetchWorkflowSteps: vi.fn(() => new Promise(() => {})), + fetchBoardWorkflows: (...args: unknown[]) => fetchBoardWorkflowsMock(...args), + promoteTask: vi.fn().mockResolvedValue({}), + fetchTaskDetail: vi.fn(() => new Promise(() => {})), + batchUpdateTaskModels: vi.fn(), + fetchNodes: vi.fn(() => new Promise(() => {})), +})); + +vi.mock("../../sse-bus", () => ({ subscribeSse: vi.fn(() => () => {}) })); + +/* The Column mock is the probe: it captures the fan-out map Board computed and handed down. */ +let captured: ReadonlyMap | undefined; +let renderedColumns = 0; +vi.mock("../Column", () => ({ + Column: ({ blockerFanoutMap }: { blockerFanoutMap?: ReadonlyMap }) => { + renderedColumns += 1; + if (blockerFanoutMap) captured = blockerFanoutMap; + return
; + }, +})); + +const RENAME: Record = { + todo: "drafting", + "in-progress": "building", + "in-review": "checking", + done: "shipped", + archived: "filed", +}; + +function workflowsPayload(renamed: boolean): BoardWorkflowsPayload { + const id = (legacy: string) => (renamed ? RENAME[legacy] : legacy); + return { + flagEnabled: true, + defaultWorkflowId: "builtin:coding", + /* Every card is explicitly mapped, so the per-task accessor resolves against THIS workflow + rather than falling through the unmapped path. */ + taskWorkflowIds: { "KB-BLOCK": "builtin:coding", "KB-DEP1": "builtin:coding", "KB-DEP2": "builtin:coding" }, + workflows: [ + { + id: "builtin:coding", + name: "Coding", + columns: [ + { id: id("todo"), name: "Hold", flags: { hold: true, intake: true } }, + { id: id("in-progress"), name: "Building", flags: { countsTowardWip: true } }, + { + id: id("in-review"), + name: "Checking", + flags: { countsTowardWip: true, mergeBlocker: true, humanReview: true }, + }, + { id: id("done"), name: "Shipped", flags: { complete: true } }, + { id: id("archived"), name: "Filed", flags: { archived: true, hiddenFromBoard: true } }, + ], + }, + ], + } as unknown as BoardWorkflowsPayload; +} + +/** One blocker plus two cards held behind it, in whichever lane plays the hold role. */ +function tasksFor(holdLane: string): Task[] { + const base = { description: "t", createdAt: "2026-06-01T00:00:00.000Z", updatedAt: "2026-06-01T00:00:00.000Z" }; + return [ + { id: "KB-BLOCK", title: "the blocker", column: holdLane, ...base }, + { id: "KB-DEP1", title: "dep one", column: holdLane, dependencies: ["KB-BLOCK"], ...base }, + { id: "KB-DEP2", title: "dep two", column: holdLane, dependencies: ["KB-BLOCK"], ...base }, + ] as unknown as Task[]; +} + +async function renderBoard(renamed: boolean): Promise { + captured = undefined; + fetchBoardWorkflowsMock.mockResolvedValue(workflowsPayload(renamed)); + const holdLane = renamed ? "drafting" : "todo"; + const props = { + tasks: tasksFor(holdLane), + projectId: "p1", + maxConcurrent: 2, + onMoveTask: vi.fn(), + onOpenDetail: vi.fn(), + addToast: vi.fn(), + onQuickCreate: vi.fn(), + onNewTask: vi.fn(), + autoMerge: true, + onToggleAutoMerge: vi.fn(), + showWorktreeGrouping: false, + planAutoApproveEnabled: false, + onTogglePlanAutoApprove: vi.fn(), + } as unknown as React.ComponentProps; + render(); + /* ANTI-VACUITY: a Board that throws during render also produces no fan-out map, and the assertions + below would then read `undefined` rather than a wrong number. Prove the tree actually rendered + before trusting anything it handed down. */ + await waitFor(() => expect(renderedColumns).toBeGreaterThan(0)); + await waitFor(() => expect(captured).toBeDefined()); + return captured?.get("KB-BLOCK"); +} + +describe("blocker fan-out under a renamed board vocabulary", () => { + beforeEach(() => { + captured = undefined; + vi.clearAllMocks(); + }); + + /* Control: the default vocabulary counts both dependents. Passes before and after the fix, so a + generally broken fan-out cannot hide behind the renamed case below. */ + it("default vocabulary: both held dependents are counted", async () => { + const entry = await renderBoard(false); + expect(entry?.totalCount).toBe(2); + expect(entry?.activeTodoCount).toBe(2); + }); + + /* The defect: before the fix `holdColumn` was the literal "todo", which this board does not have, + so the held count came back zero while two cards sat blocked. */ + it("renamed vocabulary: both held dependents are counted", async () => { + const entry = await renderBoard(true); + expect(entry?.totalCount).toBe(2); + expect(entry?.activeTodoCount).toBe(2); + }); +}); diff --git a/packages/dashboard/app/hooks/__tests__/useBlockerFanout.test.ts b/packages/dashboard/app/hooks/__tests__/useBlockerFanout.test.ts index 4f302763e5..6a6d8cd18b 100644 --- a/packages/dashboard/app/hooks/__tests__/useBlockerFanout.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useBlockerFanout.test.ts @@ -143,3 +143,87 @@ describe("computeBlockerFanoutMap", () => { expect(source).toContain("SelfHealingManager must call resolveMaxAutoMergeRetries(settings)"); }); }); + + +/* +FNXC:WorkflowResolvedColumns 2026-07-30-23:30: +The dashboard wrapper passed core NO lane answers, so every fan-out surface classified against +`todo` / `in-review` / `done`. Core defines ACTIVE by exclusion — not terminal — so on a renamed +board a FINISHED card never became terminal and stayed an active blocker forever. + +The board below is the built-in vocabulary renamed and nothing else, which is the point: every case +above passes either way because `done` satisfies the literal default. +*/ +describe("computeBlockerFanoutMap on a RENAMED board", () => { + const shippedFlags = { complete: true }; + const draftingFlags = { hold: true }; + const buildingFlags = { countsTowardWip: true }; + + it("does NOT count a dependent of a FINISHED card as active", () => { + const blocker = createTask("FN-BLOCKER", "shipped"); + const dependent = createTask("FN-DEPENDENT", "drafting", { dependencies: ["FN-BLOCKER"] }); + const flags = new Map([ + ["FN-BLOCKER", shippedFlags], + ["FN-DEPENDENT", draftingFlags], + ]); + + const withLanes = computeBlockerFanoutMap([blocker, dependent], { columnFlagsByTaskId: flags }); + const withoutLanes = computeBlockerFanoutMap([blocker, dependent]); + + /* The dependent rests in the board's HOLD lane, so it is counted there — not as a legacy todo. */ + expect(withLanes.get("FN-BLOCKER")?.activeTodoCount).toBe(1); + /* Unresolved, `drafting` is not `todo`, so the same card lands in neither bucket correctly. */ + expect(withoutLanes.get("FN-BLOCKER")?.activeTodoCount).toBe(0); + }); + + it("counts a dependent in a renamed WIP lane as active", () => { + const blocker = createTask("FN-BLOCKER", "building"); + const dependent = createTask("FN-DEPENDENT", "building", { dependencies: ["FN-BLOCKER"] }); + const flags = new Map([ + ["FN-BLOCKER", buildingFlags], + ["FN-DEPENDENT", buildingFlags], + ]); + + /* `totalCount` is the ACTIVE count — active is "not terminal". */ + expect(computeBlockerFanoutMap([blocker, dependent], { columnFlagsByTaskId: flags }) + .get("FN-BLOCKER")?.totalCount).toBe(1); + }); + + it("escalates a stale high-fan-out blocker resting in a renamed WIP lane", () => { + /* + The sharpest consequence: `shouldEscalate` requires the blocker to be in an ESCALATION lane + (wip ∪ review). Unresolved, `building` is neither `in-progress` nor `in-review`, so escalation + was false for every blocker on a renamed board — a stale blocker holding up many cards never + escalated. The fan-out numbers stayed correct, which is what makes it easy to miss: the metric + says there is a problem and the mechanism that acts on it is switched off. + */ + const threeHoursAgo = new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(); + const blocker = createTask("FN-BLOCKER", "building", { + columnMovedAt: threeHoursAgo, + updatedAt: threeHoursAgo, + }); + /* HIGH_FANOUT_BLOCKER_TODO_THRESHOLD is 5, counted via `blockedBy` in the HOLD lane. */ + const dependents = Array.from({ length: 5 }, (_, i) => + createTask(`FN-DEP-${i}`, "drafting", { blockedBy: "FN-BLOCKER" })); + const flags = new Map([ + ["FN-BLOCKER", buildingFlags], + ...dependents.map((task) => [task.id, draftingFlags] as const), + ]); + const tasks = [blocker, ...dependents]; + + expect(computeBlockerFanoutMap(tasks, { columnFlagsByTaskId: flags }).get("FN-BLOCKER")?.escalation) + .toBeDefined(); + /* Same board, no resolved traits: not high fan-out (nothing is in `todo`) and never escalates. */ + expect(computeBlockerFanoutMap(tasks).get("FN-BLOCKER")?.escalation).toBeUndefined(); + }); + + it("is byte-identical when no traits resolved (the pre-load window)", () => { + const tasks = [ + createTask("FN-1", "in-progress"), + createTask("FN-2", "todo", { dependencies: ["FN-1"] }), + ]; + + expect(computeBlockerFanoutMap(tasks, { columnFlagsByTaskId: new Map() }).get("FN-1")) + .toEqual(computeBlockerFanoutMap(tasks).get("FN-1")); + }); +}); diff --git a/packages/dashboard/app/hooks/useBlockerFanout.ts b/packages/dashboard/app/hooks/useBlockerFanout.ts index 1f40c96c02..596b0de917 100644 --- a/packages/dashboard/app/hooks/useBlockerFanout.ts +++ b/packages/dashboard/app/hooks/useBlockerFanout.ts @@ -11,16 +11,110 @@ export type { BlockerFanoutEntry }; // FNXC:AutoMergeRetries 2026-06-17-04:20: Dashboard fanout copy uses this as a display fallback until task-card surfaces receive live project settings; engine/self-healing decisions use resolveMaxAutoMergeRetries(settings) and are authoritative. export const MAX_AUTO_MERGE_RETRIES = 3; +/* +FNXC:WorkflowResolvedColumns 2026-07-30-23:10 (dashboard fan-out read the LEGACY board): +The per-task trait index, the same shape `App.tsx` already builds for the footer +(`footerColumnFlagsByTaskId`): a task's own workflow, then that workflow's entry for the column the +card rests in. + +Until this existed the dashboard wrapper called core with NO lane answers, so every blocker-fanout +surface classified against `todo` / `in-review` / `done`. Core defines "active" by EXCLUSION — not +terminal — so on a renamed board a FINISHED card in (say) `shipped` was never terminal and stayed an +active blocker forever: the Executor bar's highest-overlap blocker and the task modal's blocking- +dependents list both kept naming work that had already landed. + +PER TASK rather than a board-wide union, for the reason `blocker-fanout.ts` documents on `classify`: +an id means something only relative to its OWN workflow, and this board renders several at once. +Optional — omitted, core keeps its legacy defaults, so an unconverted caller is byte-identical. +*/ +export interface BlockerFanoutColumnFlags { + readonly complete?: boolean; + readonly archived?: boolean; + readonly hold?: boolean; + readonly countsTowardWip?: boolean; + readonly mergeOrchestration?: boolean; + readonly mergeBlocker?: boolean; + readonly humanReview?: boolean; +} + export interface UseBlockerFanoutOptions { staleHighFanoutAgeThresholdMs?: number; + columnFlagsByTaskId?: ReadonlyMap; +} + +/** Review is the union of the three review roles — the answer every converted reader gives. */ +function isReviewRole(flags: BlockerFanoutColumnFlags): boolean { + return flags.mergeOrchestration === true || flags.mergeBlocker === true || flags.humanReview === true; +} + +/* +Escalation is wip ∪ review, mirroring `scheduler.ts`'s own construction exactly. The two must agree: +the scheduler decides that a blocker escalates and the dashboard is where an operator sees it. +*/ +function isEscalationRole(flags: BlockerFanoutColumnFlags): boolean { + return flags.countsTowardWip === true || isReviewRole(flags); +} + +/* +DELIBERATE-LITERAL — the unresolved-card fallback, reviewed 2026-07-30-23:40. + +Hoisted into named helpers rather than written inline for two reasons: the census reads markers from +a declaration's leading comments (an inline one attaches to the wrong node and is silently ignored), +and this is a documented degraded mode that deserves a name. + +Reached only for a card the board could not resolve traits for — the pre-load window, or a stranded +card resting in a column its workflow no longer declares. Fabricating a role there would be worse +than the legacy answer: it would invent lifecycle state the operator never configured. These are the +same literals `blocker-fanout.ts` uses for its own unconverted-caller defaults, so an unresolved card +behaves exactly as it did before this seam existed. +*/ +function legacyIsHold(column: string): boolean { + return column === "todo"; +} + +/* DELIBERATE-LITERAL — the terminal half of the same unresolved-card fallback. */ +function legacyIsTerminal(column: string): boolean { + return column === "done" || column === "archived"; +} + +/* DELIBERATE-LITERAL — the escalation half of the same unresolved-card fallback. */ +function legacyIsEscalation(column: string): boolean { + return column === "in-progress" || column === "in-review"; } export function computeBlockerFanoutMap( tasks: Task[], options: UseBlockerFanoutOptions = {}, ): Map { + const flagsByTaskId = options.columnFlagsByTaskId; return computeBlockerFanoutMapCore(tasks, MAX_AUTO_MERGE_RETRIES, { staleHighFanoutAgeThresholdMs: options.staleHighFanoutAgeThresholdMs, + /* + Only supplied when the board actually resolved traits. An EMPTY map is not "no card is terminal"; + it is the pre-load window and the remote-node case, where fabricating answers would be worse than + the documented legacy default. + */ + ...(flagsByTaskId && flagsByTaskId.size > 0 + ? { + classify: (task: Task) => { + const flags = flagsByTaskId.get(task.id); + /* A card whose traits did not resolve keeps the legacy answer rather than a fabricated one. */ + if (!flags) return { isHold: legacyIsHold(task.column), isTerminal: legacyIsTerminal(task.column) }; + return { isHold: flags.hold === true, isTerminal: flags.complete === true || flags.archived === true }; + }, + escalationClassify: (task: Task) => { + const flags = flagsByTaskId.get(task.id); + if (!flags) return legacyIsEscalation(task.column); + return isEscalationRole(flags); + }, + reviewColumns: new Set( + tasks.filter((task) => { + const flags = flagsByTaskId.get(task.id); + return flags !== undefined && isReviewRole(flags); + }).map((task) => task.column), + ), + } + : {}), }); } @@ -28,8 +122,21 @@ export function useBlockerFanout( tasks: Task[], options: UseBlockerFanoutOptions = {}, ): Map { + /* + FNXC:WorkflowResolvedColumns 2026-07-30-23:45: + `columnFlagsByTaskId` MUST be a dependency, or the whole seam is inert on the board. + + The board builds its trait index from `boardWorkflows`, which is null until an async fetch + resolves — so the FIRST computation always runs against an empty map and takes the documented + legacy fallback. When the index populates, neither `tasks` nor the threshold has changed, so a + memo keyed on those two never recomputes and the pre-load answer survives for the life of the + mount. Threaded end to end, correctly typed, and never arriving. + + This repo has no `react-hooks/exhaustive-deps` rule, so a stale dep array here is invisible to + lint and a disable directive for that rule fails CI. The list is maintained by hand. + */ return useMemo( () => computeBlockerFanoutMap(tasks, options), - [tasks, options.staleHighFanoutAgeThresholdMs], + [tasks, options.staleHighFanoutAgeThresholdMs, options.columnFlagsByTaskId], ); } diff --git a/scripts/lib/lifecycle-column-census-baseline.json b/scripts/lib/lifecycle-column-census-baseline.json index 732b680241..ccf44edba7 100644 --- a/scripts/lib/lifecycle-column-census-baseline.json +++ b/scripts/lib/lifecycle-column-census-baseline.json @@ -104,6 +104,11 @@ "packages/dashboard/app/components/TaskDetailModal.tsx\u0000in-progress": 1, "packages/dashboard/app/components/TaskDetailModal.tsx\u0000in-review": 1, "packages/dashboard/app/components/TaskDetailModal.tsx\u0000todo": 1, + "packages/dashboard/app/hooks/useBlockerFanout.ts\u0000archived": 1, + "packages/dashboard/app/hooks/useBlockerFanout.ts\u0000done": 1, + "packages/dashboard/app/hooks/useBlockerFanout.ts\u0000in-progress": 1, + "packages/dashboard/app/hooks/useBlockerFanout.ts\u0000in-review": 1, + "packages/dashboard/app/hooks/useBlockerFanout.ts\u0000todo": 1, "packages/dashboard/app/utils/columnRoles.ts\u0000todo": 1, "packages/dashboard/app/utils/quickAddStart.ts\u0000todo": 1, "packages/dashboard/src/github-tracking-comments.ts\u0000done": 1,