FN-7116: suppress transient footer connecting flashes
Keep executor footer stats stable through a one-off suspension-like fetch blip. - Debounce transient executor stats failures after a successful poll while retaining last-good stats. - Preserve explicit error reporting for first-load, non-transient, and sustained failures. - Cover desktop and mobile footer behavior plus hook retry and visibility-resume cases. - Document the footer behavior and add a patch changeset for the published CLI package. Files changed: .changeset/fn-7116-footer-connecting-flash.md | 7 ++ docs/dashboard-guide.md | 2 + .../__tests__/ExecutorStatusBar.test.tsx | 24 +++- .../app/hooks/__tests__/useExecutorStats.test.ts | 132 ++++++++++++++++++++- packages/dashboard/app/hooks/useExecutorStats.ts | 24 +++- 5 files changed, 179 insertions(+), 10 deletions(-) Fusion-Task-Id: FN-7116 Fusion-Task-Lineage: 617b91a3-3d7c-42f1-8a44-ba3245eaf0dc Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7116-footer-connecting-flash.md
Normal file
7
.changeset/fn-7116-footer-connecting-flash.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Suppress brief footer Connecting flashes after one transient executor stats poll failure.
|
||||
category: fix
|
||||
dev: Debounces post-success suspension-like /api/executor/stats failures in useExecutorStats.
|
||||
@@ -1002,6 +1002,8 @@ Use this panel when upgrading a project with pre-FN-6245/FN-6277 in-review rows
|
||||
|
||||
The global AI engine stop/start control and triage pause/resume control live in the executor footer status bar rather than the header. Select the small engine-controls button beside the executor state badge, or select the state text such as **Running**, to open the footer popover. The popover includes **Stop AI engine** / **Start AI engine**, **Pause triage** / **Resume scheduling**, and live scheduler sliders for max concurrent tasks, max triage concurrency, and max worktrees. The global and current-project concurrency sliders also show how many agents are running, including actively-triaging planners (`triage` + `planning`, not paused), and a dot on the slider track for current use, clamped to the track when usage exceeds the configured cap. Slider changes save through the existing `/api/settings` path with the same debounced behavior used by Command Center controls; no separate backend route is required.
|
||||
|
||||
Brief, single-poll executor stats fetch blips keep showing the last good footer stats instead of flashing **Connecting…**. The footer only switches to **Connecting…** for sustained suspension-like stats failures, or to an explicit error state for non-transient failures.
|
||||
|
||||
### Engine status banner
|
||||
|
||||
When a project dashboard is open but no project engine is connected, Fusion shows a sticky **Engine disconnected** banner above the project content. This covers paused projects, failed or still-starting project engines, delayed reconciliation, and dashboard-only/dev launches where the UI is available before an engine manager is attached.
|
||||
|
||||
@@ -553,6 +553,27 @@ describe("ExecutorStatusBar", () => {
|
||||
});
|
||||
|
||||
describe("error state", () => {
|
||||
it.each(["desktop", "mobile"] as const)("keeps populated stats on %s when a transient stats-fetch blip is debounced", (viewportMode) => {
|
||||
viewportModeMock.value = viewportMode;
|
||||
vi.mocked(mockUseExecutorStats).mockReturnValue({
|
||||
stats: defaultStats,
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
});
|
||||
|
||||
render(<ExecutorStatusBar tasks={emptyTasks} />);
|
||||
|
||||
const statusBar = screen.getByRole("status");
|
||||
expect(statusBar).toHaveTextContent("Queued");
|
||||
expect(statusBar).toHaveTextContent("Running");
|
||||
expect(statusBar).toHaveTextContent("Blocked");
|
||||
expect(statusBar).toHaveTextContent("In Review");
|
||||
expect(statusBar).not.toHaveClass("executor-status-bar--connecting");
|
||||
expect(statusBar.querySelector(".executor-status-bar--connecting")).toBeNull();
|
||||
expect(screen.queryByText("Connecting…")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows error message when error is present", () => {
|
||||
vi.mocked(mockUseExecutorStats).mockReturnValue({
|
||||
stats: defaultStats,
|
||||
@@ -568,7 +589,8 @@ describe("ExecutorStatusBar", () => {
|
||||
expect(statusBar).toHaveClass("executor-status-bar--error");
|
||||
});
|
||||
|
||||
it("shows connecting state instead of suspension error text", () => {
|
||||
it.each(["desktop", "mobile"] as const)("shows connecting state for sustained suspension errors on %s", (viewportMode) => {
|
||||
viewportModeMock.value = viewportMode;
|
||||
vi.mocked(mockUseExecutorStats).mockReturnValue({
|
||||
stats: defaultStats,
|
||||
loading: false,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { renderHook, act, fireEvent } from "@testing-library/react";
|
||||
import { useExecutorStats } from "../useExecutorStats";
|
||||
import * as apiModule from "../../api";
|
||||
import type { Task } from "@fusion/core";
|
||||
@@ -19,6 +19,10 @@ describe("useExecutorStats", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
configurable: true,
|
||||
get: () => "visible",
|
||||
});
|
||||
mockFetchExecutorStats.mockResolvedValue({
|
||||
globalPause: false,
|
||||
enginePaused: false,
|
||||
@@ -446,7 +450,7 @@ describe("useExecutorStats", () => {
|
||||
|
||||
describe("error handling", () => {
|
||||
it("sets error state when API call fails", async () => {
|
||||
mockFetchExecutorStats.mockRejectedValue(new Error("Network error"));
|
||||
mockFetchExecutorStats.mockRejectedValue(new Error("Request failed: 500"));
|
||||
|
||||
const { result } = renderHook(() => useExecutorStats([]));
|
||||
|
||||
@@ -454,11 +458,11 @@ describe("useExecutorStats", () => {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
});
|
||||
|
||||
expect(result.current.error).toBe("Network error");
|
||||
expect(result.current.error).toBe("Request failed: 500");
|
||||
});
|
||||
|
||||
it("clears error on successful refresh", async () => {
|
||||
mockFetchExecutorStats.mockRejectedValueOnce(new Error("Network error"));
|
||||
mockFetchExecutorStats.mockRejectedValueOnce(new Error("Request failed: 500"));
|
||||
|
||||
const { result } = renderHook(() => useExecutorStats([]));
|
||||
|
||||
@@ -466,7 +470,7 @@ describe("useExecutorStats", () => {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
});
|
||||
|
||||
expect(result.current.error).toBe("Network error");
|
||||
expect(result.current.error).toBe("Request failed: 500");
|
||||
|
||||
mockFetchExecutorStats.mockResolvedValueOnce({
|
||||
globalPause: false,
|
||||
@@ -480,6 +484,124 @@ describe("useExecutorStats", () => {
|
||||
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it("surfaces a first-ever transient fetch failure when no last-good stats exist", async () => {
|
||||
mockFetchExecutorStats.mockRejectedValueOnce(new Error("Load failed"));
|
||||
|
||||
const { result } = renderHook(() => useExecutorStats([]));
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
});
|
||||
|
||||
expect(result.current.error).toBe("Load failed");
|
||||
});
|
||||
|
||||
it("suppresses a single transient failure after a prior success and keeps last-good stats", async () => {
|
||||
const { result } = renderHook(() => useExecutorStats([]));
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
});
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(result.current.stats.maxConcurrent).toBe(4);
|
||||
|
||||
mockFetchExecutorStats.mockRejectedValueOnce(new Error("Load failed"));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refresh();
|
||||
});
|
||||
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(result.current.stats.maxConcurrent).toBe(4);
|
||||
});
|
||||
|
||||
it("surfaces sustained consecutive transient failures after the debounce threshold", async () => {
|
||||
const { result } = renderHook(() => useExecutorStats([]));
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
});
|
||||
|
||||
mockFetchExecutorStats.mockRejectedValueOnce(new Error("Load failed"));
|
||||
await act(async () => {
|
||||
await result.current.refresh();
|
||||
});
|
||||
expect(result.current.error).toBeNull();
|
||||
|
||||
mockFetchExecutorStats.mockRejectedValueOnce(new Error("Failed to fetch"));
|
||||
await act(async () => {
|
||||
await result.current.refresh();
|
||||
});
|
||||
|
||||
expect(result.current.error).toBe("Failed to fetch");
|
||||
});
|
||||
|
||||
it("resets the transient failure counter after a successful refresh", async () => {
|
||||
const { result } = renderHook(() => useExecutorStats([]));
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
});
|
||||
|
||||
mockFetchExecutorStats.mockRejectedValueOnce(new Error("Load failed"));
|
||||
await act(async () => {
|
||||
await result.current.refresh();
|
||||
});
|
||||
expect(result.current.error).toBeNull();
|
||||
|
||||
mockFetchExecutorStats.mockResolvedValueOnce({
|
||||
globalPause: false,
|
||||
enginePaused: false,
|
||||
maxConcurrent: 6,
|
||||
});
|
||||
await act(async () => {
|
||||
await result.current.refresh();
|
||||
});
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(result.current.stats.maxConcurrent).toBe(6);
|
||||
|
||||
mockFetchExecutorStats.mockRejectedValueOnce(new Error("Failed to fetch"));
|
||||
await act(async () => {
|
||||
await result.current.refresh();
|
||||
});
|
||||
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps visibility-resume suppression ahead of transient failure debounce", async () => {
|
||||
const { result } = renderHook(() => useExecutorStats([]));
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
});
|
||||
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
configurable: true,
|
||||
get: () => "hidden",
|
||||
});
|
||||
act(() => {
|
||||
fireEvent(document, new Event("visibilitychange"));
|
||||
});
|
||||
Object.defineProperty(document, "visibilityState", {
|
||||
configurable: true,
|
||||
get: () => "visible",
|
||||
});
|
||||
act(() => {
|
||||
fireEvent(document, new Event("visibilitychange"));
|
||||
});
|
||||
|
||||
mockFetchExecutorStats.mockRejectedValueOnce(new Error("Load failed"));
|
||||
await act(async () => {
|
||||
await result.current.refresh();
|
||||
});
|
||||
mockFetchExecutorStats.mockRejectedValueOnce(new Error("Failed to fetch"));
|
||||
await act(async () => {
|
||||
await result.current.refresh();
|
||||
});
|
||||
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("board-sync regression", () => {
|
||||
|
||||
@@ -3,9 +3,14 @@ import type { Task } from "@fusion/core";
|
||||
import { fetchExecutorStats } from "../api";
|
||||
import type { ExecutorStats, ExecutorState } from "../api";
|
||||
import { isTaskStuck } from "../utils/taskStuck";
|
||||
import { isVisibilityResumeError, useTabVisibilitySuspension } from "./visibilitySuspension";
|
||||
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 */
|
||||
@@ -123,6 +128,7 @@ export function useExecutorStats(tasks: Task[], projectId?: string, taskStuckTim
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const hasFetchedStatsRef = useRef(false);
|
||||
const consecutiveFailuresRef = useRef(0);
|
||||
const visibilitySuspension = useTabVisibilitySuspension();
|
||||
|
||||
const shouldSuppressVisibilityResumeError = useCallback((errorMessage: string): boolean => {
|
||||
@@ -138,9 +144,10 @@ export function useExecutorStats(tasks: Task[], projectId?: string, taskStuckTim
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await fetchExecutorStats(projectId);
|
||||
consecutiveFailuresRef.current = 0;
|
||||
hasFetchedStatsRef.current = true;
|
||||
setError(null);
|
||||
setApiData(data);
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
@@ -148,9 +155,18 @@ export function useExecutorStats(tasks: Task[], projectId?: string, taskStuckTim
|
||||
return;
|
||||
}
|
||||
const errorMessage = err instanceof Error ? err.message : "Failed to fetch executor stats";
|
||||
if (!shouldSuppressVisibilityResumeError(errorMessage)) {
|
||||
setError(errorMessage);
|
||||
if (shouldSuppressVisibilityResumeError(errorMessage)) {
|
||||
return;
|
||||
}
|
||||
if (hasFetchedStatsRef.current && isLikelyTabSuspensionError(errorMessage)) {
|
||||
consecutiveFailuresRef.current += 1;
|
||||
if (consecutiveFailuresRef.current >= TRANSIENT_FAILURE_THRESHOLD) {
|
||||
setError(errorMessage);
|
||||
}
|
||||
return;
|
||||
}
|
||||
consecutiveFailuresRef.current = 0;
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user