feat(engine): guard one engine per project per machine

Adds a per-machine singleton lock that engages in
ProjectEngineManager.createAndStart() before any engine subsystems
spin up. Two fn dashboard processes can no longer run engines for the
same project on the same host — previously they would share .fusion/
state and corrupt worktrees / task rows for in-process projects.

The guard combines two independent checks:
  - A proper-lockfile file at <project>/.fusion/engine.lock with
    stale-lock recovery (auto-released on process death).
  - A loopback listener on a hashed per-project address — UDS on
    POSIX, named pipe on Windows. Stale UDS files are probed and
    unlinked before a retry bind.

Failures raise EngineAlreadyRunningError. Both guards are released
from stopAll() and pauseProject(); a release on engine.start()
failure lets retries re-acquire cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-19 22:50:39 -07:00
parent 959f7cd4eb
commit 98033bc869
8 changed files with 458 additions and 3 deletions

View File

@@ -22,6 +22,11 @@ import { ProjectEngine } from "./project-engine.js";
import type { ProjectEngineOptions } from "./project-engine.js";
import type { ProjectRuntimeConfig } from "./project-runtime.js";
import { AgentSemaphore } from "./concurrency.js";
import {
acquireEngineSingleton,
EngineAlreadyRunningError,
type EngineSingletonLock,
} from "./engine-singleton-lock.js";
import { runtimeLog } from "./logger.js";
/**
@@ -41,6 +46,7 @@ export const DEFAULT_RECONCILIATION_INTERVAL_MS = 30_000;
export class ProjectEngineManager {
private engines = new Map<string, ProjectEngine>();
private starting = new Map<string, Promise<ProjectEngine>>();
private singletonLocks = new Map<string, EngineSingletonLock>();
private stopped = false;
/**
@@ -135,6 +141,8 @@ export class ProjectEngineManager {
// Remove from starting set to prevent a stalled start from completing
this.starting.delete(projectId);
await this.releaseSingleton(projectId);
}
/**
@@ -250,6 +258,30 @@ export class ProjectEngineManager {
await Promise.all(stops);
this.engines.clear();
this.starting.clear();
// Release all singleton locks so another fusion process can take over.
const releases = Array.from(this.singletonLocks.values()).map((lock) =>
lock.release().catch((err) => {
const message = err instanceof Error ? err.message : String(err);
runtimeLog.warn(`Singleton lock release error: ${message}`);
}),
);
await Promise.all(releases);
this.singletonLocks.clear();
}
private async releaseSingleton(projectId: string): Promise<void> {
const lock = this.singletonLocks.get(projectId);
if (!lock) return;
this.singletonLocks.delete(projectId);
try {
await lock.release();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
runtimeLog.warn(
`Singleton lock release error for ${projectId}: ${message}`,
);
}
}
/**
@@ -378,13 +410,41 @@ export class ProjectEngineManager {
const runtimeConfig = await this.buildRuntimeConfig(project);
const engineOptions = this.buildEngineOptions(project, overrides);
// Acquire the per-machine singleton guard before spinning up any engine
// subsystems. This prevents two fusion processes from running engines for
// the same project on one machine.
const singleton = await acquireEngineSingleton(
projectId,
runtimeConfig.workingDirectory,
(err) => {
runtimeLog.warn(
`Engine singleton lock for ${projectId} was compromised: ${err.message}`,
);
},
).catch((err) => {
if (err instanceof EngineAlreadyRunningError) {
runtimeLog.warn(
`Refusing to start engine for ${projectId}: ${err.message}`,
);
}
throw err;
});
this.singletonLocks.set(projectId, singleton);
const engine = new ProjectEngine(
runtimeConfig,
this.centralCore,
engineOptions,
);
await engine.start();
try {
await engine.start();
} catch (err) {
// If engine start fails we must release the singleton so a retry can
// re-acquire it.
await this.releaseSingleton(projectId);
throw err;
}
this.engines.set(projectId, engine);
this.starting.delete(projectId);