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