fix(FN-2698): load plugins during CLI runtime startup

- Load configured plugins during dashboard startup before launching the UI flow
- Load plugins during serve and daemon startup so runtime hooks are available immediately
- Add targeted CLI command tests for dashboard, serve, and daemon auto-load behavior at startup
- Add a changeset documenting the plugin runtime startup fix for @runfusion/fusion
This commit is contained in:
Fusion
2026-04-27 09:10:14 -07:00
committed by gsxdsm
parent aa325e5491
commit 347cae8e6c
7 changed files with 157 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Load enabled plugins during dashboard, serve, and daemon startup so plugin runtimes are available to agent runtime selection immediately after boot.

View File

@@ -255,6 +255,7 @@ const mocks = vi.hoisted(() => {
const pluginLoaderCtor = vi.fn().mockImplementation(() => {
const pluginLoader = {
loadPlugin: vi.fn().mockResolvedValue(undefined),
loadAllPlugins: vi.fn().mockResolvedValue({ loaded: 0, errors: 0 }),
stopPlugin: vi.fn().mockResolvedValue(undefined),
reloadPlugin: vi.fn().mockResolvedValue(undefined),
getPluginRoutes: vi.fn().mockReturnValue([]),
@@ -647,6 +648,39 @@ describe("runDaemon", () => {
await triggerSignal("SIGINT");
});
it("auto-loads installed plugins during startup", async () => {
const { PluginLoader } = await import("@fusion/core");
await runDaemon({});
const loaderInstance = (PluginLoader as unknown as ReturnType<typeof vi.fn>).mock.results[0]?.value as
| { loadAllPlugins: ReturnType<typeof vi.fn> }
| undefined;
expect(loaderInstance?.loadAllPlugins).toHaveBeenCalledTimes(1);
await triggerSignal("SIGINT");
});
it("continues startup when plugin auto-load fails", async () => {
const { PluginLoader } = await import("@fusion/core");
(PluginLoader as unknown as ReturnType<typeof vi.fn>).mockImplementationOnce(() => ({
loadPlugin: vi.fn().mockResolvedValue(undefined),
loadAllPlugins: vi.fn().mockRejectedValue(new Error("plugin load failed")),
stopPlugin: vi.fn().mockResolvedValue(undefined),
reloadPlugin: vi.fn().mockResolvedValue(undefined),
getPluginRoutes: vi.fn().mockReturnValue([]),
getPlugin: vi.fn(),
getLoadedPlugins: vi.fn().mockReturnValue([]),
}));
await expect(runDaemon({})).resolves.toBeUndefined();
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining("[plugins] Failed to load plugins: plugin load failed")
);
await triggerSignal("SIGINT");
});
it("passes provided token to createServer daemon option", async () => {
const providedToken = "fn_custom_token_1234567890123456";

View File

@@ -1990,6 +1990,54 @@ describe("runDashboard — --dev mode", () => {
});
});
describe("runDashboard — plugin auto-load", () => {
let mockStore: ReturnType<typeof makeMockStore>;
beforeEach(async () => {
vi.clearAllMocks();
resetGitHubMocks();
mockStore = makeMockStore();
const { TaskStore } = await import("@fusion/core");
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => mockStore);
});
it("auto-loads installed plugins during startup", async () => {
const { PluginLoader } = await import("@fusion/core");
await runDashboard(0, { open: false });
const loaderInstance = (PluginLoader as unknown as ReturnType<typeof vi.fn>).mock.results[0]?.value as
| { loadAllPlugins: ReturnType<typeof vi.fn> }
| undefined;
expect(loaderInstance?.loadAllPlugins).toHaveBeenCalledTimes(1);
});
it("continues startup when plugin auto-load fails", async () => {
const { PluginLoader } = await import("@fusion/core");
(PluginLoader as unknown as ReturnType<typeof vi.fn>).mockImplementationOnce(() => {
const emitter = new EventEmitter();
return {
loadPlugin: vi.fn().mockResolvedValue(undefined),
loadAllPlugins: vi.fn().mockRejectedValue(new Error("plugin load failed")),
stopPlugin: vi.fn().mockResolvedValue(undefined),
reloadPlugin: vi.fn().mockResolvedValue(undefined),
getPluginRoutes: vi.fn().mockReturnValue([]),
getPlugin: vi.fn(),
getLoadedPlugins: vi.fn().mockReturnValue([]),
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
emitter.on(event, handler);
}),
off: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
emitter.off(event, handler);
}),
emit: emitter.emit.bind(emitter),
};
});
await expect(runDashboard(0, { open: false })).resolves.toBeDefined();
});
});
describe("runDashboard — merge conflict retry logic", () => {
let mockStore: ReturnType<typeof makeMockStore>;
let consoleSpy: ReturnType<typeof vi.spyOn>;

View File

@@ -281,6 +281,7 @@ const mocks = vi.hoisted(() => {
const pluginLoaderCtor = vi.fn().mockImplementation(() => {
const pluginLoader = {
loadPlugin: vi.fn().mockResolvedValue(undefined),
loadAllPlugins: vi.fn().mockResolvedValue({ loaded: 0, errors: 0 }),
stopPlugin: vi.fn().mockResolvedValue(undefined),
reloadPlugin: vi.fn().mockResolvedValue(undefined),
getPluginRoutes: vi.fn().mockReturnValue([]),
@@ -892,6 +893,41 @@ describe("runServe — Plugin wiring", () => {
await triggerSignal("SIGINT");
});
it("auto-loads installed plugins during startup", async () => {
const { PluginLoader } = await import("@fusion/core");
await runServe(4040, {});
const loaderInstance = (PluginLoader as unknown as ReturnType<typeof vi.fn>).mock.results[0]?.value as
| { loadAllPlugins: ReturnType<typeof vi.fn> }
| undefined;
expect(loaderInstance?.loadAllPlugins).toHaveBeenCalledTimes(1);
await triggerSignal("SIGINT");
});
it("continues startup when plugin auto-load fails", async () => {
const { PluginLoader } = await import("@fusion/core");
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
(PluginLoader as unknown as ReturnType<typeof vi.fn>).mockImplementationOnce(() => ({
loadPlugin: vi.fn().mockResolvedValue(undefined),
loadAllPlugins: vi.fn().mockRejectedValue(new Error("plugin load failed")),
stopPlugin: vi.fn().mockResolvedValue(undefined),
reloadPlugin: vi.fn().mockResolvedValue(undefined),
getPluginRoutes: vi.fn().mockReturnValue([]),
getPlugin: vi.fn(),
getLoadedPlugins: vi.fn().mockReturnValue([]),
}));
await expect(runServe(4040, {})).resolves.toBeUndefined();
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining("[plugins] Failed to load plugins: plugin load failed")
);
await triggerSignal("SIGINT");
errorSpy.mockRestore();
});
it("includes plugin wiring in headless server", async () => {
const { createServer } = await import("@fusion/dashboard");

View File

@@ -369,6 +369,17 @@ export async function runDaemon(opts: DaemonOptions = {}) {
taskStore: store,
});
// Auto-load all enabled plugins so runtime UI (NewAgentDialog, AgentDetailView)
// can discover installed runtimes like Hermes and OpenClaw.
try {
const { loaded, errors } = await pluginLoader.loadAllPlugins();
console.log(`[plugins] Loaded ${loaded} plugins (${errors} errors)`);
} catch (err) {
console.error(
`[plugins] Failed to load plugins: ${err instanceof Error ? err.message : err}`
);
}
// Get subsystems from the cwd engine for the HTTP layer
const heartbeatMonitor = cwdEngine.getRuntime().getHeartbeatMonitor();
const missionAutopilot = cwdEngine.getRuntime().getMissionAutopilot();

View File

@@ -1023,6 +1023,18 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
taskStore: store,
});
// Auto-load all enabled plugins so runtime UI (NewAgentDialog, AgentDetailView)
// can discover installed runtimes like Hermes and OpenClaw.
try {
const { loaded, errors } = await pluginLoader.loadAllPlugins();
logSink.log(`Loaded ${loaded} plugins (${errors} errors)`, "plugins");
} catch (err) {
logSink.log(
`Failed to load plugins: ${err instanceof Error ? err.message : err}`,
"plugins"
);
}
// ── HeartbeatMonitor + HeartbeatTriggerScheduler ──────────────────────
//
// In non-dev mode: obtained from ProjectEngine after engine.start(), which

View File

@@ -422,6 +422,17 @@ export async function runServe(
taskStore: store,
});
// Auto-load all enabled plugins so runtime UI (NewAgentDialog, AgentDetailView)
// can discover installed runtimes like Hermes and OpenClaw.
try {
const { loaded, errors } = await pluginLoader.loadAllPlugins();
console.log(`[plugins] Loaded ${loaded} plugins (${errors} errors)`);
} catch (err) {
console.error(
`[plugins] Failed to load plugins: ${err instanceof Error ? err.message : err}`
);
}
// Get subsystems from the cwd engine for the HTTP layer
const heartbeatMonitor = cwdEngine.getRuntime().getHeartbeatMonitor();
const missionAutopilot = cwdEngine.getRuntime().getMissionAutopilot();