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:
gsxdsm
2026-05-26 21:45:13 -07:00
parent 9bea9510c0
commit 6a6c6fdbfd
15 changed files with 515 additions and 164 deletions

View File

@@ -0,0 +1,15 @@
---
"@runfusion/fusion": patch
"@fusion/dashboard": patch
"@fusion/engine": patch
---
Dashboard startup and request-storm fixes:
- **Faster startup**: parallelized independent store inits, started CentralCore init early in background, and ran plugin loading concurrently with extension resolution. The duplicate-runtime root cause is also fixed — `shouldUseHybridExecutor` no longer auto-enables for local-only multi-project setups, where `ProjectEngineManager` already handles project lifecycle (set `FUSION_HYBRID_EXECUTOR=1` to force-enable). Eliminates ~7s of redundant self-healing pipeline work per cold start.
- **Per-page request reduction**: added in-flight request dedupe (`packages/dashboard/app/api/dedupe.ts`) wrapped around the top API offenders. A single page load went from ~177 requests to ~101, with `/api/plugins/ui-slots` dropping from 17× to 1×.
- **Stale-data-after-mutation hazard**: `forceFresh` option on the deduped fetchers now redirects ALL in-flight waiters to receive the fresh post-mutation response, not just the forcing caller. Generation counters in `useAgents` and `AgentListModal` provide a second layer of protection against slow polls overwriting fresh state.
- **SSE refresh storm**: agent SSE event handler now debounces (250ms) with a trailing-edge guard, so multi-agent activity bursts coalesce to at most 2 refetches per burst instead of one per event.
- **Live isolation-mode transition**: PATCH `/api/projects/:id` with an `isolationMode` change now returns a 503 with actionable guidance when HybridExecutor is unavailable (local-only single-node), instead of silently persisting a config that the live runtime won't honor.
- **Error handling regression**: restored try/catch around `HybridExecutor.initialize` and `engineManager.ensureEngine` in the parallel engine setup so a paused or broken cwd project no longer aborts dashboard startup.
- **TaskStore migration race**: sequenced the SQLite store inits (TaskStore → AutomationStore → PluginStore → AgentStore) since they all open the same `.fusion/fusion.db` and run `addColumnIfMissing` migrations with a TOCTOU `hasColumn``ALTER` pattern.

View File

@@ -3,6 +3,7 @@ export const DASHBOARD_STARTUP_STATUS = {
startingFileWatcher: "Starting file watcher…", startingFileWatcher: "Starting file watcher…",
initializingAgentStore: "Initializing agent store…", initializingAgentStore: "Initializing agent store…",
startingAgents: "Starting agents…", startingAgents: "Starting agents…",
loadingExtensions: "Loading extensions…",
startingEngine: "Starting engine…", startingEngine: "Starting engine…",
} as const; } as const;

View File

@@ -51,12 +51,13 @@ describe("dashboard startup chain", () => {
expect(yieldFn).toHaveBeenCalledTimes(1); expect(yieldFn).toHaveBeenCalledTimes(1);
}); });
it("exports the five startup status labels in order", () => { it("exports the startup status labels in order", () => {
expect(Object.values(DASHBOARD_STARTUP_STATUS)).toEqual([ expect(Object.values(DASHBOARD_STARTUP_STATUS)).toEqual([
"Initializing task store…", "Initializing task store…",
"Starting file watcher…", "Starting file watcher…",
"Initializing agent store…", "Initializing agent store…",
"Starting agents…", "Starting agents…",
"Loading extensions…",
"Starting engine…", "Starting engine…",
]); ]);
}); });

View File

