diff --git a/packages/core/src/metadata.ts b/packages/core/src/metadata.ts index 3a9685b07..3d70e52d9 100644 --- a/packages/core/src/metadata.ts +++ b/packages/core/src/metadata.ts @@ -22,6 +22,7 @@ import { readFileSync, writeFileSync, + renameSync, existsSync, mkdirSync, unlinkSync, @@ -63,6 +64,16 @@ function serializeMetadata(data: Record): string { ); } +/** + * Atomically write a file by writing to a temp file then renaming. + * rename() is atomic on POSIX, so concurrent writers never produce torn data. + */ +function atomicWriteFileSync(filePath: string, content: string): void { + const tmpPath = `${filePath}.tmp.${process.pid}.${Date.now()}`; + writeFileSync(tmpPath, content, "utf-8"); + renameSync(tmpPath, filePath); +} + /** Validate sessionId to prevent path traversal. */ const VALID_SESSION_ID = /^[a-zA-Z0-9_-]+$/; @@ -100,6 +111,7 @@ export function readMetadata(dataDir: string, sessionId: SessionId): SessionMeta agent: raw["agent"], createdAt: raw["createdAt"], runtimeHandle: raw["runtimeHandle"], + restoredAt: raw["restoredAt"], role: raw["role"], dashboardPort: raw["dashboardPort"] ? Number(raw["dashboardPort"]) : undefined, terminalWsPort: raw["terminalWsPort"] ? Number(raw["terminalWsPort"]) : undefined, @@ -144,6 +156,7 @@ export function writeMetadata( if (metadata.agent) data["agent"] = metadata.agent; if (metadata.createdAt) data["createdAt"] = metadata.createdAt; if (metadata.runtimeHandle) data["runtimeHandle"] = metadata.runtimeHandle; + if (metadata.restoredAt) data["restoredAt"] = metadata.restoredAt; if (metadata.role) data["role"] = metadata.role; if (metadata.dashboardPort !== undefined) data["dashboardPort"] = String(metadata.dashboardPort); @@ -152,7 +165,7 @@ export function writeMetadata( if (metadata.directTerminalWsPort !== undefined) data["directTerminalWsPort"] = String(metadata.directTerminalWsPort); - writeFileSync(path, serializeMetadata(data), "utf-8"); + atomicWriteFileSync(path, serializeMetadata(data)); } /** @@ -183,7 +196,7 @@ export function updateMetadata( } mkdirSync(dirname(path), { recursive: true }); - writeFileSync(path, serializeMetadata(existing), "utf-8"); + atomicWriteFileSync(path, serializeMetadata(existing)); } /** diff --git a/packages/web/src/lib/serialize.ts b/packages/web/src/lib/serialize.ts index b4a31e032..d38ae5ff9 100644 --- a/packages/web/src/lib/serialize.ts +++ b/packages/web/src/lib/serialize.ts @@ -380,7 +380,7 @@ export async function enrichSessionsMetadata( export function computeStats(sessions: DashboardSession[]): DashboardStats { return { totalSessions: sessions.length, - workingSessions: sessions.filter((s) => s.activity === "active").length, + workingSessions: sessions.filter((s) => s.activity !== null && s.activity !== "exited").length, openPRs: sessions.filter((s) => s.pr?.state === "open").length, needsReview: sessions.filter((s) => s.pr && !s.pr.isDraft && s.pr.reviewDecision === "pending") .length, diff --git a/packages/web/src/lib/types.ts b/packages/web/src/lib/types.ts index b90e48bf5..1d6165381 100644 --- a/packages/web/src/lib/types.ts +++ b/packages/web/src/lib/types.ts @@ -187,16 +187,18 @@ export function getAttentionLevel(session: DashboardSession): AttentionLevel { } // ── Respond: agent is waiting for human input ───────────────────── + // Check status-based error conditions first — these are authoritative + // and should not be masked by a stale activity value. if ( - session.activity === ACTIVITY_STATE.WAITING_INPUT || - session.activity === ACTIVITY_STATE.BLOCKED + session.status === SESSION_STATUS.ERRORED || + session.status === SESSION_STATUS.NEEDS_INPUT || + session.status === SESSION_STATUS.STUCK ) { return "respond"; } if ( - session.status === SESSION_STATUS.NEEDS_INPUT || - session.status === SESSION_STATUS.STUCK || - session.status === SESSION_STATUS.ERRORED + session.activity === ACTIVITY_STATE.WAITING_INPUT || + session.activity === ACTIVITY_STATE.BLOCKED ) { return "respond"; }