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:
15
.changeset/dashboard-startup-perf-and-request-storm-fixes.md
Normal file
15
.changeset/dashboard-startup-perf-and-request-storm-fixes.md
Normal 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.
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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…",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
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,
|
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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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]));
|
||||||
|
|||||||
@@ -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",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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" };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user