Files
fusion/packages/dashboard/app/hooks/useAgents.ts
gsxdsm 6a6c6fdbfd perf(dashboard): speed up startup and eliminate API request storms
Multiple coordinated fixes for the perceived "dashboard takes forever to
load" complaint. Per-page-load HTTP requests drop from ~177 to ~101 and
duplicate per-project InProcessRuntime creation is eliminated.

- engine: shouldUseHybridExecutor no longer auto-enables for local-only
  multi-project setups (set FUSION_HYBRID_EXECUTOR=1 to force). The
  duplicate-runtime path was running self-healing twice per project and
  contending on the same SQLite file. ProjectEngineManager already
  handles N local projects with one InProcessRuntime each.
- dashboard cli: parallelized independent store inits, started
  CentralCore.init early in background, ran plugin loading concurrently
  with extension resolution. Sequenced SQLite store inits to avoid a
  TOCTOU race in addColumnIfMissing migrations across TaskStore /
  AutomationStore / PluginStore / AgentStore (all open the same
  .fusion/fusion.db). Restored try/catch around HybridExecutor.initialize
  and engineManager.ensureEngine so a paused or broken cwd project no
  longer aborts dashboard startup.
- dashboard client: added in-flight request dedupe wrapped around the
  top API offenders. /api/plugins/ui-slots drops from 17x to 1x per load.
  dedupe.forceFresh redirects ALL in-flight waiters to receive the fresh
  post-mutation response, not just the forcing caller. Generation
  counters in useAgents and AgentListModal protect against slow polls
  overwriting fresh state.
- dashboard SSE: agent event handler now debounces 250ms with a
  trailing-edge guard so multi-agent activity bursts coalesce to at
  most 2 refetches per burst.
- dashboard route: PATCH /api/projects/:id with isolationMode change
  returns 503 with actionable guidance when HybridExecutor is
  unavailable, instead of silently persisting a config the live runtime
  won't honor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:57:31 -07:00

213 lines
8.6 KiB
TypeScript

