Correct the dashboard footer counter contract so active task pressure matches board state coverage. - Count triage and planning lanes as queued while excluding done, archived, and inactive custom lanes. - Treat only actionable blockedBy values as blocked tasks and keep running/stuck scoped to in-progress work. - Cover every visible footer counter, background/overlap segments, and the intentional absence of a Done footer count in regression tests. - Add a patch changeset for the published Fusion package. Files changed: .changeset/fn-7461-footer-counters.md | 7 ++ packages/dashboard/app/api/legacy.ts | 6 +- .../__tests__/ExecutorStatusBar.test.tsx | 86 +++++++++++++++++++++- .../app/hooks/__tests__/useExecutorStats.test.ts | 59 ++++++++------- packages/dashboard/app/hooks/useExecutorStats.ts | 24 +++++- 5 files changed, 150 insertions(+), 32 deletions(-) Fusion-Task-Id: FN-7461 Fusion-Task-Lineage: a1eceb98-76b2-4820-95c4-888ad61b4162 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
279 lines
9.7 KiB
TypeScript
279 lines
9.7 KiB
TypeScript
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";
|
|
import { isLikelyTabSuspensionError, isVisibilityResumeError, useTabVisibilitySuspension } from "./visibilitySuspension";
|
|
|
|
const POLL_INTERVAL_MS = 5000; // 5 seconds - different from useProjectHealth's 10s
|
|
/*
|
|
* FNXC:ExecutorStatusBar 2026-06-27-00:00:
|
|
* Executor stats polling can hit one-off tab-suspension-like fetch errors while the tab remains visible. Keep the last-good footer stats through one transient poll so the bottom bar does not flash "Connecting…"; only sustained consecutive transient failures may surface the connection state.
|
|
*/
|
|
const TRANSIENT_FAILURE_THRESHOLD = 2;
|
|
|
|
export interface UseExecutorStatsResult {
|
|
/** Aggregated executor statistics */
|
|
stats: ExecutorStats;
|
|
/** Whether the stats are currently loading */
|
|
loading: boolean;
|
|
/** Error message if the last fetch failed */
|
|
error: string | null;
|
|
/** Manually refresh stats */
|
|
refresh: () => Promise<void>;
|
|
}
|
|
|
|
/**
|
|
* Derive the executor state from globalPause, enginePaused, and runningTaskCount.
|
|
*
|
|
* - "stopped": globalPause is true
|
|
* - "idle": (enginePaused is true AND runningTaskCount is 0) OR not paused with nothing running
|
|
* - "paused": enginePaused is true AND runningTaskCount > 0
|
|
* - "running": globalPause is false AND enginePaused is false AND runningTaskCount > 0
|
|
*
|
|
* FNXC:EngineControls 2026-06-22-00:00:
|
|
* `globalPause` dominates the footer state matrix so an operator-stopped engine is distinct from idle even if in-progress tasks still exist.
|
|
*/
|
|
function deriveExecutorState(
|
|
globalPause: boolean,
|
|
enginePaused: boolean,
|
|
runningTaskCount: number
|
|
): ExecutorState {
|
|
if (globalPause) {
|
|
return "stopped";
|
|
}
|
|
if (enginePaused && runningTaskCount === 0) {
|
|
return "idle";
|
|
}
|
|
if (enginePaused && runningTaskCount > 0) {
|
|
return "paused";
|
|
}
|
|
// globalPause is false and enginePaused is false
|
|
if (runningTaskCount > 0) {
|
|
return "running";
|
|
}
|
|
return "idle";
|
|
}
|
|
|
|
/**
|
|
* Derive statistics from the task list.
|
|
*
|
|
* FNXC:ExecutorStatusBar 2026-07-03-00:18:
|
|
* Footer task counters must mirror the board's operator-facing active work states: Queued includes todo plus planning/triage work, Running and Stuck stay scoped to in-progress execution, Done remains absent from the footer contract unless a labeled Done segment is introduced, and archived/completed/non-planning custom lanes never inflate active pressure counts.
|
|
*/
|
|
function deriveStatsFromTasks(tasks: Task[], taskStuckTimeoutMs?: number, lastFetchTimeMs?: number): Pick<
|
|
ExecutorStats,
|
|
"runningTaskCount" | "blockedTaskCount" | "stuckTaskCount" | "queuedTaskCount" | "inReviewCount"
|
|
> {
|
|
let runningTaskCount = 0;
|
|
let blockedTaskCount = 0;
|
|
let stuckTaskCount = 0;
|
|
let queuedTaskCount = 0;
|
|
let inReviewCount = 0;
|
|
|
|
for (const task of tasks) {
|
|
switch (task.column) {
|
|
case "in-progress":
|
|
runningTaskCount++;
|
|
if (isTaskStuck(task, taskStuckTimeoutMs, lastFetchTimeMs)) {
|
|
stuckTaskCount++;
|
|
}
|
|
break;
|
|
case "todo":
|
|
case "triage":
|
|
queuedTaskCount++;
|
|
break;
|
|
case "in-review":
|
|
inReviewCount++;
|
|
break;
|
|
default:
|
|
if (task.status === "planning" && !isTerminalOrActiveTaskColumn(task.column)) {
|
|
queuedTaskCount++;
|
|
}
|
|
break;
|
|
}
|
|
|
|
if (hasActionableBlockedBy(task.blockedBy)) {
|
|
blockedTaskCount++;
|
|
}
|
|
}
|
|
|
|
return {
|
|
runningTaskCount,
|
|
blockedTaskCount,
|
|
stuckTaskCount,
|
|
queuedTaskCount,
|
|
inReviewCount,
|
|
};
|
|
}
|
|
|
|
function isTerminalOrActiveTaskColumn(column: Task["column"]): boolean {
|
|
return column === "in-progress" || column === "in-review" || column === "done" || column === "archived";
|
|
}
|
|
|
|
function hasActionableBlockedBy(blockedBy: Task["blockedBy"] | string[] | null): boolean {
|
|
if (Array.isArray(blockedBy)) {
|
|
return blockedBy.some((id) => typeof id === "string" && id.trim().length > 0);
|
|
}
|
|
|
|
return typeof blockedBy === "string" && blockedBy.trim().length > 0;
|
|
}
|
|
|
|
/**
|
|
* 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 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"
|
|
* - Returns ExecutorStats object with reactive updates
|
|
*/
|
|
const DEFAULT_API_DATA: Pick<ExecutorStats, "maxConcurrent" | "lastActivityAt"> & {
|
|
globalPause: boolean;
|
|
enginePaused: boolean;
|
|
} = {
|
|
globalPause: false,
|
|
enginePaused: false,
|
|
maxConcurrent: 2,
|
|
};
|
|
|
|
export function useExecutorStats(tasks: Task[], projectId?: string, taskStuckTimeoutMs?: number, lastFetchTimeMs?: number): UseExecutorStatsResult {
|
|
|
|
const [apiDataState, setApiDataState] = useState<{
|
|
projectId?: string;
|
|
data: typeof DEFAULT_API_DATA;
|
|
} | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [errorState, setErrorState] = useState<{ projectId?: string; message: string } | null>(null);
|
|
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
|
const abortRef = useRef<AbortController | null>(null);
|
|
const hasFetchedStatsRef = useRef(false);
|
|
const consecutiveFailuresRef = useRef(0);
|
|
const activeProjectIdRef = useRef(projectId);
|
|
const visibilitySuspension = useTabVisibilitySuspension();
|
|
|
|
const shouldSuppressVisibilityResumeError = useCallback((errorMessage: string): boolean => {
|
|
return hasFetchedStatsRef.current && isVisibilityResumeError(errorMessage, visibilitySuspension.wasRecentlyHidden());
|
|
}, [visibilitySuspension]);
|
|
|
|
const refresh = useCallback(async () => {
|
|
// Cancel any in-flight requests
|
|
if (abortRef.current) {
|
|
abortRef.current.abort();
|
|
}
|
|
abortRef.current = new AbortController();
|
|
|
|
try {
|
|
if (activeProjectIdRef.current !== projectId) {
|
|
activeProjectIdRef.current = projectId;
|
|
hasFetchedStatsRef.current = false;
|
|
consecutiveFailuresRef.current = 0;
|
|
setErrorState(null);
|
|
}
|
|
/*
|
|
* FNXC:ExecutorStatusBar 2026-06-27-00:00:
|
|
* Routine 5s heartbeat polls must not re-enter the loading state after the first successful stats fetch. The footer loading branch swaps the root subtree while idle, which unmounts EngineControlMenu, closes an open concurrency popover, and makes the footer blink (FN-7163).
|
|
*
|
|
* FNXC:ExecutorStatusBar 2026-06-27-17:30:
|
|
* Project switches are a new initial load, not a heartbeat. Reset the per-project fetched/failure guard before fetching so project B cannot inherit project A's stats or transient-error debounce state.
|
|
*/
|
|
if (!hasFetchedStatsRef.current) {
|
|
setLoading(true);
|
|
}
|
|
const requestProjectId = projectId;
|
|
const data = await fetchExecutorStats(requestProjectId);
|
|
if (activeProjectIdRef.current !== requestProjectId) {
|
|
return;
|
|
}
|
|
consecutiveFailuresRef.current = 0;
|
|
hasFetchedStatsRef.current = true;
|
|
setErrorState(null);
|
|
setApiDataState({ projectId: requestProjectId, data });
|
|
} catch (err) {
|
|
if (activeProjectIdRef.current !== projectId) {
|
|
return;
|
|
}
|
|
if (err instanceof Error && err.name === "AbortError") {
|
|
// Ignore abort errors
|
|
return;
|
|
}
|
|
const errorMessage = err instanceof Error ? err.message : "Failed to fetch executor stats";
|
|
if (shouldSuppressVisibilityResumeError(errorMessage)) {
|
|
return;
|
|
}
|
|
if (hasFetchedStatsRef.current && isLikelyTabSuspensionError(errorMessage)) {
|
|
consecutiveFailuresRef.current += 1;
|
|
if (consecutiveFailuresRef.current >= TRANSIENT_FAILURE_THRESHOLD) {
|
|
setErrorState({ projectId, message: errorMessage });
|
|
}
|
|
return;
|
|
}
|
|
consecutiveFailuresRef.current = 0;
|
|
setErrorState({ projectId, message: errorMessage });
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [projectId, shouldSuppressVisibilityResumeError]);
|
|
|
|
// Initial fetch
|
|
useEffect(() => {
|
|
refresh();
|
|
|
|
return () => {
|
|
if (abortRef.current) {
|
|
abortRef.current.abort();
|
|
}
|
|
};
|
|
}, [refresh]);
|
|
|
|
// Polling - refresh every 5 seconds
|
|
useEffect(() => {
|
|
// Clear any existing interval
|
|
if (intervalRef.current) {
|
|
clearInterval(intervalRef.current);
|
|
}
|
|
|
|
// Start new polling interval
|
|
intervalRef.current = setInterval(() => {
|
|
refresh();
|
|
}, POLL_INTERVAL_MS);
|
|
|
|
return () => {
|
|
if (intervalRef.current) {
|
|
clearInterval(intervalRef.current);
|
|
intervalRef.current = null;
|
|
}
|
|
};
|
|
}, [refresh]);
|
|
|
|
const currentProjectApiDataState = apiDataState && apiDataState.projectId === projectId ? apiDataState : null;
|
|
const apiData = currentProjectApiDataState?.data ?? DEFAULT_API_DATA;
|
|
const error = errorState && errorState.projectId === projectId ? errorState.message : null;
|
|
const effectiveLoading = loading || (!error && !currentProjectApiDataState);
|
|
|
|
// Derive stats from tasks and API data
|
|
const taskStats = deriveStatsFromTasks(tasks, taskStuckTimeoutMs, lastFetchTimeMs);
|
|
const executorState = deriveExecutorState(
|
|
apiData.globalPause,
|
|
apiData.enginePaused,
|
|
taskStats.runningTaskCount
|
|
);
|
|
|
|
const stats: ExecutorStats = {
|
|
...taskStats,
|
|
executorState,
|
|
maxConcurrent: apiData.maxConcurrent,
|
|
lastActivityAt: apiData.lastActivityAt,
|
|
};
|
|
|
|
return {
|
|
stats,
|
|
loading: effectiveLoading,
|
|
error,
|
|
refresh,
|
|
};
|
|
}
|