fix(dashboard): blocker fan-out classified every board against the LEGACY lanes (finished cards shown as blockers; escalation never fired) (#2990)

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) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-30 23:41:14 -07:00
committed by GitHub
parent bb6c08d9d6
commit 65f4e8533e
8 changed files with 419 additions and 6 deletions

View File

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

View File

@@ -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<string | null>(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<string, BlockerFanoutColumnFlags>();
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);

View File

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

View File

@@ -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(
<ExecutorStatusBar
tasks={tasks}
columnFlagsByTaskId={columnFlagsByTaskId}
staleHighFanoutBlockerAgeThresholdMs={60 * 60 * 1000}
/>,
);
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"),

View File

@@ -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<string, BlockerFanoutEntry> | undefined;
let renderedColumns = 0;
vi.mock("../Column", () => ({
Column: ({ blockerFanoutMap }: { blockerFanoutMap?: ReadonlyMap<string, BlockerFanoutEntry> }) => {
renderedColumns += 1;
if (blockerFanoutMap) captured = blockerFanoutMap;
return <section />;
},
}));
const RENAME: Record<string, string> = {
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<BlockerFanoutEntry | undefined> {
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<typeof Board>;
render(<Board {...props} />);
/* 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);
});
});

View File

@@ -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<string, { complete?: boolean; hold?: boolean; countsTowardWip?: boolean }>([
["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"));
});
});

View File

@@ -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<string, BlockerFanoutColumnFlags>;
}
/** 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<string, BlockerFanoutEntry> {
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<string, BlockerFanoutEntry> {
/*
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],
);
}

View File

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