feat(FN-3942): surface high fan-out blockers in task cards and executor sta

Adds a "high fan-out" indicator that surfaces when a task blocks an unusually large number of downstream tasks, displayed as a warning badge on TaskCards and in the ExecutorStatusBar. The feature includes core type definitions, dashboard UI updates to render the warning with appropriate styling and

Fusion-Task-Id: FN-3942
This commit is contained in:
Fusion
2026-05-10 15:55:09 -07:00
committed by gsxdsm
parent c095b0afb2
commit d7980d5a59
13 changed files with 247 additions and 16 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Surface high fan-out blockers in the dashboard by escalating blocker badges and footer status summaries when a blocker has at least 5 active todo dependents.

View File

@@ -409,15 +409,17 @@ Inspect task definition, logs, review feedback, comments, documents, workflow ou
### Identifying high-impact blockers ### Identifying high-impact blockers
Use the `Blocks N` badge on task cards to spot blockers with high downstream impact: Use blocker fan-out signals on task cards and in the footer status bar to spot blockers with high downstream impact:
- `Blocks N` counts active downstream dependents in `triage`, `todo`, `in-progress`, or `in-review`. - `Blocks N` counts active downstream dependents in `triage`, `todo`, `in-progress`, or `in-review`.
- A card is escalated to **High fan-out** when it has at least **5 active `todo` dependents** (`activeTodoCount >= 5`).
- Done and archived downstream tasks remain visible for debugging context but do **not** count toward the 5-todo alert threshold.
- The badge tooltip shows total active dependents plus how many are currently waiting in `todo`. - 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)` 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`. - Stale `dependencies[]` links are shown for awareness but are not auto-cleared by `clearStaleBlockedBy`.
- The executor footer shows the current worst high fan-out blocker (in-progress/in-review only), ranked by highest todo fan-out, then highest total fan-out, then stable task ID order.
Recommended workflow: when a blocker has high fan-out, prioritize unblocking first (reassign, split, or resolve immediately) before lower-impact tasks. Recommended workflow: ordinary chains stay as `Blocks N` so noise stays low; when a blocker crosses the 5-todo threshold, prioritize unblocking first (reassign, split, or resolve immediately) before lower-impact tasks.
### Logs → Agent Log view ### Logs → Agent Log view

View File

@@ -1,4 +1,4 @@
export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeConflictStrategy, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION } from "./types.js"; export { COLUMNS, DEFAULT_COLUMN, isColumn, normalizeColumn, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, PERMANENT_AGENT_ACTION_CATEGORIES, AGENT_PERMISSION_POLICY_ACTION_CATEGORIES, AGENT_PROVISIONING_APPROVAL_MODES, AGENT_PERMISSION_POLICY_PRESET_IDS, LEGACY_AGENT_PERMISSION_POLICY_ACTION_CATEGORY_ALIASES, APPROVAL_REQUEST_STATUSES, APPROVAL_REQUEST_AUDIT_EVENT_TYPES, normalizeApprovalRequestActionCategory, isValidApprovalRequestTransition, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, DASHBOARD_USER_ID, normalizeMessageParticipant, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeConflictStrategy, buildResearchDocumentKey, REPO_OVERRIDE_RE, SHARED_STATE_SNAPSHOT_VERSION } from "./types.js";
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeConflictStrategy, CanonicalMergeConflictStrategy, Settings, GlobalSettings, ProjectSettings, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, UnavailableNodePolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js"; export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, MeshReplicatedTaskCreatePayload, MeshReplicatedTaskApplyResult, TaskSource, SourceType, TaskDetail, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, DistributedTaskIdReserveInput, DistributedTaskIdReserveResult, DistributedTaskIdCommitInput, DistributedTaskIdCommitResult, DistributedTaskIdAbortInput, DistributedTaskIdAbortResult, DistributedTaskIdStateInput, DistributedTaskIdStateResult, AutostashOrphanRecord, AutostashOutcome, MergeDetails, MergeResult, MergeConflictStrategy, CanonicalMergeConflictStrategy, Settings, GlobalSettings, ProjectSettings, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, EvalFollowUpPolicy, EvalProjectSettings, ResolvedEvalSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, UnavailableNodePolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, PermanentAgentActionCategory, PermanentAgentSensitiveActionCategory, PermanentAgentGatingContext, AgentPermissionPolicy, AgentPermissionPolicyRules, AgentPermissionPolicyActionCategory, AgentProvisioningApprovalMode, LegacyAgentPermissionPolicyActionCategory, ApprovalRequestActionCategoryInput, ApprovalRequestActionCategory, AgentPermissionPolicyDisposition, AgentPermissionPolicyPresetId, ApprovalRequestStatus, ApprovalRequestAuditEventType, ApprovalRequestActorSnapshot, ApprovalRequestTargetAction, ApprovalRequestAuditEvent, ApprovalRequest, ApprovalRequestCreateInput, ApprovalRequestDecisionInput, ApprovalRequestCompletionInput, ApprovalRequestListInput, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
export { AGENT_VALID_TRANSITIONS } from "./types.js"; export { AGENT_VALID_TRANSITIONS } from "./types.js";
export type { TaskReviewData, TaskReviewSummary, TaskReviewItem } from "./types.js"; export type { TaskReviewData, TaskReviewSummary, TaskReviewItem } from "./types.js";

