From ac168d301926bcc1b56080f6478f600bc899fdb8 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Thu, 19 Mar 2026 14:53:14 -0700 Subject: [PATCH] Harden and parallelize session listing Replace deprecated fs.exists with stat, wrap git calls in try/catch to gracefully degrade to "idle" for inaccessible worktrees, and load all projects concurrently in loadAll. Also fix stale-lock retry in withGlobalLock to re-attempt mkdir immediately after cleanup. Co-Authored-By: Claude Opus 4.6 --- src/commands/list.ts | 25 +++++++++++++------------ src/state.ts | 35 ++++++++++++++++++++++++----------- 2 files changed, 37 insertions(+), 23 deletions(-) diff --git a/src/commands/list.ts b/src/commands/list.ts index a89b466..e2fb428 100644 --- a/src/commands/list.ts +++ b/src/commands/list.ts @@ -1,6 +1,6 @@ import { basename } from "path" import { homedir } from "os" -import { exists } from "fs/promises" +import { stat } from "fs/promises" import * as git from "../git.ts" import * as vm from "../vm.ts" import * as state from "../state.ts" @@ -47,16 +47,20 @@ async function resolveStatus( s: { branch: string; worktree: string; in_review?: boolean }, vmRunning: boolean, ): Promise { - if (!(await exists(s.worktree))) return "idle" + try { await stat(s.worktree) } catch { return "idle" } if (vmRunning) { const active = await vm.isClaudeActive(s.worktree, s.branch) if (active && s.in_review) return "review" if (active) return "active" } - const dirty = await git.isDirty(s.worktree) - if (dirty) return "dirty" - const commits = await git.hasNewCommits(s.worktree) - return commits ? "saved" : "idle" + try { + const dirty = await git.isDirty(s.worktree) + if (dirty) return "dirty" + const commits = await git.hasNewCommits(s.worktree) + return commits ? "saved" : "idle" + } catch { + return "idle" + } } async function resolveAllStatuses( @@ -135,16 +139,13 @@ export async function action(opts: { json?: boolean; all?: boolean; add?: string sessions = Object.values(st.sessions).map(s => ({ ...s, repoRoot: root })) } + const vmRunning = (await vm.status()) === "running" + if (sessions.length === 0 && !opts.json) { console.log(opts.all ? "◆ No active sessions across any project." : "◆ No active sessions.") - if (!opts.all) { - const vmRunning = (await vm.status()) === "running" - if (!vmRunning) console.log(`\n${red}VM is not running.${reset}`) - } + if (!opts.all && !vmRunning) console.log(`\n${red}VM is not running.${reset}`) return } - - const vmRunning = (await vm.status()) === "running" await backfillPrompts(sessions, vmRunning) // Key by repoRoot/branch to avoid collisions across repos with the same basename diff --git a/src/state.ts b/src/state.ts index 59db416..fb36f62 100644 --- a/src/state.ts +++ b/src/state.ts @@ -66,11 +66,9 @@ const GLOBAL_STATE_PATH = join(GLOBAL_DIR, "registry.json") async function withGlobalLock(fn: () => Promise): Promise { const lockPath = join(GLOBAL_DIR, "registry.lock") await mkdir(GLOBAL_DIR, { recursive: true }) - let acquired = false for (let i = 0; i < 20; i++) { try { await mkdir(lockPath) - acquired = true break } catch { // If the lock is older than 5 minutes, assume it's stale (crashed process) @@ -78,7 +76,11 @@ async function withGlobalLock(fn: () => Promise): Promise { const info = await stat(lockPath) if (Date.now() - info.mtimeMs > 300_000) { await rmdir(lockPath).catch(() => {}) - continue + // Retry mkdir immediately instead of continuing the loop + try { + await mkdir(lockPath) + break + } catch {} } } catch {} if (i === 19) throw new Error("Could not acquire registry lock") @@ -196,27 +198,38 @@ export async function loadAll(): Promise { const all: GlobalSession[] = [] const stale: string[] = [] - for (const project of gs.projects) { - const st = await load(project) + const results = await Promise.all(gs.projects.map(async (project) => { + let st: State + try { + st = await load(project) + } catch { + return { project, stale: true } as const + } if (Object.keys(st.sessions).length === 0) { - // Check if the state file actually exists — if not, this project is stale const file = Bun.file(join(project, ".sandlot", "state.json")) if (!(await file.exists())) { - stale.push(project) - continue + return { project, stale: true } as const } } const repo = basename(project) - for (const session of Object.values(st.sessions)) { - all.push({ ...session, repo, repoRoot: project }) + const sessions = Object.values(st.sessions).map(s => ({ ...s, repo, repoRoot: project })) + return { project, stale: false, sessions } as const + })) + + for (const r of results) { + if (r.stale) { + stale.push(r.project) + } else if ('sessions' in r) { + all.push(...r.sessions) } } // Prune projects whose state files no longer exist if (stale.length > 0) { + const staleSet = new Set(stale) await withGlobalLock(async () => { const fresh = await loadGlobal() - fresh.projects = fresh.projects.filter(p => !stale.includes(p)) + fresh.projects = fresh.projects.filter(p => !staleSet.has(p)) await saveGlobal(fresh) }).catch(() => {}) }