diff --git a/src/commands/list.ts b/src/commands/list.ts index 2c6586c..e9c986e 100644 --- a/src/commands/list.ts +++ b/src/commands/list.ts @@ -30,14 +30,10 @@ export async function action(opts: { json?: boolean }) { } catch {} } - if (sessions.length === 0) { - if (opts.json) { - console.log("[]") - } else { - console.log("◆ No active sessions.") - if ((await vm.status()) !== "running") { - console.log(`\n${red}VM is not running.${reset}`) - } + if (sessions.length === 0 && !opts.json) { + console.log("◆ No active sessions.") + if ((await vm.status()) !== "running") { + console.log(`\n${red}VM is not running.${reset}`) } return } @@ -59,13 +55,11 @@ export async function action(opts: { json?: boolean }) { ) const statuses = Object.fromEntries(statusEntries) - // Self-heal stale in_review flags — re-check activity to avoid racing with a concurrent review start - for (const s of staleReviewSessions) { - const stillActive = await vm.isClaudeActive(s.worktree, s.branch) - if (!stillActive) { - await state.patchSession(root, s.branch, { in_review: false }).catch(() => {}) - } - } + // 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] })) diff --git a/src/commands/review.ts b/src/commands/review.ts index 4798d1a..66321e7 100644 --- a/src/commands/review.ts +++ b/src/commands/review.ts @@ -74,13 +74,16 @@ Your thoughts, in brief. 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") await vm.claude(session.worktree, { prompt }) } - } finally { + } catch (e) { spin.stop() + throw e + } finally { // 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 diff --git a/src/state.ts b/src/state.ts index f30e827..a9b2764 100644 --- a/src/state.ts +++ b/src/state.ts @@ -1,5 +1,5 @@ import { join, dirname } from "path" -import { readdir, rename } from "fs/promises" +import { readdir, rename, mkdir, rmdir } from "fs/promises" import { homedir } from "os" export interface Session { @@ -36,29 +36,64 @@ export async function save(repoRoot: string, state: State): Promise { await rename(tmpPath, path) } +const LOCK_TIMEOUT = 5000 +const LOCK_RETRY_MS = 50 + +async function withStateLock(repoRoot: string, fn: () => Promise): Promise { + 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 { const state = await load(repoRoot) return state.sessions[branch] } export async function setSession(repoRoot: string, session: Session): Promise { - const state = await load(repoRoot) - state.sessions[session.branch] = session - await save(repoRoot, state) + await withStateLock(repoRoot, async () => { + const state = await load(repoRoot) + state.sessions[session.branch] = session + await save(repoRoot, state) + }) } export async function patchSession(repoRoot: string, branch: string, patch: Partial): Promise { - const state = await load(repoRoot) - if (state.sessions[branch]) { + 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) - } + }) } export async function removeSession(repoRoot: string, branch: string): Promise { - const state = await load(repoRoot) - delete state.sessions[branch] - await save(repoRoot, state) + await withStateLock(repoRoot, async () => { + const state = await load(repoRoot) + delete state.sessions[branch] + await save(repoRoot, state) + }) } export interface GlobalSession extends Session {