diff --git a/packages/core/src/__tests__/metadata.test.ts b/packages/core/src/__tests__/metadata.test.ts index 03962c370..ca62f8115 100644 --- a/packages/core/src/__tests__/metadata.test.ts +++ b/packages/core/src/__tests__/metadata.test.ts @@ -323,6 +323,116 @@ describe("readArchivedMetadataRaw", () => { }); }); +describe("atomic writes", () => { + it("writeMetadata leaves no .tmp files behind", () => { + writeMetadata(dataDir, "atomic-1", { + worktree: "/tmp/w", + branch: "main", + status: "working", + }); + + const files = readdirSync(dataDir); + const tmpFiles = files.filter((f) => f.includes(".tmp.")); + expect(tmpFiles).toHaveLength(0); + // Verify the actual file was written correctly + const meta = readMetadata(dataDir, "atomic-1"); + expect(meta!.status).toBe("working"); + }); + + it("updateMetadata leaves no .tmp files behind", () => { + writeMetadata(dataDir, "atomic-2", { + worktree: "/tmp/w", + branch: "main", + status: "spawning", + }); + + updateMetadata(dataDir, "atomic-2", { status: "working" }); + + const files = readdirSync(dataDir); + const tmpFiles = files.filter((f) => f.includes(".tmp.")); + expect(tmpFiles).toHaveLength(0); + const meta = readMetadata(dataDir, "atomic-2"); + expect(meta!.status).toBe("working"); + }); + + it("concurrent writeMetadata calls do not produce corrupt files", () => { + // Simulate rapid sequential writes (synchronous, so they serialize naturally, + // but each individual write must be atomic — no partial content) + for (let i = 0; i < 20; i++) { + writeMetadata(dataDir, "atomic-3", { + worktree: "/tmp/w", + branch: `branch-${i}`, + status: "working", + summary: `iteration ${i}`, + }); + } + + const meta = readMetadata(dataDir, "atomic-3"); + expect(meta).not.toBeNull(); + expect(meta!.branch).toBe("branch-19"); + expect(meta!.summary).toBe("iteration 19"); + + // No leftover temp files + const files = readdirSync(dataDir); + const tmpFiles = files.filter((f) => f.includes(".tmp.")); + expect(tmpFiles).toHaveLength(0); + }); +}); + +describe("restoredAt persistence", () => { + it("roundtrips restoredAt through writeMetadata and readMetadata", () => { + const now = new Date().toISOString(); + writeMetadata(dataDir, "restore-1", { + worktree: "/tmp/w", + branch: "main", + status: "working", + restoredAt: now, + }); + + const meta = readMetadata(dataDir, "restore-1"); + expect(meta).not.toBeNull(); + expect(meta!.restoredAt).toBe(now); + }); + + it("restoredAt is persisted in the key=value file", () => { + const now = "2026-03-01T12:00:00.000Z"; + writeMetadata(dataDir, "restore-2", { + worktree: "/tmp/w", + branch: "main", + status: "working", + restoredAt: now, + }); + + const content = readFileSync(join(dataDir, "restore-2"), "utf-8"); + expect(content).toContain(`restoredAt=${now}`); + }); + + it("restoredAt is undefined when not set", () => { + writeMetadata(dataDir, "restore-3", { + worktree: "/tmp/w", + branch: "main", + status: "working", + }); + + const meta = readMetadata(dataDir, "restore-3"); + expect(meta!.restoredAt).toBeUndefined(); + }); + + it("updateMetadata can set restoredAt on an existing session", () => { + writeMetadata(dataDir, "restore-4", { + worktree: "/tmp/w", + branch: "main", + status: "working", + }); + + const now = new Date().toISOString(); + updateMetadata(dataDir, "restore-4", { restoredAt: now }); + + const meta = readMetadata(dataDir, "restore-4"); + expect(meta!.restoredAt).toBe(now); + }); +}); + describe("listMetadata", () => { it("lists all session IDs", () => { writeMetadata(dataDir, "app-1", { worktree: "/tmp", branch: "a", status: "s" }); 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/core/src/session-manager.ts b/packages/core/src/session-manager.ts index a6b5ca038..126d4934e 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -152,6 +152,7 @@ function metadataToSession( agentInfo: meta["summary"] ? { summary: meta["summary"], agentSessionId: null } : null, createdAt: meta["createdAt"] ? new Date(meta["createdAt"]) : (createdAt ?? new Date()), lastActivityAt: modifiedAt ?? new Date(), + restoredAt: meta["restoredAt"] ? new Date(meta["restoredAt"]) : undefined, metadata: meta, }; } diff --git a/packages/web/src/__tests__/get-attention-level.test.ts b/packages/web/src/__tests__/get-attention-level.test.ts index 0874cb739..4856c745f 100644 --- a/packages/web/src/__tests__/get-attention-level.test.ts +++ b/packages/web/src/__tests__/get-attention-level.test.ts @@ -68,6 +68,21 @@ describe("getAttentionLevel", () => { expect(getAttentionLevel(session)).toBe("respond"); }); + it("returns respond when status is errored even if activity is active", () => { + const session = makeSession({ status: "errored", activity: "active" }); + expect(getAttentionLevel(session)).toBe("respond"); + }); + + it("returns respond when status is needs_input even if activity is active", () => { + const session = makeSession({ status: "needs_input", activity: "active" }); + expect(getAttentionLevel(session)).toBe("respond"); + }); + + it("returns respond when status is stuck even if activity is active", () => { + const session = makeSession({ status: "stuck", activity: "active" }); + expect(getAttentionLevel(session)).toBe("respond"); + }); + it("merge takes priority over respond (mergeable PR + blocked agent)", () => { const pr = makePR({ mergeability: { diff --git a/packages/web/src/components/Dashboard.tsx b/packages/web/src/components/Dashboard.tsx index 9eea166ea..054346823 100644 --- a/packages/web/src/components/Dashboard.tsx +++ b/packages/web/src/components/Dashboard.tsx @@ -229,7 +229,7 @@ function StatusLine({ stats }: { stats: DashboardStats }) { const parts: Array<{ value: number; label: string; color?: string }> = [ { value: stats.totalSessions, label: "sessions" }, ...(stats.workingSessions > 0 - ? [{ value: stats.workingSessions, label: "active", color: "var(--color-status-working)" }] + ? [{ value: stats.workingSessions, label: "working", color: "var(--color-status-working)" }] : []), ...(stats.openPRs > 0 ? [{ value: stats.openPRs, label: "PRs" }] : []), ...(stats.needsReview > 0 diff --git a/packages/web/src/lib/__tests__/serialize.test.ts b/packages/web/src/lib/__tests__/serialize.test.ts index 49e56c3ba..7742aaf1e 100644 --- a/packages/web/src/lib/__tests__/serialize.test.ts +++ b/packages/web/src/lib/__tests__/serialize.test.ts @@ -20,6 +20,7 @@ import { enrichSessionAgentSummary, enrichSessionIssueTitle, enrichSessionsMetadata, + computeStats, } from "../serialize"; import { prCache, prCacheKey } from "../cache"; import type { DashboardSession } from "../types"; @@ -947,6 +948,67 @@ describe("enrichSessionsMetadata", () => { }); }); +describe("computeStats", () => { + function makeDashboard(overrides: Partial = {}): DashboardSession { + return { + id: "test-1", + projectId: "test", + status: "working", + activity: "active", + branch: "feat/test", + issueId: null, + issueUrl: null, + issueLabel: null, + issueTitle: null, + summary: null, + summaryIsFallback: false, + createdAt: new Date().toISOString(), + lastActivityAt: new Date().toISOString(), + pr: null, + metadata: {}, + ...overrides, + }; + } + + it("counts active sessions as working", () => { + const sessions = [makeDashboard({ activity: "active" })]; + expect(computeStats(sessions).workingSessions).toBe(1); + }); + + it("counts idle sessions as working", () => { + const sessions = [makeDashboard({ activity: "idle" })]; + expect(computeStats(sessions).workingSessions).toBe(1); + }); + + it("counts ready sessions as working", () => { + const sessions = [makeDashboard({ activity: "ready" })]; + expect(computeStats(sessions).workingSessions).toBe(1); + }); + + it("excludes exited sessions from working count", () => { + const sessions = [makeDashboard({ activity: "exited" })]; + expect(computeStats(sessions).workingSessions).toBe(0); + }); + + it("excludes sessions with null activity from working count", () => { + const sessions = [makeDashboard({ activity: null })]; + expect(computeStats(sessions).workingSessions).toBe(0); + }); + + it("counts mixed activity states correctly", () => { + const sessions = [ + makeDashboard({ id: "s1", activity: "active" }), + makeDashboard({ id: "s2", activity: "idle" }), + makeDashboard({ id: "s3", activity: "ready" }), + makeDashboard({ id: "s4", activity: "exited" }), + makeDashboard({ id: "s5", activity: null }), + ]; + const stats = computeStats(sessions); + expect(stats.totalSessions).toBe(5); + expect(stats.workingSessions).toBe(3); // active + idle + ready + }); +}); + describe("basicPRToDashboard defaults", () => { it("should not look like failing CI", () => { const pr = createPRInfo(); 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"; }