From 1f062201fb9fd3ddfc90c099a1ad1fa45b93c7bc Mon Sep 17 00:00:00 2001 From: suraj-markup Date: Sun, 1 Mar 2026 18:47:29 +0530 Subject: [PATCH] fix(core): atomic metadata writes, restoredAt persistence, and session count/sorting bugs - BUG-28: Use atomic write-then-rename for metadata files to prevent race condition data loss between concurrent TS and bash writers - BUG-29: Read and write restoredAt field in metadata so it persists across server restarts - BUG-12: Count all non-exited sessions (idle, ready, active) as "working" instead of only active ones - BUG-13: Check status-based error conditions (errored, needs_input, stuck) before activity-based checks in getAttentionLevel() so errored sessions immediately appear in the Respond Kanban zone Closes #238 Co-Authored-By: Claude Opus 4.6 --- packages/core/src/metadata.ts | 17 +++++++++++++++-- packages/web/src/lib/serialize.ts | 2 +- packages/web/src/lib/types.ts | 12 +++++++----- 3 files changed, 23 insertions(+), 8 deletions(-) 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"; }