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:
gsxdsm
2026-06-27 10:38:23 -07:00
parent 368f1e0e45
commit e909d41fed
5 changed files with 179 additions and 10 deletions

View 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.

View File

@@ -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.

View File

@@ -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,

View File

@@ -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", () => {

View File

@@ -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);
}