feat(FN-4313): complete Step 3-6 — orchestrator primary engine resolution

Fusion-Task-Id: FN-4313
Fusion-Task-Lineage: 36b8aad3-20f4-475b-9b0f-63e5458d1b91
This commit is contained in:
Fusion
2026-05-13 22:35:03 -07:00
committed by gsxdsm
parent 60c0a38ed3
commit 415a7a4d4a
8 changed files with 209 additions and 60 deletions

View File

@@ -250,10 +250,10 @@ Usage:
fn dashboard --paused Start with automation paused
fn dashboard --dev Start web UI only (no AI engine)
fn dashboard --interactive Start with interactive port selection
fn serve [--port <port>] [--host <host>] [--paused] [--daemon] [--no-auto-register]
fn serve [--port <port>] [--host <host>] [--paused] [--daemon] [--project <id|name>] [--no-auto-register]
Start Fusion as a headless node (API + engine, no UI)
Auto-registers cwd project on first run (use --no-auto-register to disable)
fn daemon [--port <port>] [--host <host>] [--token <token>] [--paused] [--token-only] [--no-auto-register]
fn daemon [--port <port>] [--host <host>] [--token <token>] [--paused] [--token-only] [--project <id|name>] [--no-auto-register]
Start Fusion daemon (API + engine, auth required)
fn desktop Launch the Fusion desktop app (Electron)
fn desktop --dev Launch with hot-reload (connects to Vite dev server)
@@ -399,6 +399,11 @@ Supported file types: png, jpg, gif, webp, txt, log, json, yaml, yml, toml, csv,
`.trim();
function extractGlobalProjectFlag(argv: string[]): { cleanedArgs: string[]; projectName?: string } {
const command = argv[0];
if (command === "serve" || command === "daemon") {
return { cleanedArgs: [...argv] };
}
const cleanedArgs: string[] = [];
let projectName: string | undefined;
@@ -652,8 +657,9 @@ async function main() {
const hostIdx = args.indexOf("--host");
const host = hostIdx !== -1 && hostIdx + 1 < args.length ? args[hostIdx + 1] : undefined;
const daemon = args.includes("--daemon");
const project = getFlagValue(args, "--project");
const noAutoRegister = args.includes("--no-auto-register");
await runServe(port, { paused, interactive, host, daemon, noAutoRegister });
await runServe(port, { paused, interactive, host, daemon, project, noAutoRegister });
break;
}
@@ -669,8 +675,9 @@ async function main() {
const tokenIdx = args.indexOf("--token");
const token = tokenIdx !== -1 && tokenIdx + 1 < args.length ? args[tokenIdx + 1] : undefined;
const tokenOnly = args.includes("--token-only");
const project = getFlagValue(args, "--project");
const noAutoRegister = args.includes("--no-auto-register");
await runDaemon({ port, paused, interactive, host, token, tokenOnly, noAutoRegister });
await runDaemon({ port, paused, interactive, host, token, tokenOnly, project, noAutoRegister });
break;
}

View File

@@ -165,6 +165,7 @@ const mocks = vi.hoisted(() => {
Promise.resolve(projects.find((project) => project.id === id) ?? null),
),
listProjects: vi.fn().mockImplementation(() => Promise.resolve([...projects])),
getDefaultProjectId: vi.fn().mockResolvedValue(undefined),
listNodes: vi.fn().mockResolvedValue([
{ id: "node-local", name: "local", type: "local", status: "offline" },
]),
@@ -412,6 +413,8 @@ const mocks = vi.hoisted(() => {
heartbeatTriggerScheduler.stop();
}),
getTaskStore: vi.fn(() => store),
getProjectId: vi.fn(() => runtimeConfig.projectId),
getWorkingDirectory: vi.fn(() => runtimeConfig.workingDirectory),
getAutomationStore: vi.fn(() => automationStore),
getRuntime: vi.fn(() => ({
getHeartbeatMonitor: () => heartbeatMonitor,
@@ -847,7 +850,7 @@ describe("runDaemon", () => {
}
});
it("--no-auto-register preserves legacy exit behavior", async () => {
it("--no-auto-register falls back to existing started engines", async () => {
const freshCwd = mkdtempSync(join(tmpdir(), "daemon-no-auto-register-"));
cwdSpy.mockReturnValue(freshCwd);
@@ -858,8 +861,12 @@ describe("runDaemon", () => {
instance.registerProject.mock.calls,
);
expect(registrationCalls).toHaveLength(0);
expect(errorSpy).toHaveBeenCalledWith("[daemon] No engine started for the current project — exiting");
expect(process.exit).toHaveBeenCalledWith(1);
expect(process.exit).not.toHaveBeenCalledWith(1);
expect(logSpy).toHaveBeenCalledWith(
expect.stringContaining("[daemon] HTTP layer bound to project")
);
await triggerSignal("SIGINT");
} finally {
rmSync(freshCwd, { recursive: true, force: true });
}

View File

@@ -193,6 +193,7 @@ const mocks = vi.hoisted(() => {
Promise.resolve(projects.find((project) => project.id === id) ?? null),
),
listProjects: vi.fn().mockImplementation(() => Promise.resolve([...projects])),
getDefaultProjectId: vi.fn().mockResolvedValue(undefined),
listNodes: vi.fn().mockResolvedValue([
{ id: "node-local", name: "local", type: "local", status: "offline" },
]),
@@ -461,6 +462,8 @@ const mocks = vi.hoisted(() => {
heartbeatTriggerScheduler.stop();
}),
getTaskStore: vi.fn(() => store),
getProjectId: vi.fn(() => runtimeConfig.projectId),
getWorkingDirectory: vi.fn(() => runtimeConfig.workingDirectory),
getAutomationStore: vi.fn(() => automationStore),
getRuntime: vi.fn(() => ({
getHeartbeatMonitor: () => heartbeatMonitor,
@@ -1964,7 +1967,7 @@ describe("runServe — multi-project cwd/default engine resolution", () => {
}
});
it("--no-auto-register preserves legacy exit behavior", async () => {
it("--no-auto-register falls back to existing started engines", async () => {
const freshCwd = mkdtempSync(join(tmpdir(), "serve-no-auto-register-"));
cwdSpy.mockReturnValue(freshCwd);
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
@@ -1979,10 +1982,15 @@ describe("runServe — multi-project cwd/default engine resolution", () => {
logPrefix: "serve",
autoRegister: false,
}));
expect(process.exit).toHaveBeenCalledWith(1);
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining("[serve] No engine started for the current project")
expect(process.exit).not.toHaveBeenCalledWith(1);
expect(errorSpy).not.toHaveBeenCalledWith(
expect.stringContaining("[serve] No engines started")
);
expect(logSpy).toHaveBeenCalledWith(
expect.stringContaining("[serve] HTTP layer bound to project")
);
await triggerSignal("SIGINT");
} finally {
ensureSpy.mockRestore();
errorSpy.mockRestore();

View File

@@ -168,8 +168,10 @@ export interface DaemonOptions {
interactive?: boolean;
/** Just print/generate token without starting server */
tokenOnly?: boolean;
/** Disable cwd auto-registration and preserve legacy strict behavior */
/** Disable cwd auto-registration */
noAutoRegister?: boolean;
/** Preferred primary project (id or name). */
project?: string;
}
export async function runDaemon(opts: DaemonOptions = {}) {
@@ -355,14 +357,72 @@ export async function runDaemon(opts: DaemonOptions = {}) {
}
}
// Get the cwd project's engine and store for the HTTP layer
const cwdEngine = ntfyProjectId ? engineManager.getEngine(ntfyProjectId) : undefined;
if (!cwdEngine) {
console.error("[daemon] No engine started for the current project — exiting");
const startedEngines = [...engineManager.getAllEngines().values()];
const projects = sharedCentralCore ? await sharedCentralCore.listProjects() : [];
const resolvePrimaryEngine = async (): Promise<{
engine: (typeof startedEngines)[number];
source: "cli-flag" | "default-setting" | "cwd" | "fallback";
} | null> => {
if (opts.project) {
const byId = startedEngines.find((engine) => engine.getProjectId() === opts.project);
if (byId) {
return { engine: byId, source: "cli-flag" };
}
const projectMatch = projects.find((project) => project.name === opts.project);
if (projectMatch) {
const byName = engineManager.getEngine(projectMatch.id);
if (byName) {
return { engine: byName, source: "cli-flag" };
}
}
console.error(`[daemon] --project "${opts.project}" did not match any started engine`);
process.exit(1);
return null;
}
const defaultProjectId = await sharedCentralCore?.getDefaultProjectId?.();
if (defaultProjectId) {
const defaultEngine = engineManager.getEngine(defaultProjectId);
if (defaultEngine) {
return { engine: defaultEngine, source: "default-setting" };
}
console.warn(`[daemon] defaultProjectId ${defaultProjectId} is set but no engine started for it — falling through`);
}
const cwdEngine = ntfyProjectId ? engineManager.getEngine(ntfyProjectId) : undefined;
if (cwdEngine) {
return { engine: cwdEngine, source: "cwd" };
}
const fallback = startedEngines[0];
if (!fallback) {
return null;
}
return { engine: fallback, source: "fallback" };
};
const primarySelection = await resolvePrimaryEngine();
if (!primarySelection) {
console.error("[daemon] No engines started — registry empty or all engines failed to start. Exiting.");
process.exit(1);
return;
}
const store = cwdEngine.getTaskStore();
const primaryEngine = primarySelection.engine;
const primaryProjectId = primaryEngine.getProjectId();
ntfyProjectId = primaryProjectId;
const primaryProject = projects.find((project) => project.id === primaryProjectId);
const primaryProjectName = primaryProject?.name ?? primaryProjectId;
const primaryCwd = primaryEngine.getWorkingDirectory();
console.log(
`[daemon] HTTP layer bound to project ${primaryProjectName} (${primaryProjectId}) [source: ${primarySelection.source}]`,
);
const store = primaryEngine.getTaskStore();
await store.watch();
@@ -417,11 +477,11 @@ export async function runDaemon(opts: DaemonOptions = {}) {
);
}
// 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();
// Get subsystems from the primary engine for the HTTP layer
const heartbeatMonitor = primaryEngine.getRuntime().getHeartbeatMonitor();
const missionAutopilot = primaryEngine.getRuntime().getMissionAutopilot();
const missionExecutionLoop = primaryEngine.getRuntime().getMissionExecutionLoop();
const automationStore = primaryEngine.getAutomationStore();
const authStorage = AuthStorage.create(getFusionAuthPath());
const supplementalAuthStorage = createReadOnlyAuthFileStorage([
@@ -438,9 +498,9 @@ export async function runDaemon(opts: DaemonOptions = {}) {
try {
const agentDir = getPackageManagerAgentDir();
packageManager = new DefaultPackageManager({
cwd,
cwd: primaryCwd,
agentDir,
settingsManager: createReadOnlyProviderSettingsView(cwd, agentDir) as unknown as SettingsManager,
settingsManager: createReadOnlyProviderSettingsView(primaryCwd, agentDir) as unknown as SettingsManager,
});
const resolvedPaths = await packageManager.resolve();
const packageExtensionPaths = resolvedPaths.extensions
@@ -515,14 +575,14 @@ export async function runDaemon(opts: DaemonOptions = {}) {
setHostExtensionPaths(selfExtensionPaths);
const reconciledExtensionPaths = reconcileClaudeCliPaths(
[...selfExtensionPaths, ...getEnabledPiExtensionPaths(cwd), ...packageExtensionPaths, ...claudeCliPaths],
[...selfExtensionPaths, ...getEnabledPiExtensionPaths(primaryCwd), ...packageExtensionPaths, ...claudeCliPaths],
claudeCliPaths[0] ?? null,
);
const extensionsResult = await discoverAndLoadExtensions(
[...reconciledExtensionPaths, ...droidCliPaths, ...llamaCppPaths],
cwd,
join(cwd, ".fusion", "disabled-auto-extension-discovery"),
primaryCwd,
join(primaryCwd, ".fusion", "disabled-auto-extension-discovery"),
);
for (const { path, error } of extensionsResult.errors) {
@@ -575,10 +635,10 @@ export async function runDaemon(opts: DaemonOptions = {}) {
}, DIAGNOSTIC_INTERVAL_MS).unref?.();
const app = createServer(store, {
engine: cwdEngine,
engine: primaryEngine,
engineManager,
centralCore: sharedCentralCore ?? undefined,
onMerge: (taskId) => cwdEngine.onMerge(taskId),
onMerge: (taskId) => primaryEngine.onMerge(taskId),
authStorage: dashboardAuthStorage,
modelRegistry,
automationStore,
@@ -586,7 +646,7 @@ export async function runDaemon(opts: DaemonOptions = {}) {
missionExecutionLoop,
heartbeatMonitor: heartbeatMonitor
? {
rootDir: cwd,
rootDir: primaryCwd,
startRun: heartbeatMonitor.startRun.bind(heartbeatMonitor),
executeHeartbeat: heartbeatMonitor.executeHeartbeat.bind(heartbeatMonitor),
stopRun: heartbeatMonitor.stopRun.bind(heartbeatMonitor),

View File

@@ -224,7 +224,7 @@ function ensureProcessDiagnostics(): void {
export async function runServe(
port: number,
opts: { interactive?: boolean; paused?: boolean; host?: string; daemon?: boolean; noAutoRegister?: boolean } = {},
opts: { interactive?: boolean; paused?: boolean; host?: string; daemon?: boolean; noAutoRegister?: boolean; project?: string } = {},
) {
serveStartTime = Date.now();
ensureProcessDiagnostics();
@@ -399,15 +399,72 @@ export async function runServe(
}
}
// 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");
const startedEngines = [...engineManager.getAllEngines().values()];
const projects = sharedCentralCore ? await sharedCentralCore.listProjects() : [];
const resolvePrimaryEngine = async (): Promise<{
engine: (typeof startedEngines)[number];
source: "cli-flag" | "default-setting" | "cwd" | "fallback";
} | null> => {
if (opts.project) {
const byId = startedEngines.find((engine) => engine.getProjectId() === opts.project);
if (byId) {
return { engine: byId, source: "cli-flag" };
}
const projectMatch = projects.find((project) => project.name === opts.project);
if (projectMatch) {
const byName = engineManager.getEngine(projectMatch.id);
if (byName) {
return { engine: byName, source: "cli-flag" };
}
}
console.error(`[serve] --project "${opts.project}" did not match any started engine`);
process.exit(1);
return null;
}
const defaultProjectId = await sharedCentralCore?.getDefaultProjectId?.();
if (defaultProjectId) {
const defaultEngine = engineManager.getEngine(defaultProjectId);
if (defaultEngine) {
return { engine: defaultEngine, source: "default-setting" };
}
console.warn(`[serve] defaultProjectId ${defaultProjectId} is set but no engine started for it — falling through`);
}
const cwdEngine = ntfyProjectId ? engineManager.getEngine(ntfyProjectId) : undefined;
if (cwdEngine) {
return { engine: cwdEngine, source: "cwd" };
}
const fallback = startedEngines[0];
if (!fallback) {
return null;
}
return { engine: fallback, source: "fallback" };
};
const primarySelection = await resolvePrimaryEngine();
if (!primarySelection) {
console.error("[serve] No engines started — registry empty or all engines failed to start. Exiting.");
process.exit(1);
return; // unreachable in production, but needed for test mocks
return;
}
const store = cwdEngine.getTaskStore();
const primaryEngine = primarySelection.engine;
const primaryProjectId = primaryEngine.getProjectId();
ntfyProjectId = primaryProjectId;
const primaryProject = projects.find((project) => project.id === primaryProjectId);
const primaryProjectName = primaryProject?.name ?? primaryProjectId;
const primaryCwd = primaryEngine.getWorkingDirectory();
console.log(
`[serve] HTTP layer bound to project ${primaryProjectName} (${primaryProjectId}) [source: ${primarySelection.source}]`,
);
const store = primaryEngine.getTaskStore();
// InProcessRuntime does not call store.watch() — do it here so SSE events
// and file-watcher triggers are active for the HTTP layer.
@@ -503,11 +560,11 @@ export async function runServe(
);
}
// 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();
// Get subsystems from the primary engine for the HTTP layer
const heartbeatMonitor = primaryEngine.getRuntime().getHeartbeatMonitor();
const missionAutopilot = primaryEngine.getRuntime().getMissionAutopilot();
const missionExecutionLoop = primaryEngine.getRuntime().getMissionExecutionLoop();
const automationStore = primaryEngine.getAutomationStore();
const authStorage = AuthStorage.create(getFusionAuthPath());
const supplementalAuthStorage = createReadOnlyAuthFileStorage([
@@ -524,9 +581,9 @@ export async function runServe(
try {
const agentDir = getPackageManagerAgentDir();
packageManager = new DefaultPackageManager({
cwd,
cwd: primaryCwd,
agentDir,
settingsManager: createReadOnlyProviderSettingsView(cwd, agentDir) as unknown as SettingsManager,
settingsManager: createReadOnlyProviderSettingsView(primaryCwd, agentDir) as unknown as SettingsManager,
});
const resolvedPaths = await packageManager.resolve();
const packageExtensionPaths = resolvedPaths.extensions
@@ -602,14 +659,14 @@ export async function runServe(
const extensionsResult = await discoverAndLoadExtensions(
[
...selfExtensionPaths,
...getEnabledPiExtensionPaths(cwd),
...getEnabledPiExtensionPaths(primaryCwd),
...packageExtensionPaths,
...claudeCliPaths,
...droidCliPaths,
...llamaCppPaths,
],
cwd,
join(cwd, ".fusion", "disabled-auto-extension-discovery"),
primaryCwd,
join(primaryCwd, ".fusion", "disabled-auto-extension-discovery"),
);
for (const { path, error } of extensionsResult.errors) {
@@ -715,10 +772,10 @@ export async function runServe(
: undefined;
const app = createServer(store, {
engine: cwdEngine,
engine: primaryEngine,
engineManager,
centralCore: sharedCentralCore ?? undefined,
onMerge: (taskId) => cwdEngine.onMerge(taskId),
onMerge: (taskId) => primaryEngine.onMerge(taskId),
authStorage: dashboardAuthStorage,
modelRegistry,
automationStore,
@@ -726,7 +783,7 @@ export async function runServe(
missionExecutionLoop,
heartbeatMonitor: heartbeatMonitor
? {
rootDir: cwd,
rootDir: primaryCwd,
startRun: heartbeatMonitor.startRun.bind(heartbeatMonitor),
executeHeartbeat: heartbeatMonitor.executeHeartbeat.bind(heartbeatMonitor),
stopRun: heartbeatMonitor.stopRun.bind(heartbeatMonitor),