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:
parent
4cda43795b
commit
1f062201fb
|
|
@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue