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 { 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<string> {
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"

View File

@ -66,12 +66,14 @@ const GLOBAL_STATE_PATH = join(GLOBAL_DIR, "registry.json")
async function withGlobalLock<T>(fn: () => Promise<T>): Promise<T> {
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<T>(fn: () => Promise<T>): Promise<T> {
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<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)))
}
@ -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<GlobalSession[]> {
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