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 <noreply@anthropic.com>
This commit is contained in:
Chris Wanstrath 2026-03-19 14:53:14 -07:00
parent c46ad53fa3
commit ac168d3019
2 changed files with 37 additions and 23 deletions

View File

@ -1,6 +1,6 @@
import { basename } from "path" import { basename } from "path"
import { homedir } from "os" import { homedir } from "os"
import { exists } from "fs/promises" import { stat } 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"
@ -47,16 +47,20 @@ 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" try { await stat(s.worktree) } catch { 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"
if (active) return "active" if (active) return "active"
} }
try {
const dirty = await git.isDirty(s.worktree) const dirty = await git.isDirty(s.worktree)
if (dirty) return "dirty" if (dirty) return "dirty"
const commits = await git.hasNewCommits(s.worktree) const commits = await git.hasNewCommits(s.worktree)
return commits ? "saved" : "idle" return commits ? "saved" : "idle"
} catch {
return "idle"
}
} }
async function resolveAllStatuses<T extends { branch: string; worktree: string; in_review?: boolean; repoRoot: string }>( async function resolveAllStatuses<T extends { branch: string; worktree: string; in_review?: boolean; repoRoot: string }>(
@ -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 })) sessions = Object.values(st.sessions).map(s => ({ ...s, repoRoot: root }))
} }
const vmRunning = (await vm.status()) === "running"
if (sessions.length === 0 && !opts.json) { if (sessions.length === 0 && !opts.json) {
console.log(opts.all ? "◆ No active sessions across any project." : "◆ No active sessions.") console.log(opts.all ? "◆ No active sessions across any project." : "◆ No active sessions.")
if (!opts.all) { if (!opts.all && !vmRunning) console.log(`\n${red}VM is not running.${reset}`)
const vmRunning = (await vm.status()) === "running"
if (!vmRunning) console.log(`\n${red}VM is not running.${reset}`)
}
return return
} }
const vmRunning = (await vm.status()) === "running"
await backfillPrompts(sessions, vmRunning) await backfillPrompts(sessions, vmRunning)
// Key by repoRoot/branch to avoid collisions across repos with the same basename // Key by repoRoot/branch to avoid collisions across repos with the same basename

View File

@ -66,11 +66,9 @@ 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 5 minutes, assume it's stale (crashed process) // If the lock is older than 5 minutes, assume it's stale (crashed process)
@ -78,7 +76,11 @@ async function withGlobalLock<T>(fn: () => Promise<T>): Promise<T> {
const info = await stat(lockPath) const info = await stat(lockPath)
if (Date.now() - info.mtimeMs > 300_000) { if (Date.now() - info.mtimeMs > 300_000) {
await rmdir(lockPath).catch(() => {}) await rmdir(lockPath).catch(() => {})
continue // Retry mkdir immediately instead of continuing the loop
try {
await mkdir(lockPath)
break
} catch {}
} }
} catch {} } catch {}
if (i === 19) throw new Error("Could not acquire registry lock") if (i === 19) throw new Error("Could not acquire registry lock")
@ -196,27 +198,38 @@ export async function loadAll(): Promise<GlobalSession[]> {
const all: GlobalSession[] = [] const all: GlobalSession[] = []
const stale: string[] = [] const stale: string[] = []
for (const project of gs.projects) { const results = await Promise.all(gs.projects.map(async (project) => {
const st = await load(project) let st: State
try {
st = await load(project)
} catch {
return { project, stale: true } as const
}
if (Object.keys(st.sessions).length === 0) { 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")) const file = Bun.file(join(project, ".sandlot", "state.json"))
if (!(await file.exists())) { if (!(await file.exists())) {
stale.push(project) return { project, stale: true } as const
continue
} }
} }
const repo = basename(project) const repo = basename(project)
for (const session of Object.values(st.sessions)) { const sessions = Object.values(st.sessions).map(s => ({ ...s, repo, repoRoot: project }))
all.push({ ...session, 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 // Prune projects whose state files no longer exist
if (stale.length > 0) { if (stale.length > 0) {
const staleSet = new Set(stale)
await withGlobalLock(async () => { await withGlobalLock(async () => {
const fresh = await loadGlobal() 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) await saveGlobal(fresh)
}).catch(() => {}) }).catch(() => {})
} }