Remove state file locking and simplify review cleanup

The mkdir-based lock was unreliable (stale lock recovery was racy) and
added latency. The atomic rename in save() already prevents corruption,
and concurrent writes to different keys are rare enough to not warrant
the complexity. Also inlines stale review self-healing into the map
callback and collapses the review try/catch/finally into just finally.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Wanstrath 2026-03-19 11:11:16 -07:00
parent bd9d481e81
commit 9336deed9c
3 changed files with 20 additions and 64 deletions

View File

@ -39,13 +39,15 @@ export async function action(opts: { json?: boolean }) {
} }
// Determine status for each session in parallel // Determine status for each session in parallel
const staleReviewSessions: state.Session[] = []
const statusEntries = await Promise.all( const statusEntries = await Promise.all(
sessions.map(async (s): Promise<[string, string]> => { sessions.map(async (s): Promise<[string, string]> => {
const active = await vm.isClaudeActive(s.worktree, s.branch) const active = await vm.isClaudeActive(s.worktree, s.branch)
if (active && s.in_review) return [s.branch, "review"] if (active && s.in_review) return [s.branch, "review"]
// Collect stale in_review flags for batch self-heal below if (!active && s.in_review) {
if (!active && s.in_review) staleReviewSessions.push(s) // Self-heal stale in_review flag (fire-and-forget)
state.patchSession(root, s.branch, { in_review: false }).catch(() => {})
s.in_review = false
}
if (active) return [s.branch, "active"] if (active) return [s.branch, "active"]
const dirty = await git.isDirty(s.worktree) const dirty = await git.isDirty(s.worktree)
if (dirty) return [s.branch, "dirty"] if (dirty) return [s.branch, "dirty"]
@ -55,12 +57,6 @@ export async function action(opts: { json?: boolean }) {
) )
const statuses = Object.fromEntries(statusEntries) const statuses = Object.fromEntries(statusEntries)
// Self-heal stale in_review flags in parallel and update in-memory objects
await Promise.all(staleReviewSessions.map(async (s) => {
await state.patchSession(root, s.branch, { in_review: false }).catch(() => {})
s.in_review = false
}))
if (opts.json) { if (opts.json) {
const withStatus = sessions.map(s => ({ ...s, status: statuses[s.branch] })) const withStatus = sessions.map(s => ({ ...s, status: statuses[s.branch] }))
console.log(JSON.stringify(withStatus, null, 2)) console.log(JSON.stringify(withStatus, null, 2))

View File

@ -80,13 +80,9 @@ Your thoughts, in brief.
spin.succeed("Session ready") spin.succeed("Session ready")
await vm.claude(session.worktree, { prompt }) await vm.claude(session.worktree, { prompt })
} }
} catch (e) {
spin.stop()
throw e
} finally { } finally {
// Clear review flag before saveChanges to minimize the race window spin.stop()
await state.patchSession(root, session.branch, { in_review: false }).catch(() => {}) await state.patchSession(root, session.branch, { in_review: false }).catch(() => {})
// Save worktree changes only in interactive mode if (!opts.print) await saveChanges(session.worktree, session.branch).catch(() => {})
if (!opts.print) await saveChanges(session.worktree, session.branch)
} }
} }

View File

@ -1,5 +1,5 @@
import { join, dirname } from "path" import { join, dirname } from "path"
import { readdir, rename, mkdir, rmdir } from "fs/promises" import { readdir, rename } from "fs/promises"
import { homedir } from "os" import { homedir } from "os"
export interface Session { export interface Session {
@ -36,64 +36,28 @@ export async function save(repoRoot: string, state: State): Promise<void> {
await rename(tmpPath, path) await rename(tmpPath, path)
} }
const LOCK_TIMEOUT = 5000
const LOCK_RETRY_MS = 50
async function withStateLock<T>(repoRoot: string, fn: () => Promise<T>): Promise<T> {
const lockPath = statePath(repoRoot) + ".lock"
const start = Date.now()
while (true) {
try {
await mkdir(lockPath)
break
} catch (e: any) {
if (e.code !== "EEXIST") throw e
if (Date.now() - start > LOCK_TIMEOUT) {
// Stale lock — force acquire
await rmdir(lockPath).catch(() => {})
await mkdir(lockPath)
break
}
await Bun.sleep(LOCK_RETRY_MS)
}
}
try {
return await fn()
} finally {
await rmdir(lockPath).catch(() => {})
}
}
export async function getSession(repoRoot: string, branch: string): Promise<Session | undefined> { export async function getSession(repoRoot: string, branch: string): Promise<Session | undefined> {
const state = await load(repoRoot) const st = await load(repoRoot)
return state.sessions[branch] return st.sessions[branch]
} }
export async function setSession(repoRoot: string, session: Session): Promise<void> { export async function setSession(repoRoot: string, session: Session): Promise<void> {
await withStateLock(repoRoot, async () => { const st = await load(repoRoot)
const state = await load(repoRoot) st.sessions[session.branch] = session
state.sessions[session.branch] = session await save(repoRoot, st)
await save(repoRoot, state)
})
} }
export async function patchSession(repoRoot: string, branch: string, patch: Partial<Session>): Promise<void> { export async function patchSession(repoRoot: string, branch: string, patch: Partial<Session>): Promise<void> {
await withStateLock(repoRoot, async () => { const st = await load(repoRoot)
const state = await load(repoRoot) if (!st.sessions[branch]) throw new Error(`session not found: ${branch}`)
if (!state.sessions[branch]) throw new Error(`session not found: ${branch}`) Object.assign(st.sessions[branch], patch)
Object.assign(state.sessions[branch], patch) await save(repoRoot, st)
await save(repoRoot, state)
})
} }
export async function removeSession(repoRoot: string, branch: string): Promise<void> { export async function removeSession(repoRoot: string, branch: string): Promise<void> {
await withStateLock(repoRoot, async () => { const st = await load(repoRoot)
const state = await load(repoRoot) delete st.sessions[branch]
delete state.sessions[branch] await save(repoRoot, st)
await save(repoRoot, state)
})
} }
export interface GlobalSession extends Session { export interface GlobalSession extends Session {