Remove ListSession type and harden lock acquisition
Consolidate on state.GlobalSession to eliminate redundant interface, extract clearStaleReviews for clarity, and fix withGlobalLock to explicitly throw when the lock cannot be acquired instead of silently proceeding.
This commit is contained in:
parent
3d07643547
commit
e5f1de4717
|
|
@ -6,25 +6,20 @@ import * as vm from "../vm.ts"
|
|||
import * as state from "../state.ts"
|
||||
import { die, reset, dim, green, yellow, cyan, magenta, red } from "../fmt.ts"
|
||||
|
||||
interface ListSession extends state.Session {
|
||||
repoRoot: string
|
||||
repo?: string
|
||||
}
|
||||
|
||||
// ── Shared rendering ─────────────────────────────────────────────────
|
||||
|
||||
const styleDefs: [string, string, string][] = [
|
||||
["idle", dim, "◯"], ["active", cyan, "◎"], ["dirty", yellow, "◐"],
|
||||
["saved", green, "●"], ["review", magenta, "⊛"],
|
||||
["saved", green, "●"], ["review", magenta, "⦿"],
|
||||
]
|
||||
const styles = Object.fromEntries(
|
||||
styleDefs.map(([k, c, ch]) => [k, { icon: `${c}${ch}${reset}`, color: c }])
|
||||
)
|
||||
|
||||
function renderSessions(
|
||||
sessions: ListSession[],
|
||||
sessions: state.GlobalSession[],
|
||||
statuses: Record<string, string>,
|
||||
keyFn: (s: ListSession) => string,
|
||||
keyFn: (s: state.GlobalSession) => string,
|
||||
) {
|
||||
const branchWidth = Math.max(6, ...sessions.map(s => s.branch.length))
|
||||
const cols = process.stdout.columns || 80
|
||||
|
|
@ -43,7 +38,7 @@ function renderSessions(
|
|||
}
|
||||
|
||||
function renderLegend() {
|
||||
console.log(`\n${dim}◯ idle${reset} · ${cyan}◎ active${reset} · ${yellow}◐ unsaved${reset} · ${green}● saved${reset} · ${magenta}⊛ review${reset}`)
|
||||
console.log(`\n${dim}◯ idle${reset} · ${cyan}◎ active${reset} · ${yellow}◐ unsaved${reset} · ${green}● saved${reset} · ${magenta}⦿ review${reset}`)
|
||||
}
|
||||
|
||||
// ── Shared logic ─────────────────────────────────────────────────────
|
||||
|
|
@ -75,9 +70,15 @@ async function resolveAllStatuses<T extends { branch: string; worktree: string;
|
|||
): Promise<Record<string, string>> {
|
||||
const results = await Promise.all(sessions.map(s => resolveStatus(s, vmRunning)))
|
||||
const statuses = Object.fromEntries(sessions.map((s, i) => [keyFn(s), results[i]]))
|
||||
await clearStaleReviews(sessions, results)
|
||||
return statuses
|
||||
}
|
||||
|
||||
// Clear stale reviews: in_review is set but Claude isn't actually active
|
||||
// Re-load state before saving to narrow the race window with concurrent writers
|
||||
/** Clear in_review flags for sessions where Claude is no longer active. */
|
||||
async function clearStaleReviews<T extends { branch: string; in_review?: boolean; repoRoot: string }>(
|
||||
sessions: T[],
|
||||
results: string[],
|
||||
) {
|
||||
const staleByRepo = new Map<string, string[]>()
|
||||
for (let i = 0; i < sessions.length; i++) {
|
||||
if (sessions[i].in_review && results[i] !== "review") {
|
||||
|
|
@ -94,8 +95,6 @@ async function resolveAllStatuses<T extends { branch: string; worktree: string;
|
|||
}
|
||||
await state.save(repoRoot, fresh).catch(() => {})
|
||||
}
|
||||
|
||||
return statuses
|
||||
}
|
||||
|
||||
async function backfillPrompts(sessions: { worktree: string; prompt?: string }[], vmRunning: boolean) {
|
||||
|
|
@ -130,13 +129,13 @@ export async function action(opts: { json?: boolean; all?: boolean; add?: string
|
|||
if (opts.remove) return actionRemove(opts.remove)
|
||||
|
||||
// Load sessions with repoRoot attached
|
||||
let sessions: ListSession[]
|
||||
let sessions: state.GlobalSession[]
|
||||
if (opts.all) {
|
||||
sessions = await state.loadAll()
|
||||
} else {
|
||||
const root = await git.repoRoot()
|
||||
const st = await state.load(root)
|
||||
sessions = Object.values(st.sessions).map(s => ({ ...s, repoRoot: root }))
|
||||
sessions = Object.values(st.sessions).map(s => ({ ...s, repo: basename(root), repoRoot: root }))
|
||||
}
|
||||
|
||||
const vmRunning = (await vm.status()) === "running"
|
||||
|
|
@ -149,7 +148,7 @@ export async function action(opts: { json?: boolean; all?: boolean; add?: string
|
|||
await backfillPrompts(sessions, vmRunning)
|
||||
|
||||
// Key by repoRoot/branch to avoid collisions across repos with the same basename
|
||||
const keyFn = (s: ListSession) => `${s.repoRoot}/${s.branch}`
|
||||
const keyFn = (s: state.GlobalSession) => `${s.repoRoot}/${s.branch}`
|
||||
const statuses = await resolveAllStatuses(sessions, vmRunning, keyFn)
|
||||
|
||||
if (opts.json) {
|
||||
|
|
@ -159,7 +158,7 @@ export async function action(opts: { json?: boolean; all?: boolean; add?: string
|
|||
}
|
||||
|
||||
if (opts.all) {
|
||||
const byRepo = Map.groupBy(sessions as state.GlobalSession[], s => s.repoRoot)
|
||||
const byRepo = Map.groupBy(sessions, s => s.repoRoot)
|
||||
for (const [repoRoot, repoSessions] of byRepo) {
|
||||
console.log(`\n${dim}── ${reset}${basename(repoRoot)}${dim} ──${reset}`)
|
||||
renderSessions(repoSessions, statuses, keyFn)
|
||||
|
|
@ -173,7 +172,12 @@ export async function action(opts: { json?: boolean; all?: boolean; add?: string
|
|||
}
|
||||
|
||||
async function actionAdd(dir: string) {
|
||||
const registered = await state.scanAndRegister(dir)
|
||||
let registered: string[]
|
||||
try {
|
||||
registered = await state.scanAndRegister(dir)
|
||||
} catch (e) {
|
||||
die(e instanceof Error ? e.message : `Failed to scan ${dir}`)
|
||||
}
|
||||
if (registered.length === 0) {
|
||||
console.log(`No sandlot projects found under ${dir}`)
|
||||
} else {
|
||||
|
|
@ -185,7 +189,7 @@ async function actionAdd(dir: string) {
|
|||
}
|
||||
|
||||
async function actionRemove(dir: string) {
|
||||
let removed: boolean
|
||||
let removed = false
|
||||
try {
|
||||
removed = await state.unregisterProject(dir)
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -66,9 +66,11 @@ const GLOBAL_STATE_PATH = join(GLOBAL_DIR, "registry.json")
|
|||
async function withGlobalLock<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const lockPath = join(GLOBAL_DIR, "registry.lock")
|
||||
await mkdir(GLOBAL_DIR, { recursive: true })
|
||||
let acquired = false
|
||||
for (let i = 0; i < 20; i++) {
|
||||
try {
|
||||
await mkdir(lockPath)
|
||||
acquired = true
|
||||
break
|
||||
} catch {
|
||||
// If the lock is older than 5 minutes, assume it's stale (crashed process)
|
||||
|
|
@ -76,6 +78,7 @@ async function withGlobalLock<T>(fn: () => Promise<T>): Promise<T> {
|
|||
const info = await stat(lockPath)
|
||||
if (Date.now() - info.mtimeMs > 300_000) {
|
||||
await rmdir(lockPath).catch(() => {})
|
||||
await Bun.sleep(50)
|
||||
continue
|
||||
}
|
||||
} catch {}
|
||||
|
|
@ -83,6 +86,7 @@ async function withGlobalLock<T>(fn: () => Promise<T>): Promise<T> {
|
|||
await Bun.sleep(50)
|
||||
}
|
||||
}
|
||||
if (!acquired) throw new Error("Could not acquire registry lock")
|
||||
try {
|
||||
return await fn()
|
||||
} finally {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user