feat(FN-822): unify stuck-task detection with configurable project timeout

- Extract isTaskStuck and countStuckTasks into shared utils/taskStuck.ts utility
- Replace hardcoded 10-minute threshold with project taskStuckTimeoutMs setting
- Thread taskStuckTimeoutMs from App settings fetch through ExecutorStatusBar to useExecutorStats
- Return 0 stuck tasks when timeout is undefined/0 (disabled), matching engine behavior
- Update tests to verify disabled state, custom thresholds, and zero threshold edge case
This commit is contained in:
gsxdsm
2026-04-04 00:35:21 -07:00
parent cf83579a34
commit 38a095cc25
6 changed files with 119 additions and 32 deletions

View File

@@ -2,9 +2,9 @@ import { useState, useEffect, useCallback, useRef } from "react";
import type { Task } from "@fusion/core";
import { fetchExecutorStats } from "../api";
import type { ExecutorStats, ExecutorState } from "../api";
import { isTaskStuck } from "../utils/taskStuck";
const POLL_INTERVAL_MS = 5000; // 5 seconds - different from useProjectHealth's 10s
const STUCK_TASK_THRESHOLD_MS = 10 * 60 * 1000; // 10 minutes
export interface UseExecutorStatsResult {
/** Aggregated executor statistics */
@@ -45,22 +45,10 @@ function deriveExecutorState(
return "idle";
}
/**
* Check if a task is stuck (no activity for > 10 minutes).
*/
function isTaskStuck(task: Task): boolean {
if (task.column !== "in-progress") {
return false;
}
const updatedAt = new Date(task.updatedAt).getTime();
const now = Date.now();
return now - updatedAt > STUCK_TASK_THRESHOLD_MS;
}
/**
* Derive statistics from the task list.
*/
function deriveStatsFromTasks(tasks: Task[]): Pick<
function deriveStatsFromTasks(tasks: Task[], taskStuckTimeoutMs?: number): Pick<
ExecutorStats,
"runningTaskCount" | "blockedTaskCount" | "stuckTaskCount" | "queuedTaskCount" | "inReviewCount"
> {
@@ -74,7 +62,7 @@ function deriveStatsFromTasks(tasks: Task[]): Pick<
switch (task.column) {
case "in-progress":
runningTaskCount++;
if (isTaskStuck(task)) {
if (isTaskStuck(task, taskStuckTimeoutMs)) {
stuckTaskCount++;
}
break;
@@ -103,16 +91,17 @@ function deriveStatsFromTasks(tasks: Task[]): Pick<
/**
* Hook for aggregating executor statistics for the status bar.
*
*
* - Receives the shared task list directly (same instance used by the board)
* 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 by checking if any "in-progress" task has updatedAt > 10 minutes ago
* - Derives stuckTaskCount using the project's `taskStuckTimeoutMs` setting;
* returns 0 when the setting is undefined/disabled
* - Derives executorState from globalPause and enginePaused flags
* - Returns ExecutorStats object with reactive updates
*/
export function useExecutorStats(tasks: Task[], projectId?: string): UseExecutorStatsResult {
export function useExecutorStats(tasks: Task[], projectId?: string, taskStuckTimeoutMs?: number): UseExecutorStatsResult {
const [apiData, setApiData] = useState<{
globalPause: boolean;
@@ -184,7 +173,7 @@ export function useExecutorStats(tasks: Task[], projectId?: string): UseExecutor
}, [refresh]);
// Derive stats from tasks and API data
const taskStats = deriveStatsFromTasks(tasks);
const taskStats = deriveStatsFromTasks(tasks, taskStuckTimeoutMs);
const executorState = deriveExecutorState(
apiData.globalPause,
apiData.enginePaused,