import { useState, useEffect, useCallback, useRef } from "react";
import type { Agent, AgentState, AgentCapability, AgentStats } from "../api";
import { fetchAgents, fetchAgentStats } from "../api";
import { isEphemeralAgent } from "@fusion/core";
import { subscribeSse } from "../sse-bus";
import { readCache, SWR_CACHE_KEYS, SWR_DEFAULT_MAX_AGE_MS, writeCache } from "../utils/swrCache";
interface UseAgentsOptions {
filterState?: AgentState | "all";
showSystemAgents?: boolean;
}
interface AgentFilter {
state?: AgentState;
role?: AgentCapability;
includeEphemeral?: boolean;
}
// Debounce window for SSE-triggered refreshes. A burst of agent:updated /
// agent:stateChanged events during multi-agent activity used to fire one
// forceFresh fetch per event, defeating the dedupe layer. Coalescing inside
// this window collapses the burst into one network round trip.
const SSE_REFRESH_DEBOUNCE_MS = 250;
export function useAgents(projectId?: string, options?: UseAgentsOptions) {
const [agents, setAgents] = useState<Agent[]>(() => {
const cached = readCache<Agent[]>(SWR_CACHE_KEYS.AGENTS, { maxAgeMs: SWR_DEFAULT_MAX_AGE_MS });
return Array.isArray(cached) ? cached : [];
});
const [stats, setStats] = useState<AgentStats | null>(() => {
const cached = readCache<AgentStats>(SWR_CACHE_KEYS.AGENT_STATS, { maxAgeMs: SWR_DEFAULT_MAX_AGE_MS });
return cached ?? null;
});
const [isLoading, setIsLoading] = useState(false);
const hasCachedHydrationRef = useRef(agents.length > 0 || stats !== null);
// Generation counters. Each loadAgents/loadStats call gets a monotonically
// increasing id; only the latest call's response is allowed to write state.
// This neutralizes two races at once:
// (a) poll-vs-mutation: if a slow poll resolves AFTER a forceFresh
// mutation refetch, the poll's setAgents is dropped instead of
// overwriting fresh post-mutation data.
// (b) concurrent forceFresh: if two mutations fire near-simultaneously,
// only the second's response updates state regardless of which HTTP
// response happens to arrive first (TCP/HTTP ordering is not
// guaranteed).
// With this in place, callers don't need to remember to pass forceFresh
// for correctness — the gen counter handles stale-response suppression
// regardless. forceFresh still helps by triggering an actual fresh
// network request, useful when mutations have already committed and the
// caller wants the post-mutation snapshot now rather than waiting for the
// next SSE event.
const agentsGenRef = useRef(0);
const statsGenRef = useRef(0);
const loadAgents = useCallback(async (filter?: AgentFilter, opts?: { forceFresh?: boolean }) => {
const gen = ++agentsGenRef.current;
if (!hasCachedHydrationRef.current) {
setIsLoading(true);
}
try {
const filterState = options?.filterState;
const baseFilter = filterState && filterState !== "all" ? { state: filterState } : undefined;
const includeEphemeral = options?.showSystemAgents ?? false;
const mergedFilter = {
...baseFilter,
...filter,
includeEphemeral: filter?.includeEphemeral ?? includeEphemeral,
};
const data = opts?.forceFresh
? await fetchAgents(mergedFilter, projectId, { forceFresh: true })
: await fetchAgents(mergedFilter, projectId);
// A newer call superseded us — drop this response so we don't clobber
// fresher state with stale data.
if (gen !== agentsGenRef.current) return;
// Defensive dedupe: a race between the initial fetch and an SSE refresh
// (or a backend that returned the same agent twice) would otherwise put
// duplicate ids into every list rendered from this hook, flooding React
// with duplicate-key warnings until the dashboard runs out of heap.
const unique = Array.from(new Map(data.map((a) => [a.id, a])).values());
setAgents(unique);
writeCache(SWR_CACHE_KEYS.AGENTS, unique, { maxBytes: 500_000 });
hasCachedHydrationRef.current = hasCachedHydrationRef.current || unique.length > 0;
} catch (err) {
console.error("Failed to load agents:", err);
} finally {
if (gen === agentsGenRef.current) setIsLoading(false);
}
}, [projectId, options?.filterState, options?.showSystemAgents]);
const loadStats = useCallback(async (opts?: { forceFresh?: boolean }) => {
const gen = ++statsGenRef.current;
try {
const data = opts?.forceFresh
? await fetchAgentStats(projectId, { forceFresh: true })
: await fetchAgentStats(projectId);
if (gen !== statsGenRef.current) return;
setStats(data);
writeCache(SWR_CACHE_KEYS.AGENT_STATS, data, { maxBytes: 500_000 });
hasCachedHydrationRef.current = true;
} catch (err) {
console.error("Failed to load agent stats:", err);
}
}, [projectId]);
useEffect(() => {
void loadAgents();
void loadStats();
}, [loadAgents, loadStats]);
// SSE subscription for agent events. SSE events fire AFTER the backend
// commits the mutation, so we want the post-mutation snapshot. Refresh is
// debounced to coalesce bursts (e.g. many agent:updated events during a
// batch operation) into a single network round trip.
//
// Trailing-edge guard: a naive leading-edge debounce degrades to one fetch
// per ~250ms when sustained event bursts arrive faster than the fetch
// completes — events that land during an in-flight fetch each schedule a
// fresh timer. To prevent this storm: while a fetch is in-flight, mark
// pendingDuringFetch and skip arming new timers. When the fetch settles,
// if any events arrived during it, fire ONE more refresh to capture the
// latest state. Net guarantee: per burst of N events arriving within a
// fetch+debounce window, we issue at most 2 fetches (the initial debounced
// fetch and one trailing catch-up).
useEffect(() => {
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let fetchInProgress = false;
let pendingDuringFetch = false;
let unmounted = false;
const fire = async (): Promise<void> => {
fetchInProgress = true;
pendingDuringFetch = false;
try {
await Promise.all([
loadAgents(undefined, { forceFresh: true }),
loadStats({ forceFresh: true }),
]);
} finally {
fetchInProgress = false;
if (!unmounted && pendingDuringFetch) {
// Events arrived during the fetch — run one trailing refresh to
// capture them. Use a normal debounce so a continued burst still
// coalesces.
schedule();
}
}
};
const schedule = (): void => {
if (debounceTimer || fetchInProgress) {
if (fetchInProgress) pendingDuringFetch = true;
return;
}
debounceTimer = setTimeout(() => {
debounceTimer = null;
void fire();
}, SSE_REFRESH_DEBOUNCE_MS);
};
const refresh = (): void => {
if (fetchInProgress) {
// Mark a trailing refresh; don't schedule a new timer that would race
// the in-flight fetch and (via forceFresh) discard its response.
pendingDuringFetch = true;
return;
}
schedule();
};
const unsubscribe = subscribeSse(`/api/events${query}`, {
events: {
"agent:created": refresh,
"agent:updated": refresh,
"agent:deleted": refresh,
"agent:stateChanged": refresh,
"approval:requested": refresh,
"approval:updated": refresh,
"approval:decided": refresh,
},
});
return () => {
unmounted = true;
if (debounceTimer) clearTimeout(debounceTimer);
unsubscribe();
};
}, [projectId, loadAgents, loadStats]);
// refreshAgents is the canonical post-mutation refetch entrypoint. It
// defaults to forceFresh so consumers don't have to remember — anyone
// calling refreshAgents() after a save/delete gets a fresh round trip,
// never a stale in-flight pre-mutation read.
const refreshAgents = useCallback(async () => {
await Promise.all([
loadAgents(undefined, { forceFresh: true }),
loadStats({ forceFresh: true }),
]);
}, [loadAgents, loadStats]);
const showSystemAgents = options?.showSystemAgents ?? false;
const activeAgents = agents.filter((agent) => {
if (agent.state !== "active" && agent.state !== "running") {
return false;
}
return showSystemAgents || !isEphemeralAgent(agent);
});
return { agents, activeAgents, stats, isLoading, loadAgents, loadStats, refreshAgents };
}