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:
parent
bd9d481e81
commit
9336deed9c
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
62
src/state.ts
62
src/state.ts
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user