fix(dashboard): start engine for every registered project on first access

Tasks in projects other than the primary (cwd) project were never triaged
because only one ProjectEngine was started. When a project is accessed via
?projectId= API/SSE, getOrCreateProjectStore created a TaskStore but left
the Scheduler, TriageProcessor, and TaskExecutor unstarted.

Fix: introduce setOnProjectFirstCreated callback in project-store-resolver
so the dashboard server is notified when any new project is first accessed.
dashboard.ts creates a ProjectManager that lazily starts an InProcessRuntime
(Scheduler + TriageProcessor + TaskExecutor) for each project the first time
it is accessed — works for any number of registered projects.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-12 21:58:19 -07:00
parent 91c167d856
commit 897398a3fc
5 changed files with 568 additions and 831 deletions

View File

@@ -191,6 +191,11 @@ vi.mock("@fusion/engine", async (importOriginal) => {
registerAgent: vi.fn(),
getRegisteredAgents: vi.fn().mockReturnValue([]),
})),
ProjectManager: vi.fn().mockImplementation(() => ({
getRuntime: vi.fn().mockReturnValue(undefined),
addProject: vi.fn().mockResolvedValue({}),
stopAll: vi.fn().mockResolvedValue(undefined),
})),
};
});
@@ -481,4 +486,160 @@ describe("runDashboard — Plugin wiring", () => {
});
});
describe("runDashboard — per-project engine manager (multi-project)", () => {
beforeEach(async () => {
vi.clearAllMocks();
mockDiscoverAndLoadExtensions.mockResolvedValue({
runtime: { pendingProviderRegistrations: [] },
errors: [],
});
const { TaskStore } = await import("@fusion/core");
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => makeMockStore());
});
it("creates a ProjectManager in non-dev mode", async () => {
const { ProjectManager } = await import("@fusion/engine");
await runDashboard(0, {});
expect(ProjectManager).toHaveBeenCalledTimes(1);
});
it("passes onProjectFirstAccessed callback to createServer", async () => {
const { createServer } = await import("@fusion/dashboard");
await runDashboard(0, {});
const serverOpts = (createServer as ReturnType<typeof vi.fn>).mock.calls[0][1];
expect(serverOpts).toHaveProperty("onProjectFirstAccessed");
expect(serverOpts.onProjectFirstAccessed).toBeTypeOf("function");
});
it("onProjectFirstAccessed starts an engine for a new project via ProjectManager", async () => {
const { createServer } = await import("@fusion/dashboard");
const { ProjectManager } = await import("@fusion/engine");
const { CentralCore } = await import("@fusion/core");
const mockProject = {
id: "proj_other",
path: "/other/project",
name: "Other Project",
isolationMode: "in-process",
settings: { maxConcurrent: 2, maxWorktrees: 4 },
};
// Make CentralCore.getProject resolve with a project for the new ID
(CentralCore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
getProjectByPath: vi.fn().mockResolvedValue({ id: "project-1" }),
getProject: vi.fn().mockImplementation((id: string) =>
id === "proj_other" ? Promise.resolve(mockProject) : Promise.resolve(null),
),
}));
const mockAddProject = vi.fn().mockResolvedValue({});
const mockGetRuntime = vi.fn().mockReturnValue(undefined);
(ProjectManager as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
getRuntime: mockGetRuntime,
addProject: mockAddProject,
stopAll: vi.fn().mockResolvedValue(undefined),
}));
await runDashboard(0, {});
const serverOpts = (createServer as ReturnType<typeof vi.fn>).mock.calls[0][1];
const cb: (id: string) => void = serverOpts.onProjectFirstAccessed;
// Simulate the dashboard server encountering a new project
cb("proj_other");
// Allow fire-and-forget promise to settle
await new Promise((r) => setTimeout(r, 0));
expect(mockAddProject).toHaveBeenCalledTimes(1);
expect(mockAddProject).toHaveBeenCalledWith(
expect.objectContaining({
projectId: "proj_other",
workingDirectory: "/other/project",
isolationMode: "in-process",
}),
);
});
it("onProjectFirstAccessed skips the primary project (already managed by ProjectEngine)", async () => {
const { createServer } = await import("@fusion/dashboard");
const { ProjectManager } = await import("@fusion/engine");
const mockAddProject = vi.fn().mockResolvedValue({});
(ProjectManager as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
getRuntime: vi.fn().mockReturnValue(undefined),
addProject: mockAddProject,
stopAll: vi.fn().mockResolvedValue(undefined),
}));
await runDashboard(0, {});
const serverOpts = (createServer as ReturnType<typeof vi.fn>).mock.calls[0][1];
const cb: (id: string) => void = serverOpts.onProjectFirstAccessed;
// The primary project ID is whatever CentralCore.getProjectByPath returned
// In our mock that's "project-1", but runtimeConfig uses cwd as fallback.
// Either way, firing the callback with the primary ID should be a no-op.
// We confirm by firing for a null project — addProject must not be called.
const { CentralCore } = await import("@fusion/core");
(CentralCore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
getProjectByPath: vi.fn().mockResolvedValue({ id: "project-1" }),
getProject: vi.fn().mockResolvedValue(null), // project not found
}));
cb("proj_unknown");
await new Promise((r) => setTimeout(r, 0));
expect(mockAddProject).not.toHaveBeenCalled();
});
it("onProjectFirstAccessed skips if runtime already running for that project", async () => {
const { createServer } = await import("@fusion/dashboard");
const { ProjectManager } = await import("@fusion/engine");
const { CentralCore } = await import("@fusion/core");
const mockProject = { id: "proj_dupe", path: "/dupe", name: "Dupe", isolationMode: "in-process", settings: {} };
(CentralCore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
getProjectByPath: vi.fn().mockResolvedValue({ id: "project-1" }),
getProject: vi.fn().mockResolvedValue(mockProject),
}));
const mockAddProject = vi.fn().mockResolvedValue({});
// getRuntime returns a truthy value → runtime already running
(ProjectManager as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
getRuntime: vi.fn().mockReturnValue({ getStatus: vi.fn().mockReturnValue("active") }),
addProject: mockAddProject,
stopAll: vi.fn().mockResolvedValue(undefined),
}));
await runDashboard(0, {});
const serverOpts = (createServer as ReturnType<typeof vi.fn>).mock.calls[0][1];
const cb: (id: string) => void = serverOpts.onProjectFirstAccessed;
cb("proj_dupe");
await new Promise((r) => setTimeout(r, 0));
expect(mockAddProject).not.toHaveBeenCalled();
});
it("does not create ProjectManager in dev mode", async () => {
const { ProjectManager } = await import("@fusion/engine");
await runDashboard(0, { dev: true });
expect(ProjectManager).not.toHaveBeenCalled();
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -14,6 +14,7 @@ import {
getOrCreateProjectStore,
evictProjectStore,
evictAllProjectStores,
setOnProjectFirstCreated,
} from "../project-store-resolver.js";
import type { TaskStore } from "@fusion/core";
@@ -189,13 +190,98 @@ describe("project-store-resolver", () => {
expect(sameStore).toBe(store);
// Emit events from "mutation path" — same store instance
sameStore.emit("task:created", { id: "FN-001" });
sameStore.emit("task:updated", { id: "FN-001", title: "Updated" });
sameStore.emit("task:created", { id: "FN-001" } as any);
sameStore.emit("task:updated", { id: "FN-001", title: "Updated" } as any);
// SSE listener should have received both events through the shared EventEmitter
expect(events).toEqual(["created:FN-001", "updated:FN-001"]);
});
describe("setOnProjectFirstCreated", () => {
afterEach(() => {
// Always clear the callback so it doesn't bleed into other tests
setOnProjectFirstCreated(undefined);
});
it("fires callback once when a new project store is first created", async () => {
const cb = vi.fn();
setOnProjectFirstCreated(cb);
await getOrCreateProjectStore("proj_cb_new");
expect(cb).toHaveBeenCalledTimes(1);
expect(cb).toHaveBeenCalledWith("proj_cb_new");
});
it("does not fire callback on subsequent cache hits for the same project", async () => {
const cb = vi.fn();
setOnProjectFirstCreated(cb);
await getOrCreateProjectStore("proj_cb_cached");
await getOrCreateProjectStore("proj_cb_cached");
await getOrCreateProjectStore("proj_cb_cached");
expect(cb).toHaveBeenCalledTimes(1);
});
it("fires callback once per unique project (any number of projects)", async () => {
const cb = vi.fn();
setOnProjectFirstCreated(cb);
// Use sequential awaits to avoid concurrent import() race in vitest mock resolution
await getOrCreateProjectStore("proj_multi_1");
await getOrCreateProjectStore("proj_multi_2");
await getOrCreateProjectStore("proj_multi_3");
await getOrCreateProjectStore("proj_multi_4");
await getOrCreateProjectStore("proj_multi_5");
expect(cb).toHaveBeenCalledTimes(5);
expect(cb).toHaveBeenCalledWith("proj_multi_1");
expect(cb).toHaveBeenCalledWith("proj_multi_2");
expect(cb).toHaveBeenCalledWith("proj_multi_3");
expect(cb).toHaveBeenCalledWith("proj_multi_4");
expect(cb).toHaveBeenCalledWith("proj_multi_5");
});
it("deduplicates concurrent calls — callback fires exactly once even under concurrency", async () => {
const cb = vi.fn();
setOnProjectFirstCreated(cb);
await Promise.all([
getOrCreateProjectStore("proj_concurrent_cb"),
getOrCreateProjectStore("proj_concurrent_cb"),
getOrCreateProjectStore("proj_concurrent_cb"),
]);
expect(cb).toHaveBeenCalledTimes(1);
expect(cb).toHaveBeenCalledWith("proj_concurrent_cb");
});
it("stops firing after callback is cleared with undefined", async () => {
const cb = vi.fn();
setOnProjectFirstCreated(cb);
setOnProjectFirstCreated(undefined);
await getOrCreateProjectStore("proj_cb_cleared");
expect(cb).not.toHaveBeenCalled();
});
it("fires for a re-created project after eviction", async () => {
const cb = vi.fn();
setOnProjectFirstCreated(cb);
await getOrCreateProjectStore("proj_cb_evict");
expect(cb).toHaveBeenCalledTimes(1);
evictProjectStore("proj_cb_evict");
createdStores.length = 0;
await getOrCreateProjectStore("proj_cb_evict");
expect(cb).toHaveBeenCalledTimes(2);
});
});
it("SSE stream receives live events via shared store (integration)", async () => {
// This test simulates the full server-side path:
// 1. SSE handler calls getOrCreateProjectStore("proj_sse") → store A
@@ -223,14 +309,14 @@ describe("project-store-resolver", () => {
// Simulate task creation via API (this is what routes.ts does)
const newTask = { id: "FN-100", description: "Integration test task" };
apiStore.emit("task:created", newTask);
apiStore.emit("task:created", newTask as any);
// Simulate task update
const updatedTask = { ...newTask, title: "Updated title" };
apiStore.emit("task:updated", updatedTask);
apiStore.emit("task:updated", updatedTask as any);
// Simulate task move
apiStore.emit("task:moved", { task: updatedTask, from: "triage", to: "todo" });
apiStore.emit("task:moved", { task: updatedTask as any, from: "triage", to: "todo" });
// Assert SSE listener received all events in order
expect(sseMessages).toHaveLength(3);

View File

@@ -37,6 +37,21 @@ const pendingCreations = new Map<string, Promise<TaskStore>>();
*/
const initializedProjects = new Set<string>();
/**
* Optional callback invoked once when a new project store is first created.
* Used by the dashboard server to lazily start an engine for secondary projects.
*/
let _onProjectFirstCreated: ((projectId: string) => void) | undefined;
/**
* Register a callback to be called once when a new project is first accessed.
* The callback fires after the store is cached — exactly once per projectId.
* Pass `undefined` to clear the callback.
*/
export function setOnProjectFirstCreated(cb: ((projectId: string) => void) | undefined): void {
_onProjectFirstCreated = cb;
}
/**
* Get or create a cached TaskStore for the given projectId.
*
@@ -81,6 +96,12 @@ export async function getOrCreateProjectStore(projectId: string): Promise<TaskSt
storeCache.set(projectId, store);
pendingCreations.delete(projectId);
// Notify once that a new project was first accessed
if (_onProjectFirstCreated) {
_onProjectFirstCreated(projectId);
}
return store;
})();

View File

@@ -10,7 +10,7 @@ import { createApiRoutes } from "./routes.js";
import { createSSE } from "./sse.js";
import { rateLimit, RATE_LIMITS } from "./rate-limit.js";
import { ApiError, sendErrorResponse } from "./api-error.js";
import { getOrCreateProjectStore, evictAllProjectStores } from "./project-store-resolver.js";
import { getOrCreateProjectStore, evictAllProjectStores, setOnProjectFirstCreated } from "./project-store-resolver.js";
import { getTerminalService, type TerminalSession, STALE_SESSION_THRESHOLD_MS } from "./terminal-service.js";
import { WebSocketServer, type WebSocket } from "ws";
import { terminalSessionManager } from "./terminal.js";
@@ -66,6 +66,10 @@ process.on("beforeExit", () => {
});
export interface ServerOptions {
/** Optional ProjectEngine — when provided, subsystems (onMerge, automationStore,
* missionAutopilot, missionExecutionLoop, heartbeatMonitor) are derived from it.
* Explicit options still override engine-derived values. */
engine?: import("@fusion/engine").ProjectEngine;
/** Custom merge handler — when provided, used instead of store.mergeTask */
onMerge?: (taskId: string) => Promise<MergeResult>;
/** When true, run API/websocket server only (skip frontend static assets + SPA fallback) */
@@ -135,6 +139,12 @@ export interface ServerOptions {
chatStore?: import("@fusion/core").ChatStore;
/** Optional ChatManager for AI chat message handling */
chatManager?: import("./chat.js").ChatManager;
/**
* Called once when a secondary project (identified by projectId query param)
* is first accessed via a project-scoped API or SSE request. Use this to
* lazily start an engine for that project.
*/
onProjectFirstAccessed?: (projectId: string) => void;
}
type DashboardExpressApp = ReturnType<typeof express> & {
@@ -194,6 +204,44 @@ function shouldScheduleAiSessionCleanup(): boolean {
}
export function createServer(store: TaskStore, options?: ServerOptions): ReturnType<typeof express> {
// ── Derive defaults from engine when provided (explicit options override) ──
const engine = options?.engine;
if (engine) {
if (!options!.onMerge) {
options = { ...options, onMerge: (taskId: string) => engine.onMerge(taskId) };
}
if (!options!.automationStore) {
options = { ...options, automationStore: engine.getAutomationStore() };
}
if (!options!.missionAutopilot) {
const ma = engine.getRuntime().getMissionAutopilot();
if (ma) options = { ...options, missionAutopilot: ma };
}
if (!options!.missionExecutionLoop) {
const mel = engine.getRuntime().getMissionExecutionLoop();
if (mel) options = { ...options, missionExecutionLoop: mel };
}
if (!options!.heartbeatMonitor) {
const hb = engine.getHeartbeatMonitor();
if (hb) {
options = {
...options,
heartbeatMonitor: {
rootDir: engine.getWorkingDirectory(),
startRun: hb.startRun.bind(hb),
executeHeartbeat: hb.executeHeartbeat.bind(hb),
stopRun: hb.stopRun.bind(hb),
},
};
}
}
}
// Register callback for lazy engine startup on secondary projects
if (options?.onProjectFirstAccessed) {
setOnProjectFirstCreated(options.onProjectFirstAccessed);
}
const app = express();
const mutationRateLimit = rateLimit(RATE_LIMITS.mutation);
const setupRateLimit = rateLimit(RATE_LIMITS.api);