feat: remove stuck-task tagging from the dashboard; fix liveness-ratchet scan path

Removes the dashboard's stuck-task tagging per operator request: the Stuck
card/status badges, stuck row styling, the footer Stuck segment and
stuckTaskCount stat, utils/taskStuck.ts, the isStuck agent-activity gate,
and the taskStuckTimeoutMs prop plumbing (App -> Board/Lane/Column/
WorktreeGroup/MainContent -> TaskCard/ListView/ExecutorStatusBar). Stuck-task
tests are deleted or reconciled. The taskStuckTimeoutMs setting and the
engine's recovery sweeps (including the stuck-killed status) are unchanged —
the setting is engine-side only now.

Also repoints the FN-6756 liveness-gate ratchet's facade scans at
executor/task-executor-session-facades.ts, where the wave20 extraction moved
hasLiveSessionSurface/clearPhantomExecutorBinding (the two pre-existing red
tests on main).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-08-17 15:47:43 -07:00
parent 5f29935056
commit 2eae0b2507
29 changed files with 105 additions and 875 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Remove stuck-task tagging from the dashboard — no more Stuck badges, card styling, or footer stuck count.
category: feature
dev: "Deletes utils/taskStuck.ts, the stuck ExecutorStats field, and taskStuckTimeoutMs prop plumbing; the setting remains and engine recovery sweeps still consume it. Also repoints the FN-6756 liveness ratchet at the extracted executor session facades."

View File

