feat(FN-3897): add blocker fan-out hook, badge, and detail modal section

Implemented the blocker fan-out feature across the dashboard: added a `useBlockerFanout` hook to track blocking relationships, rendered a fan-out badge on task cards, and added a blocking section to the task detail modal, with tests covering the hook and responsive modal behavior.

Fusion-Task-Id: FN-3897
This commit is contained in:
Fusion
2026-05-09 19:15:42 -07:00
committed by gsxdsm
parent ff9fb551e3
commit 8c715168c6
13 changed files with 462 additions and 5 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": minor
---
Show downstream blocker fan-out count on the board so high-impact blockers are visible at a glance.

View File

@@ -381,6 +381,18 @@ Inspect task definition, logs, review feedback, comments, documents, workflow ou
- Review supports a manual **Refresh** action in-place: PR mode pulls latest GitHub review state/decision, while direct mode rehydrates reviewer-agent feedback from persisted task data (no GitHub call).
- In direct/non-PR auto-merge mode, Review renders normalized reviewer-agent feedback (verdict/step/timestamp/detail) with dedicated loading/error/empty states; it does not require users to read raw agent logs.
### Identifying high-impact blockers
Use the `Blocks N` badge on task cards to spot blockers with high downstream impact:
- `Blocks N` counts active downstream dependents in `triage`, `todo`, `in-progress`, or `in-review`.
- The badge tooltip shows total active dependents plus how many are currently waiting in `todo`.
- Open a task to the **Blocking** section in Task Detail to view each downstream dependent and click through quickly.
- `(stale)` markers mean the dependent is blocked through `blockedBy` and matches stale conditions that `clearStaleBlockedBy` self-healing should clear automatically.
- Stale `dependencies[]` links are shown for awareness but are not auto-cleared by `clearStaleBlockedBy`.
Recommended workflow: when a blocker has high fan-out, prioritize unblocking first (reassign, split, or resolve immediately) before lower-impact tasks.
### Logs → Agent Log view
The **Logs** tab includes an **Agent Log** subview designed for debugging long-running and tool-heavy sessions:

View File

