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:
parent
3f8a3839f1
commit
7bea6f376b
|
|
@ -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"
|
||||||
|
|
|
||||||
30
src/state.ts
30
src/state.ts
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user