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 bb5b5266f2
commit 425f398e15
5 changed files with 568 additions and 831 deletions

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);