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 <noreply@anthropic.com>
This commit is contained in:
Chris Wanstrath 2026-03-19 14:41:10 -07:00
parent 3f8a3839f1
commit 7bea6f376b
2 changed files with 27 additions and 5 deletions

View File

@ -1,5 +1,6 @@
import { basename } from "path" import { basename } from "path"
import { homedir } from "os" import { homedir } from "os"
import { exists } from "fs/promises"
import * as git from "../git.ts" import * as git from "../git.ts"
import * as vm from "../vm.ts" import * as vm from "../vm.ts"
import * as state from "../state.ts" import * as state from "../state.ts"
@ -46,6 +47,7 @@ async function resolveStatus(
s: { branch: string; worktree: string; in_review?: boolean }, s: { branch: string; worktree: string; in_review?: boolean },
vmRunning: boolean, vmRunning: boolean,
): Promise<string> { ): Promise<string> {
if (!(await exists(s.worktree))) return "idle"
if (vmRunning) { if (vmRunning) {
const active = await vm.isClaudeActive(s.worktree, s.branch) const active = await vm.isClaudeActive(s.worktree, s.branch)
if (active && s.in_review) return "review" if (active && s.in_review) return "review"

View File

@ -66,12 +66,14 @@ const GLOBAL_STATE_PATH = join(GLOBAL_DIR, "registry.json")
async function withGlobalLock<T>(fn: () => Promise<T>): Promise<T> { async function withGlobalLock<T>(fn: () => Promise<T>): Promise<T> {
const lockPath = join(GLOBAL_DIR, "registry.lock") const lockPath = join(GLOBAL_DIR, "registry.lock")
await mkdir(GLOBAL_DIR, { recursive: true }) await mkdir(GLOBAL_DIR, { recursive: true })
let acquired = false
for (let i = 0; i < 20; i++) { for (let i = 0; i < 20; i++) {
try { try {
await mkdir(lockPath) await mkdir(lockPath)
acquired = true
break break
} catch { } 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 { try {
const info = await stat(lockPath) const info = await stat(lockPath)
if (Date.now() - info.mtimeMs > 300_000) { if (Date.now() - info.mtimeMs > 300_000) {
@ -83,6 +85,7 @@ async function withGlobalLock<T>(fn: () => Promise<T>): Promise<T> {
await Bun.sleep(50) await Bun.sleep(50)
} }
} }
if (!acquired) throw new Error("Could not acquire registry lock")
try { try {
return await fn() return await fn()
} finally { } finally {
@ -161,7 +164,7 @@ export async function scanAndRegister(dir: string, maxDepth = 5): Promise<string
} }
} }
const children = entries.filter(e => 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))) await Promise.all(children.map(entry => walk(join(d, entry.name), depth + 1)))
} }
@ -190,19 +193,36 @@ export interface GlobalSession extends Session {
repoRoot: string repoRoot: string
} }
/** Load all sessions across all registered projects. */ /** Load all sessions across all registered projects. Prunes stale entries. */
export async function loadAll(): Promise<GlobalSession[]> { export async function loadAll(): Promise<GlobalSession[]> {
const gs = await loadGlobal() const gs = await loadGlobal()
const all: GlobalSession[] = [] const all: GlobalSession[] = []
const stale: string[] = []
for (const project of gs.projects) { for (const project of gs.projects) {
try { 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) const repo = basename(project)
for (const session of Object.values(st.sessions)) { for (const session of Object.values(st.sessions)) {
all.push({ ...session, repo, repoRoot: project }) 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 return all