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>
213 lines
8.6 KiB
TypeScript
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 };
|
|
}
|