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

@@ -101,6 +101,7 @@ function AppInner() {
const [globalPaused, setGlobalPaused] = useState(false);
const [enginePaused, setEnginePaused] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [taskStuckTimeoutMs, setTaskStuckTimeoutMs] = useState<number | undefined>(undefined);
const [githubTokenConfigured, setGithubTokenConfigured] = useState(false);
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
const [favoriteProviders, setFavoriteProviders] = useState<string[]>([]);
@@ -167,6 +168,7 @@ function AppInner() {
setGlobalPaused(!!s.globalPause);
setEnginePaused(!!s.enginePaused);
setGithubTokenConfigured(!!s.githubTokenConfigured);
setTaskStuckTimeoutMs(s.taskStuckTimeoutMs);
})
.catch(() => {/* keep default */});
fetchAuthStatus()
@@ -622,7 +624,7 @@ function AppInner() {
{renderMainContent()}
</div>
{viewMode === "project" && currentProject && (
<ExecutorStatusBar tasks={tasks} projectId={currentProject.id} />
<ExecutorStatusBar tasks={tasks} projectId={currentProject.id} taskStuckTimeoutMs={taskStuckTimeoutMs} />
)}
{detailTask && (
<TaskDetailModal

View File

@@ -9,6 +9,8 @@ interface ExecutorStatusBarProps {
tasks: Task[];
/** Project ID for fetching project-specific stats */
projectId?: string;
/** Project-level stuck task timeout in milliseconds (undefined = disabled) */
taskStuckTimeoutMs?: number;
}
/**
@@ -58,8 +60,8 @@ function getStateDisplay(state: ExecutorState): { label: string; color: string;
* - Executor state badge (idle/running/paused)
* - Last activity timestamp
*/
export function ExecutorStatusBar({ tasks, projectId }: ExecutorStatusBarProps) {
const { stats, loading, error } = useExecutorStats(tasks, projectId);
export function ExecutorStatusBar({ tasks, projectId, taskStuckTimeoutMs }: ExecutorStatusBarProps) {
const { stats, loading, error } = useExecutorStats(tasks, projectId, taskStuckTimeoutMs);
const stateDisplay = useMemo(() => getStateDisplay(stats.executorState), [stats.executorState]);

View File

@@ -328,13 +328,13 @@ describe("ExecutorStatusBar", () => {
const tasks: any[] = [{ id: "FN-001" }];
render(<ExecutorStatusBar tasks={tasks} projectId="proj_abc123" />);
expect(mockUseExecutorStats).toHaveBeenCalledWith(tasks, "proj_abc123");
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);
expect(mockUseExecutorStats).toHaveBeenCalledWith(emptyTasks, undefined, undefined);
});
});
@@ -377,7 +377,7 @@ describe("ExecutorStatusBar", () => {
render(<ExecutorStatusBar tasks={tasks} />);
// useExecutorStats receives the tasks array as first argument
expect(mockUseExecutorStats).toHaveBeenCalledWith(tasks, undefined);
expect(mockUseExecutorStats).toHaveBeenCalledWith(tasks, undefined, undefined);
});
it("renders stuck segment with correct count when stuck tasks detected", () => {

View File

@@ -164,7 +164,7 @@ describe("useExecutorStats", () => {
});
describe("stuck task detection", () => {
it("detects tasks in in-progress with no activity for > 10 minutes as stuck", async () => {
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[] = [
@@ -172,7 +172,8 @@ describe("useExecutorStats", () => {
{ ...createMockTask("FN-002", "in-progress") }, // just updated
];
const { result } = renderHook(() => useExecutorStats(tasks));
// Pass 10-minute (600000ms) threshold
const { result } = renderHook(() => useExecutorStats(tasks, undefined, 600000));
await act(async () => {
await vi.advanceTimersByTimeAsync(100);
@@ -181,13 +182,13 @@ describe("useExecutorStats", () => {
expect(result.current.stats.stuckTaskCount).toBe(1);
});
it("does not count non-in-progress tasks as stuck even if old", async () => {
// Set updatedAt to 11 minutes ago for a todo task
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", "todo"), updatedAt: elevenMinutesAgo },
{ ...createMockTask("FN-001", "in-progress"), updatedAt: elevenMinutesAgo },
];
// No threshold = stuck detection disabled
const { result } = renderHook(() => useExecutorStats(tasks));
await act(async () => {
@@ -197,14 +198,62 @@ describe("useExecutorStats", () => {
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
// 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));
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);

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,

View File

@@ -0,0 +1,45 @@
import type { Task } from "@fusion/core";
/**
* 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
*
* When `taskStuckTimeoutMs` is undefined, null, or 0, stuck detection is
* disabled and this function always returns false.
*/
export function isTaskStuck(task: Task, taskStuckTimeoutMs: number | undefined): boolean {
if (task.column !== "in-progress") {
return false;
}
if (!taskStuckTimeoutMs || taskStuckTimeoutMs <= 0) {
return false;
}
const updatedAt = new Date(task.updatedAt).getTime();
const now = Date.now();
return now - updatedAt > taskStuckTimeoutMs;
}
/**
* Derive the stuck task count from a list of tasks using the given threshold.
*
* Returns 0 when stuck detection is disabled (undefined/0 threshold).
*/
export function countStuckTasks(tasks: Task[], taskStuckTimeoutMs: number | undefined): number {
if (!taskStuckTimeoutMs || taskStuckTimeoutMs <= 0) {
return 0;
}
let count = 0;
for (const task of tasks) {
if (isTaskStuck(task, taskStuckTimeoutMs)) {
count++;
}
}
return count;
}