diff --git a/packages/cli/src/lib/running-state.ts b/packages/cli/src/lib/running-state.ts index c6876d4c1..5e6eb7535 100644 --- a/packages/cli/src/lib/running-state.ts +++ b/packages/cli/src/lib/running-state.ts @@ -6,6 +6,7 @@ import { openSync, closeSync, constants, + statSync, } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; @@ -24,6 +25,7 @@ const STATE_FILE = join(STATE_DIR, "running.json"); const STATE_LOCK_FILE = join(STATE_DIR, "running.lock"); const STARTUP_LOCK_FILE = join(STATE_DIR, "startup.lock"); const MAX_LOCK_AGE_MS = 30 * 60 * 1000; +const UNPARSEABLE_LOCK_GRACE_MS = 5_000; interface LockMetadata { pid: number; @@ -62,10 +64,20 @@ function readLockMetadata(lockFile: string): LockMetadata | null { } function isStaleLockOwner(owner: LockMetadata): boolean { + if (isProcessAlive(owner.pid)) return false; const acquiredAt = Date.parse(owner.acquiredAt); return Number.isFinite(acquiredAt) && Date.now() - acquiredAt > MAX_LOCK_AGE_MS; } +function isStaleUnparseableLock(lockFile: string): boolean { + try { + const mtimeMs = statSync(lockFile).mtimeMs; + return Date.now() - mtimeMs > UNPARSEABLE_LOCK_GRACE_MS; + } catch { + return false; + } +} + /** Try to create the lockfile atomically. Returns a release function on success, null on failure. */ function tryAcquire(lockFile: string): (() => void) | null { try { @@ -110,7 +122,8 @@ async function acquireLock( if (release) return release; const owner = readLockMetadata(lockFile); - if (owner && (isStaleLockOwner(owner) || !isProcessAlive(owner.pid))) { + if ((!owner && isStaleUnparseableLock(lockFile)) + || (owner && (isStaleLockOwner(owner) || !isProcessAlive(owner.pid)))) { try { unlinkSync(lockFile); } catch { /* ignore */ } const retryRelease = tryAcquire(lockFile); if (retryRelease) return retryRelease; diff --git a/packages/web/src/__tests__/api-routes.test.ts b/packages/web/src/__tests__/api-routes.test.ts index 39505f483..a297c9c45 100644 --- a/packages/web/src/__tests__/api-routes.test.ts +++ b/packages/web/src/__tests__/api-routes.test.ts @@ -394,7 +394,7 @@ describe("API Routes", () => { expect(mockSessionManager.listCached).toHaveBeenCalledWith("my-app"); }); - it("omits dead orchestrators from project-scoped orchestrator payloads when none are live", async () => { + it("keeps dead orchestrators as the fallback project-scoped payload when none are live", async () => { const deadLifecycle = createInitialCanonicalLifecycle("orchestrator", new Date("2026-04-19T11:00:00.000Z")); deadLifecycle.session.state = "terminated"; deadLifecycle.session.reason = "runtime_missing"; @@ -423,7 +423,9 @@ describe("API Routes", () => { const data = await res.json(); expect(data.orchestratorId).toBeNull(); - expect(data.orchestrators).toEqual([]); + expect(data.orchestrators).toEqual([ + { id: "my-app-orchestrator-0", projectId: "my-app", projectName: "My App" }, + ]); expect(data.sessions).toEqual([]); }); diff --git a/packages/web/src/app/api/sessions/route.ts b/packages/web/src/app/api/sessions/route.ts index 1744ae9ac..ec74de844 100644 --- a/packages/web/src/app/api/sessions/route.ts +++ b/packages/web/src/app/api/sessions/route.ts @@ -52,6 +52,16 @@ function listLiveDashboardOrchestrators( ); } +function listPreferredProjectOrchestrators( + sessions: Parameters[0], + projects: Parameters[1], +) { + const liveOrchestrators = listLiveDashboardOrchestrators(sessions, projects); + return liveOrchestrators.length > 0 + ? liveOrchestrators + : listDashboardOrchestrators(sessions, projects); +} + export async function GET(request: Request) { const correlationId = getCorrelationId(request); const startedAt = Date.now(); @@ -69,7 +79,7 @@ export async function GET(request: Request) { const coreSessions = await sessionManager.listCached(requestedProjectId); const visibleSessions = filterProjectSessions(coreSessions, projectFilter, config.projects); const orchestrators = requestedProjectId - ? listLiveDashboardOrchestrators(visibleSessions, config.projects) + ? listPreferredProjectOrchestrators(visibleSessions, config.projects) : listDashboardOrchestrators(visibleSessions, config.projects); const orchestratorId = requestedProjectId ? selectPreferredOrchestratorId(visibleSessions, config.projects)