diff --git a/packages/engine/src/__tests__/project-engine-manager.test.ts b/packages/engine/src/__tests__/project-engine-manager.test.ts index 4dbdea6991..b533a90eea 100644 --- a/packages/engine/src/__tests__/project-engine-manager.test.ts +++ b/packages/engine/src/__tests__/project-engine-manager.test.ts @@ -830,5 +830,39 @@ describe("ProjectEngineManager", () => { warnSpy.mockRestore(); vi.useRealTimers(); }); + + it("clears the external marker even if the takeover start fails", async () => { + // External owner first, then it exits so acquire succeeds — but + // engine.start() throws. The marker must be cleared (at acquire time) so + // hasRunningEngine() does not report a phantom engine that never started. + const manager = new ProjectEngineManager(centralCore); + const acquire = acquireEngineSingleton as ReturnType; + + acquire.mockRejectedValueOnce( + new EngineAlreadyRunningError("proj_aaa", "socket"), + ); + await expect(manager.ensureEngine("proj_aaa")).rejects.toBeInstanceOf( + EngineAlreadyRunningError, + ); + expect(manager.getExternalEngineIds().has("proj_aaa")).toBe(true); + + // Owner exits: acquire succeeds (default mock), but the engine fails to start. + (ProjectEngine as unknown as ReturnType).mockImplementationOnce( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function (config: any) { + return { + start: vi.fn().mockRejectedValue(new Error("boom")), + stop: vi.fn().mockResolvedValue(undefined), + getTaskStore: vi.fn().mockReturnValue({ projectId: config.projectId }), + _config: config, + }; + }, + ); + await expect(manager.ensureEngine("proj_aaa")).rejects.toThrow("boom"); + + expect(manager.getExternalEngineIds().has("proj_aaa")).toBe(false); + expect(manager.getEngine("proj_aaa")).toBeUndefined(); + expect(manager.hasRunningEngine()).toBe(false); + }); }); }); diff --git a/packages/engine/src/project-engine-manager.ts b/packages/engine/src/project-engine-manager.ts index 9c51c6580e..f862a46403 100644 --- a/packages/engine/src/project-engine-manager.ts +++ b/packages/engine/src/project-engine-manager.ts @@ -479,6 +479,11 @@ export class ProjectEngineManager { throw err; }); this.singletonLocks.set(projectId, singleton); + // Acquiring the singleton proves no other process owns the engine, so clear + // any prior "owned by another process" marker now — before engine.start(). + // If start fails below we release the lock and a later tick retries; leaving + // the marker set here would make hasRunningEngine() report a phantom engine. + this.externalEngines.delete(projectId); const engine = new ProjectEngine( runtimeConfig, @@ -497,8 +502,6 @@ export class ProjectEngineManager { this.engines.set(projectId, engine); this.starting.delete(projectId); - // We now own the engine — clear any prior "owned by another process" marker. - this.externalEngines.delete(projectId); runtimeLog.log( `Started engine for ${project.name ?? projectId} (${projectId})`, );