From 965f23324564d495a4ec346dcacfc0ffd591641f Mon Sep 17 00:00:00 2001 From: Chris Wanstrath Date: Fri, 20 Mar 2026 10:40:54 -0700 Subject: [PATCH] Simplify session status tracking with identity-keyed Map Replace the repoRoot/branch composite-key workaround with a Map keyed directly by session objects. Also tighten the global lock to properly throw on acquisition failure and fix prompt truncation at narrow widths. Co-Authored-By: Claude Opus 4.6 --- src/commands/list.ts | 44 ++++++++++++++++++-------------------------- src/state.ts | 13 +++++++------ 2 files changed, 25 insertions(+), 32 deletions(-) diff --git a/src/commands/list.ts b/src/commands/list.ts index 85fc1d1..71dd28a 100644 --- a/src/commands/list.ts +++ b/src/commands/list.ts @@ -1,4 +1,4 @@ -import { basename } from "path" +import { basename, resolve } from "path" import { homedir } from "os" import { stat } from "fs/promises" import * as git from "../git.ts" @@ -18,8 +18,7 @@ const styles = Object.fromEntries( function renderSessions( sessions: state.GlobalSession[], - statuses: Record, - keyFn: (s: state.GlobalSession) => string, + statusMap: Map, ) { const branchWidth = Math.max(6, ...sessions.map(s => s.branch.length)) const cols = process.stdout.columns || 80 @@ -29,10 +28,10 @@ function renderSessions( for (const s of sessions) { const prompt = (s.prompt ?? "").split("\n")[0] - const status = statuses[keyFn(s)] ?? "idle" + const status = statusMap.get(s) ?? "idle" const { icon, color: bc } = styles[status] const maxPrompt = cols - prefixWidth - const truncated = maxPrompt > 3 && prompt.length > maxPrompt ? prompt.slice(0, maxPrompt - 3) + "..." : prompt + const truncated = maxPrompt < 1 ? "" : maxPrompt > 3 && prompt.length > maxPrompt ? prompt.slice(0, maxPrompt - 3) + "..." : prompt console.log(`${icon} ${bc}${s.branch.padEnd(branchWidth)}${reset} ${dim}${truncated}${reset}`) } } @@ -68,19 +67,14 @@ async function clearStaleReviews( sessions: state.GlobalSession[], results: string[], ) { - const staleByRepo = new Map() - for (let i = 0; i < sessions.length; i++) { - if (sessions[i].in_review && results[i] !== "review") { - sessions[i].in_review = false - const arr = staleByRepo.get(sessions[i].repoRoot) ?? [] - arr.push(sessions[i].branch) - staleByRepo.set(sessions[i].repoRoot, arr) - } - } - for (const [repoRoot, branches] of staleByRepo) { + const stale = sessions.filter((s, i) => s.in_review && results[i] !== "review") + if (stale.length === 0) return + for (const s of stale) s.in_review = false + const byRepo = Map.groupBy(stale, s => s.repoRoot) + for (const [repoRoot, staleSessions] of byRepo) { const fresh = await state.load(repoRoot) - for (const branch of branches) { - if (fresh.sessions[branch]) fresh.sessions[branch].in_review = false + for (const s of staleSessions) { + if (fresh.sessions[s.branch]) fresh.sessions[s.branch].in_review = false } await state.save(repoRoot, fresh).catch(() => {}) } @@ -133,14 +127,12 @@ 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: state.GlobalSession) => `${s.repoRoot}/${s.branch}` const results = await Promise.all(sessions.map(s => resolveStatus(s, vmRunning))) - const statuses = Object.fromEntries(sessions.map((s, i) => [keyFn(s), results[i]])) + const statusMap = new Map(sessions.map((s, i) => [s, results[i]])) await clearStaleReviews(sessions, results) if (opts.json) { - const withStatus = sessions.map(s => ({ ...s, status: statuses[keyFn(s)] })) + const withStatus = sessions.map(s => ({ ...s, status: statusMap.get(s) ?? "idle" })) console.log(JSON.stringify(withStatus, null, 2)) return } @@ -149,10 +141,10 @@ export async function action(opts: { json?: boolean; all?: boolean; add?: string 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) + renderSessions(repoSessions, statusMap) } } else { - renderSessions(sessions, statuses, keyFn) + renderSessions(sessions, statusMap) } renderLegend() @@ -164,7 +156,7 @@ async function actionAdd(dir: string) { try { registered = await state.scanAndRegister(dir) } catch (e) { - die(e instanceof Error ? e.message : `Failed to scan ${dir}`) + return die(e instanceof Error ? e.message : `Failed to scan ${dir}`) } if (registered.length === 0) { console.log(`No sandlot projects found under ${dir}`) @@ -177,12 +169,12 @@ async function actionAdd(dir: string) { } async function actionRemove(dir: string) { - const resolved = state.normalizePath(dir) + const resolved = resolve(dir.replace(/^~(?=\/|$)/, homedir())) let removed = false try { removed = await state.unregisterProject(dir) } catch { - die("Could not acquire registry lock") + return die("Could not acquire registry lock") } if (removed) { console.log(`${red}-${reset} ${resolved}`) diff --git a/src/state.ts b/src/state.ts index 9969ee5..0cc6761 100644 --- a/src/state.ts +++ b/src/state.ts @@ -66,9 +66,11 @@ const GLOBAL_STATE_PATH = join(GLOBAL_DIR, "registry.json") async function withGlobalLock(fn: () => Promise): Promise { 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) @@ -84,6 +86,7 @@ async function withGlobalLock(fn: () => Promise): Promise { await Bun.sleep(50) } } + if (!acquired) throw new Error("Could not acquire registry lock") try { return await fn() } finally { @@ -97,7 +100,7 @@ async function loadGlobal(): Promise { try { const data = await file.json() if (data && Array.isArray(data.projects)) { - return { projects: data.projects.filter((p: unknown) => typeof p === "string") } + return { projects: data.projects as string[] } } } catch {} } @@ -110,7 +113,7 @@ async function saveGlobal(gs: GlobalState): Promise { await rename(tmpPath, GLOBAL_STATE_PATH) } -export function normalizePath(dir: string): string { +function normalizePath(dir: string): string { return resolve(dir.replace(/^~(?=\/|$)/, homedir())) } @@ -204,10 +207,8 @@ export async function loadAll(): Promise { return } if (Object.keys(st.sessions).length === 0) { - if (!(await Bun.file(join(project, ".sandlot", "state.json")).exists())) { - stale.push(project) - return - } + try { await stat(join(project, ".sandlot")) } catch { stale.push(project) } + return } const repo = basename(project) for (const s of Object.values(st.sessions)) {