Use patchSession to avoid race conditions in review flag updates

The load-modify-save pattern could overwrite concurrent state changes.
patchSession does an atomic read-patch-write, and the list command now
re-checks activity before clearing stale flags to avoid racing with a
review that just started.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Wanstrath 2026-03-19 10:56:50 -07:00
parent ad90c9dcc1
commit 69ba73b3c3
3 changed files with 17 additions and 19 deletions

View File

@ -59,15 +59,11 @@ export async function action(opts: { json?: boolean }) {
)
const statuses = Object.fromEntries(statusEntries)
// Batch self-heal stale in_review flags — reload fresh state to avoid overwriting concurrent changes
if (staleReviewSessions.length > 0) {
for (const s of staleReviewSessions) s.in_review = false
const fresh = await state.load(root).catch(() => null)
if (fresh) {
// Self-heal stale in_review flags — re-check activity to avoid racing with a concurrent review start
for (const s of staleReviewSessions) {
if (fresh.sessions[s.branch]) fresh.sessions[s.branch].in_review = false
}
await state.save(root, fresh).catch(() => {})
const stillActive = await vm.isClaudeActive(s.worktree, s.branch)
if (!stillActive) {
await state.patchSession(root, s.branch, { in_review: false }).catch(() => {})
}
}

View File

@ -68,14 +68,12 @@ Your thoughts, in brief.
`
if (extra) prompt += "\n\n" + extra
session.in_review = true
await state.setSession(root, session)
await state.patchSession(root, session.branch, { in_review: true })
try {
if (opts.print) {
spin.text = "Running review…"
const result = await vm.claude(session.worktree, { print: prompt })
spin.stop()
if (result.output) process.stdout.write(result.output + "\n")
} else {
spin.succeed("Session ready")
@ -83,13 +81,9 @@ Your thoughts, in brief.
}
} finally {
spin.stop()
// Clear review flag before saveChanges to minimize the race window
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(() => {})
// Patch only in_review on fresh state to avoid overwriting concurrent changes
const current = await state.load(root).catch(() => null)
if (current?.sessions[session.branch]) {
current.sessions[session.branch].in_review = false
await state.save(root, current).catch(() => {})
}
if (!opts.print) await saveChanges(session.worktree, session.branch)
}
}

View File

@ -47,6 +47,14 @@ export async function setSession(repoRoot: string, session: Session): Promise<vo
await save(repoRoot, state)
}
export async function patchSession(repoRoot: string, branch: string, patch: Partial<Session>): Promise<void> {
const state = await load(repoRoot)
if (state.sessions[branch]) {
Object.assign(state.sessions[branch], patch)
await save(repoRoot, state)
}
}
export async function removeSession(repoRoot: string, branch: string): Promise<void> {
const state = await load(repoRoot)
delete state.sessions[branch]