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 <noreply@anthropic.com>
This commit is contained in:
suraj-markup 2026-03-01 18:47:29 +05:30
parent 4cda43795b
commit 1f062201fb
3 changed files with 23 additions and 8 deletions

View File

@ -22,6 +22,7 @@
import {
readFileSync,
writeFileSync,
renameSync,
existsSync,
mkdirSync,
unlinkSync,
@ -63,6 +64,16 @@ function serializeMetadata(data: Record<string, string>): 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));
}
/**

View File

@ -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,

View File

@ -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";
}