View File

@@ -26,6 +26,12 @@ export type TaskPriority = (typeof TASK_PRIORITIES)[number];
*/ */
export const DEFAULT_TASK_PRIORITY: TaskPriority = "normal"; export const DEFAULT_TASK_PRIORITY: TaskPriority = "normal";
/**
* Dashboard high-fan-out blocker threshold. A blocker is considered high impact
* when at least this many active todo tasks are waiting on it.
*/
export const HIGH_FANOUT_BLOCKER_TODO_THRESHOLD = 5;
/** /**
* Execution mode for task implementation. * Execution mode for task implementation.
* Controls how the executor agent approaches the task: * Controls how the executor agent approaches the task:

View File

@@ -264,6 +264,7 @@ A persistent footer status bar at the bottom of the dashboard displays real-time
- **Stuck**: Count of tasks in "in-progress" with no activity for longer than the project's `taskStuckTimeoutMs` setting (shown only when > 0 and the setting is enabled). Uses the same `isTaskStuck()` predicate as task cards and list rows, so the footer count always matches the visible stuck indicators on the board - **Stuck**: Count of tasks in "in-progress" with no activity for longer than the project's `taskStuckTimeoutMs` setting (shown only when > 0 and the setting is enabled). Uses the same `isTaskStuck()` predicate as task cards and list rows, so the footer count always matches the visible stuck indicators on the board
- **Queued**: Count of tasks in "todo" column - **Queued**: Count of tasks in "todo" column
- **In Review**: Count of tasks in "in-review" column - **In Review**: Count of tasks in "in-review" column
- **High Fan-out**: Shows the worst current blocker in `in-progress`/`in-review` once it reaches **5 todo dependents**; rank order is highest todo count, then highest active total, then stable task ID.
- **Executor State**: Current state badge (Idle/Running/Paused) - **Executor State**: Current state badge (Idle/Running/Paused)
- **Last Activity**: Relative timestamp of most recent task event - **Last Activity**: Relative timestamp of most recent task event
@@ -273,7 +274,8 @@ A persistent footer status bar at the bottom of the dashboard displays real-time
- **Running**: Engine active with running tasks - **Running**: Engine active with running tasks
**Features**: **Features**:
- **Shared task list**: Task counts are derived from the same task list used by the board and list views, so the footer always matches the board state exactly. Stuck task detection uses a shared `isTaskStuck()` utility (see `utils/taskStuck.ts`) so the footer count and individual card/row indicators are always consistent - **Shared task list**: Task counts are derived from the same task list used by the board and list views, so the footer always matches the board state exactly. Stuck task detection uses a shared `isTaskStuck()` utility (see `utils/taskStuck.ts`) so the footer count and individual card/row indicators are always consistent.
- **Thresholded blocker escalation**: Task cards keep ordinary `Blocks N` visibility for non-critical chains while escalating only blockers with `activeTodoCount >= 5` to a distinct `High fan-out` signal. Done/archived downstream tasks never contribute to the threshold.
- **Footer-safe layout**: Project-view content (board, list view, agents view) automatically reserves space for the fixed footer using a CSS custom property (`--executor-footer-height`). The `project-content--with-footer` wrapper class sets this token to 36px on desktop and 32px on mobile, ensuring all content remains fully visible and scrollable above the status bar - **Footer-safe layout**: Project-view content (board, list view, agents view) automatically reserves space for the fixed footer using a CSS custom property (`--executor-footer-height`). The `project-content--with-footer` wrapper class sets this token to 36px on desktop and 32px on mobile, ensuring all content remains fully visible and scrollable above the status bar
- Real-time updates via 5-second polling for executor state (globalPause, enginePaused, maxConcurrent) - Real-time updates via 5-second polling for executor state (globalPause, enginePaused, maxConcurrent)
- Responsive design: collapses labels on mobile screens (<768px); footer height reduces from 36px to 32px - Responsive design: collapses labels on mobile screens (<768px); footer height reduces from 36px to 32px

View File

@@ -47,6 +47,11 @@
color: var(--color-error); color: var(--color-error);
} }
.executor-status-bar__segment--fanout {
color: var(--color-warning);
min-width: 0;
}
/* Colored dot indicator */ /* Colored dot indicator */
/* Colored dot indicator */ /* Colored dot indicator */
.executor-status-bar__indicator { .executor-status-bar__indicator {
@@ -91,6 +96,10 @@
background: var(--in-review); background: var(--in-review);
} }
.executor-status-bar__indicator--fanout {
background: var(--color-warning);
}
/* Numeric count display */ /* Numeric count display */
.executor-status-bar__count { .executor-status-bar__count {
font-family: var(--font-mono); font-family: var(--font-mono);
@@ -121,6 +130,15 @@
line-height: 1; line-height: 1;
} }
.executor-status-bar__fanout-summary {
color: var(--text);
font-family: var(--font-mono);
font-weight: 600;
max-width: 20ch;
overflow: hidden;
text-overflow: ellipsis;
}
/* Separator between count and max */ /* Separator between count and max */
.executor-status-bar__separator { .executor-status-bar__separator {
font-family: var(--font-mono); font-family: var(--font-mono);
@@ -257,6 +275,9 @@
/* Responsive: collapse on small screens */ /* Responsive: collapse on small screens */
@media (max-width: 768px) { @media (max-width: 768px) {
.executor-status-bar__fanout-summary {
max-width: 14ch;
}
/* Update the footer height token for mobile - must be on the wrapper, not on :root */ /* Update the footer height token for mobile - must be on the wrapper, not on :root */
.project-content--with-footer { .project-content--with-footer {
--executor-footer-height: calc(var(--space-lg) * 2 + var(--space-xs)); --executor-footer-height: calc(var(--space-lg) * 2 + var(--space-xs));

View File

@@ -1,7 +1,8 @@
import "./ExecutorStatusBar.css"; import "./ExecutorStatusBar.css";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import type { Task } from "@fusion/core"; import { HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, type Task } from "@fusion/core";
import { AlertTriangle, Clock, Folder, Pause, Play, Zap } from "lucide-react"; import { AlertTriangle, Clock, Folder, Pause, Play, Zap } from "lucide-react";
import { computeBlockerFanoutMap } from "../hooks/useBlockerFanout";
import { useExecutorStats } from "../hooks/useExecutorStats"; import { useExecutorStats } from "../hooks/useExecutorStats";
import type { ExecutorState, AiSessionSummary } from "../api"; import type { ExecutorState, AiSessionSummary } from "../api";
import { BackgroundTasksIndicator } from "./BackgroundTasksIndicator"; import { BackgroundTasksIndicator } from "./BackgroundTasksIndicator";
@@ -87,6 +88,30 @@ export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, backgr
const relativeTime = useMemo(() => formatRelativeTime(stats.lastActivityAt), [stats.lastActivityAt]); const relativeTime = useMemo(() => formatRelativeTime(stats.lastActivityAt), [stats.lastActivityAt]);
const highestFanoutBlocker = useMemo(() => {
const fanoutMap = computeBlockerFanoutMap(tasks);
const candidates = tasks
.filter((task) => task.column === "in-progress" || task.column === "in-review")
.map((task) => {
const fanout = fanoutMap.get(task.id);
if (!fanout || !fanout.isHighFanout) return null;
return {
id: task.id,
activeTodoCount: fanout.activeTodoCount,
totalCount: fanout.totalCount,
staleCount: fanout.staleBlockedByDependentIds.length,
};
})
.filter((entry): entry is { id: string; activeTodoCount: number; totalCount: number; staleCount: number } => Boolean(entry))
.sort((a, b) => {
if (b.activeTodoCount !== a.activeTodoCount) return b.activeTodoCount - a.activeTodoCount;
if (b.totalCount !== a.totalCount) return b.totalCount - a.totalCount;
return a.id.localeCompare(b.id, "en", { numeric: true, sensitivity: "base" });
});
return candidates[0] ?? null;
}, [tasks]);
const StateIcon = stateDisplay.icon; const StateIcon = stateDisplay.icon;
if (error) { if (error) {
@@ -187,6 +212,23 @@ export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs, backgr
<span className="executor-status-bar__count">{stats.inReviewCount}</span> <span className="executor-status-bar__count">{stats.inReviewCount}</span>
</div> </div>
{highestFanoutBlocker && (
<>
<span className="executor-status-bar__divider" aria-hidden="true" />
<div className="executor-status-bar__segment executor-status-bar__segment--fanout">
<span className="executor-status-bar__indicator executor-status-bar__indicator--fanout executor-status-bar__indicator--active" aria-hidden="true" />
<span className="executor-status-bar__label">High Fan-out</span>
<span
className="executor-status-bar__fanout-summary"
title={`Top blocker ${highestFanoutBlocker.id}: ${highestFanoutBlocker.activeTodoCount} todo waiting (threshold ${HIGH_FANOUT_BLOCKER_TODO_THRESHOLD}), ${highestFanoutBlocker.totalCount} active total`}
>
{highestFanoutBlocker.id} · {highestFanoutBlocker.activeTodoCount} todo
{highestFanoutBlocker.staleCount > 0 ? ` · ${highestFanoutBlocker.staleCount} stale` : ""}
</span>
</div>
</>
)}
{currentProjectPath && onOpenProjectDirectory && ( {currentProjectPath && onOpenProjectDirectory && (
<> <>
<span className="executor-status-bar__divider" aria-hidden="true" /> <span className="executor-status-bar__divider" aria-hidden="true" />

View File

@@ -432,6 +432,18 @@
padding-inline: calc(var(--space-xs) / 2); padding-inline: calc(var(--space-xs) / 2);
} }
.card-fanout-badge--high-impact {
color: var(--color-warning);
background: color-mix(in srgb, var(--color-warning) 16%, transparent);
border-radius: var(--radius-pill);
padding-inline: var(--space-xs);
font-weight: 600;
}
.card-fanout-badge--high-impact .card-fanout-count {
color: var(--text);
}
.card-scope-badge[data-tooltip]:hover::after, .card-scope-badge[data-tooltip]:hover::after,
.card-fanout-badge[data-tooltip]:hover::after { .card-fanout-badge[data-tooltip]:hover::after {
content: attr(data-tooltip); content: attr(data-tooltip);
@@ -439,7 +451,7 @@
bottom: 100%; bottom: 100%;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
background: var(--bg-card); background: var(--card);
color: var(--text); color: var(--text);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
@@ -544,6 +556,10 @@
.card-agent-badge-text { .card-agent-badge-text {
display: none; display: none;
} }
.card-fanout-badge--high-impact {
padding-inline: calc(var(--space-xs) / 2);
}
} }
.card-agent-badge--loading { .card-agent-badge--loading {

View File

@@ -2,7 +2,14 @@ import "./TaskCard.css";
import { memo, useCallback, useState, useRef, useEffect, useMemo } from "react"; import { memo, useCallback, useState, useRef, useEffect, useMemo } from "react";
import { Link, Clock, Layers, Pencil, ChevronDown, Folder, Target, Bot, Trash2, RotateCw, Zap, GitBranch } 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 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 {
COLUMN_LABELS,
DEFAULT_TASK_PRIORITY,
HIGH_FANOUT_BLOCKER_TODO_THRESHOLD,
TASK_PRIORITIES,
VALID_TRANSITIONS,
getErrorMessage,
} from "@fusion/core";
import { fetchTaskDetail, uploadAttachment, fetchMission, fetchAgent } from "../api"; import { fetchTaskDetail, uploadAttachment, fetchMission, fetchAgent } from "../api";
import { GitHubBadge } from "./GitHubBadge"; import { GitHubBadge } from "./GitHubBadge";
import { pickPreferredBadge } from "./TaskCardBadge"; import { pickPreferredBadge } from "./TaskCardBadge";
@@ -406,6 +413,7 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
previous.disableDrag === next.disableDrag && previous.disableDrag === next.disableDrag &&
previous.fanout?.totalCount === next.fanout?.totalCount && previous.fanout?.totalCount === next.fanout?.totalCount &&
previous.fanout?.activeTodoCount === next.fanout?.activeTodoCount && previous.fanout?.activeTodoCount === next.fanout?.activeTodoCount &&
previous.fanout?.isHighFanout === next.fanout?.isHighFanout &&
areTaskDependenciesEqual(previous.fanout?.dependentIds ?? [], next.fanout?.dependentIds ?? []) && areTaskDependenciesEqual(previous.fanout?.dependentIds ?? [], next.fanout?.dependentIds ?? []) &&
areTaskDependenciesEqual(previous.fanout?.staleBlockedByDependentIds ?? [], next.fanout?.staleBlockedByDependentIds ?? []) && areTaskDependenciesEqual(previous.fanout?.staleBlockedByDependentIds ?? [], next.fanout?.staleBlockedByDependentIds ?? []) &&
previousTask.id === nextTask.id && previousTask.id === nextTask.id &&
@@ -1640,12 +1648,14 @@ function TaskCardComponent({
)} )}
{fanout && fanout.totalCount > 0 && ( {fanout && fanout.totalCount > 0 && (
<span <span
className={`card-fanout-badge${fanout.staleBlockedByDependentIds.length > 0 ? " card-fanout-badge--stale" : ""}`} className={`card-fanout-badge${fanout.staleBlockedByDependentIds.length > 0 ? " card-fanout-badge--stale" : ""}${fanout.isHighFanout ? " card-fanout-badge--high-impact" : ""}`}
data-tooltip={`Blocking ${fanout.totalCount} task(s); ${fanout.activeTodoCount} waiting in todo`} data-tooltip={`Blocking ${fanout.totalCount} active task(s); ${fanout.activeTodoCount} waiting in todo${fanout.isHighFanout ? ` (high fan-out threshold: ${HIGH_FANOUT_BLOCKER_TODO_THRESHOLD})` : ""}`}
> >
<GitBranch size={12} style={{ verticalAlign: "middle" }} /> <GitBranch size={12} style={{ verticalAlign: "middle" }} />
<span> <span>
Blocks <span className="card-fanout-count">{fanout.totalCount}</span> {fanout.isHighFanout ? "High fan-out" : "Blocks"}{" "}
<span className="card-fanout-count">{fanout.totalCount}</span>
{fanout.isHighFanout ? ` (${fanout.activeTodoCount} todo)` : ""}
{fanout.staleBlockedByDependentIds.length > 0 ? ` (${fanout.staleBlockedByDependentIds.length} stale)` : ""} {fanout.staleBlockedByDependentIds.length > 0 ? ` (${fanout.staleBlockedByDependentIds.length} stale)` : ""}
</span> </span>
</span> </span>

View File

@@ -16,6 +16,21 @@ const mockUseExecutorStats = useExecutorStats as ReturnType<typeof vi.fn>;
/** Minimal empty task list used by tests that mock the hook. */ /** Minimal empty task list used by tests that mock the hook. */
const emptyTasks: any[] = []; const emptyTasks: any[] = [];
function makeTask(id: string, column: string, overrides: Record<string, unknown> = {}) {
return {
id,
description: `Task ${id}`,
column,
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
};
}
describe("ExecutorStatusBar", () => { describe("ExecutorStatusBar", () => {
const defaultStats: ExecutorStats = { const defaultStats: ExecutorStats = {
runningTaskCount: 2, runningTaskCount: 2,
@@ -51,6 +66,44 @@ describe("ExecutorStatusBar", () => {
expect(statusBar).toHaveTextContent("Blocked"); expect(statusBar).toHaveTextContent("Blocked");
expect(statusBar).toHaveTextContent("Queued"); expect(statusBar).toHaveTextContent("Queued");
expect(statusBar).toHaveTextContent("In Review"); expect(statusBar).toHaveTextContent("In Review");
expect(statusBar).not.toHaveTextContent("High Fan-out");
});
it("shows highest high fan-out blocker summary with stable tie-break ordering", () => {
const tasks = [
makeTask("FN-010", "in-progress"),
makeTask("FN-002", "in-review"),
makeTask("FN-101", "todo", { dependencies: ["FN-010"] }),
makeTask("FN-102", "todo", { dependencies: ["FN-010"] }),
makeTask("FN-103", "todo", { dependencies: ["FN-010"] }),
makeTask("FN-104", "todo", { dependencies: ["FN-010"] }),
makeTask("FN-105", "todo", { dependencies: ["FN-010"] }),
makeTask("FN-201", "todo", { dependencies: ["FN-002"] }),
makeTask("FN-202", "todo", { dependencies: ["FN-002"] }),
makeTask("FN-203", "todo", { dependencies: ["FN-002"] }),
makeTask("FN-204", "todo", { dependencies: ["FN-002"] }),
makeTask("FN-205", "todo", { dependencies: ["FN-002"] }),
];
render(<ExecutorStatusBar tasks={tasks} />);
const statusBar = screen.getByRole("status");
expect(statusBar).toHaveTextContent("High Fan-out");
expect(statusBar).toHaveTextContent("FN-002 · 5 todo");
});
it("does not show high fan-out summary for ordinary chains below threshold", () => {
const tasks = [
makeTask("FN-500", "in-progress"),
makeTask("FN-501", "todo", { dependencies: ["FN-500"] }),
makeTask("FN-502", "todo", { dependencies: ["FN-500"] }),
makeTask("FN-503", "todo", { dependencies: ["FN-500"] }),
makeTask("FN-504", "todo", { dependencies: ["FN-500"] }),
];
render(<ExecutorStatusBar tasks={tasks} />);
expect(screen.getByRole("status")).not.toHaveTextContent("High Fan-out");
}); });
it("displays running task count", () => { it("displays running task count", () => {

View File

@@ -194,7 +194,7 @@ describe("TaskCard", () => {
rerender( rerender(
<TaskCard <TaskCard
task={makeTask({ column: "todo" })} task={makeTask({ column: "todo" })}
fanout={{ totalCount: 0, activeTodoCount: 0, dependentIds: [], staleBlockedByDependentIds: [] }} fanout={{ totalCount: 0, activeTodoCount: 0, dependentIds: [], staleBlockedByDependentIds: [], isHighFanout: false }}
onOpenDetail={noop} onOpenDetail={noop}
addToast={noop} addToast={noop}
/>, />,
@@ -207,7 +207,7 @@ describe("TaskCard", () => {
render( render(
<TaskCard <TaskCard
task={makeTask({ column: "in-progress" })} task={makeTask({ column: "in-progress" })}
fanout={{ totalCount: 7, activeTodoCount: 4, dependentIds: ["FN-002"], staleBlockedByDependentIds: [] }} fanout={{ totalCount: 7, activeTodoCount: 4, dependentIds: ["FN-002"], staleBlockedByDependentIds: [], isHighFanout: false }}
onOpenDetail={noop} onOpenDetail={noop}
addToast={noop} addToast={noop}
/>, />,
@@ -216,14 +216,14 @@ describe("TaskCard", () => {
const badge = screen.getByText("Blocks").closest(".card-fanout-badge") as HTMLElement; const badge = screen.getByText("Blocks").closest(".card-fanout-badge") as HTMLElement;
expect(badge).not.toBeNull(); expect(badge).not.toBeNull();
expect(badge.textContent).toContain("Blocks 7"); expect(badge.textContent).toContain("Blocks 7");
expect(badge.getAttribute("data-tooltip")).toBe("Blocking 7 task(s); 4 waiting in todo"); expect(badge.getAttribute("data-tooltip")).toBe("Blocking 7 active task(s); 4 waiting in todo");
}); });
it("applies stale fan-out modifier when stale blockedBy dependents exist", () => { it("applies stale fan-out modifier when stale blockedBy dependents exist", () => {
const { container } = render( const { container } = render(
<TaskCard <TaskCard
task={makeTask({ column: "in-progress" })} task={makeTask({ column: "in-progress" })}
fanout={{ totalCount: 3, activeTodoCount: 1, dependentIds: ["FN-003"], staleBlockedByDependentIds: ["FN-003"] }} fanout={{ totalCount: 3, activeTodoCount: 1, dependentIds: ["FN-003"], staleBlockedByDependentIds: ["FN-003"], isHighFanout: false }}
onOpenDetail={noop} onOpenDetail={noop}
addToast={noop} addToast={noop}
/>, />,
@@ -234,6 +234,34 @@ describe("TaskCard", () => {
expect(badge.textContent).toContain("(1 stale)"); expect(badge.textContent).toContain("(1 stale)");
}); });
it("escalates only threshold-crossing fan-out badges", () => {
const { rerender } = render(
<TaskCard
task={makeTask({ column: "in-progress" })}
fanout={{ totalCount: 8, activeTodoCount: 5, dependentIds: ["FN-003"], staleBlockedByDependentIds: [], isHighFanout: true }}
onOpenDetail={noop}
addToast={noop}
/>,
);
let badge = document.querySelector(".card-fanout-badge--high-impact") as HTMLElement;
expect(badge).not.toBeNull();
expect(badge.textContent).toContain("High fan-out");
expect(badge.textContent).toContain("(5 todo)");
rerender(
<TaskCard
task={makeTask({ column: "in-progress" })}
fanout={{ totalCount: 8, activeTodoCount: 4, dependentIds: ["FN-003"], staleBlockedByDependentIds: [], isHighFanout: false }}
onOpenDetail={noop}
addToast={noop}
/>,
);
badge = screen.getByText("Blocks").closest(".card-fanout-badge") as HTMLElement;
expect(badge).not.toHaveClass("card-fanout-badge--high-impact");
});
it("shows plain paused label when pausedByAgentId is not set", () => { it("shows plain paused label when pausedByAgentId is not set", () => {
render( render(
<TaskCard task={makeTask({ paused: true })} onOpenDetail={noop} addToast={noop} />, <TaskCard task={makeTask({ paused: true })} onOpenDetail={noop} addToast={noop} />,

View File

@@ -41,6 +41,7 @@ describe("computeBlockerFanoutMap", () => {
activeTodoCount: 1, activeTodoCount: 1,
dependentIds: ["FN-2"], dependentIds: ["FN-2"],
staleBlockedByDependentIds: [], staleBlockedByDependentIds: [],
isHighFanout: false,
}); });
}); });
@@ -56,6 +57,7 @@ describe("computeBlockerFanoutMap", () => {
activeTodoCount: 1, activeTodoCount: 1,
dependentIds: ["FN-2", "FN-3"], dependentIds: ["FN-2", "FN-3"],
staleBlockedByDependentIds: [], staleBlockedByDependentIds: [],
isHighFanout: false,
}); });
}); });
@@ -72,6 +74,7 @@ describe("computeBlockerFanoutMap", () => {
activeTodoCount: 1, activeTodoCount: 1,
dependentIds: ["FN-2", "FN-3", "FN-4"], dependentIds: ["FN-2", "FN-3", "FN-4"],
staleBlockedByDependentIds: [], staleBlockedByDependentIds: [],
isHighFanout: false,
}); });
}); });
@@ -86,6 +89,7 @@ describe("computeBlockerFanoutMap", () => {
activeTodoCount: 2, activeTodoCount: 2,
dependentIds: ["FN-2", "FN-3"], dependentIds: ["FN-2", "FN-3"],
staleBlockedByDependentIds: ["FN-3"], staleBlockedByDependentIds: ["FN-3"],
isHighFanout: false,
}); });
}); });
@@ -126,6 +130,46 @@ describe("computeBlockerFanoutMap", () => {
activeTodoCount: 2, activeTodoCount: 2,
dependentIds: ["D1", "D2", "D3", "D4"], dependentIds: ["D1", "D2", "D3", "D4"],
staleBlockedByDependentIds: [], staleBlockedByDependentIds: [],
isHighFanout: false,
});
});
it("flags blockers with at least 5 active todo dependents as high fan-out", () => {
const tasks = [
createTask("B", "in-progress"),
createTask("D1", "todo", { dependencies: ["B"] }),
createTask("D2", "todo", { dependencies: ["B"] }),
createTask("D3", "todo", { blockedBy: "B" }),
createTask("D4", "todo", { blockedBy: "B" }),
createTask("D5", "todo", { dependencies: ["B"] }),
createTask("DONE", "done", { dependencies: ["B"] }),
];
expect(computeBlockerFanoutMap(tasks).get("B")).toEqual({
totalCount: 5,
activeTodoCount: 5,
dependentIds: ["D1", "D2", "D3", "D4", "D5", "DONE"],
staleBlockedByDependentIds: [],
isHighFanout: true,
});
});
it("does not flag ordinary fan-out chains below threshold", () => {
const tasks = [
createTask("B", "in-review"),
createTask("D1", "todo", { dependencies: ["B"] }),
createTask("D2", "todo", { blockedBy: "B" }),
createTask("D3", "todo", { dependencies: ["B"] }),
createTask("D4", "todo", { dependencies: ["B"] }),
createTask("ARCH", "archived", { dependencies: ["B"] }),
];
expect(computeBlockerFanoutMap(tasks).get("B")).toEqual({
totalCount: 4,
activeTodoCount: 4,
dependentIds: ["D1", "D2", "D3", "D4", "ARCH"],
staleBlockedByDependentIds: [],
isHighFanout: false,
}); });
}); });

View File

@@ -1,11 +1,12 @@
import { useMemo } from "react"; import { useMemo } from "react";
import type { Task } from "@fusion/core"; import { HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, type Task } from "@fusion/core";
export interface BlockerFanoutEntry { export interface BlockerFanoutEntry {
totalCount: number; totalCount: number;
activeTodoCount: number; activeTodoCount: number;
dependentIds: string[]; dependentIds: string[];
staleBlockedByDependentIds: string[]; staleBlockedByDependentIds: string[];
isHighFanout: boolean;
} }
// Keep in sync with packages/engine/src/self-healing.ts // Keep in sync with packages/engine/src/self-healing.ts
@@ -91,6 +92,7 @@ export function computeBlockerFanoutMap(tasks: Task[]): Map<string, BlockerFanou
activeTodoCount: entry.activeTodoCount, activeTodoCount: entry.activeTodoCount,
dependentIds: entry.dependentIds, dependentIds: entry.dependentIds,
staleBlockedByDependentIds, staleBlockedByDependentIds,
isHighFanout: entry.activeTodoCount >= HIGH_FANOUT_BLOCKER_TODO_THRESHOLD,
}); });
} }