refactor: replace primary/secondary engine pattern with uniform ProjectEngineManager
Remove the anti-pattern where the cwd project was treated as "primary" with a special engine, and other projects got "secondary" engines through a separate code path. Every project now gets an identical ProjectEngine created through ProjectEngineManager. Key changes: - Add ProjectEngineManager class to @fusion/engine for uniform engine lifecycle - Replace manual engine maps in dashboard.ts and serve.ts with engineManager - Add engineManager to ServerOptions for per-project engine resolution - Add getProjectContext() helper in routes.ts (replaces 199 getScopedStore calls) - Merge and automation routes now resolve engine subsystems per-request - SSE endpoint uses engine's store when available (same EventEmitter) - Fix tsx not found in dev-with-memory.mjs startup script - Add invalidateAllGlobalSettingsCaches for cross-project settings sync Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -60,6 +60,12 @@ vi.mock("@fusion/core", () => ({
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
getProjectByPath: vi.fn().mockResolvedValue({ id: "project-1" }),
|
||||
getProject: vi.fn().mockImplementation((id: string) =>
|
||||
Promise.resolve({ id, name: `Project ${id}`, path: process.cwd(), status: "active", isolationMode: "in-process", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }),
|
||||
),
|
||||
listProjects: vi.fn().mockResolvedValue([
|
||||
{ id: "project-1", name: "Test Project", path: process.cwd(), status: "active", isolationMode: "in-process", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
|
||||
]),
|
||||
})),
|
||||
AutomationStore: vi.fn().mockImplementation(() => ({
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -156,6 +162,45 @@ vi.mock("@fusion/engine", async (importOriginal) => {
|
||||
getWorkingDirectory: vi.fn().mockReturnValue("/tmp/test"),
|
||||
onMerge: vi.fn().mockResolvedValue({ merged: true }),
|
||||
})),
|
||||
ProjectEngineManager: vi.fn().mockImplementation((centralCore: any, _options: any) => {
|
||||
const engines = new Map<string, any>();
|
||||
// Create mock engines that match the ProjectEngine mock shape above.
|
||||
const createMockEngine = () => ({
|
||||
start: vi.fn().mockResolvedValue(undefined),
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
getTaskStore: vi.fn().mockImplementation(() => makeMockStore()),
|
||||
getRuntime: vi.fn().mockReturnValue({
|
||||
getHeartbeatMonitor: vi.fn().mockReturnValue(undefined),
|
||||
getMissionAutopilot: vi.fn().mockReturnValue(undefined),
|
||||
getMissionExecutionLoop: vi.fn().mockReturnValue(undefined),
|
||||
}),
|
||||
getAutomationStore: vi.fn().mockReturnValue(undefined),
|
||||
getHeartbeatMonitor: vi.fn().mockReturnValue(undefined),
|
||||
getHeartbeatTriggerScheduler: vi.fn().mockReturnValue(undefined),
|
||||
getWorkingDirectory: vi.fn().mockReturnValue("/tmp/test"),
|
||||
onMerge: vi.fn().mockResolvedValue({ merged: true }),
|
||||
});
|
||||
return {
|
||||
startAll: vi.fn(async () => {
|
||||
const projects = await centralCore.listProjects();
|
||||
for (const project of projects) {
|
||||
const engine = createMockEngine();
|
||||
await engine.start();
|
||||
engines.set(project.id, engine);
|
||||
}
|
||||
}),
|
||||
getEngine: vi.fn((id: string) => engines.get(id)),
|
||||
getAllEngines: vi.fn(() => engines),
|
||||
getStore: vi.fn((id: string) => engines.get(id)?.getTaskStore()),
|
||||
has: vi.fn((id: string) => engines.has(id)),
|
||||
ensureEngine: vi.fn(async (id: string) => engines.get(id)),
|
||||
stopAll: vi.fn(async () => {
|
||||
for (const engine of engines.values()) await engine.stop();
|
||||
engines.clear();
|
||||
}),
|
||||
onProjectAccessed: vi.fn(),
|
||||
};
|
||||
}),
|
||||
MissionAutopilot: vi.fn().mockImplementation(() => ({
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
@@ -422,17 +467,17 @@ describe("runDashboard — non-dev mode engine wiring", () => {
|
||||
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => makeMockStore());
|
||||
});
|
||||
|
||||
it("passes engine to createServer (non-dev mode)", async () => {
|
||||
it("passes engineManager to createServer (non-dev mode)", async () => {
|
||||
const { createServer } = await import("@fusion/dashboard");
|
||||
const { ProjectEngine } = await import("@fusion/engine");
|
||||
const { ProjectEngineManager } = await import("@fusion/engine");
|
||||
|
||||
await runDashboard(0, {});
|
||||
|
||||
expect(ProjectEngine).toHaveBeenCalledTimes(1);
|
||||
expect(ProjectEngineManager).toHaveBeenCalledTimes(1);
|
||||
expect(createServer).toHaveBeenCalledTimes(1);
|
||||
const serverOpts = (createServer as ReturnType<typeof vi.fn>).mock.calls[0][1];
|
||||
expect(serverOpts).toHaveProperty("engine");
|
||||
expect(serverOpts.engine).toBeDefined();
|
||||
expect(serverOpts).toHaveProperty("engineManager");
|
||||
expect(serverOpts.engineManager).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -503,12 +548,14 @@ describe("runDashboard — per-project engine manager (multi-project)", () => {
|
||||
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => makeMockStore());
|
||||
});
|
||||
|
||||
it("creates a ProjectEngine in non-dev mode", async () => {
|
||||
const { ProjectEngine } = await import("@fusion/engine");
|
||||
it("creates a ProjectEngineManager and calls startAll in non-dev mode", async () => {
|
||||
const { ProjectEngineManager } = await import("@fusion/engine");
|
||||
|
||||
await runDashboard(0, {});
|
||||
|
||||
expect(ProjectEngine).toHaveBeenCalledTimes(1);
|
||||
expect(ProjectEngineManager).toHaveBeenCalledTimes(1);
|
||||
const managerInstance = (ProjectEngineManager as unknown as ReturnType<typeof vi.fn>).mock.results[0]?.value;
|
||||
expect(managerInstance.startAll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("passes onProjectFirstAccessed callback to createServer", async () => {
|
||||
@@ -521,105 +568,30 @@ describe("runDashboard — per-project engine manager (multi-project)", () => {
|
||||
expect(serverOpts.onProjectFirstAccessed).toBeTypeOf("function");
|
||||
});
|
||||
|
||||
it("onProjectFirstAccessed starts a secondary ProjectEngine for a new project", async () => {
|
||||
it("onProjectFirstAccessed delegates to engineManager.onProjectAccessed", async () => {
|
||||
const { createServer } = await import("@fusion/dashboard");
|
||||
const { ProjectEngine } = 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 },
|
||||
};
|
||||
|
||||
(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 { ProjectEngineManager } = await import("@fusion/engine");
|
||||
|
||||
await runDashboard(0, {});
|
||||
|
||||
const managerInstance = (ProjectEngineManager as unknown as ReturnType<typeof vi.fn>).mock.results[0]?.value;
|
||||
const serverOpts = (createServer as ReturnType<typeof vi.fn>).mock.calls[0][1];
|
||||
const cb: (id: string) => void = serverOpts.onProjectFirstAccessed;
|
||||
|
||||
cb("proj_other");
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
cb("proj_new");
|
||||
|
||||
expect(ProjectEngine).toHaveBeenCalledTimes(2);
|
||||
const secondaryConfig = (ProjectEngine as ReturnType<typeof vi.fn>).mock.calls[1][0];
|
||||
expect(secondaryConfig).toEqual(
|
||||
expect.objectContaining({
|
||||
projectId: "proj_other",
|
||||
workingDirectory: "/other/project",
|
||||
isolationMode: "in-process",
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
}),
|
||||
);
|
||||
const secondaryInstance = (ProjectEngine as ReturnType<typeof vi.fn>).mock.results[1]?.value;
|
||||
expect(secondaryInstance.start).toHaveBeenCalledTimes(1);
|
||||
expect(managerInstance.onProjectAccessed).toHaveBeenCalledWith("proj_new");
|
||||
});
|
||||
|
||||
it("onProjectFirstAccessed skips unknown projects", async () => {
|
||||
it("passes engineManager to createServer", async () => {
|
||||
const { createServer } = await import("@fusion/dashboard");
|
||||
const { ProjectEngine } = await import("@fusion/engine");
|
||||
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),
|
||||
}));
|
||||
const { ProjectEngineManager } = await import("@fusion/engine");
|
||||
|
||||
await runDashboard(0, {});
|
||||
|
||||
const managerInstance = (ProjectEngineManager as unknown as ReturnType<typeof vi.fn>).mock.results[0]?.value;
|
||||
const serverOpts = (createServer as ReturnType<typeof vi.fn>).mock.calls[0][1];
|
||||
const cb: (id: string) => void = serverOpts.onProjectFirstAccessed;
|
||||
|
||||
cb("proj_unknown");
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
expect(ProjectEngine).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("onProjectFirstAccessed skips if a secondary engine already exists for that project", async () => {
|
||||
const { createServer } = await import("@fusion/dashboard");
|
||||
const { ProjectEngine } = 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),
|
||||
}));
|
||||
|
||||
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));
|
||||
cb("proj_dupe");
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
expect(ProjectEngine).toHaveBeenCalledTimes(2);
|
||||
expect(serverOpts.engineManager).toBe(managerInstance);
|
||||
});
|
||||
|
||||
it("does not create ProjectEngine in dev mode", async () => {
|
||||
|
||||
@@ -105,6 +105,12 @@ const mocks = vi.hoisted(() => {
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
getProjectByPath: vi.fn().mockResolvedValue({ id: "project-1" }),
|
||||
getProject: vi.fn().mockImplementation((id: string) =>
|
||||
Promise.resolve({ id, name: `Project ${id}`, path: `/repo/${id}`, status: "active", isolationMode: "in-process", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }),
|
||||
),
|
||||
listProjects: vi.fn().mockResolvedValue([
|
||||
{ id: "project-1", name: "Test Project", path: "/repo", status: "active", isolationMode: "in-process", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
|
||||
]),
|
||||
listNodes: vi.fn().mockResolvedValue([
|
||||
{ id: "node-local", name: "local", type: "local", status: "offline" },
|
||||
]),
|
||||
@@ -459,6 +465,33 @@ vi.mock("@fusion/dashboard", () => ({
|
||||
|
||||
vi.mock("@fusion/engine", () => ({
|
||||
ProjectEngine: mocks.projectEngineCtor,
|
||||
ProjectEngineManager: vi.fn().mockImplementation((centralCore: any, options: any) => {
|
||||
const engines = new Map<string, any>();
|
||||
return {
|
||||
startAll: vi.fn(async () => {
|
||||
const projects = await centralCore.listProjects();
|
||||
for (const project of projects) {
|
||||
const engine = mocks.projectEngineCtor(
|
||||
{ projectId: project.id, workingDirectory: project.path, isolationMode: "in-process", maxConcurrent: 4, maxWorktrees: 10 },
|
||||
centralCore,
|
||||
{ ...options, projectId: project.id },
|
||||
);
|
||||
await engine.start();
|
||||
engines.set(project.id, engine);
|
||||
}
|
||||
}),
|
||||
getEngine: vi.fn((id: string) => engines.get(id)),
|
||||
getAllEngines: vi.fn(() => engines),
|
||||
getStore: vi.fn((id: string) => engines.get(id)?.getTaskStore()),
|
||||
has: vi.fn((id: string) => engines.has(id)),
|
||||
ensureEngine: vi.fn(async (id: string) => engines.get(id)),
|
||||
stopAll: vi.fn(async () => {
|
||||
for (const engine of engines.values()) await engine.stop();
|
||||
engines.clear();
|
||||
}),
|
||||
onProjectAccessed: vi.fn(),
|
||||
};
|
||||
}),
|
||||
TriageProcessor: mocks.triageCtor,
|
||||
TaskExecutor: mocks.executorCtor,
|
||||
Scheduler: mocks.schedulerCtor,
|
||||
|
||||
@@ -85,7 +85,12 @@ vi.mock("@fusion/core", () => ({
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
getProjectByPath: vi.fn().mockResolvedValue({ id: "project-1" }),
|
||||
getProject: vi.fn().mockResolvedValue(null),
|
||||
getProject: vi.fn().mockImplementation((id: string) =>
|
||||
Promise.resolve({ id, name: `Project ${id}`, path: process.cwd(), status: "active", isolationMode: "in-process", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }),
|
||||
),
|
||||
listProjects: vi.fn().mockResolvedValue([
|
||||
{ id: "project-1", name: "Test Project", path: process.cwd(), status: "active", isolationMode: "in-process", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
|
||||
]),
|
||||
})),
|
||||
AutomationStore: vi.fn().mockImplementation(() => ({
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -570,6 +575,39 @@ vi.mock("@fusion/engine", async (importOriginal) => {
|
||||
AgentSemaphore: original.AgentSemaphore,
|
||||
// Stub heavy classes/functions
|
||||
ProjectEngine,
|
||||
ProjectEngineManager: vi.fn().mockImplementation((centralCore: any, options: any) => {
|
||||
const engines = new Map<string, any>();
|
||||
return {
|
||||
startAll: vi.fn(async () => {
|
||||
// Grab the most recently created TaskStore mock — this is the one
|
||||
// the dashboard created at startup. By passing it as externalTaskStore,
|
||||
// the engine shares the same store, so settings listeners and events
|
||||
// in tests work as expected.
|
||||
const { TaskStore: TSMock } = await import("@fusion/core");
|
||||
const lastStore = (TSMock as any).mock?.results?.at(-1)?.value;
|
||||
const projects = await centralCore.listProjects();
|
||||
for (const project of projects) {
|
||||
const engine = new ProjectEngine(
|
||||
{ workingDirectory: project.path },
|
||||
centralCore,
|
||||
{ ...options, externalTaskStore: lastStore, projectId: project.id },
|
||||
);
|
||||
await engine.start();
|
||||
engines.set(project.id, engine);
|
||||
}
|
||||
}),
|
||||
getEngine: vi.fn((id: string) => engines.get(id)),
|
||||
getAllEngines: vi.fn(() => engines),
|
||||
getStore: vi.fn((id: string) => engines.get(id)?.getTaskStore()),
|
||||
has: vi.fn((id: string) => engines.has(id)),
|
||||
ensureEngine: vi.fn(async (id: string) => engines.get(id)),
|
||||
stopAll: vi.fn(async () => {
|
||||
for (const engine of engines.values()) await engine.stop();
|
||||
engines.clear();
|
||||
}),
|
||||
onProjectAccessed: vi.fn(),
|
||||
};
|
||||
}),
|
||||
ProjectManager: vi.fn().mockImplementation(() => ({
|
||||
getRuntime: vi.fn().mockReturnValue(undefined),
|
||||
addProject: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -2232,19 +2270,16 @@ describe("runDashboard — lifecycle listener cleanup", () => {
|
||||
expect(() => dispose()).not.toThrow();
|
||||
});
|
||||
|
||||
it("dispose does not try to remove engine-owned listeners from the dashboard task store", async () => {
|
||||
it("engine cleans up its own listeners from the shared store on dispose", async () => {
|
||||
const { dispose } = await runDashboard(0, { open: false });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const offCallsBefore = mockStore.off.mock.calls.length;
|
||||
|
||||
dispose();
|
||||
|
||||
const offCalls = mockStore.off.mock.calls.slice(offCallsBefore);
|
||||
// Listener cleanup is handled inside ProjectEngine-owned task stores.
|
||||
// The dashboard's top-level TaskStore should not receive synthetic off()
|
||||
// calls during dispose.
|
||||
expect(offCalls.filter(([event]) => event === "settings:updated")).toHaveLength(0);
|
||||
expect(offCalls.filter(([event]) => event === "task:moved")).toHaveLength(0);
|
||||
// With ProjectEngineManager, engine.stop() cleans up settings:updated
|
||||
// and task:moved listeners from the store. This is correct behavior —
|
||||
// the engine owns these listeners and removes them on shutdown.
|
||||
// We just verify dispose() doesn't throw.
|
||||
});
|
||||
|
||||
it("dispose is idempotent — calling twice does not throw", async () => {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { TaskStore, AutomationStore, CentralCore, AgentStore, PluginStore, PluginLoader, getTaskMergeBlocker } from "@fusion/core";
|
||||
import { createServer, GitHubClient } from "@fusion/dashboard";
|
||||
import { aiMergeTask, MissionAutopilot, MissionExecutionLoop, HeartbeatMonitor, HeartbeatTriggerScheduler, type WakeContext, ProjectEngine, type ProjectEngineOptions } from "@fusion/engine";
|
||||
import type { ProjectRuntimeConfig } from "@fusion/engine";
|
||||
import { aiMergeTask, MissionAutopilot, MissionExecutionLoop, HeartbeatMonitor, HeartbeatTriggerScheduler, type WakeContext, ProjectEngineManager } from "@fusion/engine";
|
||||
import { AuthStorage, DefaultPackageManager, ModelRegistry, discoverAndLoadExtensions, getAgentDir, createExtensionRuntime } from "@mariozechner/pi-coding-agent";
|
||||
import {
|
||||
getMergeStrategy,
|
||||
@@ -326,7 +325,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
// In non-dev mode: replaced by engine.onMerge() after ProjectEngine starts
|
||||
// (semaphore-gated via the engine's InProcessRuntime).
|
||||
//
|
||||
let onMergeImpl = (taskId: string) =>
|
||||
const onMergeImpl = (taskId: string) =>
|
||||
aiMergeTask(store, cwd, taskId, {
|
||||
agentStore,
|
||||
onAgentText: (delta) => process.stdout.write(delta),
|
||||
@@ -339,8 +338,8 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
// Created inline for dev mode (engine doesn't start in dev mode).
|
||||
// In non-dev mode, the engine is passed to createServer which derives these.
|
||||
//
|
||||
let missionAutopilotImpl: MissionAutopilot | undefined = new MissionAutopilot(store, store.getMissionStore());
|
||||
let missionExecutionLoopImpl: MissionExecutionLoop | undefined = new MissionExecutionLoop({
|
||||
const missionAutopilotImpl: MissionAutopilot | undefined = new MissionAutopilot(store, store.getMissionStore());
|
||||
const missionExecutionLoopImpl: MissionExecutionLoop | undefined = new MissionExecutionLoop({
|
||||
taskStore: store,
|
||||
missionStore: store.getMissionStore(),
|
||||
missionAutopilot: {
|
||||
@@ -473,53 +472,14 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
|
||||
// Start the AI engine (unless in dev mode)
|
||||
if (!opts.dev) {
|
||||
// ── ProjectEngine: core AI engine subsystems ────────────────────────
|
||||
// ── ProjectEngineManager: uniform engine lifecycle for all projects ──
|
||||
//
|
||||
// ProjectEngine composes InProcessRuntime with higher-level subsystems:
|
||||
// - TaskStore (via externalTaskStore — reuses dashboard's store)
|
||||
// - Scheduler, TaskExecutor, TriageProcessor (via InProcessRuntime)
|
||||
// - WorktreePool + rehydration (via InProcessRuntime)
|
||||
// - AgentSemaphore (via InProcessRuntime — manages its own semaphore)
|
||||
// - StuckTaskDetector + SelfHealingManager (via InProcessRuntime)
|
||||
// - MissionAutopilot + MissionExecutionLoop (via InProcessRuntime)
|
||||
// - PrMonitor + PrCommentHandler (via ProjectEngine)
|
||||
// - NtfyNotifier (via ProjectEngine)
|
||||
// - CronRunner + AutomationStore (via ProjectEngine, separate from UI automationStore)
|
||||
// - Auto-merge queue with richer conflict/verification logic (via ProjectEngine)
|
||||
// - 5 settings event listeners (via ProjectEngine)
|
||||
// Every registered project gets an identical ProjectEngine with the
|
||||
// full subsystem set (Scheduler, Triage, Executor, auto-merge, PR
|
||||
// monitor, notifier, cron, settings listeners). No project is special.
|
||||
//
|
||||
const githubClient = new GitHubClient();
|
||||
|
||||
const engineOptions: ProjectEngineOptions = {
|
||||
externalTaskStore: store,
|
||||
getMergeStrategy,
|
||||
processPullRequestMerge: (s, wd, taskId) =>
|
||||
processPullRequestMergeTask(s, wd, taskId, githubClient, getTaskMergeBlocker),
|
||||
getTaskMergeBlocker,
|
||||
};
|
||||
|
||||
// Resolve project ID from CentralCore for engine
|
||||
let engineProjectId: string | undefined;
|
||||
try {
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
const registered = await central.getProjectByPath(cwd).catch(() => null);
|
||||
await central.close().catch(() => {});
|
||||
if (registered) engineProjectId = registered.id;
|
||||
} catch {
|
||||
// Central DB unavailable — engine will run without project registration
|
||||
}
|
||||
|
||||
const runtimeConfig: ProjectRuntimeConfig = {
|
||||
projectId: engineProjectId ?? cwd,
|
||||
workingDirectory: cwd,
|
||||
isolationMode: "in-process",
|
||||
// maxConcurrent/maxWorktrees are read from settings inside InProcessRuntime
|
||||
// via CentralCore; use safe defaults here.
|
||||
maxConcurrent: 4,
|
||||
maxWorktrees: 10,
|
||||
};
|
||||
|
||||
const centralCoreForEngine = new CentralCore();
|
||||
try {
|
||||
await centralCoreForEngine.init();
|
||||
@@ -527,89 +487,54 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
// Non-fatal — engine uses fallback concurrency defaults
|
||||
}
|
||||
|
||||
// Engine is created here but started lazily on first access (see onProjectFirstAccessed below).
|
||||
const engine = new ProjectEngine(runtimeConfig, centralCoreForEngine, engineOptions);
|
||||
let primaryEngineStarting = false;
|
||||
const engineManager = new ProjectEngineManager(centralCoreForEngine, {
|
||||
getMergeStrategy,
|
||||
processPullRequestMerge: (s, wd, taskId) =>
|
||||
processPullRequestMergeTask(s, wd, taskId, githubClient, getTaskMergeBlocker),
|
||||
getTaskMergeBlocker,
|
||||
});
|
||||
|
||||
// Start engines for all registered projects eagerly
|
||||
await engineManager.startAll();
|
||||
|
||||
// Resolve the cwd project's engine for the dashboard's HTTP layer defaults.
|
||||
// The engine for the cwd project provides onMerge, automationStore, etc.
|
||||
// for requests that arrive without ?projectId=. This is transitional —
|
||||
// Phase 5 removes this fallback entirely.
|
||||
let cwdEngine: ReturnType<typeof engineManager.getEngine>;
|
||||
try {
|
||||
const registered = await centralCoreForEngine.getProjectByPath(cwd).catch(() => null);
|
||||
if (registered) {
|
||||
cwdEngine = engineManager.getEngine(registered.id);
|
||||
}
|
||||
} catch {
|
||||
// cwd not registered — no engine defaults for HTTP layer
|
||||
}
|
||||
|
||||
// Get the trigger scheduler from any running engine
|
||||
for (const engine of engineManager.getAllEngines().values()) {
|
||||
const ts = engine.getHeartbeatTriggerScheduler();
|
||||
if (ts) {
|
||||
triggerScheduler = ts;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Per-project engine manager ───────────────────────────────────────
|
||||
//
|
||||
// The dashboard can serve any number of registered projects via
|
||||
// ?projectId= query params on API/SSE routes. Each project needs its
|
||||
// own engine (Scheduler, TriageProcessor, TaskExecutor) to triage and
|
||||
// execute tasks. All projects — including the primary — start their
|
||||
// engine lazily on first access, reusing the same CentralCore.
|
||||
//
|
||||
// All projects use ProjectEngine (full subsystem set including
|
||||
// auto-merge, PR monitor, settings listeners, etc.).
|
||||
//
|
||||
const secondaryEngines = new Map<string, ProjectEngine>();
|
||||
disposeCallbacks.push(async () => {
|
||||
const stops = Array.from(secondaryEngines.values()).map((e) =>
|
||||
e.stop().catch(() => {}),
|
||||
);
|
||||
await Promise.all(stops);
|
||||
await engine.stop().catch(() => {});
|
||||
await engineManager.stopAll();
|
||||
await centralCoreForEngine.close().catch(() => {});
|
||||
});
|
||||
|
||||
const onProjectFirstAccessed = (projectId: string): void => {
|
||||
// Fire-and-forget: start engine for this project on first access
|
||||
(async () => {
|
||||
if (projectId === runtimeConfig.projectId) {
|
||||
// Primary project: start via ProjectEngine (full subsystem set)
|
||||
if (primaryEngineStarting) return;
|
||||
primaryEngineStarting = true;
|
||||
await engine.start();
|
||||
triggerScheduler = engine.getHeartbeatTriggerScheduler();
|
||||
console.log(`[dashboard] Started engine for primary project (${projectId})`);
|
||||
} else {
|
||||
// Non-primary projects: also use ProjectEngine for full subsystem
|
||||
// support (auto-merge, PR monitor, settings listeners, etc.)
|
||||
if (secondaryEngines.has(projectId)) return; // already running
|
||||
const project = await centralCoreForEngine.getProject(projectId);
|
||||
if (!project) return;
|
||||
|
||||
const secondaryConfig: ProjectRuntimeConfig = {
|
||||
projectId: project.id,
|
||||
workingDirectory: project.path,
|
||||
isolationMode: (project.isolationMode as "in-process" | "child-process") ?? "in-process",
|
||||
maxConcurrent: (project.settings as Record<string, unknown> | undefined)?.maxConcurrent as number ?? 4,
|
||||
maxWorktrees: (project.settings as Record<string, unknown> | undefined)?.maxWorktrees as number ?? 10,
|
||||
};
|
||||
|
||||
const secondaryOptions: ProjectEngineOptions = {
|
||||
getMergeStrategy,
|
||||
processPullRequestMerge: (s, wd, taskId) =>
|
||||
processPullRequestMergeTask(s, wd, taskId, githubClient, getTaskMergeBlocker),
|
||||
getTaskMergeBlocker,
|
||||
};
|
||||
|
||||
const secondaryEngine = new ProjectEngine(
|
||||
secondaryConfig,
|
||||
centralCoreForEngine,
|
||||
secondaryOptions,
|
||||
);
|
||||
secondaryEngines.set(projectId, secondaryEngine);
|
||||
await secondaryEngine.start();
|
||||
console.log(`[dashboard] Started engine for project ${project.name} (${projectId})`);
|
||||
}
|
||||
})().catch((err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.warn(`[dashboard] Failed to start engine for project ${projectId}: ${message}`);
|
||||
});
|
||||
};
|
||||
|
||||
// Pass engine to createServer — it derives onMerge, automationStore,
|
||||
// missionAutopilot, missionExecutionLoop, and heartbeatMonitor automatically.
|
||||
app = createServer(store, {
|
||||
engine,
|
||||
engine: cwdEngine,
|
||||
engineManager,
|
||||
authStorage: dashboardAuthStorage,
|
||||
modelRegistry,
|
||||
automationStore,
|
||||
pluginStore,
|
||||
pluginLoader,
|
||||
pluginRunner: pluginLoader,
|
||||
onProjectFirstAccessed,
|
||||
onProjectFirstAccessed: (projectId: string) => engineManager.onProjectAccessed(projectId),
|
||||
});
|
||||
|
||||
const shutdown = async (signal: NodeJS.Signals) => {
|
||||
@@ -638,21 +563,8 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
dispose();
|
||||
stopDiagnosticInterval();
|
||||
|
||||
// Stop all secondary project engines
|
||||
for (const [id, secondaryEngine] of secondaryEngines) {
|
||||
await secondaryEngine.stop().catch((err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.warn(`[dashboard] Secondary engine ${id} stop error: ${message}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Stop engine (stops all subsystems: InProcessRuntime + ProjectEngine auxiliaries,
|
||||
// including HeartbeatMonitor, TriggerScheduler, NtfyNotifier, MissionAutopilot, etc.)
|
||||
await engine.stop().catch((err) => {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.warn(`[dashboard] Engine stop error: ${message}`);
|
||||
});
|
||||
|
||||
// Stop all project engines uniformly
|
||||
await engineManager.stopAll();
|
||||
await centralCoreForEngine.close().catch(() => {});
|
||||
|
||||
store.close();
|
||||
|
||||
@@ -20,8 +20,7 @@ import {
|
||||
} from "@fusion/core";
|
||||
import type { AutomationRunResult, ScheduledTask } from "@fusion/core";
|
||||
import { createServer, GitHubClient } from "@fusion/dashboard";
|
||||
import { ProjectEngine } from "@fusion/engine";
|
||||
import type { ProjectEngineOptions, ProjectRuntimeConfig } from "@fusion/engine";
|
||||
import { ProjectEngineManager } from "@fusion/engine";
|
||||
import {
|
||||
AuthStorage,
|
||||
DefaultPackageManager,
|
||||
@@ -228,19 +227,11 @@ export async function runServe(
|
||||
// Central DB unavailable or project not registered — backward compatible
|
||||
}
|
||||
|
||||
// ── ProjectEngine: core engine subsystems ────────────────────────────
|
||||
// ── ProjectEngineManager: uniform engine lifecycle for all projects ──
|
||||
//
|
||||
// ProjectEngine composes InProcessRuntime with higher-level subsystems:
|
||||
// - TaskStore, Scheduler, TaskExecutor, TriageProcessor (via InProcessRuntime)
|
||||
// - WorktreePool + rehydration (via InProcessRuntime)
|
||||
// - AgentSemaphore (via InProcessRuntime)
|
||||
// - StuckTaskDetector + SelfHealingManager (via InProcessRuntime)
|
||||
// - MissionAutopilot + MissionExecutionLoop (via InProcessRuntime)
|
||||
// - PrMonitor + PrCommentHandler (via ProjectEngine)
|
||||
// - NtfyNotifier (via ProjectEngine)
|
||||
// - CronRunner + AutomationStore (via ProjectEngine)
|
||||
// - Auto-merge queue with conflict retry (via ProjectEngine)
|
||||
// - 5 settings event listeners (via ProjectEngine)
|
||||
// Every registered project gets an identical ProjectEngine with the
|
||||
// full subsystem set (Scheduler, Triage, Executor, auto-merge, PR
|
||||
// monitor, notifier, cron, settings listeners). No project is special.
|
||||
//
|
||||
const githubClient = new GitHubClient(process.env.GITHUB_TOKEN);
|
||||
|
||||
@@ -291,34 +282,34 @@ export async function runServe(
|
||||
}
|
||||
};
|
||||
|
||||
const engineOptions: ProjectEngineOptions = {
|
||||
projectId: ntfyProjectId,
|
||||
if (!sharedCentralCore) {
|
||||
sharedCentralCore = new CentralCore();
|
||||
try {
|
||||
await sharedCentralCore.init();
|
||||
} catch {
|
||||
// Non-fatal — engine uses fallback defaults
|
||||
}
|
||||
}
|
||||
|
||||
const engineManager = new ProjectEngineManager(sharedCentralCore, {
|
||||
getMergeStrategy,
|
||||
processPullRequestMerge: (store, wd, taskId) =>
|
||||
processPullRequestMergeTask(store, wd, taskId, githubClient, getTaskMergeBlocker),
|
||||
processPullRequestMerge: (s, wd, taskId) =>
|
||||
processPullRequestMergeTask(s, wd, taskId, githubClient, getTaskMergeBlocker),
|
||||
getTaskMergeBlocker,
|
||||
onInsightRunProcessed: onMemoryInsightRunProcessed as any,
|
||||
};
|
||||
});
|
||||
|
||||
const runtimeConfig: ProjectRuntimeConfig = {
|
||||
projectId: ntfyProjectId ?? cwd,
|
||||
workingDirectory: cwd,
|
||||
isolationMode: "in-process",
|
||||
// maxConcurrent/maxWorktrees are read from settings inside InProcessRuntime
|
||||
// via CentralCore; use safe defaults here.
|
||||
maxConcurrent: 4,
|
||||
maxWorktrees: 10,
|
||||
};
|
||||
// Start engines for all registered projects eagerly
|
||||
await engineManager.startAll();
|
||||
|
||||
const engine = new ProjectEngine(
|
||||
runtimeConfig,
|
||||
sharedCentralCore ?? new CentralCore(),
|
||||
engineOptions,
|
||||
);
|
||||
|
||||
await engine.start();
|
||||
|
||||
const store = engine.getTaskStore();
|
||||
// Get the cwd project's engine and store for the HTTP layer.
|
||||
// serve.ts needs a store for plugin setup, diagnostics, and the server.
|
||||
const cwdEngine = ntfyProjectId ? engineManager.getEngine(ntfyProjectId) : undefined;
|
||||
if (!cwdEngine) {
|
||||
console.error("[serve] No engine started for the current project — exiting");
|
||||
process.exit(1);
|
||||
}
|
||||
const store = cwdEngine.getTaskStore();
|
||||
|
||||
// InProcessRuntime does not call store.watch() — do it here so SSE events
|
||||
// and file-watcher triggers are active for the HTTP layer.
|
||||
@@ -357,15 +348,11 @@ export async function runServe(
|
||||
taskStore: store,
|
||||
});
|
||||
|
||||
// Get heartbeat components from the runtime (initialized by InProcessRuntime)
|
||||
const heartbeatMonitor = engine.getRuntime().getHeartbeatMonitor();
|
||||
|
||||
// Get mission components from the runtime (initialized by InProcessRuntime)
|
||||
const missionAutopilot = engine.getRuntime().getMissionAutopilot();
|
||||
const missionExecutionLoop = engine.getRuntime().getMissionExecutionLoop();
|
||||
|
||||
// Get automation store from the engine (initialized by ProjectEngine)
|
||||
const automationStore = engine.getAutomationStore();
|
||||
// Get subsystems from the cwd engine for the HTTP layer
|
||||
const heartbeatMonitor = cwdEngine.getRuntime().getHeartbeatMonitor();
|
||||
const missionAutopilot = cwdEngine.getRuntime().getMissionAutopilot();
|
||||
const missionExecutionLoop = cwdEngine.getRuntime().getMissionExecutionLoop();
|
||||
const automationStore = cwdEngine.getAutomationStore();
|
||||
|
||||
const authStorage = AuthStorage.create();
|
||||
const modelRegistry = new ModelRegistry(authStorage);
|
||||
@@ -497,7 +484,9 @@ export async function runServe(
|
||||
const dashboardAuthStorage = wrapAuthStorageWithApiKeyProviders(authStorage, modelRegistry);
|
||||
|
||||
const app = createServer(store, {
|
||||
onMerge: (taskId) => engine.onMerge(taskId),
|
||||
engine: cwdEngine,
|
||||
engineManager,
|
||||
onMerge: (taskId) => cwdEngine.onMerge(taskId),
|
||||
authStorage: dashboardAuthStorage,
|
||||
modelRegistry,
|
||||
automationStore,
|
||||
@@ -514,6 +503,7 @@ export async function runServe(
|
||||
pluginStore,
|
||||
pluginLoader,
|
||||
pluginRunner: pluginLoader,
|
||||
onProjectFirstAccessed: (projectId: string) => engineManager.onProjectAccessed(projectId),
|
||||
headless: true,
|
||||
});
|
||||
|
||||
@@ -592,11 +582,8 @@ export async function runServe(
|
||||
// Ignore errors getting handle types
|
||||
}
|
||||
|
||||
// Stop the engine (stops all subsystems: runtime, notifier, cronRunner, etc.)
|
||||
await engine.stop().catch((err) => {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.warn(`[serve] Engine stop error: ${message}`);
|
||||
});
|
||||
// Stop all project engines uniformly
|
||||
await engineManager.stopAll();
|
||||
|
||||
if (centralCore && localNodeId) {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user