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:
7
.changeset/blocker-fanout-dashboard-lanes.md
Normal file
7
.changeset/blocker-fanout-dashboard-lanes.md
Normal 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.
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user