From 7bea6f376b4b62d62005fe006b1e87a57be9f847 Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Thu, 19 Mar 2026 14:41:10 -0700 Subject: [PATCH] Harden state management against stale and missing entries The registry lock could spin silently forever on contention, worktrees could vanish between runs, and loadAll swallowed errors from projects whose state files were removed. Also skip symlinks during scan to avoid cycles. Co-Authored-By: Claude Opus 4.6 --- src/commands/list.ts | 2 ++ src/state.ts | 30 +++++++++++++++++++++++++----- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/src/commands/list.ts b/src/commands/list.ts index d0bb12f..1970c84 100644 --- a/src/commands/list.ts +++ b/src/commands/list.ts @@ -1,5 +1,6 @@ import { basename } from "path" import { homedir } from "os" +import { exists } from "fs/promises" import * as git from "../git.ts" import * as vm from "../vm.ts" import * as state from "../state.ts" @@ -46,6 +47,7 @@ async function resolveStatus( s: { branch: string; worktree: string; in_review?: boolean }, vmRunning: boolean, ): Promise { + if (!(await exists(s.worktree))) return "idle" if (vmRunning) { const active = await vm.isClaudeActive(s.worktree, s.branch) if (active && s.in_review) return "review" diff --git a/src/state.ts b/src/state.ts index 15f051b..63f491a 100644 --- a/src/state.ts +++ b/src/state.ts @@ -66,12 +66,14 @@ 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 30 seconds, assume it's stale (crashed process) + // If the lock is older than 5 minutes, assume it's stale (crashed process) try { const info = await stat(lockPath) if (Date.now() - info.mtimeMs > 300_000) { @@ -83,6 +85,7 @@ async function withGlobalLock(fn: () => Promise): Promise { await Bun.sleep(50) } } + if (!acquired) throw new Error("Could not acquire registry lock") try { return await fn() } finally { @@ -161,7 +164,7 @@ export async function scanAndRegister(dir: string, maxDepth = 5): Promise e.isDirectory() && !e.name.startsWith(".") && e.name !== "node_modules") + const children = entries.filter(e => e.isDirectory() && !e.isSymbolicLink() && !e.name.startsWith(".") && e.name !== "node_modules") await Promise.all(children.map(entry => walk(join(d, entry.name), depth + 1))) } @@ -190,19 +193,36 @@ export interface GlobalSession extends Session { repoRoot: string } -/** Load all sessions across all registered projects. */ +/** Load all sessions across all registered projects. Prunes stale entries. */ export async function loadAll(): Promise { const gs = await loadGlobal() const all: GlobalSession[] = [] + const stale: string[] = [] for (const project of gs.projects) { try { - const st = await load(project) + const file = Bun.file(join(project, ".sandlot", "state.json")) + if (!(await file.exists())) { + stale.push(project) + continue + } + const st: State = await file.json() const repo = basename(project) for (const session of Object.values(st.sessions)) { all.push({ ...session, repo, repoRoot: project }) } - } catch {} + } catch { + stale.push(project) + } + } + + // Prune projects whose state files no longer exist + if (stale.length > 0) { + withGlobalLock(async () => { + const fresh = await loadGlobal() + fresh.projects = fresh.projects.filter(p => !stale.includes(p)) + await saveGlobal(fresh) + }).catch(() => {}) } return all