From e2a76a6ad97a0d91337745190040ebd8aa6f4570 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Thu, 19 Mar 2026 14:26:24 -0700 Subject: [PATCH] Extract resolveAllStatuses helper and fix stale review detection Move stale review flag outside the vm-running block so sessions marked in_review are caught even when the VM is stopped. Extract shared status-resolution logic into resolveAllStatuses to deduplicate the list and list-all commands. Add stale lock detection to prevent deadlocks from crashed processes, and include status in JSON output for list-all. Co-Authored-By: Claude Opus 4.6 --- src/commands/list.ts | 58 +++++++++++++++++++++----------------------- src/state.ts | 24 +++++++++--------- 2 files changed, 39 insertions(+), 43 deletions(-) diff --git a/src/commands/list.ts b/src/commands/list.ts index 786d732..1ed9a26 100644 --- a/src/commands/list.ts +++ b/src/commands/list.ts @@ -50,9 +50,9 @@ async function resolveStatus( if (vmRunning) { const active = await vm.isClaudeActive(s.worktree, s.branch) if (active && s.in_review) return { key, status: "review", staleReview: false } - staleReview = !active && !!s.in_review if (active) return { key, status: "active", staleReview: false } } + staleReview = !!s.in_review const dirty = await git.isDirty(s.worktree) if (dirty) return { key, status: "dirty", staleReview } const commits = await git.hasNewCommits(s.worktree) @@ -71,6 +71,25 @@ async function clearStaleReviews(staleReviews: { repoRoot: string; branch: strin } } +async function resolveAllStatuses( + sessions: T[], + vmRunning: boolean, + keyFn: (s: T) => string, + repoRootFn: (s: T) => string, +): Promise> { + const results = await Promise.all(sessions.map(s => resolveStatus(s, keyFn(s), vmRunning))) + const statuses = Object.fromEntries(results.map(r => [r.key, r.status])) + const staleReviews: { repoRoot: string; branch: string }[] = [] + for (let i = 0; i < results.length; i++) { + if (results[i].staleReview) { + sessions[i].in_review = false + staleReviews.push({ repoRoot: repoRootFn(sessions[i]), branch: sessions[i].branch }) + } + } + await clearStaleReviews(staleReviews) + return statuses +} + async function backfillPrompts(sessions: { worktree: string; prompt?: string }[], vmRunning: boolean) { if (!vmRunning) return const needsPrompt = sessions.filter(s => !s.prompt) @@ -113,19 +132,7 @@ export async function action(opts: { json?: boolean; all?: boolean; add?: string return } - const results = await Promise.all( - sessions.map(s => resolveStatus(s, s.branch, vmRunning)) - ) - const statuses = Object.fromEntries(results.map(r => [r.key, r.status])) - - const staleReviews: { repoRoot: string; branch: string }[] = [] - for (let i = 0; i < results.length; i++) { - if (results[i].staleReview) { - sessions[i].in_review = false - staleReviews.push({ repoRoot: root, branch: sessions[i].branch }) - } - } - await clearStaleReviews(staleReviews) + const statuses = await resolveAllStatuses(sessions, vmRunning, s => s.branch, () => root) if (opts.json) { const withStatus = sessions.map(s => ({ ...s, status: statuses[s.branch] })) @@ -165,12 +172,7 @@ async function actionRemove(dir: string) { async function actionAll(opts: { json?: boolean }) { const sessions = await state.loadAll() - if (opts.json) { - console.log(JSON.stringify(sessions, null, 2)) - return - } - - if (sessions.length === 0) { + if (sessions.length === 0 && !opts.json) { console.log("◆ No active sessions across any project.") return } @@ -179,19 +181,15 @@ async function actionAll(opts: { json?: boolean }) { await backfillPrompts(sessions, vmRunning) - const results = await Promise.all( - sessions.map(s => resolveStatus(s, `${s.repo}/${s.branch}`, vmRunning)) + const statuses = await resolveAllStatuses( + sessions, vmRunning, s => `${s.repo}/${s.branch}`, s => s.repoRoot, ) - const statuses = Object.fromEntries(results.map(r => [r.key, r.status])) - const staleReviews: { repoRoot: string; branch: string }[] = [] - for (let i = 0; i < results.length; i++) { - if (results[i].staleReview) { - sessions[i].in_review = false - staleReviews.push({ repoRoot: sessions[i].repoRoot, branch: sessions[i].branch }) - } + if (opts.json) { + const withStatus = sessions.map(s => ({ ...s, status: statuses[`${s.repo}/${s.branch}`] })) + console.log(JSON.stringify(withStatus, null, 2)) + return } - await clearStaleReviews(staleReviews) const byRepo = Map.groupBy(sessions, s => s.repo) diff --git a/src/state.ts b/src/state.ts index 3454c76..faf5fb1 100644 --- a/src/state.ts +++ b/src/state.ts @@ -1,5 +1,5 @@ import { join, basename, resolve } from "path" -import { readdir, rename, mkdir, rmdir } from "fs/promises" +import { readdir, rename, mkdir, rmdir, stat } from "fs/promises" import { homedir } from "os" export interface Session { @@ -71,6 +71,14 @@ async function withGlobalLock(fn: () => Promise): Promise { await mkdir(lockPath) break } catch { + // If the lock is older than 30 seconds, assume it's stale (crashed process) + try { + const info = await stat(lockPath) + if (Date.now() - info.mtimeMs > 30_000) { + await rmdir(lockPath).catch(() => {}) + continue + } + } catch {} if (i === 19) throw new Error("Could not acquire registry lock") await Bun.sleep(50) } @@ -159,18 +167,8 @@ export async function scanAndRegister(dir: string, maxDepth = 5): Promise 0) { - await withGlobalLock(async () => { - const gs = await loadGlobal() - let changed = false - for (const p of found) { - if (!gs.projects.includes(p)) { - gs.projects.push(p) - changed = true - } - } - if (changed) await saveGlobal(gs) - }) + for (const p of found) { + await registerProject(p) } return found