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>
This commit is contained in:
109
packages/dashboard/app/api/dedupe.ts
Normal file
109
packages/dashboard/app/api/dedupe.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
// In-flight request deduplication with redirect-on-forceFresh.
|
||||
//
|
||||
// Basic case: when N components mount and each calls the same fetcher, the
|
||||
// "check cache → fetch → store on completion" pattern fires N concurrent
|
||||
// requests because every caller sees an empty cache. Routing those callers
|
||||
// through this helper collapses the burst into a single network request.
|
||||
//
|
||||
// forceFresh case: a caller that has just committed a mutation passes
|
||||
// `forceFresh: true` so it doesn't join a pre-mutation in-flight request and
|
||||
// return stale data. The OLD callers that already joined the in-flight
|
||||
// request are ALSO redirected — they receive the fresh response from the new
|
||||
// fetch, not the stale one. This is implemented by decoupling the external
|
||||
// promise that callers await from the inner fetch promise: a forceFresh
|
||||
// invocation discards the old inner fetch's eventual resolution and assigns
|
||||
// the new inner fetch's resolution to the SAME external promise that old
|
||||
// callers are waiting on. No caller ever observes pre-mutation data once a
|
||||
// post-mutation forceFresh has been requested.
|
||||
//
|
||||
// Layered caching (e.g. usePluginUiSlots' 60s TTL) is unaffected — that runs
|
||||
// at the hook layer, above this helper.
|
||||
|
||||
interface InFlightEntry<T> {
|
||||
/** The promise callers await. Resolves to whichever inner fetch wins. */
|
||||
external: Promise<T>;
|
||||
/** Resolves the external promise. Guarded by `done`. */
|
||||
resolve: (value: T) => void;
|
||||
/** Rejects the external promise. Guarded by `done`. */
|
||||
reject: (err: unknown) => void;
|
||||
/** Once resolved or rejected, further fetches must not write to external. */
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
const inFlight = new Map<string, InFlightEntry<unknown>>();
|
||||
|
||||
export interface DedupeOptions {
|
||||
/**
|
||||
* Skip joining an existing in-flight request and start a new fetch. Any
|
||||
* callers already awaiting the prior in-flight request will be redirected
|
||||
* to receive the new fetch's response instead — they will NOT see the
|
||||
* pre-forceFresh response. Use after a mutation when callers must observe
|
||||
* the post-mutation server snapshot.
|
||||
*/
|
||||
forceFresh?: boolean;
|
||||
}
|
||||
|
||||
function makeEntry<T>(): InFlightEntry<T> {
|
||||
// Manually-constructed Deferred so we can assign whichever inner fetch
|
||||
// wins to the same external promise.
|
||||
let resolve!: (v: T) => void;
|
||||
let reject!: (e: unknown) => void;
|
||||
const external = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { external, resolve, reject, done: false };
|
||||
}
|
||||
|
||||
function attachInnerToEntry<T>(entry: InFlightEntry<T>, inner: Promise<T>): void {
|
||||
inner.then(
|
||||
(value) => {
|
||||
if (entry.done) return;
|
||||
entry.done = true;
|
||||
entry.resolve(value);
|
||||
},
|
||||
(err) => {
|
||||
if (entry.done) return;
|
||||
entry.done = true;
|
||||
entry.reject(err);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function dedupe<T>(
|
||||
key: string,
|
||||
fn: () => Promise<T>,
|
||||
options?: DedupeOptions,
|
||||
): Promise<T> {
|
||||
const existing = inFlight.get(key) as InFlightEntry<T> | undefined;
|
||||
|
||||
if (existing && !existing.done) {
|
||||
if (!options?.forceFresh) {
|
||||
// Standard dedupe — join the in-flight request.
|
||||
return existing.external;
|
||||
}
|
||||
// forceFresh with an existing in-flight: start a new inner fetch and
|
||||
// redirect existing.external to its result. The old inner fetch's
|
||||
// eventual resolution is discarded by the `done` guard in
|
||||
// attachInnerToEntry.
|
||||
attachInnerToEntry(existing, fn());
|
||||
return existing.external;
|
||||
}
|
||||
|
||||
// No live entry (or forceFresh with nothing in flight) — start fresh.
|
||||
const entry = makeEntry<T>();
|
||||
attachInnerToEntry(entry, fn());
|
||||
// Schedule cleanup once the external promise settles. We compare by
|
||||
// identity so a later forceFresh that swaps in a new entry under the same
|
||||
// key doesn't get deleted by this old cleanup.
|
||||
void entry.external.then(
|
||||
() => {
|
||||
if (inFlight.get(key) === entry) inFlight.delete(key);
|
||||
},
|
||||
() => {
|
||||
if (inFlight.get(key) === entry) inFlight.delete(key);
|
||||
},
|
||||
);
|
||||
inFlight.set(key, entry);
|
||||
return entry.external;
|
||||
}
|
||||
@@ -90,6 +90,11 @@ import type {
|
||||
ResearchProviderOption,
|
||||
} from "../research-types";
|
||||
import { appendTokenQuery, getAuthToken, withTokenHeader } from "../auth";
|
||||
import { dedupe, type DedupeOptions } from "./dedupe";
|
||||
|
||||
/** Options accepted by deduped fetchers. Pass `{ forceFresh: true }` after a
|
||||
* mutation to bypass any in-flight pre-mutation request and force a new one. */
|
||||
export type FetchOptions = DedupeOptions;
|
||||
|
||||
// Re-export skills types for use by hooks and components
|
||||
export type { DiscoveredSkill, CatalogEntry, CatalogFetchResult, ToggleSkillResult, SkillContent, SkillFileEntry };
|
||||
@@ -607,11 +612,13 @@ export function rejectPlan(id: string, projectId?: string): Promise<Task> {
|
||||
}
|
||||
|
||||
export function fetchConfig(projectId?: string): Promise<{ maxConcurrent: number; rootDir: string }> {
|
||||
return api<{ maxConcurrent: number; rootDir: string }>(withProjectId("/config", projectId));
|
||||
const path = withProjectId("/config", projectId);
|
||||
return dedupe(path, () => api<{ maxConcurrent: number; rootDir: string }>(path));
|
||||
}
|
||||
|
||||
export function fetchSettings(projectId?: string): Promise<Settings> {
|
||||
return api<Settings>(withProjectId("/settings", projectId));
|
||||
export function fetchSettings(projectId?: string, options?: FetchOptions): Promise<Settings> {
|
||||
const path = withProjectId("/settings", projectId);
|
||||
return dedupe(path, () => api<Settings>(path), options);
|
||||
}
|
||||
|
||||
export function updateSettings(settings: Partial<Settings>, projectId?: string): Promise<Settings> {
|
||||
@@ -1040,8 +1047,8 @@ export function testMemoryRetrieval(query: string, projectId?: string): Promise<
|
||||
}
|
||||
|
||||
/** Fetch global (user-level) settings from ~/.fusion/settings.json */
|
||||
export function fetchGlobalSettings(): Promise<GlobalSettings> {
|
||||
return api<GlobalSettings>("/settings/global");
|
||||
export function fetchGlobalSettings(options?: FetchOptions): Promise<GlobalSettings> {
|
||||
return dedupe("/settings/global", () => api<GlobalSettings>("/settings/global"), options);
|
||||
}
|
||||
|
||||
/** Update global (user-level) settings. These persist across all fn projects. */
|
||||
@@ -2008,14 +2015,14 @@ export async function probeProviderModels(params: ProbeModelsParams): Promise<Pr
|
||||
}
|
||||
|
||||
/** Fetch authentication status for all OAuth providers */
|
||||
export function fetchAuthStatus(): Promise<{
|
||||
export function fetchAuthStatus(options?: FetchOptions): Promise<{
|
||||
providers: AuthProvider[];
|
||||
ghCli?: { available: boolean; authenticated: boolean };
|
||||
}> {
|
||||
return api<{
|
||||
return dedupe("/auth/status", () => api<{
|
||||
providers: AuthProvider[];
|
||||
ghCli?: { available: boolean; authenticated: boolean };
|
||||
}>("/auth/status");
|
||||
}>("/auth/status"), options);
|
||||
}
|
||||
|
||||
/** Initiate OAuth login for a provider. Returns the auth URL to open in a new tab. */
|
||||
@@ -4763,7 +4770,8 @@ export function clearActivityLog(projectId?: string): Promise<{ success: boolean
|
||||
|
||||
/** Fetch all workflow step definitions */
|
||||
export function fetchWorkflowSteps(projectId?: string): Promise<WorkflowStep[]> {
|
||||
return api<WorkflowStep[]>(withProjectId("/workflow-steps", projectId));
|
||||
const path = withProjectId("/workflow-steps", projectId);
|
||||
return dedupe(path, () => api<WorkflowStep[]>(path));
|
||||
}
|
||||
|
||||
/** Create a new workflow step */
|
||||
@@ -5153,6 +5161,7 @@ export function proxyApi<T>(path: string, opts?: RequestInit & { nodeId?: string
|
||||
export function fetchAgents(
|
||||
filter?: { state?: AgentState; role?: AgentCapability; includeEphemeral?: boolean },
|
||||
projectId?: string,
|
||||
options?: FetchOptions,
|
||||
): Promise<Agent[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (filter?.state) params.set("state", filter.state);
|
||||
@@ -5160,7 +5169,8 @@ export function fetchAgents(
|
||||
if (filter?.includeEphemeral === true) params.set("includeEphemeral", "true");
|
||||
if (projectId) params.set("projectId", projectId);
|
||||
const query = params.size > 0 ? `?${params.toString()}` : "";
|
||||
return api<Agent[]>(`/agents${query}`);
|
||||
const path = `/agents${query}`;
|
||||
return dedupe(path, () => api<Agent[]>(path), options);
|
||||
}
|
||||
|
||||
/** Fetch a single agent with heartbeat history */
|
||||
@@ -5524,8 +5534,9 @@ export function fetchAgentRunTimeline(
|
||||
}
|
||||
|
||||
/** Fetch aggregate agent stats */
|
||||
export function fetchAgentStats(projectId?: string): Promise<AgentStats> {
|
||||
return api<AgentStats>(withProjectId("/agents/stats", projectId));
|
||||
export function fetchAgentStats(projectId?: string, options?: FetchOptions): Promise<AgentStats> {
|
||||
const path = withProjectId("/agents/stats", projectId);
|
||||
return dedupe(path, () => api<AgentStats>(path), options);
|
||||
}
|
||||
|
||||
/** Fetch the chain of command for an agent (self → manager → grand-manager → ...) */
|
||||
@@ -6242,12 +6253,12 @@ export function hasNodeMappingsSupport(project: ProjectInfoWithSource): boolean
|
||||
|
||||
/** Fetch all registered projects from all nodes (local + remote) */
|
||||
export function fetchProjectsAcrossNodes(): Promise<ProjectInfoWithSource[]> {
|
||||
return api<ProjectInfoWithSource[]>("/projects/across-nodes");
|
||||
return dedupe("/projects/across-nodes", () => api<ProjectInfoWithSource[]>("/projects/across-nodes"));
|
||||
}
|
||||
|
||||
/** Fetch all registered nodes */
|
||||
export function fetchNodes(): Promise<NodeInfo[]> {
|
||||
return api<NodeInfo[]>("/nodes");
|
||||
return dedupe("/nodes", () => api<NodeInfo[]>("/nodes"));
|
||||
}
|
||||
|
||||
/** Fetch discovery runtime status and active config. */
|
||||
@@ -6520,12 +6531,13 @@ export function fetchExecutorStats(projectId?: string): Promise<{
|
||||
maxConcurrent: number;
|
||||
lastActivityAt?: string;
|
||||
}> {
|
||||
return api<{
|
||||
const path = withProjectId("/executor/stats", projectId);
|
||||
return dedupe(path, () => api<{
|
||||
globalPause: boolean;
|
||||
enginePaused: boolean;
|
||||
maxConcurrent: number;
|
||||
lastActivityAt?: string;
|
||||
}>(withProjectId("/executor/stats", projectId));
|
||||
}>(path));
|
||||
}
|
||||
|
||||
export interface SystemStatsSnapshot {
|
||||
@@ -8539,7 +8551,8 @@ export interface PluginRuntimeInfo {
|
||||
|
||||
/** Fetch all UI slot definitions from active plugins */
|
||||
export async function fetchPluginUiSlots(projectId?: string): Promise<PluginUiSlotEntry[]> {
|
||||
return api<PluginUiSlotEntry[]>(withProjectId("/plugins/ui-slots", projectId));
|
||||
const path = withProjectId("/plugins/ui-slots", projectId);
|
||||
return dedupe(path, () => api<PluginUiSlotEntry[]>(path));
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user