Harden and parallelize session listing
Replace deprecated fs.exists with stat, wrap git calls in try/catch to gracefully degrade to "idle" for inaccessible worktrees, and load all projects concurrently in loadAll. Also fix stale-lock retry in withGlobalLock to re-attempt mkdir immediately after cleanup. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c46ad53fa3
commit
ac168d3019
|
|
@ -1,6 +1,6 @@
|
||||||
import { basename } from "path"
|
import { basename } from "path"
|
||||||
import { homedir } from "os"
|
import { homedir } from "os"
|
||||||
import { exists } from "fs/promises"
|
import { stat } from "fs/promises"
|
||||||
import * as git from "../git.ts"
|
import * as git from "../git.ts"
|
||||||
import * as vm from "../vm.ts"
|
import * as vm from "../vm.ts"
|
||||||
import * as state from "../state.ts"
|
import * as state from "../state.ts"
|
||||||
|
|
@ -47,16 +47,20 @@ async function resolveStatus(
|
||||||
s: { branch: string; worktree: string; in_review?: boolean },
|
s: { branch: string; worktree: string; in_review?: boolean },
|
||||||
vmRunning: boolean,
|
vmRunning: boolean,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
if (!(await exists(s.worktree))) return "idle"
|
try { await stat(s.worktree) } catch { return "idle" }
|
||||||
if (vmRunning) {
|
if (vmRunning) {
|
||||||
const active = await vm.isClaudeActive(s.worktree, s.branch)
|
const active = await vm.isClaudeActive(s.worktree, s.branch)
|
||||||
if (active && s.in_review) return "review"
|
if (active && s.in_review) return "review"
|
||||||
if (active) return "active"
|
if (active) return "active"
|
||||||
}
|
}
|
||||||
const dirty = await git.isDirty(s.worktree)
|
try {
|
||||||
if (dirty) return "dirty"
|
const dirty = await git.isDirty(s.worktree)
|
||||||
const commits = await git.hasNewCommits(s.worktree)
|
if (dirty) return "dirty"
|
||||||
return commits ? "saved" : "idle"
|
const commits = await git.hasNewCommits(s.worktree)
|
||||||
|
return commits ? "saved" : "idle"
|
||||||
|
} catch {
|
||||||
|
return "idle"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveAllStatuses<T extends { branch: string; worktree: string; in_review?: boolean; repoRoot: string }>(
|
async function resolveAllStatuses<T extends { branch: string; worktree: string; in_review?: boolean; repoRoot: string }>(
|
||||||
|
|
@ -135,16 +139,13 @@ export async function action(opts: { json?: boolean; all?: boolean; add?: string
|
||||||
sessions = Object.values(st.sessions).map(s => ({ ...s, repoRoot: root }))
|
sessions = Object.values(st.sessions).map(s => ({ ...s, repoRoot: root }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const vmRunning = (await vm.status()) === "running"
|
||||||
|
|
||||||
if (sessions.length === 0 && !opts.json) {
|
if (sessions.length === 0 && !opts.json) {
|
||||||
console.log(opts.all ? "◆ No active sessions across any project." : "◆ No active sessions.")
|
console.log(opts.all ? "◆ No active sessions across any project." : "◆ No active sessions.")
|
||||||
if (!opts.all) {
|
if (!opts.all && !vmRunning) console.log(`\n${red}VM is not running.${reset}`)
|
||||||
const vmRunning = (await vm.status()) === "running"
|
|
||||||
if (!vmRunning) console.log(`\n${red}VM is not running.${reset}`)
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const vmRunning = (await vm.status()) === "running"
|
|
||||||
await backfillPrompts(sessions, vmRunning)
|
await backfillPrompts(sessions, vmRunning)
|
||||||
|
|
||||||
// Key by repoRoot/branch to avoid collisions across repos with the same basename
|
// Key by repoRoot/branch to avoid collisions across repos with the same basename
|
||||||
|
|
|
||||||
35
src/state.ts
35
src/state.ts
|
|
@ -66,11 +66,9 @@ const GLOBAL_STATE_PATH = join(GLOBAL_DIR, "registry.json")
|
||||||
async function withGlobalLock<T>(fn: () => Promise<T>): Promise<T> {
|
async function withGlobalLock<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
const lockPath = join(GLOBAL_DIR, "registry.lock")
|
const lockPath = join(GLOBAL_DIR, "registry.lock")
|
||||||
await mkdir(GLOBAL_DIR, { recursive: true })
|
await mkdir(GLOBAL_DIR, { recursive: true })
|
||||||
let acquired = false
|
|
||||||
for (let i = 0; i < 20; i++) {
|
for (let i = 0; i < 20; i++) {
|
||||||
try {
|
try {
|
||||||
await mkdir(lockPath)
|
await mkdir(lockPath)
|
||||||
acquired = true
|
|
||||||
break
|
break
|
||||||
} catch {
|
} catch {
|
||||||
// If the lock is older than 5 minutes, assume it's stale (crashed process)
|
// If the lock is older than 5 minutes, assume it's stale (crashed process)
|
||||||
|
|
@ -78,7 +76,11 @@ async function withGlobalLock<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
const info = await stat(lockPath)
|
const info = await stat(lockPath)
|
||||||
if (Date.now() - info.mtimeMs > 300_000) {
|
if (Date.now() - info.mtimeMs > 300_000) {
|
||||||
await rmdir(lockPath).catch(() => {})
|
await rmdir(lockPath).catch(() => {})
|
||||||
continue
|
// Retry mkdir immediately instead of continuing the loop
|
||||||
|
try {
|
||||||
|
await mkdir(lockPath)
|
||||||
|
break
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
if (i === 19) throw new Error("Could not acquire registry lock")
|
if (i === 19) throw new Error("Could not acquire registry lock")
|
||||||
|
|
@ -196,27 +198,38 @@ export async function loadAll(): Promise<GlobalSession[]> {
|
||||||
const all: GlobalSession[] = []
|
const all: GlobalSession[] = []
|
||||||
const stale: string[] = []
|
const stale: string[] = []
|
||||||
|
|
||||||
for (const project of gs.projects) {
|
const results = await Promise.all(gs.projects.map(async (project) => {
|
||||||
const st = await load(project)
|
let st: State
|
||||||
|
try {
|
||||||
|
st = await load(project)
|
||||||
|
} catch {
|
||||||
|
return { project, stale: true } as const
|
||||||
|
}
|
||||||
if (Object.keys(st.sessions).length === 0) {
|
if (Object.keys(st.sessions).length === 0) {
|
||||||
// Check if the state file actually exists — if not, this project is stale
|
|
||||||
const file = Bun.file(join(project, ".sandlot", "state.json"))
|
const file = Bun.file(join(project, ".sandlot", "state.json"))
|
||||||
if (!(await file.exists())) {
|
if (!(await file.exists())) {
|
||||||
stale.push(project)
|
return { project, stale: true } as const
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const repo = basename(project)
|
const repo = basename(project)
|
||||||
for (const session of Object.values(st.sessions)) {
|
const sessions = Object.values(st.sessions).map(s => ({ ...s, repo, repoRoot: project }))
|
||||||
all.push({ ...session, repo, repoRoot: project })
|
return { project, stale: false, sessions } as const
|
||||||
|
}))
|
||||||
|
|
||||||
|
for (const r of results) {
|
||||||
|
if (r.stale) {
|
||||||
|
stale.push(r.project)
|
||||||
|
} else if ('sessions' in r) {
|
||||||
|
all.push(...r.sessions)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prune projects whose state files no longer exist
|
// Prune projects whose state files no longer exist
|
||||||
if (stale.length > 0) {
|
if (stale.length > 0) {
|
||||||
|
const staleSet = new Set(stale)
|
||||||
await withGlobalLock(async () => {
|
await withGlobalLock(async () => {
|
||||||
const fresh = await loadGlobal()
|
const fresh = await loadGlobal()
|
||||||
fresh.projects = fresh.projects.filter(p => !stale.includes(p))
|
fresh.projects = fresh.projects.filter(p => !staleSet.has(p))
|
||||||
await saveGlobal(fresh)
|
await saveGlobal(fresh)
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user