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
const staleReviewSessions: state.Session[] = []
const statusEntries = await Promise.all(
sessions.map(async (s): Promise<[string, string]> => {
const active = await vm.isClaudeActive(s.worktree, s.branch)
if (active && s.in_review) return [s.branch, "review"]
// Collect stale in_review flags for batch self-heal below
if (!active && s.in_review) staleReviewSessions.push(s)
if (!active && s.in_review) {
// 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"]
const dirty = await git.isDirty(s.worktree)
if (dirty) return [s.branch, "dirty"]
@ -55,12 +57,6 @@ export async function action(opts: { json?: boolean }) {
)
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) {
const withStatus = sessions.map(s => ({ ...s, status: statuses[s.branch] }))
console.log(JSON.stringify(withStatus, null, 2))

View File

@ -80,13 +80,9 @@ Your thoughts, in brief.
spin.succeed("Session ready")
await vm.claude(session.worktree, { prompt })
}
} catch (e) {
spin.stop()
throw e
} finally {
// Clear review flag before saveChanges to minimize the race window
spin.stop()
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)
if (!opts.print) await saveChanges(session.worktree, session.branch).catch(() => {})
}
}

View File

@ -1,5 +1,5 @@
import { join, dirname } from "path"
import { readdir, rename, mkdir, rmdir } from "fs/promises"
import { readdir, rename } from "fs/promises"
import { homedir } from "os"
export interface Session {
@ -36,64 +36,28 @@ export async function save(repoRoot: string, state: State): Promise<void> {
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> {
const state = await load(repoRoot)
return state.sessions[branch]
const st = await load(repoRoot)
return st.sessions[branch]
}
export async function setSession(repoRoot: string, session: Session): Promise<void> {
await withStateLock(repoRoot, async () => {
const state = await load(repoRoot)
state.sessions[session.branch] = session
await save(repoRoot, state)
})
const st = await load(repoRoot)
st.sessions[session.branch] = session
await save(repoRoot, st)
}
export async function patchSession(repoRoot: string, branch: string, patch: Partial<Session>): Promise<void> {
await withStateLock(repoRoot, async () => {
const state = await load(repoRoot)
if (!state.sessions[branch]) throw new Error(`session not found: ${branch}`)
Object.assign(state.sessions[branch], patch)
await save(repoRoot, state)
})
const st = await load(repoRoot)
if (!st.sessions[branch]) throw new Error(`session not found: ${branch}`)
Object.assign(st.sessions[branch], patch)
await save(repoRoot, st)
}
export async function removeSession(repoRoot: string, branch: string): Promise<void> {
await withStateLock(repoRoot, async () => {
const state = await load(repoRoot)
delete state.sessions[branch]
await save(repoRoot, state)
})
const st = await load(repoRoot)
delete st.sessions[branch]
await save(repoRoot, st)
}
export interface GlobalSession extends Session {