@@ -850,7 +850,6 @@ function AppInner() {
showWorktreeGrouping,
globalPaused,
isTestMode,
taskStuckTimeoutMs,
staleHighFanoutBlockerAgeThresholdMs,
capacityRiskBannerEnabled,
capacityRiskTodoThreshold,
@@ -1765,7 +1764,6 @@ function AppInner() {
handleOpenDetailWithTab,
handleToggleFavorite,
handleToggleModelFavorite,
taskStuckTimeoutMs,
staleHighFanoutBlockerAgeThresholdMs,
lastFetchTimeMs,
openCreateWorkflowWithNav,
@@ -2032,7 +2030,6 @@ function AppInner() {
tasks={footerTasks}
projectId={currentProject.id}
columnFlagsByTaskId={footerColumnFlagsByTaskId}
taskStuckTimeoutMs={taskStuckTimeoutMs}
staleHighFanoutBlockerAgeThresholdMs={staleHighFanoutBlockerAgeThresholdMs}
lastFetchTimeMs={lastFetchTimeMs}
currentProjectPath={currentProject.path}

View File

@@ -1127,7 +1127,6 @@ describe("ExecutorStats type", () => {
const stats: ExecutorStats = {
runningTaskCount: 3,
blockedTaskCount: 2,
stuckTaskCount: 1,
queuedTaskCount: 10,
inReviewCount: 4,
executorState: "running",
@@ -1137,7 +1136,6 @@ describe("ExecutorStats type", () => {
expect(stats.runningTaskCount).toBe(3);
expect(stats.blockedTaskCount).toBe(2);
expect(stats.stuckTaskCount).toBe(1);
expect(stats.queuedTaskCount).toBe(10);
expect(stats.inReviewCount).toBe(4);
expect(stats.executorState).toBe("running");
@@ -1149,7 +1147,6 @@ describe("ExecutorStats type", () => {
const idleStats: ExecutorStats = {
runningTaskCount: 0,
blockedTaskCount: 0,
stuckTaskCount: 0,
queuedTaskCount: 5,
inReviewCount: 0,
executorState: "idle",
@@ -1159,7 +1156,6 @@ describe("ExecutorStats type", () => {
const runningStats: ExecutorStats = {
runningTaskCount: 2,
blockedTaskCount: 1,
stuckTaskCount: 0,
queuedTaskCount: 3,
inReviewCount: 1,
executorState: "running",
@@ -1169,7 +1165,6 @@ describe("ExecutorStats type", () => {
const pausedStats: ExecutorStats = {
runningTaskCount: 1,
blockedTaskCount: 0,
stuckTaskCount: 0,
queuedTaskCount: 8,
inReviewCount: 2,
executorState: "paused",
@@ -1185,7 +1180,6 @@ describe("ExecutorStats type", () => {
const stats: ExecutorStats = {
runningTaskCount: 0,
blockedTaskCount: 0,
stuckTaskCount: 0,
queuedTaskCount: 0,
inReviewCount: 0,
executorState: "idle",

View File

@@ -74,7 +74,7 @@ export type ExecutorState = "idle" | "running" | "paused" | "stopped";
/** Aggregated executor statistics for the status bar.
*
* Counts (runningTaskCount, blockedTaskCount, queuedTaskCount, inReviewCount, stuckTaskCount)
* Counts (runningTaskCount, blockedTaskCount, queuedTaskCount, inReviewCount)
* are derived client-side from the same tasks array shared with the board, ensuring
* the footer counts always match the active work states displayed on screen. Queued covers
* todo plus planning/triage work; Done is intentionally not exposed unless a footer Done
@@ -97,8 +97,11 @@ export interface ExecutorStats {
runningTaskCount: number;
/** Number of tasks with blockedBy field set (waiting on file overlap) */
blockedTaskCount: number;
/** Number of "in-progress" tasks with no activity for > 10 minutes */
stuckTaskCount: number;
/*
FNXC:StuckTagRemoval 2026-08-17-22:30:
Operator removed stuck-task tagging from the dashboard (stuckTaskCount deleted here);
engine recovery sweeps still consume taskStuckTimeoutMs server-side.
*/
/** Number of tasks in "todo" plus planning/triage work states */
queuedTaskCount: number;
/** Number of tasks in "in-review" column */

View File

@@ -90,8 +90,6 @@ interface BoardProps {
favoriteModels?: string[];
onToggleFavorite?: (provider: string) => void;
onToggleModelFavorite?: (modelId: string) => void;
/** Project-level stuck task timeout in milliseconds (undefined = disabled) */
taskStuckTimeoutMs?: number;
/** Called when user clicks a mission badge on a task card */
onOpenMission?: (missionId: string) => void;
/** Age threshold in milliseconds before high fan-out blockers escalate in dashboard surfaces. */
@@ -177,7 +175,7 @@ function columnDefOffersArchiveAllDone(columnDef: { flags: { complete?: boolean;
return columnDef.flags.complete === true && columnDef.flags.archived !== true;
}
export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, onMoveTask, onPauseTask, onUnpauseTask, onResetTask, onDuplicateTask, onMergeTask, onOpenDetail, onOpenRefine, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, mergeStrategy = "direct", onToggleAutoMerge, planAutoApproveEnabled, onTogglePlanAutoApprove, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onRevertTask, onReviseTask, onDeleteTask, onArchiveAllDone, onLoadArchivedTasks, onLoadMoreArchivedTasks, archivedHasMore, archivedLoadingMore, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, taskStuckTimeoutMs, onOpenMission, staleHighFanoutBlockerAgeThresholdMs, lastFetchTimeMs, prAuthAvailable, onOpenWorkflowEditor, onCreateWorkflow, workflowControlsInHeader = false }: BoardProps) {
export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, onMoveTask, onPauseTask, onUnpauseTask, onResetTask, onDuplicateTask, onMergeTask, onOpenDetail, onOpenRefine, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, mergeStrategy = "direct", onToggleAutoMerge, planAutoApproveEnabled, onTogglePlanAutoApprove, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onRevertTask, onReviseTask, onDeleteTask, onArchiveAllDone, onLoadArchivedTasks, onLoadMoreArchivedTasks, archivedHasMore, archivedLoadingMore, searchQuery = "", availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, onOpenMission, staleHighFanoutBlockerAgeThresholdMs, lastFetchTimeMs, prAuthAvailable, onOpenWorkflowEditor, onCreateWorkflow, workflowControlsInHeader = false }: BoardProps) {
const [archivedCollapsed, setArchivedCollapsed] = useState(true);
/*
FNXC:DoneColumnSorting 2026-06-29-16:57:
@@ -1001,7 +999,6 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
onToggleFavorite={onToggleFavorite}
onToggleModelFavorite={onToggleModelFavorite}
isSearchActive={isSearchActive}
taskStuckTimeoutMs={taskStuckTimeoutMs}
onOpenMission={onOpenMission}
lastFetchTimeMs={lastFetchTimeMs}
taskCardFieldDefs={taskCardFieldDefs}
@@ -1104,7 +1101,6 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
onToggleFavorite={onToggleFavorite}
onToggleModelFavorite={onToggleModelFavorite}
isSearchActive={isSearchActive}
taskStuckTimeoutMs={taskStuckTimeoutMs}
onOpenMission={onOpenMission}
lastFetchTimeMs={lastFetchTimeMs}
taskCardFieldDefs={taskCardFieldDefs}
@@ -1171,7 +1167,6 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
onToggleFavorite={onToggleFavorite}
onToggleModelFavorite={onToggleModelFavorite}
isSearchActive={isSearchActive}
taskStuckTimeoutMs={taskStuckTimeoutMs}
onOpenMission={onOpenMission}
lastFetchTimeMs={lastFetchTimeMs}
taskCardFieldDefs={taskCardFieldDefs}

View File

@@ -182,8 +182,6 @@ interface ColumnProps {
onToggleModelFavorite?: (modelId: string) => void;
/** When true, search is active — bypass pagination so all matching tasks are visible. */
isSearchActive?: boolean;
/** Project-level stuck task timeout in milliseconds (undefined = disabled) */
taskStuckTimeoutMs?: number;
/** Called when user clicks a mission badge on a task card */
onOpenMission?: (missionId: string) => void;
/** Timestamp (ms) when task data was last confirmed fresh from the server. Used for freshness-aware stuck detection. */
@@ -241,7 +239,7 @@ interface ColumnProps {
getDraggingTaskId?: () => string | null;
}
function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktreeGrouping, onMoveTask, onPauseTask, onUnpauseTask, onResetTask, onDuplicateTask, onMergeTask, onOpenDetail, onOpenRefine, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, mergeStrategy = "direct", onToggleAutoMerge, planAutoApproveEnabled, onTogglePlanAutoApprove, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onRevertTask, onDeleteTask, onArchiveAllDone, doneSortMode, onDoneSortModeChange, collapsed, onToggleCollapse, archivedHasMore, archivedLoadingMore, onLoadMoreArchived, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs, taskCardFieldDefs, taskWorkflowBadges, blockerFanoutMap, prAuthAvailable, holdTaskIds, workflowMode, workflowId, workflowOptions, defaultWorkflowId, columnDisplayName, columnDescription, columnFlags, workflowContextMenuColumns, taskContextMenuColumnsByTaskId, onPromote, canDropTask, getDraggingTaskId }: ColumnProps) {
function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktreeGrouping, onMoveTask, onPauseTask, onUnpauseTask, onResetTask, onDuplicateTask, onMergeTask, onOpenDetail, onOpenRefine, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, mergeStrategy = "direct", onToggleAutoMerge, planAutoApproveEnabled, onTogglePlanAutoApprove, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onRevertTask, onDeleteTask, onArchiveAllDone, doneSortMode, onDoneSortModeChange, collapsed, onToggleCollapse, archivedHasMore, archivedLoadingMore, onLoadMoreArchived, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, onOpenMission, lastFetchTimeMs, taskCardFieldDefs, taskWorkflowBadges, blockerFanoutMap, prAuthAvailable, holdTaskIds, workflowMode, workflowId, workflowOptions, defaultWorkflowId, columnDisplayName, columnDescription, columnFlags, workflowContextMenuColumns, taskContextMenuColumnsByTaskId, onPromote, canDropTask, getDraggingTaskId }: ColumnProps) {
const { t } = useTranslation("app");
// Anchor the board.rejection.* catalog keys for the i18next extractor (it
// scopes `t` to the useTranslation binding, so the shared translateRejection
@@ -1144,7 +1142,6 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree
onRevertTask={onRevertTask}
onDeleteTask={onDeleteTask}
onOpenDetailWithTab={onOpenDetailWithTab}
taskStuckTimeoutMs={taskStuckTimeoutMs}
onOpenMission={onOpenMission}
lastFetchTimeMs={lastFetchTimeMs}
taskCardFieldDefs={taskCardFieldDefs}
@@ -1187,7 +1184,6 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, showWorktree
onRevertTask={onRevertTask}
onDeleteTask={onDeleteTask}
onOpenDetailWithTab={onOpenDetailWithTab}
taskStuckTimeoutMs={taskStuckTimeoutMs}
onOpenMission={onOpenMission}
onMoveTask={onMoveTask}
taskColumnFlags={getTaskColumnFlags(task)}

View File

@@ -99,10 +99,9 @@ FN-6887 makes the footer status bar the canonical desktop/tablet terminal launch
border-radius: var(--radius-sm);
}
.executor-status-bar__segment--stuck {
color: var(--color-error);
}
/*
FNXC:StuckTagRemoval 2026-08-17-22:30: Operator removed stuck-task tagging from the dashboard; engine recovery sweeps still consume taskStuckTimeoutMs server-side.
*/
.executor-status-bar__segment--fanout {
color: var(--color-error);
min-width: 0;
@@ -136,14 +135,6 @@ FN-6887 makes the footer status bar the canonical desktop/tablet terminal launch
background: var(--triage);
}
.executor-status-bar__indicator--stuck {
background: var(--color-error);
}
.executor-status-bar__indicator--stuck.executor-status-bar__indicator--active {
animation: executor-pulse 1s ease-in-out infinite;
}
.executor-status-bar__indicator--queued {
background: var(--todo);
}

View File

@@ -18,7 +18,11 @@ import { EngineControlMenu, type EngineControlMenuHandle } from "./EngineControl
import { TerminalLauncher } from "./TerminalLauncher";
import { useViewportMode } from "../hooks/useViewportMode";
type FooterStatId = "queued" | "running" | "stuck" | "blocked" | "fanout";
/*
FNXC:StuckTagRemoval 2026-08-17-22:30: Operator removed stuck-task tagging from the dashboard; engine recovery sweeps still consume taskStuckTimeoutMs server-side.
The footer no longer renders a Stuck segment or takes taskStuckTimeoutMs.
*/
type FooterStatId = "queued" | "running" | "blocked" | "fanout";
interface OpenStatTooltip {
id: FooterStatId;
@@ -63,11 +67,9 @@ interface ExecutorStatusBarProps {
projectId?: string;
/** Optional task-scoped board trait index supplied by an embedding board. */
columnFlagsByTaskId?: ReadonlyMap<string, ExecutorColumnFlags>;
/** Project-level stuck task timeout in milliseconds (undefined = disabled) */
taskStuckTimeoutMs?: number;
/** Age threshold in milliseconds before high fan-out blockers escalate in dashboard surfaces. */
staleHighFanoutBlockerAgeThresholdMs?: number;
/** Timestamp (ms) when task data was last confirmed fresh from the server. Used for freshness-aware stuck detection. */
/** Timestamp (ms) when task data was last confirmed fresh from the server. */
lastFetchTimeMs?: number;
/** Absolute path for the currently selected project directory. */
currentProjectPath?: string;
@@ -143,7 +145,7 @@ function getStateDisplay(state: ExecutorState, t: TFunction<"app">): { label: st
* - Executor state badge (idle/running/paused/stopped)
* - Last activity timestamp
*/
export function ExecutorStatusBar({ tasks, projectId, columnFlagsByTaskId: suppliedColumnFlagsByTaskId, taskStuckTimeoutMs, staleHighFanoutBlockerAgeThresholdMs, lastFetchTimeMs, currentProjectPath, onOpenProjectDirectory, keyboardOpen, hideWhenKeyboardOpen, onToggleTerminal, onOpenScripts, onRunScript, quickChatButtonMode = "off", onOpenQuickChat }: ExecutorStatusBarProps) {
export function ExecutorStatusBar({ tasks, projectId, columnFlagsByTaskId: suppliedColumnFlagsByTaskId, staleHighFanoutBlockerAgeThresholdMs, currentProjectPath, onOpenProjectDirectory, keyboardOpen, hideWhenKeyboardOpen, onToggleTerminal, onOpenScripts, onRunScript, quickChatButtonMode = "off", onOpenQuickChat }: ExecutorStatusBarProps) {
const { t } = useTranslation("app");
const viewportMode = useViewportMode();
const isMobile = viewportMode === "mobile";
@@ -159,7 +161,7 @@ export function ExecutorStatusBar({ tasks, projectId, columnFlagsByTaskId: suppl
* Settings can route Quick Chat to a footer launcher beside Terminal, keep the draggable floating FAB, or hide the launcher entirely. Footer launch stays desktop/tablet-only like Terminal while mobile opens from the floating path as a full-screen modal.
*/
const showQuickChatFooterLauncher = !isMobile && quickChatButtonMode === "footer" && Boolean(onOpenQuickChat);
const { stats, loading, error } = useExecutorStats(tasks, projectId, taskStuckTimeoutMs, lastFetchTimeMs, columnFlagsByTaskId);
const { stats, loading, error } = useExecutorStats(tasks, projectId, columnFlagsByTaskId);
const [isProjectPathVisible, setIsProjectPathVisible] = useState(false);
const [openStatTooltip, setOpenStatTooltip] = useState<OpenStatTooltip | null>(null);
const engineControlMenuRef = useRef<EngineControlMenuHandle>(null);
@@ -224,7 +226,7 @@ export function ExecutorStatusBar({ tasks, projectId, columnFlagsByTaskId: suppl
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 same per-task trait index this bar already takes for its 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.
*/
@@ -334,25 +336,6 @@ export function ExecutorStatusBar({ tasks, projectId, columnFlagsByTaskId: suppl
{/* Separator */}
<span className="executor-status-bar__divider" aria-hidden="true" />
{/* Stuck tasks */}
{stats.stuckTaskCount > 0 && (
<>
<MobileStatSegment
className="executor-status-bar__segment--stuck"
id="stuck"
isMobile={isMobile}
isOpen={openStatTooltip?.id === "stuck"}
label={t("executor.stuck", "Stuck")}
onToggle={toggleStatTooltip}
>
<span className="executor-status-bar__indicator executor-status-bar__indicator--stuck executor-status-bar__indicator--active" aria-hidden="true" />
<span className="executor-status-bar__label">{t("executor.stuck", "Stuck")}</span>
<span className="executor-status-bar__count executor-status-bar__count--error">{stats.stuckTaskCount}</span>
</MobileStatSegment>
<span className="executor-status-bar__divider" aria-hidden="true" />
</>
)}
{/* Blocked tasks */}
<MobileStatSegment
id="blocked"

View File

@@ -65,7 +65,6 @@ export interface LaneProps {
onToggleFavorite?: (provider: string) => void;
onToggleModelFavorite?: (modelId: string) => void;
isSearchActive?: boolean;
taskStuckTimeoutMs?: number;
onOpenMission?: (missionId: string) => void;
lastFetchTimeMs?: number;
/** Per-task card-placed custom field definitions (U13/KTD-14). */
@@ -211,7 +210,6 @@ function LaneComponent(props: LaneProps) {
onToggleFavorite={props.onToggleFavorite}
onToggleModelFavorite={props.onToggleModelFavorite}
isSearchActive={props.isSearchActive}
taskStuckTimeoutMs={props.taskStuckTimeoutMs}
onOpenMission={props.onOpenMission}
lastFetchTimeMs={props.lastFetchTimeMs}
taskCardFieldDefs={props.taskCardFieldDefs}

View File

@@ -612,13 +612,8 @@ No border-left on the detail pane. Keeping a border-left here would produce a se
}
/*
FNXC:ListView 2026-06-20-03:25:
FN-6774 removes the saturated stuck-task edge stripe from list rows. Keep the subtle triage background and stuck status badge so stuck rows stay distinguishable without changing the neutral row border.
FNXC:StuckTagRemoval 2026-08-17-22:30: Operator removed stuck-task tagging from the dashboard; engine recovery sweeps still consume taskStuckTimeoutMs server-side.
*/
.list-row.stuck {
background: color-mix(in srgb, var(--triage) 8%, transparent);
}
/*
FNXC:ListView 2026-06-16-00:00:
FN-6529 requires list-view agent-active tasks to use a simple static highlight instead of the animated board-card glow, so the list row keeps a flat inset indicator and subtle background without referencing agent-glow.
@@ -784,12 +779,6 @@ rows while preserving their existing badge geometry.
color: var(--color-error-dark);
}
.list-status-badge.stuck {
background: color-mix(in srgb, var(--triage) 20%, transparent);
color: var(--triage);
animation: stuck-pulse 2s ease-in-out infinite;
}
.list-status-badge.pulsing {
animation: pulse 1.5s ease-in-out infinite;
}

View File

@@ -15,7 +15,6 @@ import type { BoardWorkflowColumn, BoardWorkflowsPayload, ModelInfo, NodeInfo, R
import { QuickEntryBox } from "./QuickEntryBox";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { NodeHealthDot } from "./NodeHealthDot";
import { isTaskStuck } from "../utils/taskStuck";
import { hasPendingAutomaticRecovery, isTaskManuallyRetryable } from "../utils/taskRecovery";
import type { ToastType } from "../hooks/useToast";
import { useViewportMode } from "../hooks/useViewportMode";
@@ -309,11 +308,13 @@ interface ListViewProps {
projectId?: string;
/** Project name for display (optional) */
projectName?: string;
/** Project-level stuck task timeout in milliseconds (undefined = disabled) */
taskStuckTimeoutMs?: number;
/*
FNXC:StuckTagRemoval 2026-08-17-22:30: Operator removed stuck-task tagging from the dashboard; engine recovery sweeps still consume taskStuckTimeoutMs server-side.
ListView no longer takes taskStuckTimeoutMs or renders stuck rows/badges; lastFetchTimeMs stays for failed-state recovery freshness.
*/
/** External search query from header search (defaults to "") */
searchQuery?: string;
/** Timestamp (ms) when task data was last confirmed fresh from the server. Used for freshness-aware stuck detection. */
/** Timestamp (ms) when task data was last confirmed fresh from the server. */
lastFetchTimeMs?: number;
prAuthAvailable?: boolean;
autoMerge?: boolean;
@@ -389,7 +390,6 @@ export function ListView({
onTasksUpdated,
projectId,
projectName: _projectName,
taskStuckTimeoutMs,
searchQuery = "",
lastFetchTimeMs,
prAuthAvailable,
@@ -3139,8 +3139,7 @@ export function ListView({
const visualStatus = isDoneColumn ? "done" : task.status;
const isFailed = !isDoneColumn && task.status === "failed" && !hasPendingAutomaticRecovery(task, lastFetchTimeMs);
const isPaused = !isDoneColumn && task.paused === true;
const isStuckState = isTaskStuck(task, taskStuckTimeoutMs, lastFetchTimeMs, getTaskColumnFlags(task));
const isAgentActive = isTaskAgentActive(task, { globalPaused, isStuck: isStuckState, columnFlags: getTaskColumnFlags(task) });
const isAgentActive = isTaskAgentActive(task, { globalPaused, columnFlags: getTaskColumnFlags(task) });
// FNXC:TaskStatusBadge 2026-07-28-12:00: FN-8300 renders the same transient Planning badge as TaskCard so fresh planner logs never make grouped-list cards appear idle.
const isTransientPlannerActive = isIntakeColumnForTask(task)
&& !visualStatus
@@ -3163,14 +3162,13 @@ export function ListView({
FNXC:TaskCardBadgePrecedence 2026-08-06-14:53:
Keep card and both list render paths on the shared precedence rule: a visible
non-planning review gate displaces only Planning, while Plan Review remains
additive and pause/stuck/approval states keep their existing render branches. The table
additive and pause/approval states keep their existing render branches. The table
path also omits its otherwise-empty dash shell when the gate is the sole badge.
*/
const suppressPlanningStatusBadge = showOptionalGateBadge && isNonPlanningOptionalGateBadge(optionalGateBadge);
const isPlanningStatusBadge = !isReviewBudgetExhausted
&& (isLivePlanning || isTransientPlannerActive || visualStatus === "planning");
const wipLifecycleBadgeLabel = !isPaused
&& !isStuckState
&& !isReviewBudgetExhausted
&& !showOptionalGateBadge
? getTaskWipLifecycleBadgeLabel(visualStatus, t, {
@@ -3245,8 +3243,6 @@ export function ListView({
<span className="list-card-spacer" />
{isPaused && task.pausedByAgentId ? (
<span className="list-status-badge paused">{t("listView.pausedByAgent", "paused by agent")}</span>
) : isStuckState ? (
<span className="list-status-badge stuck">{t("listView.stuck", "Stuck")}</span>
) : hasStatus ? (
<span
className={`list-status-badge list-status-badge--${task.column}${isReviewBudgetExhausted ? " list-status-badge--review-budget-exhausted" : ""}${isFailed ? " failed" : ""}${isAgentActive ? " pulsing" : ""}`}
@@ -3434,8 +3430,7 @@ export function ListView({
const visualStatus = isDoneColumn ? "done" : task.status;
const isFailed = !isDoneColumn && task.status === "failed" && !hasPendingAutomaticRecovery(task, lastFetchTimeMs);
const isPaused = !isDoneColumn && task.paused === true;
const isStuckState = isTaskStuck(task, taskStuckTimeoutMs, lastFetchTimeMs, getTaskColumnFlags(task));
const isAgentActive = isTaskAgentActive(task, { globalPaused, isStuck: isStuckState, columnFlags: getTaskColumnFlags(task) });
const isAgentActive = isTaskAgentActive(task, { globalPaused, columnFlags: getTaskColumnFlags(task) });
const isReviewBudgetExhausted = isReviewBudgetExhaustedApproval(task);
const isTransientPlannerActive = isIntakeColumnForTask(task)
&& !visualStatus
@@ -3457,7 +3452,6 @@ export function ListView({
const isPlanningStatusBadge = !isReviewBudgetExhausted
&& (isLivePlanning || isTransientPlannerActive || visualStatus === "planning");
const wipLifecycleBadgeLabel = !isPaused
&& !isStuckState
&& !isReviewBudgetExhausted
&& !showOptionalGateBadge
? getTaskWipLifecycleBadgeLabel(visualStatus, t, {
@@ -3482,9 +3476,7 @@ export function ListView({
return (
<tr
key={task.id}
className={`list-row${isFailed ? " failed" : ""}${isPaused ? " paused" : ""}${
isStuckState ? " stuck" : ""
}${isAgentActive ? " agent-active" : ""}${
className={`list-row${isFailed ? " failed" : ""}${isPaused ? " paused" : ""}${isAgentActive ? " agent-active" : ""}${
isDragging ? " dragging" : ""
}${selectedTaskId === task.id ? " list-row--selected" : ""}`}
onClick={() => handleRowClick(task)}
@@ -3536,10 +3528,6 @@ export function ListView({
<td className="list-cell">
{isPaused && task.pausedByAgentId ? (
<span className="list-status-badge paused">{t("listView.pausedByAgent", "paused by agent")}</span>
) : isStuckState ? (
<span className="list-status-badge stuck">
{t("listView.stuck", "Stuck")}
</span>
) : showStatusBadge ? (
<span
className={`list-status-badge list-status-badge--${task.column}${isReviewBudgetExhausted ? " list-status-badge--review-budget-exhausted" : ""}${isFailed ? " failed" : ""}${

View File

@@ -422,12 +422,6 @@ and no hardcoded color. Same geometry as every other status badge; only the fill
color: var(--color-error-dark);
}
.card-status-badge.stuck {
background: var(--status-triage-bg-deep);
color: var(--triage);
animation: stuck-pulse 2s ease-in-out infinite;
}
.card-status-badge.in-review-stall {
background: color-mix(in srgb, var(--color-warning) 14%, transparent);
color: var(--color-warning);
@@ -547,22 +541,8 @@ not enlarge that shared box, so icon-only and text header chips stay one height
}
/*
FNXC:TaskCard 2026-06-20-03:25:
FN-6774 removes the saturated stuck-task edge stripe from board cards. Keep the triage-tinted surface plus the stuck status badge so the state remains legible while the card uses the neutral board border.
FNXC:StuckTagRemoval 2026-08-17-22:30: Operator removed stuck-task tagging from the dashboard; engine recovery sweeps still consume taskStuckTimeoutMs server-side.
*/
.card.stuck {
background: color-mix(in srgb, var(--triage) 6%, transparent);
}
@keyframes stuck-pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.65;
}
}
/*
FNXC:TaskCardLayout 2026-08-01-04:48 (FN-8665):
The size chip must derive its box from the shared header-chip padding, line-height, and border so it renders at the same height as status and metadata chips. As a direct child after .card-id, keep it anchored to the header's first row when the middle badge group wraps.

View File

@@ -33,7 +33,6 @@ import { getFreshBatchData } from "../hooks/useBatchBadgeFetch";
import { useTaskDiffStats } from "../hooks/useTaskDiffStats";
import { useAgentsMapCache } from "../hooks/useAgentsMapCache";
import { useLiveTimeTicker } from "../hooks/useLiveTimeTicker";
import { isTaskStuck } from "../utils/taskStuck";
import {
isArchivedColumnRole,
isCompleteColumnRole,
@@ -644,8 +643,10 @@ interface TaskCardProps {
onDuplicateTask?: (id: string) => Promise<Task>;
onMergeTask?: (id: string) => Promise<MergeResult>;
onOpenDetailWithTab?: (task: Task | TaskDetail, initialTab: "changes" | "retries" | "workflow") => void;
/** Project-level stuck task timeout in milliseconds (undefined = disabled) */
taskStuckTimeoutMs?: number;
/*
FNXC:StuckTagRemoval 2026-08-17-22:30: Operator removed stuck-task tagging from the dashboard; engine recovery sweeps still consume taskStuckTimeoutMs server-side.
TaskCard no longer takes taskStuckTimeoutMs or renders stuck classes/badges; lastFetchTimeMs stays for retry/recovery freshness checks.
*/
/** Called when user clicks the mission badge on a task card. */
onOpenMission?: (missionId: string) => void;
/** Called when user moves a task to a different column from the card. */
@@ -658,7 +659,7 @@ interface TaskCardProps {
onPromote?: (taskId: string) => Promise<void>;
/** True while this task's promote action is in flight. */
isPromoting?: boolean;
/** Timestamp (ms) when task data was last confirmed fresh from the server. Used for freshness-aware stuck detection. */
/** Timestamp (ms) when task data was last confirmed fresh from the server. */
lastFetchTimeMs?: number;
/** Disable card drag semantics when embedding in custom draggable containers (e.g. dependency graph). */
disableDrag?: boolean;
@@ -834,7 +835,6 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
previous.queued === next.queued &&
previous.projectId === next.projectId &&
previous.globalPaused === next.globalPaused &&
previous.taskStuckTimeoutMs === next.taskStuckTimeoutMs &&
previous.prAuthAvailable === next.prAuthAvailable &&
previous.autoMergeEnabled === next.autoMergeEnabled &&
previous.mergeStrategy === next.mergeStrategy &&
@@ -1018,7 +1018,6 @@ function TaskCardComponent({
onDuplicateTask,
onMergeTask,
onOpenDetailWithTab,
taskStuckTimeoutMs,
onOpenMission,
onMoveTask,
taskColumnFlags,
@@ -1502,7 +1501,6 @@ function TaskCardComponent({
const normalizedPriority = normalizeTaskPriorityValue(task.priority);
const showPriorityBadge = normalizedPriority !== DEFAULT_TASK_PRIORITY;
const PriorityBadgeIcon = getPriorityIcon(normalizedPriority);
const isStuck = isTaskStuck(task, taskStuckTimeoutMs, lastFetchTimeMs, taskColumnFlags);
const stalledReview = getStalledReviewSignal(task);
const showStalledReview = Boolean(stalledReview && isReviewColumn && !isPaused);
const hasInReviewStall = shouldShowInReviewStallBadge(task, taskColumnFlags);
@@ -1570,10 +1568,10 @@ function TaskCardComponent({
optional-gate activity and the column's executing count all read idle. Threading
ListView alone left this path — the board cards — still broken.
*/
const isAgentActive = isTaskAgentActive(task, { globalPaused, queued, isStuck, columnFlags: taskColumnFlags });
const isAgentActive = isTaskAgentActive(task, { globalPaused, queued, columnFlags: taskColumnFlags });
/*
FNXC:TaskCardOptionalGateBadge 2026-07-21-22:30:
Match FN-8055: optional-gate badges pulse only while the card is agent-active (queue/pause/stuck gates suppress the badge).
Match FN-8055: optional-gate badges pulse only while the card is agent-active (queue/pause gates suppress the badge).
*/
const showOptionalGateBadge = Boolean(optionalGateBadge) && isAgentActive;
/*
@@ -3154,7 +3152,7 @@ function TaskCardComponent({
}
}, [addToast, isRetrying, onRetryTask, task.id]);
const cardClass = `card${dragging ? " dragging" : ""}${queued ? " queued" : ""}${isAgentActive ? " agent-active" : ""}${isFailed ? " failed" : ""}${isPaused ? " paused" : ""}${isStuck ? " stuck" : ""}${isAwaitingApproval ? " awaiting-approval" : ""}${isAwaitingInput ? " awaiting-input" : ""}${fileDragOver ? " file-drop-target" : ""}${isEditing ? " card-editing" : ""}${isSaving ? " card-saving" : ""}`;
const cardClass = `card${dragging ? " dragging" : ""}${queued ? " queued" : ""}${isAgentActive ? " agent-active" : ""}${isFailed ? " failed" : ""}${isPaused ? " paused" : ""}${isAwaitingApproval ? " awaiting-approval" : ""}${isAwaitingInput ? " awaiting-input" : ""}${fileDragOver ? " file-drop-target" : ""}${isEditing ? " card-editing" : ""}${isSaving ? " card-saving" : ""}`;
const filesChangedButton = (() => {
if (isWipColumn) {
@@ -3423,11 +3421,10 @@ function TaskCardComponent({
A visible non-planning gate is the lifecycle authority while it runs, so suppress only the
contradictory Planning status shell. Plan Review is intentionally excluded by the shared helper:
Planning + Plan Review expresses nested planning, while Planning + Code Review is stale state.
Paused, stuck, approval, merge, and other operator states retain their existing precedence.
Paused, approval, merge, and other operator states retain their existing precedence.
*/
const suppressPlanningStatusBadge = showOptionalGateBadge && isNonPlanningOptionalGateBadge(optionalGateBadge);
const isPlanningStatusBadge = !isStuck
&& !isPlanReviewReplanCapApproval
const isPlanningStatusBadge = !isPlanReviewReplanCapApproval
&& !isAwaitingApproval
&& !isAwaitingInput
&& (isLivePlanning || isTransientPlannerActive || visualStatus === "planning");
@@ -3442,7 +3439,6 @@ function TaskCardComponent({
&& !isWipColumn
&& (queued || visualStatus === "queued");
const wipLifecycleBadgeLabel = !isPaused
&& !isStuck
&& !isPlanReviewReplanCapApproval
&& !isAwaitingApproval
&& !showOptionalGateBadge
@@ -3466,9 +3462,7 @@ function TaskCardComponent({
states the card's own status ("Planning"). The two badges stay orthogonal: what the card IS, and
which gate is RUNNING.
*/
const statusBadgeLabel = isStuck
? t("tasks.stuck", "Stuck")
: isPlanReviewReplanCapApproval
const statusBadgeLabel = isPlanReviewReplanCapApproval
? t("tasks.reviewBudgetExhausted", "Review budget exhausted")
: isAwaitingApproval
? t("tasks.awaitingApproval", "Awaiting Approval")
@@ -3508,7 +3502,6 @@ function TaskCardComponent({
|| cliNeedsAttention
|| Boolean(hasStalePausedReview && stalePausedReviewCopy)
|| Boolean(hasTaskAgeStaleness && taskAgeStalenessCopy)
|| Boolean(isStuck && (isPaused || !task.status || task.status === "queued"))
|| Boolean(Array.isArray((task as TaskWithBranchProgress).branchProgress) && (task as TaskWithBranchProgress).branchProgress!.length > 0)
|| showPlannerOverseerStateBadge
|| Boolean(showStalledReview && stalledReview)
@@ -3651,7 +3644,7 @@ function TaskCardComponent({
)}
{(showStatusBadge || showQueuedToPlanBadge || showQueuedBadge) && (
<span
className={`card-status-badge card-status-badge--${task.column}${showQueuedToPlanBadge ? " queued-to-plan" : ""}${showQueuedBadge && (task.overlapBlockedBy || task.blockedBy) ? " card-status-badge--queued-with-reason" : ""}${isAwaitingApproval ? " awaiting-approval" : ""}${isPlanReviewReplanCapApproval ? " awaiting-approval--plan-review-replan-cap" : ""}${isAwaitingInput ? " awaiting-input" : ""}${isAgentActive ? " pulsing" : ""}${isFailed ? " failed" : ""}${isStuck ? " stuck" : ""}`}
className={`card-status-badge card-status-badge--${task.column}${showQueuedToPlanBadge ? " queued-to-plan" : ""}${showQueuedBadge && (task.overlapBlockedBy || task.blockedBy) ? " card-status-badge--queued-with-reason" : ""}${isAwaitingApproval ? " awaiting-approval" : ""}${isPlanReviewReplanCapApproval ? " awaiting-approval--plan-review-replan-cap" : ""}${isAwaitingInput ? " awaiting-input" : ""}${isAgentActive ? " pulsing" : ""}${isFailed ? " failed" : ""}`}
title={
isPlanReviewReplanCapApproval
? t(
@@ -3705,7 +3698,7 @@ function TaskCardComponent({
{showOptionalGateBadge && optionalGateBadge && (
/*
FNXC:TaskCardPlanReviewBadge 2026-07-11-12:06:
The Reviewing badge is additive to the normal header status badge so operators can distinguish "planning" from active Plan Review without hiding paused/stuck/status affordances.
The Reviewing badge is additive to the normal header status badge so operators can distinguish "planning" from active Plan Review without hiding paused/status affordances.
FNXC:TaskCardOptionalGateBadge 2026-07-21-22:30:
Same additive pattern for Code Review / Browser Verification in In-review. Label is the gate's own name. These gates stay out of the WIP bullet list.
@@ -3796,11 +3789,6 @@ function TaskCardComponent({
{taskAgeStalenessCopy.badgeLabel}
</span>
)}
{isStuck && (isPaused || !task.status || task.status === "queued") && (
<span className="card-status-badge stuck">
{t("tasks.stuck", "Stuck")}
</span>
)}
{/* U13/U9: per-branch progress badges while the card is in a parallel
window. Reads an optional additive `branchProgress` field on the task
payload (server-persisted by U13); absent → nothing renders. */}

View File

@@ -45,8 +45,6 @@ interface WorktreeGroupProps {
githubIssueAction?: GithubIssueAction;
}) => Promise<Task>;
onOpenDetailWithTab?: (task: Task | TaskDetail, initialTab: "changes" | "retries" | "workflow") => void;
/** Project-level stuck task timeout in milliseconds (undefined = disabled) */
taskStuckTimeoutMs?: number;
/** Called when user clicks a mission badge on a task card */
onOpenMission?: (missionId: string) => void;
/** Timestamp (ms) when task data was last confirmed fresh from the server. Used for freshness-aware stuck detection. */
@@ -96,7 +94,6 @@ function WorktreeGroupComponent({
onRevertTask,
onDeleteTask,
onOpenDetailWithTab,
taskStuckTimeoutMs,
onOpenMission,
lastFetchTimeMs,
taskCardFieldDefs,
@@ -169,7 +166,6 @@ function WorktreeGroupComponent({
onRevertTask={onRevertTask}
onDeleteTask={onDeleteTask}
onOpenDetailWithTab={onOpenDetailWithTab}
taskStuckTimeoutMs={taskStuckTimeoutMs}
onOpenMission={onOpenMission}
lastFetchTimeMs={lastFetchTimeMs}
cardFieldDefs={taskCardFieldDefs?.get(task.id)}
@@ -208,7 +204,6 @@ function WorktreeGroupComponent({
onRevertTask={onRevertTask}
onDeleteTask={onDeleteTask}
onOpenDetailWithTab={onOpenDetailWithTab}
taskStuckTimeoutMs={taskStuckTimeoutMs}
onOpenMission={onOpenMission}
lastFetchTimeMs={lastFetchTimeMs}
cardFieldDefs={taskCardFieldDefs?.get(task.id)}

View File

@@ -109,11 +109,11 @@ function makeBackgroundSession(id: string, status: AiSessionSummary["status"]):
};
}
// FNXC:StuckTagRemoval 2026-08-17-22:30: stuck-task tagging removed from the dashboard; stuck coverage deleted with it.
describe("ExecutorStatusBar", () => {
const defaultStats: ExecutorStats = {
runningTaskCount: 2,
blockedTaskCount: 1,
stuckTaskCount: 0,
queuedTaskCount: 5,
inReviewCount: 3,
executorState: "running",
@@ -158,7 +158,6 @@ describe("ExecutorStatusBar", () => {
queuedTaskCount: 9,
runningTaskCount: 2,
maxConcurrent: 4,
stuckTaskCount: 1,
blockedTaskCount: 2,
inReviewCount: 1,
},
@@ -194,7 +193,6 @@ describe("ExecutorStatusBar", () => {
expectSegmentCount("Waiting", "9");
expectSegmentCount("Running", "2");
expect(within(getSegmentByLabel("Running")).getByText("4")).toHaveClass("executor-status-bar__max");
expectSegmentCount("Stuck", "1");
expectSegmentCount("Blocked", "2");
expect(statusBar).not.toHaveTextContent("In Review");
expect(statusBar).toHaveTextContent("Overlap queue");
@@ -487,26 +485,6 @@ describe("ExecutorStatusBar", () => {
expect(screen.queryByTestId("scripts-btn")).toBeNull();
});
it("does not show stuck tasks segment when count is 0", () => {
render(<ExecutorStatusBar tasks={emptyTasks} />);
expect(screen.queryByText("Stuck")).not.toBeInTheDocument();
});
it("shows stuck tasks segment when count is > 0", () => {
vi.mocked(mockUseExecutorStats).mockReturnValue({
stats: { ...defaultStats, stuckTaskCount: 2 },
loading: false,
error: null,
refresh: vi.fn(),
});
render(<ExecutorStatusBar tasks={emptyTasks} />);
const statusBar = screen.getByRole("status");
expect(statusBar).toHaveTextContent("Stuck");
expect(statusBar).toHaveTextContent("2");
});
});
describe("mobile stat tooltips", () => {
@@ -579,15 +557,8 @@ describe("ExecutorStatusBar", () => {
it("adds stat tooltips only for conditional segments that exist", async () => {
const user = userEvent.setup();
const { rerender } = render(<ExecutorStatusBar tasks={emptyTasks} />);
expect(screen.queryByTestId("executor-stat-stuck")).toBeNull();
expect(screen.queryByTestId("executor-stat-fanout")).toBeNull();
vi.mocked(mockUseExecutorStats).mockReturnValue({
stats: { ...defaultStats, stuckTaskCount: 1 },
loading: false,
error: null,
refresh: vi.fn(),
});
const fanoutTasks = [
makeTask("FN-010", "in-progress"),
makeTask("FN-101", "todo", { blockedBy: "FN-010" }),
@@ -598,8 +569,6 @@ describe("ExecutorStatusBar", () => {
];
rerender(<ExecutorStatusBar tasks={fanoutTasks} />);
await user.click(screen.getByTestId("executor-stat-stuck"));
expect(screen.getByRole("tooltip")).toHaveTextContent("Stuck");
await user.click(screen.getByTestId("executor-stat-fanout"));
expect(screen.getByRole("tooltip")).toHaveTextContent("Overlap queue");
});
@@ -964,21 +933,6 @@ describe("ExecutorStatusBar", () => {
expect(blockedSegment?.parentElement?.querySelector(".executor-status-bar__count")).toHaveClass("executor-status-bar__count--warning");
});
it("applies error class to stuck count when stuck tasks exist", () => {
vi.mocked(mockUseExecutorStats).mockReturnValue({
stats: { ...defaultStats, stuckTaskCount: 1 },
loading: false,
error: null,
refresh: vi.fn(),
});
render(<ExecutorStatusBar tasks={emptyTasks} />);
const statusBar = screen.getByRole("status");
const stuckSegment = statusBar.querySelector(".executor-status-bar__segment--stuck");
expect(stuckSegment?.querySelector(".executor-status-bar__count")).toHaveClass("executor-status-bar__count--error");
});
it("applies active class to running indicator when tasks are running", () => {
render(<ExecutorStatusBar tasks={emptyTasks} />);
@@ -1016,13 +970,13 @@ describe("ExecutorStatusBar", () => {
const tasks: any[] = [{ id: "FN-001" }];
render(<ExecutorStatusBar tasks={tasks} projectId="proj_abc123" />);
expect(mockUseExecutorStats).toHaveBeenCalledWith(tasks, "proj_abc123", undefined, undefined, undefined);
expect(mockUseExecutorStats).toHaveBeenCalledWith(tasks, "proj_abc123", undefined);
});
it("passes tasks and undefined to useExecutorStats when projectId not provided", () => {
render(<ExecutorStatusBar tasks={emptyTasks} />);
expect(mockUseExecutorStats).toHaveBeenCalledWith(emptyTasks, undefined, undefined, undefined, undefined);
expect(mockUseExecutorStats).toHaveBeenCalledWith(emptyTasks, undefined, undefined);
});
});
@@ -1104,24 +1058,7 @@ describe("ExecutorStatusBar", () => {
render(<ExecutorStatusBar tasks={tasks} />);
// useExecutorStats receives the tasks array as first argument
expect(mockUseExecutorStats).toHaveBeenCalledWith(tasks, undefined, undefined, undefined, undefined);
});
it("renders stuck segment with correct count when stuck tasks detected", () => {
vi.mocked(mockUseExecutorStats).mockReturnValue({
stats: { ...defaultStats, stuckTaskCount: 3, runningTaskCount: 2 },
loading: false,
error: null,
refresh: vi.fn(),
});
render(<ExecutorStatusBar tasks={emptyTasks} />);
const statusBar = screen.getByRole("status");
expect(statusBar).toHaveTextContent("Stuck");
const stuckCount = statusBar.querySelector(".executor-status-bar__segment--stuck .executor-status-bar__count");
expect(stuckCount).toHaveTextContent("3");
expect(stuckCount).toHaveClass("executor-status-bar__count--error");
expect(mockUseExecutorStats).toHaveBeenCalledWith(tasks, undefined, undefined);
});
});

View File

@@ -2654,13 +2654,6 @@ describe("ListView", () => {
expectedColor: "var(--color-error-dark)",
disallowedColor: "var(--in-review)",
},
{
name: "stuck + todo uses triage token color",
classes: "list-status-badge list-status-badge--todo stuck",
expectedClass: "stuck",
expectedColor: "var(--triage)",
disallowedColor: "var(--todo)",
},
])("FN-4208 keeps list badge state precedence: $name", ({ classes, expectedClass, expectedColor, disallowedColor }) => {
const cleanupCss = mountCssForBadgeTests();
try {
@@ -3048,62 +3041,7 @@ describe("ListView", () => {
expect(row?.className).not.toContain("agent-active");
});
it("renders stuck indicator when task is stuck and timeout is set", () => {
const staleTime = new Date(Date.now() - 600000).toISOString();
const tasks = [
createMockTask({
id: "FN-001",
status: "executing",
column: "in-progress",
updatedAt: staleTime,
}),
];
renderListView({ tasks, taskStuckTimeoutMs: 600000 });
const row = screen.getByText("FN-001").closest("tr");
expect(row?.className).toContain("stuck");
const statusBadge = screen.getByText("Stuck");
expect(statusBadge.className).toContain("stuck");
});
it("does not render stuck indicator when taskStuckTimeoutMs is undefined", () => {
const staleTime = new Date(Date.now() - 600000).toISOString();
const tasks = [
createMockTask({
id: "FN-001",
status: "executing",
column: "in-progress",
updatedAt: staleTime,
}),
];
renderListView({ tasks });
const row = screen.getByText("FN-001").closest("tr");
expect(row?.className).not.toContain("stuck");
expect(screen.getByText("executing")).toBeInTheDocument();
});
it("stuck indicator takes precedence over agent-active", () => {
const staleTime = new Date(Date.now() - 600000).toISOString();
const tasks = [
createMockTask({
id: "FN-001",
status: "executing",
column: "in-progress",
updatedAt: staleTime,
}),
];
renderListView({ tasks, taskStuckTimeoutMs: 600000, globalPaused: false });
const row = screen.getByText("FN-001").closest("tr");
expect(row?.className).toContain("stuck");
expect(row?.className).not.toContain("agent-active");
expect(screen.getByText("Stuck")).toBeInTheDocument();
});
// FNXC:StuckTagRemoval 2026-08-17-22:30: stuck-task tagging removed from the dashboard; stuck coverage deleted with it.
it("renders column badges with correct colors", () => {
const columns = ["triage", "todo", "in-progress", "in-review", "done"] as const;

View File

@@ -1,92 +0,0 @@
import { describe, expect, it } from "vitest";
import { loadAllAppCss } from "../../test/cssFixture";
const css = loadAllAppCss();
type CssRule = {
selector: string;
body: string;
};
function allRules(): CssRule[] {
return Array.from(css.matchAll(/([^{}]+)\{([^{}]*)\}/g), ([, selector, body]) => ({
selector: selector.replace(/\/\*[\s\S]*?\*\//g, "").trim(),
body: body.trim(),
}));
}
function selectorParts(selector: string): string[] {
return selector.split(",").map((part) => part.trim());
}
function rulesFor(selector: string): CssRule[] {
return allRules().filter((rule) => selectorParts(rule.selector).includes(selector));
}
function baseRule(selector: string): string {
const rule = rulesFor(selector).find((candidate) => !candidate.selector.startsWith("@"));
expect(rule, `${selector} should have a CSS rule`).toBeTruthy();
return rule?.body ?? "";
}
function expectNoEmptyVariantShell(selector: string, axis: "border-left-color" | "border-top-color"): void {
for (const rule of rulesFor(selector)) {
const body = rule.body.trim();
expect(body, `${selector} must not leave an empty CSS rule shell`).not.toBe("");
expect(body, `${selector} must not keep a colored edge override`).not.toContain(axis);
expect(
/(background|box-shadow|border:\s*1px\s+solid\s+var\(--border\))/.test(body),
`${selector} rules should either be deleted or carry a non-stripe legibility declaration`,
).toBe(true);
}
}
describe("stuck task and agent card colored edge borders (FN-6774)", () => {
it("keeps stuck task board cards legible without a triage left stripe", () => {
const stuckCard = baseRule(".card.stuck");
expect(stuckCard).not.toContain("border-left: 3px solid var(--triage)");
expect(stuckCard).not.toContain("border-left");
expect(stuckCard).toContain("background: color-mix(in srgb, var(--triage) 6%, transparent)");
expect(baseRule(".card-status-badge.stuck")).toContain("background: var(--status-triage-bg-deep)");
});
it("keeps stuck list rows legible without a triage left stripe", () => {
const stuckRow = baseRule(".list-row.stuck");
expect(stuckRow).not.toContain("border-left: 3px solid var(--triage)");
expect(stuckRow).not.toContain("border-left");
expect(stuckRow).toContain("background: color-mix(in srgb, var(--triage) 8%, transparent)");
expect(baseRule(".list-status-badge.stuck")).toContain("background: color-mix(in srgb, var(--triage) 20%, transparent)");
});
it("uses neutral split-sidebar agent card borders across agent states", () => {
const agentCard = baseRule(".agent-card");
const selectedCard = baseRule(".agent-card--selected");
expect(agentCard).toContain("border: 1px solid var(--border)");
expect(agentCard).not.toContain("border-left-width: 4px");
expect(selectedCard).not.toContain("border-left-color: var(--todo)");
expect(selectedCard).not.toContain("!important");
expect(selectedCard).toContain("box-shadow: inset 0 0 0 calc(var(--space-xs) / 4) var(--todo)");
for (const state of ["active", "paused", "running", "error"]) {
expectNoEmptyVariantShell(`.agent-card--${state}`, "border-left-color");
}
expect(css).not.toMatch(/\.agent-card--(?:idle|active|paused|running|error)\b[^{}]*\{[^}]*border-left-color/s);
});
it("uses neutral grid agent board card borders across agent states", () => {
const boardCard = baseRule(".agent-board-card");
expect(boardCard).toContain("border: 1px solid var(--border)");
expect(boardCard).not.toContain("border-top-width: 3px");
for (const state of ["active", "paused", "running", "error"]) {
expectNoEmptyVariantShell(`.agent-board-card--${state}`, "border-top-color");
}
expect(css).not.toMatch(/\.agent-board-card--(?:idle|active|paused|running|error)\b[^{}]*\{[^}]*border-top-color/s);
});
});

View File

@@ -53,7 +53,6 @@ describe("Utility component mobile adaptations", () => {
stats: {
runningTaskCount: 1,
blockedTaskCount: 2,
stuckTaskCount: 0,
queuedTaskCount: 3,
inReviewCount: 4,
executorState: "running",

View File

@@ -161,7 +161,6 @@ export function MainContent({
handleOpenDetailWithTab,
handleToggleFavorite,
handleToggleModelFavorite,
taskStuckTimeoutMs,
staleHighFanoutBlockerAgeThresholdMs,
lastFetchTimeMs,
openCreateWorkflowWithNav,
@@ -905,7 +904,6 @@ export function MainContent({
favoriteModels={favoriteModels}
onToggleFavorite={handleToggleFavorite}
onToggleModelFavorite={handleToggleModelFavorite}
taskStuckTimeoutMs={taskStuckTimeoutMs}
staleHighFanoutBlockerAgeThresholdMs={staleHighFanoutBlockerAgeThresholdMs}
onOpenMission={handleOpenMission}
lastFetchTimeMs={lastFetchTimeMs}
@@ -1025,7 +1023,6 @@ export function MainContent({
favoriteModels={favoriteModels}
onToggleFavorite={handleToggleFavorite}
onToggleModelFavorite={handleToggleModelFavorite}
taskStuckTimeoutMs={taskStuckTimeoutMs}
staleHighFanoutBlockerAgeThresholdMs={staleHighFanoutBlockerAgeThresholdMs}
onOpenMission={handleOpenMission}
lastFetchTimeMs={lastFetchTimeMs}
@@ -1068,7 +1065,6 @@ export function MainContent({
favoriteModels={favoriteModels}
onToggleFavorite={handleToggleFavorite}
onToggleModelFavorite={handleToggleModelFavorite}
taskStuckTimeoutMs={taskStuckTimeoutMs}
searchQuery={searchQuery}
lastFetchTimeMs={lastFetchTimeMs}
prAuthAvailable={prAuthAvailable}

View File

@@ -197,7 +197,6 @@ function mainContentProps(overrides: Partial<MainContentProps> = {}): MainConten
handleOpenDetailWithTab: vi.fn(),
handleToggleFavorite: vi.fn(),
handleToggleModelFavorite: vi.fn(),
taskStuckTimeoutMs: undefined,
staleHighFanoutBlockerAgeThresholdMs: 0,
lastFetchTimeMs: undefined,
openCreateWorkflowWithNav: vi.fn(),

View File

@@ -233,7 +233,7 @@ export interface MainContentProps {
handleOpenDetailWithTab: (task: Task | TaskDetail, initialTab: "changes" | "retries" | "workflow") => void;
handleToggleFavorite: (provider: string) => Promise<void>;
handleToggleModelFavorite: (modelId: string) => Promise<void>;
taskStuckTimeoutMs: number | undefined;
// FNXC:StuckTagRemoval 2026-08-17-22:30: stuck-task tagging removed from the dashboard; taskStuckTimeoutMs is engine-side only now.
staleHighFanoutBlockerAgeThresholdMs: number;
lastFetchTimeMs: number | undefined;
openCreateWorkflowWithNav: () => void;

View File

@@ -55,7 +55,6 @@ describe("useExecutorStats", () => {
expect(result.current.stats.runningTaskCount).toBe(0);
expect(result.current.stats.blockedTaskCount).toBe(0);
expect(result.current.stats.stuckTaskCount).toBe(0);
expect(result.current.stats.queuedTaskCount).toBe(0);
expect(result.current.stats.inReviewCount).toBe(0);
});
@@ -177,105 +176,7 @@ describe("useExecutorStats", () => {
});
});
describe("stuck task detection", () => {
it("detects tasks in in-progress with no activity beyond threshold as stuck", async () => {
// Set updatedAt to 11 minutes ago
const elevenMinutesAgo = new Date(Date.now() - 11 * 60 * 1000).toISOString();
const tasks: Task[] = [
{ ...createMockTask("FN-001", "in-progress"), updatedAt: elevenMinutesAgo },
{ ...createMockTask("FN-002", "in-progress") }, // just updated
];
// Pass 10-minute (600000ms) threshold
const { result } = renderHook(() => useExecutorStats(tasks, undefined, 600000));
await act(async () => {
await vi.advanceTimersByTimeAsync(100);
});
expect(result.current.stats.stuckTaskCount).toBe(1);
});
it("returns 0 stuck tasks when taskStuckTimeoutMs is undefined (disabled)", async () => {
const elevenMinutesAgo = new Date(Date.now() - 11 * 60 * 1000).toISOString();
const tasks: Task[] = [
{ ...createMockTask("FN-001", "in-progress"), updatedAt: elevenMinutesAgo },
];
// No threshold = stuck detection disabled
const { result } = renderHook(() => useExecutorStats(tasks));
await act(async () => {
await vi.advanceTimersByTimeAsync(100);
});
expect(result.current.stats.stuckTaskCount).toBe(0);
});
it("does not count non-in-progress tasks as stuck even if old", async () => {
// Set updatedAt to 11 minutes ago for a todo task
const elevenMinutesAgo = new Date(Date.now() - 11 * 60 * 1000).toISOString();
const tasks: Task[] = [
{ ...createMockTask("FN-001", "todo"), updatedAt: elevenMinutesAgo },
];
const { result } = renderHook(() => useExecutorStats(tasks, undefined, 600000));
await act(async () => {
await vi.advanceTimersByTimeAsync(100);
});
expect(result.current.stats.stuckTaskCount).toBe(0);
});
it("does not count recent in-progress tasks as stuck", async () => {
// Set updatedAt to 5 minutes ago — below the 10-minute threshold
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString();
const tasks: Task[] = [
{ ...createMockTask("FN-001", "in-progress"), updatedAt: fiveMinutesAgo },
];
const { result } = renderHook(() => useExecutorStats(tasks, undefined, 600000));
await act(async () => {
await vi.advanceTimersByTimeAsync(100);
});
expect(result.current.stats.stuckTaskCount).toBe(0);
});
it("respects custom threshold values", async () => {
// Set updatedAt to 3 minutes ago
const threeMinutesAgo = new Date(Date.now() - 3 * 60 * 1000).toISOString();
const tasks: Task[] = [
{ ...createMockTask("FN-001", "in-progress"), updatedAt: threeMinutesAgo },
];
// With a 2-minute threshold, it should be stuck
const { result } = renderHook(() => useExecutorStats(tasks, undefined, 120000));
await act(async () => {
await vi.advanceTimersByTimeAsync(100);
});
expect(result.current.stats.stuckTaskCount).toBe(1);
});
it("returns 0 when taskStuckTimeoutMs is 0", async () => {
const elevenMinutesAgo = new Date(Date.now() - 11 * 60 * 1000).toISOString();
const tasks: Task[] = [
{ ...createMockTask("FN-001", "in-progress"), updatedAt: elevenMinutesAgo },
];
const { result } = renderHook(() => useExecutorStats(tasks, undefined, 0));
await act(async () => {
await vi.advanceTimersByTimeAsync(100);
});
expect(result.current.stats.stuckTaskCount).toBe(0);
});
});
// FNXC:StuckTagRemoval 2026-08-17-22:30: stuck-task tagging removed from the dashboard; stuck coverage deleted with it.
describe("executor state derivation", () => {
it("returns 'stopped' when globalPause is true", async () => {
@@ -780,7 +681,7 @@ describe("useExecutorStats", () => {
{ ...createMockTask("FN-016", "custom-planning" as Task["column"]), status: "planning" } as Task,
];
const { result } = renderHook(() => useExecutorStats(tasks, undefined, 10 * 60 * 1000, now));
const { result } = renderHook(() => useExecutorStats(tasks));
await act(async () => {
await vi.advanceTimersByTimeAsync(100);
@@ -788,7 +689,6 @@ describe("useExecutorStats", () => {
expect(result.current.stats.queuedTaskCount).toBe(8); // waiting intake/hold only; live planners are Running
expect(result.current.stats.runningTaskCount).toBe(4); // live execute plus planning in any lane
expect(result.current.stats.stuckTaskCount).toBe(1); // stuck is an in-progress subset
expect(result.current.stats.blockedTaskCount).toBe(2); // actionable string/array blockedBy only
expect(result.current.stats.inReviewCount).toBe(1); // in-review only
expect("doneTaskCount" in result.current.stats).toBe(false);
@@ -815,7 +715,6 @@ describe("useExecutorStats", () => {
expect(result.current.stats.queuedTaskCount).toBe(1);
expect(result.current.stats.inReviewCount).toBe(0);
expect(result.current.stats.blockedTaskCount).toBe(0);
expect(result.current.stats.stuckTaskCount).toBe(0);
});
it("updates counts immediately when tasks array reference changes", async () => {

View File

@@ -22,7 +22,13 @@ import type { Task } from "@fusion/core";
import { applyLocalTaskPatch, mergeTaskSnapshot, useTasks } from "../useTasks";
import * as api from "../../api";
import { SWR_CACHE_KEYS } from "../../utils/swrCache";
import { isTaskStuck, countStuckTasks } from "../../utils/taskStuck";
/*
FNXC:StuckTagRemoval 2026-08-17-22:30:
Stuck-task tagging was removed from the dashboard, so these freshness assertions now use the
underlying isOverdue primitive (utils/dataFreshness) that agentHealth and other consumers still
share. The invariant under test is unchanged: lastFetchTimeMs is the as-of clock for every row.
*/
import { isOverdue } from "../../utils/dataFreshness";
import { isTaskAgentActive } from "../../utils/taskActivity";
/*
@@ -82,6 +88,8 @@ const CACHE_KEY = `${SWR_CACHE_KEYS.TASKS_PREFIX}${PROJECT_ID}`;
const TWO_HOURS_MS = 2 * 60 * 60 * 1000;
/** Project default from `packages/core/src/settings-schema.ts`. */
const TASK_STUCK_TIMEOUT_MS = 600_000;
const rowOverdue = (task: Task, dataAsOfMs: number | undefined): boolean =>
isOverdue(new Date(task.updatedAt).getTime(), TASK_STUCK_TIMEOUT_MS, dataAsOfMs);
function createInProgressTask(id: string, updatedAtMs: number): Task {
return {
@@ -358,7 +366,7 @@ describe("useTasks hydration freshness (dataAsOfMs)", () => {
expect(result.current.lastFetchTimeMs).toBe(savedAt);
});
it("does not mark a whole board stuck when the snapshot itself is hours old", () => {
it("does not report hydrated rows overdue when the snapshot itself is hours old", () => {
const savedAt = Date.now() - TWO_HOURS_MS;
// Each card was updated a minute before the snapshot was written: fresh RELATIVE TO the snapshot,
// hours old relative to now. This is the operator's 6 in-progress cards after an iOS PWA discard.
@@ -372,23 +380,20 @@ describe("useTasks hydration freshness (dataAsOfMs)", () => {
const dataAsOfMs = result.current.lastFetchTimeMs;
expect(dataAsOfMs).toBe(savedAt);
// The reported surface: TaskCard's `isStuck` / status badge.
// Freshness verdicts must measure against the snapshot clock, not now.
for (const task of result.current.tasks) {
expect(isTaskStuck(task, TASK_STUCK_TIMEOUT_MS, dataAsOfMs)).toBe(false);
expect(rowOverdue(task, dataAsOfMs)).toBe(false);
}
// Column.activeTaskCount and ExecutorStatusBar/useExecutorStats counters read the same clock.
expect(countStuckTasks(result.current.tasks, TASK_STUCK_TIMEOUT_MS, dataAsOfMs)).toBe(0);
// TaskCard's agent pulse is suppressed by `isStuck`; with an honest clock it stays lit.
// The agent pulse stays lit for rows that are fresh relative to the snapshot.
for (const task of result.current.tasks) {
const isStuck = isTaskStuck(task, TASK_STUCK_TIMEOUT_MS, dataAsOfMs);
expect(isTaskAgentActive(task, { isStuck })).toBe(true);
expect(isTaskAgentActive(task, {})).toBe(true);
}
// Guard the exact regression: the old `undefined` clock (=> Date.now()) called all six stuck.
expect(countStuckTasks(result.current.tasks, TASK_STUCK_TIMEOUT_MS, undefined)).toBe(6);
// Guard the exact regression: the old `undefined` clock (=> Date.now()) called all six overdue.
expect(result.current.tasks.filter((task) => rowOverdue(task, undefined)).length).toBe(6);
});
it("still reports a genuinely stuck card as stuck against the snapshot's own clock", () => {
it("still reports a genuinely idle card overdue against the snapshot's own clock", () => {
const savedAt = Date.now() - TWO_HOURS_MS;
seedSnapshot(
[
@@ -403,10 +408,10 @@ describe("useTasks hydration freshness (dataAsOfMs)", () => {
const { result } = renderHook(() => useTasks({ projectId: PROJECT_ID }));
const dataAsOfMs = result.current.lastFetchTimeMs;
const stuckIds = result.current.tasks
.filter((task) => isTaskStuck(task, TASK_STUCK_TIMEOUT_MS, dataAsOfMs))
const overdueIds = result.current.tasks
.filter((task) => rowOverdue(task, dataAsOfMs))
.map((task) => task.id);
expect(stuckIds).toEqual(["FN-STUCK"]);
expect(overdueIds).toEqual(["FN-STUCK"]);
});
it("advances the clock to now once the mount revalidation lands real data", async () => {
@@ -553,11 +558,9 @@ describe("useTasks freshness clock vs single-row live updates", () => {
expect(result.current.tasks.map((task) => task.id)).toContain("FN-LIVE");
// ...but it says nothing about the other three rows, so the board's age is unchanged.
expect(result.current.lastFetchTimeMs).toBe(savedAt);
const stuckHydrated = hydrated.filter((task) =>
isTaskStuck(task, TASK_STUCK_TIMEOUT_MS, result.current.lastFetchTimeMs),
);
expect(stuckHydrated).toEqual([]);
expect(countStuckTasks(result.current.tasks, TASK_STUCK_TIMEOUT_MS, result.current.lastFetchTimeMs)).toBe(0);
const overdueHydrated = hydrated.filter((task) => rowOverdue(task, result.current.lastFetchTimeMs));
expect(overdueHydrated).toEqual([]);
expect(result.current.tasks.filter((task) => rowOverdue(task, result.current.lastFetchTimeMs)).length).toBe(0);
},
);

View File

@@ -4,7 +4,6 @@ import type { Task, TraitFlags } from "@fusion/core";
import { enrichRunningAgentTaskShapeFromFlags, isRunningAgentTask, isWaitingAgentTask } from "../../../core/src/agents/live-agent-count";
import { fetchExecutorStats } from "../api";
import type { ExecutorStats, ExecutorState } from "../api";
import { isTaskStuck } from "../utils/taskStuck";
import { isLikelyTabSuspensionError, isVisibilityResumeError, useTabVisibilitySuspension, useVisibilityAwarePoll } from "./visibilitySuspension";
const POLL_INTERVAL_MS = 5000; // 5 seconds - different from useProjectHealth's 10s
@@ -14,6 +13,10 @@ const POLL_INTERVAL_MS = 5000; // 5 seconds - different from useProjectHealth's
*/
const TRANSIENT_FAILURE_THRESHOLD = 2;
/*
FNXC:StuckTagRemoval 2026-08-17-22:30: Operator removed stuck-task tagging from the dashboard; engine recovery sweeps still consume taskStuckTimeoutMs server-side.
This hook no longer derives a stuck-task count.
*/
export interface UseExecutorStatsResult {
/** Aggregated executor statistics */
stats: ExecutorStats;
@@ -71,13 +74,16 @@ export function deriveExecutorState(
*/
export type ExecutorColumnFlags = Pick<TraitFlags, "complete" | "archived" | "intake" | "hold" | "countsTowardWip" | "mergeOrchestration" | "mergeBlocker">;
export function deriveStatsFromTasks(tasks: Task[], taskStuckTimeoutMs?: number, lastFetchTimeMs?: number, columnFlagsById?: ReadonlyMap<string, ExecutorColumnFlags>, columnFlagsByTaskId?: ReadonlyMap<string, ExecutorColumnFlags>): Pick<
/*
FNXC:StuckTagRemoval 2026-08-17-22:30: Operator removed stuck-task tagging from the dashboard; engine recovery sweeps still consume taskStuckTimeoutMs server-side.
The stuck-task count and the taskStuckTimeoutMs/lastFetchTimeMs parameters are gone from this derivation.
*/
export function deriveStatsFromTasks(tasks: Task[], columnFlagsById?: ReadonlyMap<string, ExecutorColumnFlags>, columnFlagsByTaskId?: ReadonlyMap<string, ExecutorColumnFlags>): Pick<
ExecutorStats,
"runningTaskCount" | "blockedTaskCount" | "stuckTaskCount" | "queuedTaskCount" | "inReviewCount"
"runningTaskCount" | "blockedTaskCount" | "queuedTaskCount" | "inReviewCount"
> {
let runningTaskCount = 0;
let blockedTaskCount = 0;
let stuckTaskCount = 0;
let queuedTaskCount = 0;
let inReviewCount = 0;
@@ -86,17 +92,6 @@ export function deriveStatsFromTasks(tasks: Task[], taskStuckTimeoutMs?: number,
const enriched = enrichRunningAgentTaskShapeFromFlags(task, columnFlagsByTaskId?.get(task.id) ?? columnFlagsById?.get(task.column));
if (isRunningAgentTask(enriched)) {
runningTaskCount++;
/*
FNXC:WorkflowResolvedColumns 2026-07-30-00:20 (PR #2772 review):
Per-task WIP flags, same precedence as line ~86. `isTaskStuck` gained this parameter and
TaskCard supplies it, so the repo-wide seam gate was satisfied by that ONE caller — this path
was still passing three arguments and counting zero stuck tasks on a renamed board.
That is the gate's documented limit made concrete: it proves SOME caller supplies the
parameter, never that ALL do. Worth knowing before trusting it as coverage.
*/
if (isTaskStuck(task, taskStuckTimeoutMs, lastFetchTimeMs,
columnFlagsByTaskId?.get(task.id) ?? columnFlagsById?.get(task.column))) stuckTaskCount++;
}
if (isWaitingAgentTask(enriched)) queuedTaskCount++;
// Kept in the API shape for compatibility; the footer no longer renders it.
@@ -120,7 +115,6 @@ export function deriveStatsFromTasks(tasks: Task[], taskStuckTimeoutMs?: number,
return {
runningTaskCount,
blockedTaskCount,
stuckTaskCount,
queuedTaskCount,
inReviewCount,
};
@@ -141,8 +135,6 @@ function hasActionableBlockedBy(blockedBy: Task["blockedBy"] | string[] | null):
* so footer counts always match the board state
* - Polls `/api/executor/stats` every 5 seconds for executor state
* - Derives blockedTaskCount from tasks with blockedBy field set
* - Derives stuckTaskCount using the project's `taskStuckTimeoutMs` setting;
* returns 0 when the setting is undefined/disabled
* - Derives executorState from globalPause and enginePaused flags, with globalPause mapping to "stopped" and enginePaused to "paused" at any running count
* - Returns ExecutorStats object with reactive updates
*/
@@ -155,7 +147,7 @@ const DEFAULT_API_DATA: Pick<ExecutorStats, "maxConcurrent" | "lastActivityAt">
maxConcurrent: 2,
};
export function useExecutorStats(tasks: Task[], projectId?: string, taskStuckTimeoutMs?: number, lastFetchTimeMs?: number, columnFlagsByTaskId?: ReadonlyMap<string, ExecutorColumnFlags>): UseExecutorStatsResult {
export function useExecutorStats(tasks: Task[], projectId?: string, columnFlagsByTaskId?: ReadonlyMap<string, ExecutorColumnFlags>): UseExecutorStatsResult {
const [apiDataState, setApiDataState] = useState<{
projectId?: string;
@@ -257,7 +249,7 @@ export function useExecutorStats(tasks: Task[], projectId?: string, taskStuckTim
const effectiveLoading = loading || (!error && !currentProjectApiDataState);
// Derive stats from tasks and API data
const taskStats = deriveStatsFromTasks(tasks, taskStuckTimeoutMs, lastFetchTimeMs, undefined, columnFlagsByTaskId);
const taskStats = deriveStatsFromTasks(tasks, undefined, columnFlagsByTaskId);
const executorState = deriveExecutorState(
apiData.globalPause,
apiData.enginePaused,

View File

@@ -101,8 +101,8 @@ describe("isTaskAgentActive", () => {
["done column", taskWithRunningWorkflowStep({ column: "done" }), {}],
["archived column with merging status", taskWithRunningWorkflowStep({ column: "archived", status: "merging" }), {}],
["archived column with running workflow", taskWithRunningWorkflowStep({ column: "archived" }), {}],
// FNXC:StuckTagRemoval 2026-08-17-22:30: stuck-task tagging removed from the dashboard; stuck coverage deleted with it.
["render queue", taskWithRunningWorkflowStep(), { queued: true }],
["derived stuck", taskWithRunningWorkflowStep(), { isStuck: true }],
["global pause", taskWithRunningWorkflowStep(), { globalPaused: true }],
["failed status with fresh planner log", makeTask({ status: "failed", recentAgentActivityAt: new Date().toISOString() }), {}],
["paused task with fresh planner log", makeTask({ paused: true, recentAgentActivityAt: new Date().toISOString() }), {}],
@@ -111,7 +111,6 @@ describe("isTaskAgentActive", () => {
["awaiting approval with fresh planner log", makeTask({ status: "awaiting-approval", recentAgentActivityAt: new Date().toISOString() }), {}],
["awaiting user input with fresh planner log", makeTask({ status: "awaiting-user-input", recentAgentActivityAt: new Date().toISOString() }), {}],
["queued replan", makeTask({ status: "needs-replan", recentAgentActivityAt: new Date().toISOString() }), { queued: true }],
["derived stuck replan", makeTask({ status: "needs-replan", recentAgentActivityAt: new Date().toISOString() }), { isStuck: true }],
["global pause replan", makeTask({ status: "needs-replan", recentAgentActivityAt: new Date().toISOString() }), { globalPaused: true }],
["paused replan", makeTask({ status: "needs-replan", paused: true, recentAgentActivityAt: new Date().toISOString() }), {}],
["failed replan", makeTask({ status: "failed", recentAgentActivityAt: new Date().toISOString() }), {}],

View File

@@ -1,270 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { isTaskStuck, countStuckTasks } from "../taskStuck";
import type { Task } from "@fusion/core";
const createTask = (overrides: Partial<Task> = {}): Task =>
({
id: "FN-001",
description: "Test task",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
columnMovedAt: "2026-01-01T00:00:00Z",
...overrides,
}) as Task;
describe("isTaskStuck", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-04T12:00:00Z"));
});
afterEach(() => {
vi.useRealTimers();
});
it("returns false when timeout is undefined (disabled)", () => {
const task = createTask({ updatedAt: "2026-04-04T06:00:00Z" });
expect(isTaskStuck(task, undefined)).toBe(false);
});
it("returns false when timeout is 0", () => {
const task = createTask({ updatedAt: "2026-04-04T06:00:00Z" });
expect(isTaskStuck(task, 0)).toBe(false);
});
it("returns false when timeout is negative", () => {
const task = createTask({ updatedAt: "2026-04-04T06:00:00Z" });
expect(isTaskStuck(task, -1)).toBe(false);
});
it("returns false for non-in-progress tasks", () => {
const task = createTask({ column: "todo", updatedAt: "2026-04-04T06:00:00Z" });
expect(isTaskStuck(task, 600000)).toBe(false);
});
it("returns false for failed in-progress tasks", () => {
const stale = new Date(Date.now() - 600001).toISOString();
const task = createTask({ status: "failed", updatedAt: stale });
expect(isTaskStuck(task, 600000)).toBe(false);
});
it("returns false for stuck-killed in-progress tasks", () => {
const stale = new Date(Date.now() - 600001).toISOString();
const task = createTask({ status: "stuck-killed", updatedAt: stale });
expect(isTaskStuck(task, 600000)).toBe(false);
});
it("returns false for recent in-progress tasks within timeout", () => {
const recent = new Date(Date.now() - 300000).toISOString(); // 5 minutes ago
const task = createTask({ updatedAt: recent });
expect(isTaskStuck(task, 600000)).toBe(false); // 10 minute timeout
});
it("returns true for stale in-progress tasks exceeding timeout", () => {
const stale = new Date(Date.now() - 600001).toISOString(); // just over 10 minutes
const task = createTask({ updatedAt: stale });
expect(isTaskStuck(task, 600000)).toBe(true);
});
it("returns false for malformed updatedAt", () => {
const task = createTask({ updatedAt: "not-a-date" });
expect(isTaskStuck(task, 600000)).toBe(false);
});
it("returns false for empty updatedAt", () => {
const task = createTask({ updatedAt: "" });
expect(isTaskStuck(task, 600000)).toBe(false);
});
it("handles tasks in triage column", () => {
const stale = new Date(Date.now() - 600001).toISOString();
const task = createTask({ column: "triage", updatedAt: stale });
expect(isTaskStuck(task, 600000)).toBe(false);
});
it("handles tasks in done column", () => {
const stale = new Date(Date.now() - 600001).toISOString();
const task = createTask({ column: "done", updatedAt: stale });
expect(isTaskStuck(task, 600000)).toBe(false);
});
it("returns true exactly at timeout boundary (greater than)", () => {
const boundary = new Date(Date.now() - 600001).toISOString();
const task = createTask({ updatedAt: boundary });
expect(isTaskStuck(task, 600000)).toBe(true);
});
it("returns false exactly at timeout boundary (equal)", () => {
const boundary = new Date(Date.now() - 600000).toISOString();
const task = createTask({ updatedAt: boundary });
expect(isTaskStuck(task, 600000)).toBe(false);
});
describe("dataAsOfMs parameter (freshness-aware stuck detection)", () => {
it("uses dataAsOfMs instead of Date.now() when provided", () => {
// Task updatedAt is 11 minutes ago
const taskUpdatedAt = new Date(Date.now() - 11 * 60 * 1000).toISOString();
const task = createTask({ updatedAt: taskUpdatedAt });
// dataAsOfMs is 5 minutes ago (task was fresh 5 minutes ago)
const dataAsOfMs = Date.now() - 5 * 60 * 1000;
// 10 minute timeout
// With dataAsOfMs: 5 min - 11 min = -6 min < 10 min → NOT stuck
// Without dataAsOfMs: 0 min - 11 min = -11 min > 10 min → stuck
expect(isTaskStuck(task, 600000, dataAsOfMs)).toBe(false);
});
it("falls back to Date.now() when dataAsOfMs is undefined", () => {
// Task updatedAt is 5 minutes ago
const taskUpdatedAt = new Date(Date.now() - 5 * 60 * 1000).toISOString();
const task = createTask({ updatedAt: taskUpdatedAt });
// Without dataAsOfMs, should use Date.now() → NOT stuck (within 10 min timeout)
expect(isTaskStuck(task, 600000)).toBe(false);
});
it("correctly identifies a task that would be stuck with Date.now() but not with dataAsOfMs", () => {
// Scenario: Tab was in background for 20 minutes
// Task was updated 10 minutes ago (relative to dataAsOfMs)
// dataAsOfMs represents "10 minutes ago" (when we fetched fresh data)
// Date.now() is "now" (20 minutes after the fetch)
//
// This simulates the background tab scenario:
// - User opened tab at T=0, fetched tasks
// - Tab went to background at T=0
// - User came back at T=20
// - dataAsOfMs = T=0 (when we last had fresh data)
// - Task was updated at T=-10 (10 minutes before fetch)
// - task.updatedAt represents T=-10
//
// Check: dataAsOfMs - updatedAt = 0 - (-10) = 10 min < 10 min timeout → NOT stuck
// Without dataAsOfMs: Date.now() - updatedAt = 20 - (-10) = 30 min > 10 min → STUCK (false positive!)
// In fake timers, we set Date.now() to a fixed point
// Let's say Date.now() = 1000 (representing "now")
// dataAsOfMs = 0 (representing 20 minutes before "now" in fake time)
// task.updatedAt = -600 (representing 10 minutes before dataAsOfMs)
vi.setSystemTime(new Date(1000)); // Date.now() = 1000
const dataAsOfMs = 0; // 20 minutes before Date.now() in this scenario
const taskUpdatedAt = new Date(-600000).toISOString(); // 10 minutes before dataAsOfMs
const task = createTask({ updatedAt: taskUpdatedAt });
// With dataAsOfMs: 0 - (-600000) = 600000ms = 10 min = timeout → NOT stuck (boundary)
// Without dataAsOfMs: 1000 - (-600000) = 601000ms > 10 min → STUCK
// The key test: with dataAsOfMs it should NOT be stuck even though Date.now() would say it is
expect(isTaskStuck(task, 600000, dataAsOfMs)).toBe(false);
});
it("prevents false positive when tab was in background", () => {
// Simulate: Tab in background, data fetched 15 min ago
// Task.updatedAt is 12 min ago (stale from server perspective)
// taskStuckTimeoutMs = 10 min
// With fresh data (15 min ago): 15 - 12 = 3 min < 10 min → NOT stuck
// With stale Date.now(): 0 - 12 = 12 min > 10 min → STUCK (FALSE POSITIVE)
vi.setSystemTime(new Date(0)); // Date.now() = 0
const dataAsOfMs = -900000; // 15 minutes ago (in fake time)
const taskUpdatedAt = new Date(-720000).toISOString(); // 12 minutes ago (in fake time)
const task = createTask({ updatedAt: taskUpdatedAt });
// With dataAsOfMs: -900000 - (-720000) = -180000ms = -3 min < 10 min → NOT stuck
// Without dataAsOfMs: 0 - (-720000) = 720000ms = 12 min > 10 min → STUCK
expect(isTaskStuck(task, 600000, dataAsOfMs)).toBe(false);
});
it("correctly identifies genuinely stuck tasks even with dataAsOfMs", () => {
// Task really is stuck: updatedAt is 15 min ago, timeout is 10 min
// With dataAsOfMs of 2 min ago: 2 - 15 = -13 min < 10 min → NOT stuck (hmm, this is a problem)
// Actually, dataAsOfMs should represent when we last got FRESH data from the server
// If dataAsOfMs = 2 min ago and task.updatedAt = 15 min ago, the task was stale
// even when we fetched it, because 2 - 15 = -13 min > 10 min timeout
vi.setSystemTime(new Date(0));
const dataAsOfMs = -120000; // 2 minutes ago
const taskUpdatedAt = new Date(-900000).toISOString(); // 15 minutes ago
const task = createTask({ updatedAt: taskUpdatedAt });
// With dataAsOfMs: -120000 - (-900000) = 780000ms = 13 min > 10 min → STUCK
expect(isTaskStuck(task, 600000, dataAsOfMs)).toBe(true);
});
});
});
describe("countStuckTasks", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-04T12:00:00Z"));
});
afterEach(() => {
vi.useRealTimers();
});
it("returns 0 when timeout is undefined", () => {
const stale = new Date(Date.now() - 600001).toISOString();
const tasks = [createTask({ updatedAt: stale })];
expect(countStuckTasks(tasks, undefined)).toBe(0);
});
it("returns 0 when timeout is 0", () => {
const stale = new Date(Date.now() - 600001).toISOString();
const tasks = [createTask({ updatedAt: stale })];
expect(countStuckTasks(tasks, 0)).toBe(0);
});
it("counts only stuck tasks", () => {
const stale = new Date(Date.now() - 600001).toISOString();
const recent = new Date(Date.now() - 300000).toISOString();
const tasks = [
createTask({ id: "FN-001", updatedAt: stale }), // stuck
createTask({ id: "FN-002", updatedAt: recent }), // not stuck
createTask({ id: "FN-004", status: "failed", updatedAt: stale }), // terminal status
createTask({ id: "FN-003", column: "todo", updatedAt: stale }), // not in-progress
];
expect(countStuckTasks(tasks, 600000)).toBe(1);
});
it("returns 0 for empty task list", () => {
expect(countStuckTasks([], 600000)).toBe(0);
});
it("counts multiple stuck tasks", () => {
const stale = new Date(Date.now() - 600001).toISOString();
const tasks = [
createTask({ id: "FN-001", updatedAt: stale }),
createTask({ id: "FN-002", updatedAt: stale }),
];
expect(countStuckTasks(tasks, 600000)).toBe(2);
});
describe("dataAsOfMs parameter (freshness-aware stuck detection)", () => {
it("passes dataAsOfMs through to isTaskStuck", () => {
// Task would be stuck with Date.now() but not with dataAsOfMs
vi.setSystemTime(new Date(0));
const dataAsOfMs = -900000; // 15 minutes ago
const taskUpdatedAt = new Date(-720000).toISOString(); // 12 minutes ago
const tasks = [createTask({ updatedAt: taskUpdatedAt })];
// With dataAsOfMs: -900000 - (-720000) = -180000ms = -3 min < 10 min → NOT stuck
expect(countStuckTasks(tasks, 600000, dataAsOfMs)).toBe(0);
});
it("counts tasks that are genuinely stuck even with dataAsOfMs", () => {
vi.setSystemTime(new Date(0));
const dataAsOfMs = -120000; // 2 minutes ago
const taskUpdatedAt = new Date(-900000).toISOString(); // 15 minutes ago
const tasks = [createTask({ updatedAt: taskUpdatedAt })];
// With dataAsOfMs: -120000 - (-900000) = 780000ms = 13 min > 10 min → STUCK
expect(countStuckTasks(tasks, 600000, dataAsOfMs)).toBe(1);
});
});
});

View File

@@ -18,7 +18,7 @@ export const ACTIVE_STATUSES = new Set([
export interface TaskAgentActivityOptions {
globalPaused?: boolean;
queued?: boolean;
isStuck?: boolean;
// FNXC:StuckTagRemoval 2026-08-17-22:30: the isStuck gate was deleted with the dashboard's stuck-task tagging; the engine-written "stuck-killed" status below still suppresses the pulse.
/*
FNXC:WorkflowResolvedColumns 2026-07-29-00:00 (U12 — R8 drift conversion):
The task's own column traits, when the caller has them. This predicate drives the
@@ -66,7 +66,6 @@ export function isTaskAgentActive(
if (
options.globalPaused === true ||
options.queued === true ||
options.isStuck === true ||
status === "queued" ||
status === "stuck-killed" ||
task.paused === true ||

View File

@@ -1,79 +0,0 @@
import type { Task } from "@fusion/core";
import { isWipColumnRole } from "./columnRoles";
import { isOverdue } from "./dataFreshness";
const NON_STUCK_STATUSES = new Set(["failed", "stuck-killed"]);
/**
* Check if a task is stuck based on the project's stuck timeout setting.
*
* A task is considered stuck when:
* - It is in the "in-progress" column
* - A positive `taskStuckTimeoutMs` value is provided (stuck detection enabled)
* - Its `updatedAt` timestamp is older than `taskStuckTimeoutMs` milliseconds ago
* compared to `dataAsOfMs` (or `Date.now()` if `dataAsOfMs` is not provided)
*
* When `taskStuckTimeoutMs` is undefined, null, or 0, stuck detection is
* disabled and this function always returns false.
*
* The optional `dataAsOfMs` parameter represents when the task data was last
* confirmed fresh by the server. When provided, it is used instead of `Date.now()`
* for the comparison. This prevents false positives when the tab has been in
* the background and the task data is stale.
*/
export function isTaskStuck(
task: Task,
taskStuckTimeoutMs: number | undefined,
dataAsOfMs?: number,
columnFlags?: Parameters<typeof isWipColumnRole>[0],
): boolean {
/*
FNXC:WorkflowResolvedColumns 2026-07-30-13:10 (batch-dashboard-app):
"Stuck" only means anything for a card in the WIP lane. Keyed on the literal, NO card on a renamed
board could ever be reported stuck — the stuck badge and `countStuckTasks` both read zero while
work sat wedged. `columnFlags` omitted -> the legacy id.
*/
if (!isWipColumnRole(columnFlags, task.column)) {
return false;
}
if (task.status && NON_STUCK_STATUSES.has(task.status)) {
return false;
}
if (!taskStuckTimeoutMs || taskStuckTimeoutMs <= 0) {
return false;
}
/*
FNXC:MobileTabDiscard 2026-07-26-10:16:
The clock choice moved to `isOverdue` (utils/dataFreshness.ts) so the same rule serves every
staleness verdict in the client and `dataAsOfMs` cannot be omitted at a call site. Behavior is
unchanged: `dataAsOfMs ?? Date.now()`, strict `>` against a threshold already proven positive above,
and an unparseable `updatedAt` (NaN) still yields false.
*/
const updatedAt = new Date(task.updatedAt).getTime();
return isOverdue(updatedAt, taskStuckTimeoutMs, dataAsOfMs);
}
/**
* Derive the stuck task count from a list of tasks using the given threshold.
*
* Returns 0 when stuck detection is disabled (undefined/0 threshold).
*
* The optional `dataAsOfMs` parameter is passed through to `isTaskStuck()` for
* freshness-aware stuck detection.
*/
export function countStuckTasks(tasks: Task[], taskStuckTimeoutMs: number | undefined, dataAsOfMs?: number, columnFlagsByTaskId?: ReadonlyMap<string, Parameters<typeof isWipColumnRole>[0]>): number {
if (!taskStuckTimeoutMs || taskStuckTimeoutMs <= 0) {
return 0;
}
let count = 0;
for (const task of tasks) {
if (isTaskStuck(task, taskStuckTimeoutMs, dataAsOfMs, columnFlagsByTaskId?.get(task.id))) {
count++;
}
}
return count;
}

View File

@@ -104,7 +104,13 @@ function findDiscardedCalls(source: string, name: string): string[] {
}
const SELF_HEALING = "packages/engine/src/self-healing.ts";
const EXECUTOR = "packages/engine/src/executor.ts";
/*
FNXC:CodeOrganization 2026-08-17-22:04:
The wave20 extraction moved the TaskExecutor session facades (hasLiveSessionSurface,
clearPhantomExecutorBinding) out of executor.ts into executor/task-executor-session-facades.ts.
The facade scans follow the extraction; the peeled bodies below were already tracked separately.
*/
const EXECUTOR_SESSION_FACADES = "packages/engine/src/executor/task-executor-session-facades.ts";
/*
FNXC:CodeOrganization 2026-08-03-20:25:
U4 peels move free-function bodies under executor/*. Source-scan ratchets must
@@ -152,7 +158,7 @@ describe("FN-6756 liveness-gate ratchet", () => {
*/
it("clearPhantomExecutorBinding delegates to the shared hasLiveSessionSurface probe", () => {
// Facade on TaskExecutor must forward to the free function (or call the probe).
const facadeSource = stripComments(readSource(EXECUTOR));
const facadeSource = stripComments(readSource(EXECUTOR_SESSION_FACADES));
const facadeStart = facadeSource.indexOf("clearPhantomExecutorBinding(taskId: string");
expect(facadeStart, "clearPhantomExecutorBinding not found in executor source").toBeGreaterThan(-1);
const facadeBody = facadeSource.slice(facadeStart, facadeStart + 1200);
@@ -224,7 +230,7 @@ describe("FN-6756 liveness-gate ratchet", () => {
*/
it("hasLiveSessionSurface counts registered session paths, not just executor maps", () => {
// Facade must remain on TaskExecutor (public API for self-healing wiring).
const facadeSource = stripComments(readSource(EXECUTOR));
const facadeSource = stripComments(readSource(EXECUTOR_SESSION_FACADES));
const facadeStart = facadeSource.indexOf("hasLiveSessionSurface(taskId: string): boolean");
expect(facadeStart, "hasLiveSessionSurface not found — the probe was removed or renamed").toBeGreaterThan(-1);
/*