@@ -847,9 +847,52 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
ensureProcessDiagnostics(runtimeLogger); ensureProcessDiagnostics(runtimeLogger);
store = new TaskStore(cwd); store = new TaskStore(cwd);
await store.init(); const automationStore = new AutomationStore(cwd);
if (tui) tui.setLoadingStatus(DASHBOARD_STARTUP_STATUS.startingFileWatcher);
await store.watch(); // CentralCore.init() is independent of store inits — start it early so it
// overlaps with plugin loading and extension resolution instead of running
// after them.
const centralCoreInitPromise = !opts.dev
? (async () => {
const core = new CentralCore();
try { await core.init(); } catch { /* non-fatal — fallback defaults */ }
return core;
})()
: undefined;
// Phase timing instrumentation — each step logs its wall-clock duration so
// we can see at-a-glance which startup phase is the actual bottleneck.
// Cheap enough (microsecond reads, one log per phase) to leave on
// permanently; lands in the dashboard log buffer and can be diffed across
// restarts to spot regressions.
const phaseTime = async <T>(label: string, fn: () => Promise<T> | T): Promise<T> => {
const t0 = Date.now();
try {
return await fn();
} finally {
logSink.log(`startup phase ${label}: ${Date.now() - t0}ms`, "dashboard");
}
};
// TaskStore / AutomationStore / PluginStore / AgentStore all open the SAME
// .fusion/fusion.db file and run addColumnIfMissing migrations (a TOCTOU
// `hasColumn` → ALTER pattern with no per-process lock). node:sqlite's
// DatabaseSync is synchronous, so Promise.all on these gives no real
// parallelism anyway — explicit sequencing keeps the schema-migration race
// from triggering if any init() body ever introduces an `await` between
// hasColumn and ALTER TABLE.
await phaseTime("store.init", () => store.init());
await phaseTime("automationStore.init", () => automationStore.init());
const pluginStore = store.getPluginStore();
await phaseTime("pluginStore.init", () => pluginStore.init());
agentStore = new AgentStore({ rootDir: store.getFusionDir() });
if (tui) tui.setLoadingStatus(DASHBOARD_STARTUP_STATUS.initializingAgentStore);
await phaseTime("agentStore.init", () => agentStore!.init());
// store.watch() is filesystem-watcher setup — no DB schema work, safe to
// overlap with anything coming after.
await phaseTime("store.watch", () => store.watch());
if (tui) tui.setLoadingStatus(DASHBOARD_STARTUP_STATUS.startingAgents);
// Set up database health check for diagnostics // Set up database health check for diagnostics
setDiagnosticDbHealthCheck(() => store.healthCheck()); setDiagnosticDbHealthCheck(() => store.healthCheck());
@@ -1037,9 +1080,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
handlers.push({ target, event, handler }); handlers.push({ target, event, handler });
} }
// ── AutomationStore: scheduled task persistence ────────────────────── // automationStore already initialized in parallel phase above
const automationStore = new AutomationStore(cwd);
await automationStore.init();
// ── AgentStore: agent lifecycle tracking ────────────────────────── // ── AgentStore: agent lifecycle tracking ──────────────────────────
// //
@@ -1047,10 +1088,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
// and are properly managed throughout their lifecycle (creation, state // and are properly managed throughout their lifecycle (creation, state
// transitions, termination). Passed to TaskExecutor for agent spawning. // transitions, termination). Passed to TaskExecutor for agent spawning.
// //
if (tui) tui.setLoadingStatus(DASHBOARD_STARTUP_STATUS.initializingAgentStore); // agentStore already initialized in parallel phase above
agentStore = new AgentStore({ rootDir: store.getFusionDir() });
await agentStore.init();
if (tui) tui.setLoadingStatus(DASHBOARD_STARTUP_STATUS.startingAgents);
// ── Reactive TUI Updates ───────────────────────────────────────────── // ── Reactive TUI Updates ─────────────────────────────────────────────
// //
@@ -1081,8 +1119,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
// Enables the PluginManager UI to list, install, enable, disable, and // Enables the PluginManager UI to list, install, enable, disable, and
// configure plugins via the /api/plugins REST endpoints. // configure plugins via the /api/plugins REST endpoints.
// //
const pluginStore = store.getPluginStore(); // pluginStore already initialized in parallel phase above
await pluginStore.init();
// ── PluginLoader: plugin lifecycle management ─────────────────────── // ── PluginLoader: plugin lifecycle management ───────────────────────
// //
@@ -1096,20 +1133,6 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
taskStore: store, taskStore: store,
}); });
try {
const installStatus = await ensureBundledDependencyGraphPluginInstalled(pluginStore, pluginLoader);
if (installStatus === "installed") {
logSink.log("Installed bundled Dependency Graph plugin", "plugins");
} else if (installStatus === "missing-bundle") {
logSink.log("Bundled Dependency Graph plugin was not found in this build", "plugins");
}
} catch (err) {
logSink.log(
`Failed to auto-install bundled Dependency Graph plugin: ${err instanceof Error ? err.message : err}`,
"plugins",
);
}
// Lazy-install hook for bundled runtime plugins (Hermes/OpenClaw/Paperclip). // Lazy-install hook for bundled runtime plugins (Hermes/OpenClaw/Paperclip).
// Invoked by dashboard's PUT /api/plugins/:id/settings the first time the // Invoked by dashboard's PUT /api/plugins/:id/settings the first time the
// user clicks Save in Settings. Returns true if the plugin is now registered. // user clicks Save in Settings. Returns true if the plugin is now registered.
@@ -1140,28 +1163,46 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
}; };
// Auto-load all enabled plugins so runtime UI (NewAgentDialog, AgentDetailView) // Auto-load all enabled plugins so runtime UI (NewAgentDialog, AgentDetailView)
// can discover installed runtimes like Hermes and OpenClaw. // can discover installed runtimes like Hermes and OpenClaw. Run as a
try { // background promise so it overlaps with the heavyweight extension
const { loaded, errors } = await pluginLoader.loadAllPlugins(); // resolution chain below — the two touch disjoint subsystems.
logSink.log(`Loaded ${loaded} plugins (${errors} errors)`, "plugins"); const pluginLoadingPromise = (async () => {
try {
const schemaHooks = pluginLoader.getPluginSchemaInitHooks(); const installStatus = await ensureBundledDependencyGraphPluginInstalled(pluginStore, pluginLoader);
if (schemaHooks.length > 0) { if (installStatus === "installed") {
try { logSink.log("Installed bundled Dependency Graph plugin", "plugins");
await store.getDatabase().runPluginSchemaInits(schemaHooks); } else if (installStatus === "missing-bundle") {
} catch (err) { logSink.log("Bundled Dependency Graph plugin was not found in this build", "plugins");
logSink.log(
`Schema initialization failed: ${err instanceof Error ? err.message : err}`,
"plugins",
);
} }
} catch (err) {
logSink.log(
`Failed to auto-install bundled Dependency Graph plugin: ${err instanceof Error ? err.message : err}`,
"plugins",
);
} }
} catch (err) {
logSink.log( try {
`Failed to load plugins: ${err instanceof Error ? err.message : err}`, const { loaded, errors } = await pluginLoader.loadAllPlugins();
"plugins" logSink.log(`Loaded ${loaded} plugins (${errors} errors)`, "plugins");
);
} const schemaHooks = pluginLoader.getPluginSchemaInitHooks();
if (schemaHooks.length > 0) {
try {
await store.getDatabase().runPluginSchemaInits(schemaHooks);
} catch (err) {
logSink.log(
`Schema initialization failed: ${err instanceof Error ? err.message : err}`,
"plugins",
);
}
}
} catch (err) {
logSink.log(
`Failed to load plugins: ${err instanceof Error ? err.message : err}`,
"plugins"
);
}
})();
// ── HeartbeatMonitor + HeartbeatTriggerScheduler ────────────────────── // ── HeartbeatMonitor + HeartbeatTriggerScheduler ──────────────────────
// //
@@ -1272,7 +1313,11 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
const modelRegistry = ModelRegistry.create(mergedAuthStorage, getModelRegistryModelsPath()); const modelRegistry = ModelRegistry.create(mergedAuthStorage, getModelRegistryModelsPath());
const dashboardAuthStorage = wrapAuthStorageWithApiKeyProviders(mergedAuthStorage, modelRegistry); const dashboardAuthStorage = wrapAuthStorageWithApiKeyProviders(mergedAuthStorage, modelRegistry);
// PackageManager may be used for skills adapter even if extension loading fails // PackageManager may be used for skills adapter even if extension loading fails.
// packageManager.resolve() walks installed npm/git/local pi packages and is
// the slowest step in this section — show an accurate TUI status so users
// don't think "starting agents" is stuck.
if (tui) tui.setLoadingStatus(DASHBOARD_STARTUP_STATUS.loadingExtensions);
let packageManager: DefaultPackageManager | undefined; let packageManager: DefaultPackageManager | undefined;
try { try {
// Resolve extension paths from pi settings packages (npm, git, local). // Resolve extension paths from pi settings packages (npm, git, local).
@@ -1284,7 +1329,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
agentDir, agentDir,
settingsManager: createReadOnlyProviderSettingsView(cwd, agentDir) as unknown as SettingsManager, settingsManager: createReadOnlyProviderSettingsView(cwd, agentDir) as unknown as SettingsManager,
}); });
const resolvedPaths = await packageManager.resolve(); const resolvedPaths = await phaseTime("packageManager.resolve", () => packageManager!.resolve());
const packageExtensionPaths = resolvedPaths.extensions const packageExtensionPaths = resolvedPaths.extensions
.filter((r) => r.enabled) .filter((r) => r.enabled)
.map((r) => r.path); .map((r) => r.path);
@@ -1358,7 +1403,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
setHostExtensionPaths(selfExtensionPaths); setHostExtensionPaths(selfExtensionPaths);
// Load all enabled extensions: Fusion/Pi filesystem-discovered + package-resolved. // Load all enabled extensions: Fusion/Pi filesystem-discovered + package-resolved.
const extensionsResult = await discoverAndLoadExtensions( const extensionsResult = await phaseTime("discoverAndLoadExtensions", () => discoverAndLoadExtensions(
[ [
...selfExtensionPaths, ...selfExtensionPaths,
...getEnabledPiExtensionPaths(cwd), ...getEnabledPiExtensionPaths(cwd),
@@ -1369,7 +1414,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
], ],
cwd, cwd,
join(cwd, ".fusion", "disabled-auto-extension-discovery"), join(cwd, ".fusion", "disabled-auto-extension-discovery"),
); ));
for (const { path, error } of extensionsResult.errors) { for (const { path, error } of extensionsResult.errors) {
logSink.log(`Failed to load ${path}: ${error}`, "extensions"); logSink.log(`Failed to load ${path}: ${error}`, "extensions");
@@ -1502,12 +1547,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
// //
const githubClient = new GitHubClient(); const githubClient = new GitHubClient();
const centralCoreForEngine = new CentralCore(); const centralCoreForEngine = await phaseTime("centralCore.init (await)", () => centralCoreInitPromise!);
try {
await centralCoreForEngine.init();
} catch {
// Non-fatal — engine uses fallback concurrency defaults
}
try { try {
registerGithubTrackingHook?.(); registerGithubTrackingHook?.();
@@ -1532,22 +1572,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
}); });
let hybridExecutor: HybridExecutor | undefined = undefined; let hybridExecutor: HybridExecutor | undefined = undefined;
const hybridGate = await shouldUseHybridExecutor(centralCoreForEngine);
logSink.log(
`hybrid executor gate: enabled=${hybridGate.enabled} reason=${hybridGate.reason}`,
"dashboard",
);
if (hybridGate.enabled) {
hybridExecutor = new HybridExecutor(centralCoreForEngine);
await hybridExecutor.initialize();
}
// Start background reconciliation to detect and start engines for projects
// registered after startup (without requiring dashboard UI access).
// This ensures project task execution starts from backend runtime alone.
// The onProjectFirstAccessed callback in createServer remains as a fast-path
// fallback for immediate engine startup on project access, but it is NOT
// required for correctness — reconciliation handles all cases.
engineManager.startReconciliation(); engineManager.startReconciliation();
// Backfill Claude Code skills for all registered projects. No-op when // Backfill Claude Code skills for all registered projects. No-op when
@@ -1567,44 +1592,79 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
} }
})(); })();
// ── PeerExchangeService: gossip protocol for mesh peer discovery ──────
//
// Reuse centralCoreForEngine for peer exchange since it handles all mesh ops.
//
peerExchangeService = new PeerExchangeService(centralCoreForEngine); peerExchangeService = new PeerExchangeService(centralCoreForEngine);
try {
peerExchangeService.start();
const globalSettings = await store.getGlobalSettingsStore().getSettings();
peerExchangeService.updateGlobalSettings(globalSettings);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logSink.warn(`Failed to start peer exchange service: ${message}`, "dashboard");
}
// Use the same CentralCore instance for mesh operations
centralCoreForMesh = centralCoreForEngine; centralCoreForMesh = centralCoreForEngine;
// Resolve the cwd project's engine for the dashboard's HTTP layer defaults. // Hybrid gate, cwd project registration, and peer exchange settings are
// The engine for the cwd project provides onMerge, automationStore, etc. // independent — run them in parallel.
// for requests that arrive without ?projectId=. This is transitional — const [hybridGate, cwdRegistered] = await phaseTime("engine: hybridGate + cwdRegister + peerExchange", () => Promise.all([
// Phase 5 removes this fallback entirely. shouldUseHybridExecutor(centralCoreForEngine),
let cwdEngine: ReturnType<typeof engineManager.getEngine>; ensureCwdProjectRegistered({
try {
const registered = await ensureCwdProjectRegistered({
cwd, cwd,
central: centralCoreForEngine, central: centralCoreForEngine,
logPrefix: "dashboard", logPrefix: "dashboard",
autoRegister: true, autoRegister: true,
}); }).catch(() => undefined as Awaited<ReturnType<typeof ensureCwdProjectRegistered>> | undefined),
if (registered) { (async () => {
// Ensure the cwd project's engine exists before handing HTTP defaults to try {
// createServer; background startAll may still be warming other projects. peerExchangeService!.start();
cwdEngine = await engineManager.ensureEngine(registered.id); const globalSettings = await store.getGlobalSettingsStore().getSettings();
peerExchangeService!.updateGlobalSettings(globalSettings);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logSink.warn(`Failed to start peer exchange service: ${message}`, "dashboard");
}
})(),
]));
logSink.log(
`hybrid executor gate: enabled=${hybridGate.enabled} reason=${hybridGate.reason}`,
"dashboard",
);
// HybridExecutor init: keep awaited (only runs when hybridGate.enabled,
// which now requires multi-node — rare on local-only setups).
if (hybridGate.enabled) {
try {
const he = await phaseTime("engine: HybridExecutor.initialize", async () => {
const x = new HybridExecutor(centralCoreForEngine);
await x.initialize();
return x;
});
hybridExecutor = he;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logSink.warn(`HybridExecutor initialization failed: ${message}`, "engine");
} }
} catch {
// cwd not registered — no engine defaults for HTTP layer
} }
// cwd engine warmup: must complete before createServer.
//
// server.ts derives a stack of subsystem defaults from `options.engine` —
// onMerge, automationStore, missionAutopilot, missionExecutionLoop,
// heartbeatMonitor, selfHealingManager, routineStore, routineRunner. These
// defaults are captured at route-construction time (closure-bound), so we
// cannot lazily fill them in after listen(). Unscoped HTTP/webhook traffic
// (e.g. GitHub/Stripe routine webhooks, automation routes with scope=
// global, mission autopilot recovery) depends on them.
//
// An earlier iteration race-d this against a 3s deadline; that traded
// correctness for startup speed and meant slow cold-starts handed
// undefined engine to createServer, silently degrading those endpoints
// for the first multi-second window. We now await fully — the
// duplicate-runtime issue that previously made this 7s+ is gone (see
// hybrid-executor-gate change), so warmup typically runs in ~3-5s with
// engineManager.startAll() already in flight in parallel.
const cwdEngine = cwdRegistered
? await phaseTime("engine: ensureEngine(cwd)", () =>
engineManager.ensureEngine(cwdRegistered.id).catch((err) => {
const message = err instanceof Error ? err.message : String(err);
logSink.warn(`Failed to warm cwd project engine: ${message}`, "engine");
return undefined;
}),
)
: undefined;
// Get the trigger scheduler from any running engine // Get the trigger scheduler from any running engine
for (const engine of engineManager.getAllEngines().values()) { for (const engine of engineManager.getAllEngines().values()) {
const ts = engine.getHeartbeatTriggerScheduler(); const ts = engine.getHeartbeatTriggerScheduler();
@@ -1622,6 +1682,10 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
await closeCentralCoreBestEffort(centralCoreForEngine, "dispose cleanup"); await closeCentralCoreBestEffort(centralCoreForEngine, "dispose cleanup");
}); });
// Ensure plugin loading has completed before pluginLoader is handed off
// to createServer — routes derived from getPluginRoutes() rely on it.
await phaseTime("pluginLoadingPromise (await)", () => pluginLoadingPromise);
app = createServer(store, { app = createServer(store, {
engine: cwdEngine, engine: cwdEngine,
engineManager, engineManager,
@@ -1921,6 +1985,10 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
logSink.log(`HeartbeatMonitor initialization failed (continuing without agent monitoring): ${message}`, "engine"); logSink.log(`HeartbeatMonitor initialization failed (continuing without agent monitoring): ${message}`, "engine");
} }
// Ensure plugin loading has completed before pluginLoader is handed off
// to createServer — routes derived from getPluginRoutes() rely on it.
await phaseTime("pluginLoadingPromise (await)", () => pluginLoadingPromise);
// Dev mode: no engine, pass individual proxy objects to createServer // Dev mode: no engine, pass individual proxy objects to createServer
app = createServer(store, { app = createServer(store, {
onMerge, onMerge,

View 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;
}

View File

@@ -90,6 +90,11 @@ import type {
ResearchProviderOption, ResearchProviderOption,
} from "../research-types"; } from "../research-types";
import { appendTokenQuery, getAuthToken, withTokenHeader } from "../auth"; 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 // Re-export skills types for use by hooks and components
export type { DiscoveredSkill, CatalogEntry, CatalogFetchResult, ToggleSkillResult, SkillContent, SkillFileEntry }; 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 }> { 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> { export function fetchSettings(projectId?: string, options?: FetchOptions): Promise<Settings> {
return api<Settings>(withProjectId("/settings", projectId)); const path = withProjectId("/settings", projectId);
return dedupe(path, () => api<Settings>(path), options);
} }
export function updateSettings(settings: Partial<Settings>, projectId?: string): Promise<Settings> { 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 */ /** Fetch global (user-level) settings from ~/.fusion/settings.json */
export function fetchGlobalSettings(): Promise<GlobalSettings> { export function fetchGlobalSettings(options?: FetchOptions): Promise<GlobalSettings> {
return api<GlobalSettings>("/settings/global"); return dedupe("/settings/global", () => api<GlobalSettings>("/settings/global"), options);
} }
/** Update global (user-level) settings. These persist across all fn projects. */ /** 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 */ /** Fetch authentication status for all OAuth providers */
export function fetchAuthStatus(): Promise<{ export function fetchAuthStatus(options?: FetchOptions): Promise<{
providers: AuthProvider[]; providers: AuthProvider[];
ghCli?: { available: boolean; authenticated: boolean }; ghCli?: { available: boolean; authenticated: boolean };
}> { }> {
return api<{ return dedupe("/auth/status", () => api<{
providers: AuthProvider[]; providers: AuthProvider[];
ghCli?: { available: boolean; authenticated: boolean }; 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. */ /** 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 */ /** Fetch all workflow step definitions */
export function fetchWorkflowSteps(projectId?: string): Promise<WorkflowStep[]> { 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 */ /** Create a new workflow step */
@@ -5153,6 +5161,7 @@ export function proxyApi<T>(path: string, opts?: RequestInit & { nodeId?: string
export function fetchAgents( export function fetchAgents(
filter?: { state?: AgentState; role?: AgentCapability; includeEphemeral?: boolean }, filter?: { state?: AgentState; role?: AgentCapability; includeEphemeral?: boolean },
projectId?: string, projectId?: string,
options?: FetchOptions,
): Promise<Agent[]> { ): Promise<Agent[]> {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (filter?.state) params.set("state", filter.state); if (filter?.state) params.set("state", filter.state);
@@ -5160,7 +5169,8 @@ export function fetchAgents(
if (filter?.includeEphemeral === true) params.set("includeEphemeral", "true"); if (filter?.includeEphemeral === true) params.set("includeEphemeral", "true");
if (projectId) params.set("projectId", projectId); if (projectId) params.set("projectId", projectId);
const query = params.size > 0 ? `?${params.toString()}` : ""; 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 */ /** Fetch a single agent with heartbeat history */
@@ -5524,8 +5534,9 @@ export function fetchAgentRunTimeline(
} }
/** Fetch aggregate agent stats */ /** Fetch aggregate agent stats */
export function fetchAgentStats(projectId?: string): Promise<AgentStats> { export function fetchAgentStats(projectId?: string, options?: FetchOptions): Promise<AgentStats> {
return api<AgentStats>(withProjectId("/agents/stats", projectId)); 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 → ...) */ /** 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) */ /** Fetch all registered projects from all nodes (local + remote) */
export function fetchProjectsAcrossNodes(): Promise<ProjectInfoWithSource[]> { 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 */ /** Fetch all registered nodes */
export function fetchNodes(): Promise<NodeInfo[]> { export function fetchNodes(): Promise<NodeInfo[]> {
return api<NodeInfo[]>("/nodes"); return dedupe("/nodes", () => api<NodeInfo[]>("/nodes"));
} }
/** Fetch discovery runtime status and active config. */ /** Fetch discovery runtime status and active config. */
@@ -6520,12 +6531,13 @@ export function fetchExecutorStats(projectId?: string): Promise<{
maxConcurrent: number; maxConcurrent: number;
lastActivityAt?: string; lastActivityAt?: string;
}> { }> {
return api<{ const path = withProjectId("/executor/stats", projectId);
return dedupe(path, () => api<{
globalPause: boolean; globalPause: boolean;
enginePaused: boolean; enginePaused: boolean;
maxConcurrent: number; maxConcurrent: number;
lastActivityAt?: string; lastActivityAt?: string;
}>(withProjectId("/executor/stats", projectId)); }>(path));
} }
export interface SystemStatsSnapshot { export interface SystemStatsSnapshot {
@@ -8539,7 +8551,8 @@ export interface PluginRuntimeInfo {
/** Fetch all UI slot definitions from active plugins */ /** Fetch all UI slot definitions from active plugins */
export async function fetchPluginUiSlots(projectId?: string): Promise<PluginUiSlotEntry[]> { 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));
} }

View File

@@ -89,16 +89,30 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
}); });
}, [optimisticAgents, filterState]); }, [optimisticAgents, filterState]);
const loadAgents = useCallback(async () => { // Generation counter: every loadAgents call gets a unique id; only the
// latest call's response is allowed to write state. Prevents a slow poll
// that started before a mutation from resolving AFTER the post-mutation
// refetch and overwriting fresh data with stale data.
const loadAgentsGenRef = useRef(0);
// forceFresh: pass `true` for refetches that follow a mutation, so we don't
// join an in-flight pre-mutation request (which would return stale data and
// hide the just-applied change). Polling uses default (joins in-flight).
const loadAgents = useCallback(async (forceFresh = false) => {
const gen = ++loadAgentsGenRef.current;
setIsLoading(true); setIsLoading(true);
try { try {
const filter = filterState !== "all" ? { state: filterState } : undefined; const filter = filterState !== "all" ? { state: filterState } : undefined;
const data = await fetchAgents(filter, projectId); const data = forceFresh
? await fetchAgents(filter, projectId, { forceFresh: true })
: await fetchAgents(filter, projectId);
// A newer load superseded us — drop this stale response.
if (gen !== loadAgentsGenRef.current) return;
setAgents(data); setAgents(data);
} catch (err) { } catch (err) {
addToast(`Failed to load agents: ${getErrorMessage(err)}`, "error"); addToast(`Failed to load agents: ${getErrorMessage(err)}`, "error");
} finally { } finally {
setIsLoading(false); if (gen === loadAgentsGenRef.current) setIsLoading(false);
} }
}, [filterState, addToast, projectId]); }, [filterState, addToast, projectId]);
@@ -129,7 +143,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
addToast(`Agent "${newAgentName}" created`, "success"); addToast(`Agent "${newAgentName}" created`, "success");
setNewAgentName(""); setNewAgentName("");
setIsCreating(false); setIsCreating(false);
void loadAgents(); void loadAgents(true);
} catch (err) { } catch (err) {
addToast(`Failed to create agent: ${getErrorMessage(err)}`, "error"); addToast(`Failed to create agent: ${getErrorMessage(err)}`, "error");
} }
@@ -148,7 +162,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
try { try {
await updateAgentState(agentId, newState, projectId); await updateAgentState(agentId, newState, projectId);
addToast(`Agent state updated to ${newState}`, "success"); addToast(`Agent state updated to ${newState}`, "success");
await loadAgents(); await loadAgents(true);
setOptimisticStateOverrides((prev) => { setOptimisticStateOverrides((prev) => {
const next = new Map(prev); const next = new Map(prev);
next.delete(agentId); next.delete(agentId);
@@ -180,7 +194,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
try { try {
await deleteAgent(agentId, projectId); await deleteAgent(agentId, projectId);
addToast(`Agent "${agentName}" deleted`, "success"); addToast(`Agent "${agentName}" deleted`, "success");
void loadAgents(); void loadAgents(true);
} catch (err) { } catch (err) {
addToast(`Failed to delete agent: ${getErrorMessage(err)}`, "error"); addToast(`Failed to delete agent: ${getErrorMessage(err)}`, "error");
} }
@@ -200,7 +214,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
await updateAgent(agentId, { role: newRole }, projectId); await updateAgent(agentId, { role: newRole }, projectId);
addToast(`Agent role updated to ${AGENT_ROLES.find(r => r.value === newRole)?.label ?? newRole}`, "success"); addToast(`Agent role updated to ${AGENT_ROLES.find(r => r.value === newRole)?.label ?? newRole}`, "success");
setEditingRoleForAgent(null); setEditingRoleForAgent(null);
void loadAgents(); void loadAgents(true);
} catch (err) { } catch (err) {
addToast(`Failed to update role: ${getErrorMessage(err)}`, "error"); addToast(`Failed to update role: ${getErrorMessage(err)}`, "error");
} }

View File

@@ -224,7 +224,8 @@ describe("useAgents", () => {
}); });
}); });
it("refreshes agents and stats on supported SSE events", async () => { renderHook(() => useAgents()); it("refreshes agents and stats on supported SSE events (debounced — burst collapses to 1 refetch)", async () => {
renderHook(() => useAgents());
await waitFor(() => { await waitFor(() => {
expect(MockEventSource.instances.length).toBeGreaterThan(0); expect(MockEventSource.instances.length).toBeGreaterThan(0);
@@ -234,6 +235,10 @@ describe("useAgents", () => {
mockFetchAgents.mockClear(); mockFetchAgents.mockClear();
mockFetchAgentStats.mockClear(); mockFetchAgentStats.mockClear();
// Burst of 7 SSE events arriving within the debounce window. The
// refresh handler coalesces them into a single fetchAgents/fetchAgentStats
// round trip — previously this fired 7× per event, which became a
// request storm during multi-agent activity bursts.
for (const event of ["agent:created", "agent:updated", "agent:deleted", "agent:stateChanged", "approval:requested", "approval:updated", "approval:decided"]) { for (const event of ["agent:created", "agent:updated", "agent:deleted", "agent:stateChanged", "approval:requested", "approval:updated", "approval:decided"]) {
act(() => { act(() => {
es._emit(event); es._emit(event);
@@ -241,8 +246,8 @@ describe("useAgents", () => {
} }
await waitFor(() => { await waitFor(() => {
expect(mockFetchAgents).toHaveBeenCalledTimes(7); expect(mockFetchAgents).toHaveBeenCalledTimes(1);
expect(mockFetchAgentStats).toHaveBeenCalledTimes(7); expect(mockFetchAgentStats).toHaveBeenCalledTimes(1);
}); });
}); });

View File

@@ -16,6 +16,12 @@ interface AgentFilter {
includeEphemeral?: boolean; 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) { export function useAgents(projectId?: string, options?: UseAgentsOptions) {
const [agents, setAgents] = useState<Agent[]>(() => { const [agents, setAgents] = useState<Agent[]>(() => {
const cached = readCache<Agent[]>(SWR_CACHE_KEYS.AGENTS, { maxAgeMs: SWR_DEFAULT_MAX_AGE_MS }); const cached = readCache<Agent[]>(SWR_CACHE_KEYS.AGENTS, { maxAgeMs: SWR_DEFAULT_MAX_AGE_MS });
@@ -28,7 +34,27 @@ export function useAgents(projectId?: string, options?: UseAgentsOptions) {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const hasCachedHydrationRef = useRef(agents.length > 0 || stats !== null); const hasCachedHydrationRef = useRef(agents.length > 0 || stats !== null);
const loadAgents = useCallback(async (filter?: AgentFilter) => { // 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) { if (!hasCachedHydrationRef.current) {
setIsLoading(true); setIsLoading(true);
} }
@@ -36,14 +62,17 @@ export function useAgents(projectId?: string, options?: UseAgentsOptions) {
const filterState = options?.filterState; const filterState = options?.filterState;
const baseFilter = filterState && filterState !== "all" ? { state: filterState } : undefined; const baseFilter = filterState && filterState !== "all" ? { state: filterState } : undefined;
const includeEphemeral = options?.showSystemAgents ?? false; const includeEphemeral = options?.showSystemAgents ?? false;
const data = await fetchAgents( const mergedFilter = {
{ ...baseFilter,
...baseFilter, ...filter,
...filter, includeEphemeral: filter?.includeEphemeral ?? includeEphemeral,
includeEphemeral: filter?.includeEphemeral ?? includeEphemeral, };
}, const data = opts?.forceFresh
projectId, ? 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 // Defensive dedupe: a race between the initial fetch and an SSE refresh
// (or a backend that returned the same agent twice) would otherwise put // (or a backend that returned the same agent twice) would otherwise put
// duplicate ids into every list rendered from this hook, flooding React // duplicate ids into every list rendered from this hook, flooding React
@@ -55,13 +84,17 @@ export function useAgents(projectId?: string, options?: UseAgentsOptions) {
} catch (err) { } catch (err) {
console.error("Failed to load agents:", err); console.error("Failed to load agents:", err);
} finally { } finally {
setIsLoading(false); if (gen === agentsGenRef.current) setIsLoading(false);
} }
}, [projectId, options?.filterState, options?.showSystemAgents]); }, [projectId, options?.filterState, options?.showSystemAgents]);
const loadStats = useCallback(async () => { const loadStats = useCallback(async (opts?: { forceFresh?: boolean }) => {
const gen = ++statsGenRef.current;
try { try {
const data = await fetchAgentStats(projectId); const data = opts?.forceFresh
? await fetchAgentStats(projectId, { forceFresh: true })
: await fetchAgentStats(projectId);
if (gen !== statsGenRef.current) return;
setStats(data); setStats(data);
writeCache(SWR_CACHE_KEYS.AGENT_STATS, data, { maxBytes: 500_000 }); writeCache(SWR_CACHE_KEYS.AGENT_STATS, data, { maxBytes: 500_000 });
hasCachedHydrationRef.current = true; hasCachedHydrationRef.current = true;
@@ -75,15 +108,69 @@ export function useAgents(projectId?: string, options?: UseAgentsOptions) {
void loadStats(); void loadStats();
}, [loadAgents, loadStats]); }, [loadAgents, loadStats]);
// SSE subscription for agent events // 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(() => { useEffect(() => {
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : ""; const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
const refresh = () => {
void loadAgents(); let debounceTimer: ReturnType<typeof setTimeout> | null = null;
void loadStats(); 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();
}
}
}; };
return subscribeSse(`/api/events${query}`, { 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: { events: {
"agent:created": refresh, "agent:created": refresh,
"agent:updated": refresh, "agent:updated": refresh,
@@ -94,10 +181,23 @@ export function useAgents(projectId?: string, options?: UseAgentsOptions) {
"approval:decided": refresh, "approval:decided": refresh,
}, },
}); });
return () => {
unmounted = true;
if (debounceTimer) clearTimeout(debounceTimer);
unsubscribe();
};
}, [projectId, loadAgents, loadStats]); }, [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 () => { const refreshAgents = useCallback(async () => {
await Promise.all([loadAgents(), loadStats()]); await Promise.all([
loadAgents(undefined, { forceFresh: true }),
loadStats({ forceFresh: true }),
]);
}, [loadAgents, loadStats]); }, [loadAgents, loadStats]);
const showSystemAgents = options?.showSystemAgents ?? false; const showSystemAgents = options?.showSystemAgents ?? false;

View File

@@ -66,7 +66,13 @@ describe("project isolation transition route", () => {
expect((res.body as any).transitionDeferred).toBeUndefined(); expect((res.body as any).transitionDeferred).toBeUndefined();
}); });
it("falls back to direct update and marks transitionDeferred when no hybrid executor", async () => { it("returns 503 with clear remediation when no hybrid executor is configured (local-only)", async () => {
// Previously this route silently set transitionDeferred=true and persisted
// the new isolationMode without performing the live runtime transition —
// confusing UX and active-task safety check was bypassed. The new
// behavior throws 503 immediately so the user gets actionable guidance
// (restart the dashboard or force-enable HybridExecutor) and the stored
// isolationMode stays consistent with the live runtime.
const res = await request( const res = await request(
createApp(), createApp(),
"PATCH", "PATCH",
@@ -74,8 +80,8 @@ describe("project isolation transition route", () => {
JSON.stringify({ isolationMode: "child-process" }), JSON.stringify({ isolationMode: "child-process" }),
{ "content-type": "application/json" }, { "content-type": "application/json" },
); );
expect(res.status).toBe(200); expect(res.status).toBe(503);
expect((res.body as any).transitionDeferred).toBe(true); expect((res.body as { error?: string }).error).toBe("isolation_transition_unavailable");
}); });
it("returns 409 on active_tasks without force and succeeds with force", async () => { it("returns 409 on active_tasks without force and succeeds with force", async () => {

View File

@@ -647,7 +647,20 @@ export const registerProjectRoutes: ApiRouteRegistrar = (ctx) => {
} }
delete updates.isolationMode; delete updates.isolationMode;
} else { } else {
transitionDeferred = true; // No HybridExecutor available (local-only single-node setup).
// The previous behavior here was to set transitionDeferred=true
// and silently persist the new isolationMode while the live
// ProjectEngine continued under the old isolation — confusing,
// and the active-tasks safety check was also bypassed.
// Surface the limitation explicitly so the UI can show a clear
// error and the user can either restart the dashboard (which
// picks up the new isolationMode on next ProjectEngine start)
// or force-enable HybridExecutor via FUSION_HYBRID_EXECUTOR=1.
throw new ApiError(503, "isolation_transition_unavailable", {
error: "isolation_transition_unavailable",
message:
"Live isolation mode transition requires HybridExecutor, which is disabled on local-only single-node setups. Restart the dashboard to apply the new isolation mode for this project, or set FUSION_HYBRID_EXECUTOR=1 to enable live transitions.",
});
} }
} }

View File

@@ -48,20 +48,22 @@ describe("shouldUseHybridExecutor", () => {
expect(decision).toEqual({ enabled: true, reason: "multi-node" }); expect(decision).toEqual({ enabled: true, reason: "multi-node" });
}); });
it("enables for multi-project active/initializing", async () => { it("does NOT enable for local-only multi-project (ProjectEngineManager handles it)", async () => {
delete process.env.FUSION_HYBRID_EXECUTOR; delete process.env.FUSION_HYBRID_EXECUTOR;
const decision = await shouldUseHybridExecutor( const decision = await shouldUseHybridExecutor(
createMockCentralCore({ createMockCentralCore({
listProjects: async () => [{ status: "active" }, { status: "initializing" }], listProjects: async () => [{ status: "active" }, { status: "initializing" }],
}), }),
); );
expect(decision).toEqual({ enabled: true, reason: "multi-project" }); // HybridExecutor's value is cross-node routing. Local-only N-project
// setups don't need it — running it duplicates InProcessRuntime creation.
expect(decision).toEqual({ enabled: false, reason: "single-node-local-only" });
}); });
it("disables for single local project", async () => { it("disables for local-only single-node setup", async () => {
delete process.env.FUSION_HYBRID_EXECUTOR; delete process.env.FUSION_HYBRID_EXECUTOR;
const decision = await shouldUseHybridExecutor(createMockCentralCore()); const decision = await shouldUseHybridExecutor(createMockCentralCore());
expect(decision).toEqual({ enabled: false, reason: "single-project-local-only" }); expect(decision).toEqual({ enabled: false, reason: "single-node-local-only" });
}); });
it("disables when central APIs throw", async () => { it("disables when central APIs throw", async () => {

View File

@@ -110,7 +110,7 @@ describe("HybridExecutor multi-node routing", () => {
} }
}); });
it("enables multi-project on a single node", async () => { it("does NOT auto-enable for local-only multi-project (set FUSION_HYBRID_EXECUTOR=1 to force)", async () => {
const central = new CentralCore(tempDir); const central = new CentralCore(tempDir);
await central.init(); await central.init();
try { try {
@@ -124,8 +124,12 @@ describe("HybridExecutor multi-node routing", () => {
await central.updateProject(projectA.id, { status: "active" }); await central.updateProject(projectA.id, { status: "active" });
await central.updateProject(projectB.id, { status: "initializing" }); await central.updateProject(projectB.id, { status: "initializing" });
await expect(shouldUseHybridExecutor(central)).resolves.toEqual({ enabled: true, reason: "multi-project" }); // Gate intentionally OFF: ProjectEngineManager handles local
// multi-project; running HybridExecutor here duplicates InProcessRuntime
// creation and adds ~7s to cold start.
await expect(shouldUseHybridExecutor(central)).resolves.toEqual({ enabled: false, reason: "single-node-local-only" });
// Force-enabled path still works when explicitly initialized.
const executor = new HybridExecutor(central); const executor = new HybridExecutor(central);
await executor.initialize(); await executor.initialize();
expect(new Set(executor.getProjectIds())).toEqual(new Set([projectA.id, projectB.id])); expect(new Set(executor.getProjectIds())).toEqual(new Set([projectA.id, projectB.id]));

View File

@@ -65,11 +65,11 @@ describe("hybrid executor startup integration", () => {
else process.env.FUSION_HYBRID_EXECUTOR = originalEnv; else process.env.FUSION_HYBRID_EXECUTOR = originalEnv;
}); });
it("disables gate for single local project", async () => { it("disables gate for local-only single-node setup", async () => {
const central = createCentralCore(); const central = createCentralCore();
await expect(shouldUseHybridExecutor(central)).resolves.toEqual({ await expect(shouldUseHybridExecutor(central)).resolves.toEqual({
enabled: false, enabled: false,
reason: "single-project-local-only", reason: "single-node-local-only",
}); });
}); });

View File

@@ -29,13 +29,13 @@ export async function shouldUseHybridExecutor(centralCore: CentralCore): Promise
return { enabled: true, reason: "multi-node" }; return { enabled: true, reason: "multi-node" };
} }
const projects = await centralCore.listProjects(); // Local-only single-node: HybridExecutor's value is cross-node routing.
const liveProjects = projects.filter((project) => project.status === "active" || project.status === "initializing"); // ProjectEngineManager already handles N local projects with one
if (liveProjects.length > 1) { // InProcessRuntime per project; running HybridExecutor in parallel just
return { enabled: true, reason: "multi-project" }; // creates a second InProcessRuntime per project, duplicating self-healing
} // recovery and adding ~7s to cold start. Skip until a remote node is
// registered (set FUSION_HYBRID_EXECUTOR=1 to force-enable).
return { enabled: false, reason: "single-project-local-only" }; return { enabled: false, reason: "single-node-local-only" };
} catch { } catch {
return { enabled: false, reason: "central-unavailable" }; return { enabled: false, reason: "central-unavailable" };
} }