@@ -6,6 +6,7 @@ import type { ToastType } from "../hooks/useToast";
import { useState, useMemo, useEffect, useCallback, useRef } from "react";
import { useBatchBadgeFetch } from "../hooks/useBatchBadgeFetch";
import { fetchWorkflowSteps, type ModelInfo } from "../api";
import { useBlockerFanout } from "../hooks/useBlockerFanout";
interface BoardProps {
tasks: Task[];
@@ -76,6 +77,7 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
const { fetchBatch } = useBatchBadgeFetch(projectId);
const debounceTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [workflowStepNameLookup, setWorkflowStepNameLookup] = useState<ReadonlyMap<string, string>>(EMPTY_WORKFLOW_STEP_NAME_LOOKUP);
const blockerFanoutMap = useBlockerFanout(tasks);
// Normalized search-active signal: trimmed and non-empty
const isSearchActive = searchQuery.trim() !== "";
const tasksByColumnCacheRef = useRef<Record<ColumnType, Task[]>>({
@@ -222,6 +224,7 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
onOpenMission={onOpenMission}
lastFetchTimeMs={lastFetchTimeMs}
workflowStepNameLookup={workflowStepNameLookup}
blockerFanoutMap={blockerFanoutMap}
{...(col === "triage" ? { onQuickCreate, onNewTask, onPlanningMode, onSubtaskBreakdown } : {})}
{...(col === "in-review" ? { autoMerge, onToggleAutoMerge } : {})}
{...(col === "done" ? { onArchiveAllDone } : {})}

View File

@@ -11,6 +11,7 @@ import { groupByWorktree } from "../utils/worktreeGrouping";
import type { ToastType } from "../hooks/useToast";
import { ChevronDown, ChevronUp, Archive, MoreVertical } from "lucide-react";
import type { ModelInfo } from "../api";
import type { BlockerFanoutEntry } from "../hooks/useBlockerFanout";
const PAGINATED_COLUMN_THRESHOLD = 100;
const VISIBLE_TASKS_INITIAL = 50;
@@ -66,9 +67,11 @@ interface ColumnProps {
lastFetchTimeMs?: number;
/** Lookup of workflow step IDs to display names, fetched once at board level. */
workflowStepNameLookup?: ReadonlyMap<string, string>;
/** Precomputed blocker fanout keyed by blocker task ID. */
blockerFanoutMap?: ReadonlyMap<string, BlockerFanoutEntry>;
}
function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, onPauseTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs, workflowStepNameLookup }: ColumnProps) {
function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, onPauseTask, onOpenDetail, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs, workflowStepNameLookup, blockerFanoutMap }: ColumnProps) {
const [dragOver, setDragOver] = useState(false);
const [visibleTaskCount, setVisibleTaskCount] = useState(VISIBLE_TASKS_INITIAL);
const [isMenuOpen, setIsMenuOpen] = useState(false);
@@ -488,6 +491,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
onOpenMission={onOpenMission}
lastFetchTimeMs={lastFetchTimeMs}
workflowStepNameLookup={workflowStepNameLookup}
blockerFanoutMap={blockerFanoutMap}
/>
))
)
@@ -514,6 +518,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
onMoveTask={onMoveTask}
lastFetchTimeMs={lastFetchTimeMs}
workflowStepNameLookup={workflowStepNameLookup}
fanout={blockerFanoutMap?.get(task.id)}
/>
))}
{shouldPaginate && hiddenTaskCount > 0 && (

View File

@@ -411,7 +411,29 @@
cursor: default;
}
.card-scope-badge[data-tooltip]:hover::after {
.card-fanout-badge {
display: inline-flex;
align-items: center;
gap: calc(var(--space-xs) / 2);
font-size: 0.6875rem;
color: var(--color-info);
position: relative;
cursor: default;
}
.card-fanout-count {
font-family: var(--font-mono);
}
.card-fanout-badge--stale {
color: var(--color-warning);
background: color-mix(in srgb, var(--color-warning) 12%, transparent);
border-radius: var(--radius-pill);
padding-inline: calc(var(--space-xs) / 2);
}
.card-scope-badge[data-tooltip]:hover::after,
.card-fanout-badge[data-tooltip]:hover::after {
content: attr(data-tooltip);
position: absolute;
bottom: 100%;

View File

@@ -1,6 +1,6 @@
import "./TaskCard.css";
import { memo, useCallback, useState, useRef, useEffect, useMemo } from "react";
import { Link, Clock, Layers, Pencil, ChevronDown, Folder, Target, Bot, Trash2, RotateCw, Zap } from "lucide-react";
import { Link, Clock, Layers, Pencil, ChevronDown, Folder, Target, Bot, Trash2, RotateCw, Zap, GitBranch } from "lucide-react";
import type { Task, TaskDetail, Column, PrInfo, IssueInfo, TaskPriority } from "@fusion/core";
import { COLUMN_LABELS, DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, VALID_TRANSITIONS, getErrorMessage } from "@fusion/core";
import { fetchTaskDetail, uploadAttachment, fetchMission, fetchAgent } from "../api";
@@ -17,6 +17,7 @@ import { getEndToEndDurationMs, getTimedDurationMs, getWorkflowRuntimeMs, parseT
import type { ToastType } from "../hooks/useToast";
import { useConfirm } from "../hooks/useConfirm";
import { extractDependencyDeleteConflict } from "../utils/taskDelete";
import type { BlockerFanoutEntry } from "../hooks/useBlockerFanout";
// ── Mission title caching ───────────────────────────────────────────────────
@@ -277,6 +278,8 @@ interface TaskCardProps {
workflowStepNameLookup?: ReadonlyMap<string, string>;
/** Disable card drag semantics when embedding in custom draggable containers (e.g. dependency graph). */
disableDrag?: boolean;
/** Downstream fan-out entry for this task, computed at board-level. */
fanout?: BlockerFanoutEntry;
}
function areTaskBadgeInfosEqual(
@@ -401,6 +404,10 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
previous.onMoveTask === next.onMoveTask &&
previous.workflowStepNameLookup === next.workflowStepNameLookup &&
previous.disableDrag === next.disableDrag &&
previous.fanout?.totalCount === next.fanout?.totalCount &&
previous.fanout?.activeTodoCount === next.fanout?.activeTodoCount &&
areTaskDependenciesEqual(previous.fanout?.dependentIds ?? [], next.fanout?.dependentIds ?? []) &&
areTaskDependenciesEqual(previous.fanout?.staleBlockedByDependentIds ?? [], next.fanout?.staleBlockedByDependentIds ?? []) &&
previousTask.id === nextTask.id &&
previousTask.title === nextTask.title &&
previousTask.description === nextTask.description &&
@@ -465,6 +472,7 @@ function TaskCardComponent({
lastFetchTimeMs,
workflowStepNameLookup,
disableDrag,
fanout,
}: TaskCardProps) {
const [dragging, setDragging] = useState(false);
const [fileDragOver, setFileDragOver] = useState(false);
@@ -1609,7 +1617,7 @@ function TaskCardComponent({
)}
</div>
)}
{((task.dependencies && task.dependencies.length > 0) || queued || task.status === "queued" || task.blockedBy) && (
{((task.dependencies && task.dependencies.length > 0) || queued || task.status === "queued" || task.blockedBy || (fanout && fanout.totalCount > 0)) && (
<div className="card-meta">
{task.dependencies && task.dependencies.length > 0 && (
<div className="card-dep-list">
@@ -1630,6 +1638,18 @@ function TaskCardComponent({
<Layers size={12} style={{ verticalAlign: "middle" }} /> {task.blockedBy}
</span>
)}
{fanout && fanout.totalCount > 0 && (
<span
className={`card-fanout-badge${fanout.staleBlockedByDependentIds.length > 0 ? " card-fanout-badge--stale" : ""}`}
data-tooltip={`Blocking ${fanout.totalCount} task(s); ${fanout.activeTodoCount} waiting in todo`}
>
<GitBranch size={12} style={{ verticalAlign: "middle" }} />
<span>
Blocks <span className="card-fanout-count">{fanout.totalCount}</span>
{fanout.staleBlockedByDependentIds.length > 0 ? ` (${fanout.staleBlockedByDependentIds.length} stale)` : ""}
</span>
</span>
)}
{(queued || task.status === "queued") && task.column !== "in-progress" && <span className="queued-badge"><Clock size={12} style={{ verticalAlign: "middle" }} /> Queued</span>}
</div>
)}

View File

@@ -522,6 +522,11 @@
position: relative;
}
.detail-blocking-item--stale {
color: var(--color-warning);
font-size: 0.75rem;
}
.modal-actions-spacer {
flex: 1;
}

View File

@@ -40,6 +40,7 @@ import { subscribeSse } from "../sse-bus";
import { usePluginUiSlots } from "../hooks/usePluginUiSlots";
import { appendTokenQuery } from "../auth";
import { extractDependencyDeleteConflict } from "../utils/taskDelete";
import { computeBlockerFanoutMap } from "../hooks/useBlockerFanout";
interface ModelSelection {
provider?: string;
@@ -1579,6 +1580,21 @@ export function TaskDetailContent({
return bNum - aNum;
});
const blockerFanoutMap = useMemo(() => computeBlockerFanoutMap(tasks), [tasks]);
const blockingEntry = blockerFanoutMap.get(task.id);
const blockingDependents = useMemo(() => {
if (!blockingEntry) return [] as Array<{ id: string; label: string; stale: boolean }>;
const staleSet = new Set(blockingEntry.staleBlockedByDependentIds);
return blockingEntry.dependentIds.map((dependentId) => {
const dependentTask = tasks.find((candidate) => candidate.id === dependentId);
return {
id: dependentId,
label: dependentTask?.title || dependentTask?.description || dependentId,
stale: staleSet.has(dependentId),
};
});
}, [blockingEntry, tasks]);
const assignedAgentLabel = assignedAgent?.name ?? task.assignedAgentId ?? null;
const detailProviders = useMemo(() => {
const providers: string[] = [];
@@ -2538,6 +2554,43 @@ export function TaskDetailContent({
})()}
</div>
</div>
<div className="detail-deps detail-blocking">
<h4>Blocking</h4>
{blockingDependents.length > 0 ? (
<ul className="detail-dep-list">
{blockingDependents.map((dependent) => (
<li key={dependent.id} className="detail-dep-item">
<span
className="detail-dep-link"
onClick={() => handleDepClick(dependent.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleDepClick(dependent.id);
}
}}
role="link"
tabIndex={0}
title={`Click to view ${dependent.id}`}
>
<span className="detail-dep-id">{dependent.id}</span>
<span className="detail-dep-label">{truncate(dependent.label, 40)}</span>
</span>
{dependent.stale && (
<span
className="detail-blocking-item--stale"
title="Stale blockedBy edge: self-healing clearStaleBlockedBy should clear this automatically"
>
(stale)
</span>
)}
</li>
))}
</ul>
) : (
<div className="detail-empty-inline">(no downstream tasks blocked)</div>
)}
</div>
{/* PR Section - only for in-review tasks */}
{task.column === "in-review" && (
<div className="detail-section detail-pr-section">

View File

@@ -3,6 +3,7 @@ import type { Task, TaskDetail } from "@fusion/core";
import { ClipboardList, GitBranch } from "lucide-react";
import { TaskCard } from "./TaskCard";
import type { ToastType } from "../hooks/useToast";
import type { BlockerFanoutEntry } from "../hooks/useBlockerFanout";
interface WorktreeGroupProps {
label: string;
@@ -26,6 +27,8 @@ interface WorktreeGroupProps {
lastFetchTimeMs?: number;
/** Lookup of workflow step IDs to display names, fetched once at board level. */
workflowStepNameLookup?: ReadonlyMap<string, string>;
/** Precomputed blocker fanout keyed by blocker task ID. */
blockerFanoutMap?: ReadonlyMap<string, BlockerFanoutEntry>;
}
function WorktreeGroupComponent({
@@ -43,6 +46,7 @@ function WorktreeGroupComponent({
onOpenMission,
lastFetchTimeMs,
workflowStepNameLookup,
blockerFanoutMap,
}: WorktreeGroupProps) {
return (
<div className="worktree-group">
@@ -53,7 +57,7 @@ function WorktreeGroupComponent({
<span className="worktree-label">{label}</span>
</div>
{activeTasks.map((task) => (
<TaskCard key={task.id} task={task} projectId={projectId} onOpenDetail={onOpenDetail} addToast={addToast} globalPaused={globalPaused} onUpdateTask={onUpdateTask} onRetryTask={onRetryTask} onOpenDetailWithTab={onOpenDetailWithTab} taskStuckTimeoutMs={taskStuckTimeoutMs} onOpenMission={onOpenMission} lastFetchTimeMs={lastFetchTimeMs} workflowStepNameLookup={workflowStepNameLookup} />
<TaskCard key={task.id} task={task} projectId={projectId} onOpenDetail={onOpenDetail} addToast={addToast} globalPaused={globalPaused} onUpdateTask={onUpdateTask} onRetryTask={onRetryTask} onOpenDetailWithTab={onOpenDetailWithTab} taskStuckTimeoutMs={taskStuckTimeoutMs} onOpenMission={onOpenMission} lastFetchTimeMs={lastFetchTimeMs} workflowStepNameLookup={workflowStepNameLookup} fanout={blockerFanoutMap?.get(task.id)} />
))}
{queuedTasks.map((task) => (
<TaskCard
@@ -71,6 +75,7 @@ function WorktreeGroupComponent({
onOpenMission={onOpenMission}
lastFetchTimeMs={lastFetchTimeMs}
workflowStepNameLookup={workflowStepNameLookup}
fanout={blockerFanoutMap?.get(task.id)}
/>
))}
</div>

View File

@@ -6,6 +6,7 @@ import type { Task } from "@fusion/core";
// Mock lucide-react to avoid SVG rendering issues in test env
vi.mock("lucide-react", () => ({
Link: () => null,
GitBranch: () => null,
Clock: () => null,
Pencil: () => null,
Layers: () => null,
@@ -183,6 +184,65 @@ describe("TaskCard", () => {
expect(screen.queryByText("paused by agent")).toBeNull();
});
it("does not render fan-out badge when fanout is missing or zero", () => {
const { container, rerender } = render(
<TaskCard task={makeTask({ column: "todo" })} onOpenDetail={noop} addToast={noop} />,
);
expect(container.querySelector(".card-fanout-badge")).toBeNull();
rerender(
<TaskCard
task={makeTask({ column: "todo" })}
fanout={{ totalCount: 0, activeTodoCount: 0, dependentIds: [], staleBlockedByDependentIds: [] }}
onOpenDetail={noop}
addToast={noop}
/>,
);
expect(container.querySelector(".card-fanout-badge")).toBeNull();
});
it("renders fan-out badge with downstream count and tooltip", () => {
render(
<TaskCard
task={makeTask({ column: "in-progress" })}
fanout={{ totalCount: 7, activeTodoCount: 4, dependentIds: ["FN-002"], staleBlockedByDependentIds: [] }}
onOpenDetail={noop}
addToast={noop}
/>,
);
const badge = screen.getByText("Blocks").closest(".card-fanout-badge") as HTMLElement;
expect(badge).not.toBeNull();
expect(badge.textContent).toContain("Blocks 7");
expect(badge.getAttribute("data-tooltip")).toBe("Blocking 7 task(s); 4 waiting in todo");
});
it("applies stale fan-out modifier when stale blockedBy dependents exist", () => {
const { container } = render(
<TaskCard
task={makeTask({ column: "in-progress" })}
fanout={{ totalCount: 3, activeTodoCount: 1, dependentIds: ["FN-003"], staleBlockedByDependentIds: ["FN-003"] }}
onOpenDetail={noop}
addToast={noop}
/>,
);
const badge = container.querySelector(".card-fanout-badge") as HTMLElement;
expect(badge.className).toContain("card-fanout-badge--stale");
expect(badge.textContent).toContain("(1 stale)");
});
it("shows plain paused label when pausedByAgentId is not set", () => {
render(
<TaskCard task={makeTask({ paused: true })} onOpenDetail={noop} addToast={noop} />,
);
expect(screen.getByText("paused")).toBeDefined();
expect(screen.queryByText("paused by agent")).toBeNull();
});
it("hides default working branch and default base branch metadata", () => {
const { container } = render(
<TaskCard

View File

@@ -725,5 +725,32 @@ describe("TaskDetailModal", () => {
});
});
describe("blocking section", () => {
it("renders downstream dependents and stale annotations", () => {
const tasks = [
makeTask({ id: "FN-099", title: "Blocker", column: "done" as Column }),
makeTask({ id: "FN-100", title: "Todo dependent", column: "todo" as Column, dependencies: ["FN-099"] }),
makeTask({ id: "FN-101", title: "Stale blockedBy dependent", column: "todo" as Column, blockedBy: "FN-099" }),
];
const { container } = render(
<TaskDetailModal
task={tasks[0]}
tasks={tasks}
onOpenDetail={noopOpenDetail}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
addToast={noop}
/>,
);
expect(screen.getByText("Blocking")).toBeTruthy();
expect(container.textContent).toContain("FN-100");
expect(container.textContent).toContain("FN-101");
expect(container.querySelector(".detail-blocking-item--stale")?.textContent).toBe("(stale)");
});
});
});

View File

@@ -0,0 +1,138 @@
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import type { Task } from "@fusion/core";
import { computeBlockerFanoutMap, MAX_AUTO_MERGE_RETRIES } from "../useBlockerFanout";
function createTask(id: string, column: Task["column"], overrides: Partial<Task> = {}): Task {
return {
id,
description: `Task ${id}`,
column,
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
};
}
describe("computeBlockerFanoutMap", () => {
it("returns an empty map for an empty task list", () => {
expect(computeBlockerFanoutMap([]).size).toBe(0);
});
it("returns an empty map when no downstream dependencies exist", () => {
const tasks = [createTask("FN-1", "todo"), createTask("FN-2", "done")];
expect(computeBlockerFanoutMap(tasks).size).toBe(0);
});
it("tracks a single dependent via dependencies[]", () => {
const tasks = [
createTask("FN-1", "in-progress"),
createTask("FN-2", "todo", { dependencies: ["FN-1"] }),
];
expect(computeBlockerFanoutMap(tasks).get("FN-1")).toEqual({
totalCount: 1,
activeTodoCount: 1,
dependentIds: ["FN-2"],
staleBlockedByDependentIds: [],
});
});
it("tracks mixed dependencies[] and blockedBy edges", () => {
const tasks = [
createTask("FN-1", "in-progress"),
createTask("FN-2", "todo", { dependencies: ["FN-1"] }),
createTask("FN-3", "in-review", { blockedBy: "FN-1" }),
];
expect(computeBlockerFanoutMap(tasks).get("FN-1")).toEqual({
totalCount: 2,
activeTodoCount: 1,
dependentIds: ["FN-2", "FN-3"],
staleBlockedByDependentIds: [],
});
});
it("excludes done/archived dependents from totalCount but keeps dependentIds", () => {
const tasks = [
createTask("FN-1", "in-progress"),
createTask("FN-2", "done", { dependencies: ["FN-1"] }),
createTask("FN-3", "archived", { blockedBy: "FN-1" }),
createTask("FN-4", "todo", { dependencies: ["FN-1"] }),
];
expect(computeBlockerFanoutMap(tasks).get("FN-1")).toEqual({
totalCount: 1,
activeTodoCount: 1,
dependentIds: ["FN-2", "FN-3", "FN-4"],
staleBlockedByDependentIds: [],
});
});
it("marks stale blockedBy dependents only for blockedBy edges, not dependencies[]", () => {
const tasks = [
createTask("FN-2", "todo", { dependencies: ["MISSING"] }),
createTask("FN-3", "todo", { blockedBy: "MISSING" }),
];
expect(computeBlockerFanoutMap(tasks).get("MISSING")).toEqual({
totalCount: 2,
activeTodoCount: 2,
dependentIds: ["FN-2", "FN-3"],
staleBlockedByDependentIds: ["FN-3"],
});
});
it("marks blockedBy edges stale when blocker is done", () => {
const tasks = [createTask("B", "done"), createTask("D", "todo", { blockedBy: "B" })];
expect(computeBlockerFanoutMap(tasks).get("B")?.staleBlockedByDependentIds).toEqual(["D"]);
});
it("marks blockedBy edges stale when blocker is archived", () => {
const tasks = [createTask("B", "archived"), createTask("D", "todo", { blockedBy: "B" })];
expect(computeBlockerFanoutMap(tasks).get("B")?.staleBlockedByDependentIds).toEqual(["D"]);
});
it("marks blockedBy edges stale when blocker is in-review and paused", () => {
const tasks = [createTask("B", "in-review", { paused: true }), createTask("D", "todo", { blockedBy: "B" })];
expect(computeBlockerFanoutMap(tasks).get("B")?.staleBlockedByDependentIds).toEqual(["D"]);
});
it("marks blockedBy edges stale when blocker failed in-review at max retries", () => {
const tasks = [
createTask("B", "in-review", { status: "failed", mergeRetries: MAX_AUTO_MERGE_RETRIES }),
createTask("D", "todo", { blockedBy: "B" }),
];
expect(computeBlockerFanoutMap(tasks).get("B")?.staleBlockedByDependentIds).toEqual(["D"]);
});
it("FN-3897 regression: reports active and todo downstream counts for high fan-out blockers", () => {
const tasks = [
createTask("B", "in-progress"),
createTask("D1", "todo", { dependencies: ["B"] }),
createTask("D2", "todo", { blockedBy: "B" }),
createTask("D3", "in-review", { dependencies: ["B"] }),
createTask("D4", "done", { dependencies: ["B"] }),
];
expect(computeBlockerFanoutMap(tasks).get("B")).toEqual({
totalCount: 3,
activeTodoCount: 2,
dependentIds: ["D1", "D2", "D3", "D4"],
staleBlockedByDependentIds: [],
});
});
it("keeps MAX_AUTO_MERGE_RETRIES aligned with engine self-healing source", () => {
const testDir = dirname(fileURLToPath(import.meta.url));
const source = readFileSync(resolve(testDir, "../../../../engine/src/self-healing.ts"), "utf8");
const match = source.match(/const MAX_AUTO_MERGE_RETRIES = (\d+);/);
expect(match?.[1]).toBe(String(MAX_AUTO_MERGE_RETRIES));
});
});

View File

@@ -0,0 +1,102 @@
import { useMemo } from "react";
import type { Task } from "@fusion/core";
export interface BlockerFanoutEntry {
totalCount: number;
activeTodoCount: number;
dependentIds: string[];
staleBlockedByDependentIds: string[];
}
// Keep in sync with packages/engine/src/self-healing.ts
export const MAX_AUTO_MERGE_RETRIES = 3;
const ACTIVE_COLUMNS = new Set<Task["column"]>(["triage", "todo", "in-progress", "in-review"]);
function isStaleBlockedByBlocker(blocker: Task | undefined): boolean {
if (!blocker) {
return true;
}
if (blocker.column === "done" || blocker.column === "archived") {
return true;
}
if (blocker.column === "in-review" && blocker.paused === true) {
return true;
}
if (
blocker.column === "in-review" &&
blocker.status === "failed" &&
(blocker.mergeRetries ?? 0) >= MAX_AUTO_MERGE_RETRIES
) {
return true;
}
return false;
}
interface MutableEntry {
dependentIds: string[];
blockedByDependentIds: string[];
activeCount: number;
activeTodoCount: number;
}
export function computeBlockerFanoutMap(tasks: Task[]): Map<string, BlockerFanoutEntry> {
const taskById = new Map(tasks.map((task) => [task.id, task]));
const fanout = new Map<string, MutableEntry>();
const ensureEntry = (blockerId: string): MutableEntry => {
let entry = fanout.get(blockerId);
if (!entry) {
entry = { dependentIds: [], blockedByDependentIds: [], activeCount: 0, activeTodoCount: 0 };
fanout.set(blockerId, entry);
}
return entry;
};
for (const task of tasks) {
const active = ACTIVE_COLUMNS.has(task.column);
const isTodo = task.column === "todo";
const dependencyIds = task.dependencies ?? [];
for (const depId of dependencyIds) {
if (!depId) continue;
const entry = ensureEntry(depId);
entry.dependentIds.push(task.id);
if (active) entry.activeCount += 1;
if (isTodo) entry.activeTodoCount += 1;
}
if (task.blockedBy) {
const entry = ensureEntry(task.blockedBy);
entry.dependentIds.push(task.id);
entry.blockedByDependentIds.push(task.id);
if (active) entry.activeCount += 1;
if (isTodo) entry.activeTodoCount += 1;
}
}
const result = new Map<string, BlockerFanoutEntry>();
for (const [blockerId, entry] of fanout) {
const blocker = taskById.get(blockerId);
const staleBlockedByDependentIds = isStaleBlockedByBlocker(blocker)
? [...entry.blockedByDependentIds]
: [];
result.set(blockerId, {
totalCount: entry.activeCount,
activeTodoCount: entry.activeTodoCount,
dependentIds: entry.dependentIds,
staleBlockedByDependentIds,
});
}
return result;
}
export function useBlockerFanout(tasks: Task[]): Map<string, BlockerFanoutEntry> {
return useMemo(() => computeBlockerFanoutMap(tasks), [tasks]);
}