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:
7
.changeset/remove-stuck-task-tagging.md
Normal file
7
.changeset/remove-stuck-task-tagging.md
Normal 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."
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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" : ""}${
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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. */}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -53,7 +53,6 @@ describe("Utility component mobile adaptations", () => {
|
||||
stats: {
|
||||
runningTaskCount: 1,
|
||||
blockedTaskCount: 2,
|
||||
stuckTaskCount: 0,
|
||||
queuedTaskCount: 3,
|
||||
inReviewCount: 4,
|
||||
executorState: "running",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() }), {}],
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
/*
|
||||
|
||||
Reference in New Issue
Block a user