Compare commits

...

14 Commits

Author SHA1 Message Date
2da22b940a Fix list command to skip empty JSON output and batch stale-review cleanup
Collapse redundant empty-session branch so --json falls through to the
normal serialization path instead of printing "[]" separately. Replace
per-branch load/save loop with a single state cycle to avoid racing
concurrent writes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 11:29:22 -07:00
e8e5b850a0 Fix race conditions in state persistence by reading fresh session before writing
Batch-clearing stale flags in list and blindly writing back the session
in review could clobbер concurrent changes. Use per-session get/set
instead of whole-state load/save, and re-read before clearing in_review.
2026-03-19 11:24:18 -07:00
d10adac712 Replace patchSession with setSession to fix concurrent state writes
The fire-and-forget patchSession calls in list could race with each
other, each reading stale state before writing. Collecting stale
branches and doing a single load-modify-save eliminates the race.
Also emits valid JSON (`[]`) when listing with --json and no sessions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 11:17:41 -07:00
9336deed9c 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>
2026-03-19 11:11:16 -07:00
bd9d481e81 Add file-based locking to state mutations to prevent concurrent write races
Parallel operations (e.g. stale review cleanup in `list`) could
clobber each other via read-modify-write on the shared state file.
Also fix spinner lifecycle in `review` and simplify empty-list output.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 11:04:33 -07:00
69ba73b3c3 Use patchSession to avoid race conditions in review flag updates
The load-modify-save pattern could overwrite concurrent state changes.
patchSession does an atomic read-patch-write, and the list command now
re-checks activity before clearing stale flags to avoid racing with a
review that just started.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 10:56:50 -07:00
ad90c9dcc1 Fix race condition in stale review flag self-healing
Reload fresh state before saving to avoid overwriting concurrent changes
from other processes between the initial load and the heal write.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 10:47:20 -07:00
2f524da292 Fix stale review flag healing and prevent concurrent state overwrites
Use already-loaded state in list command instead of re-reading. In review
command, patch in_review on fresh state to avoid clobbering concurrent
changes, and skip worktree save in print mode. Remove unused white import
and unnecessary nullish coalescing fallback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 23:55:49 -07:00
99f5a378f1 Fix stale in_review self-healing to run in JSON mode and update local objects before state load 2026-03-18 23:48:09 -07:00
a682539db3 Batch stale review-flag writes and fix review cleanup ordering
Move in_review flag set before try block so it is always visible,
and consolidate per-session state writes into a single batch to
avoid repeated disk I/O during list. Also guard against missing
status entries with fallback defaults.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 23:38:02 -07:00
3ba550d80a Fix stale in_review flag handling and review lifecycle
Move in_review flag set inside try block so finally always clears it,
and actively clear stale flags in list when Claude is no longer active.
Previously a crash between setting the flag and entering try would
leave the session stuck in review state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 23:28:42 -07:00
bc102e416c Add in_review status to sessions and self-heal stale review flags
Review status now requires Claude to be active, preventing stale flags
from showing after a crash. Consolidates icon/color maps into a single
styles record and defers setting in_review until the container is up.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 23:16:06 -07:00
92dbad3cad Add review status tracking to sessions
The list command needs to show when a session is under active
review so users don't interrupt it. Wrapping the review body in
try/finally ensures the flag is always cleared on exit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 23:08:41 -07:00
8172065928 0.0.38 2026-03-18 22:59:33 -07:00
6 changed files with 59 additions and 28 deletions

View File

@ -120,7 +120,8 @@ Always use `.nothrow()` for commands that may fail non-fatally. Use `.quiet()` t
"branch": "branch-name",
"worktree": "/Users/you/.sandlot/repo/branch-name",
"created_at": "2026-02-16T10:30:00Z",
"prompt": "optional initial prompt text"
"prompt": "optional initial prompt text",
"in_review": false
}
}
}
@ -149,7 +150,8 @@ Always use `.nothrow()` for commands that may fail non-fatally. Use `.quiet()` t
- `sandlot squash` generates an AI commit message for the squash commit via `claudePipe()`; falls back to `"squash <branch>"`
- `sandlot merge` and `sandlot squash` both delegate to `mergeAndClose()` in `helpers.ts`, which merges, resolves conflicts, commits, and then calls `closeAction()` to clean up
- `sandlot list` discovers missing session prompts by parsing Claude's `history.jsonl` from inside the container
- `sandlot list` shows four status icons: idle (dim `◌`), active (cyan `◯`), dirty/unsaved (yellow `◎`), saved (green `●`)
- `sandlot list` shows five status icons: idle (dim `◯`), active (cyan `◎`), dirty/unsaved (yellow `◐`), saved (green `●`), review (magenta `⦿`)
- `sandlot review` sets `in_review` on the session during the review and clears it in a `finally` block on exit; `list` detects stale `in_review` flags (Claude not active) and clears them from state
- `sandlot new` and `sandlot open` auto-save changes when Claude exits (disable with `--no-save`)
- `sandlot close` has a hidden `rm` alias
- Default behavior (no subcommand): always runs `list` (which prints "No active sessions." if empty)

View File

@ -1,6 +1,6 @@
{
"name": "@because/sandlot",
"version": "0.0.37",
"version": "0.0.38",
"description": "Sandboxed, branch-based development with Claude",
"type": "module",
"bin": {

View File

@ -2,7 +2,7 @@ import { homedir } from "os"
import * as git from "../git.ts"
import * as vm from "../vm.ts"
import * as state from "../state.ts"
import { reset, dim, bold, green, yellow, cyan, white, red } from "../fmt.ts"
import { reset, dim, green, yellow, cyan, magenta, red } from "../fmt.ts"
export async function action(opts: { json?: boolean }) {
const root = await git.repoRoot()
@ -30,12 +30,7 @@ export async function action(opts: { json?: boolean }) {
} catch {}
}
if (opts.json) {
console.log(JSON.stringify(sessions, null, 2))
return
}
if (sessions.length === 0) {
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}`)
@ -44,9 +39,16 @@ export async function action(opts: { json?: boolean }) {
}
// Determine status for each session in parallel
const staleReviewBranches: string[] = []
const statusEntries = await Promise.all(
sessions.map(async (s): Promise<[string, string]> => {
if (await vm.isClaudeActive(s.worktree, s.branch)) return [s.branch, "active"]
const active = await vm.isClaudeActive(s.worktree, s.branch)
if (active && s.in_review) return [s.branch, "review"]
if (!active && s.in_review) {
staleReviewBranches.push(s.branch)
s.in_review = false
}
if (active) return [s.branch, "active"]
const dirty = await git.isDirty(s.worktree)
if (dirty) return [s.branch, "dirty"]
const commits = await git.hasNewCommits(s.worktree)
@ -55,14 +57,28 @@ export async function action(opts: { json?: boolean }) {
)
const statuses = Object.fromEntries(statusEntries)
// Clear stale in_review flags in a single load/save cycle
if (staleReviewBranches.length > 0) {
const fresh = await state.load(root)
for (const branch of staleReviewBranches) {
if (fresh.sessions[branch]) fresh.sessions[branch].in_review = false
}
await state.save(root, fresh).catch(() => {})
}
if (opts.json) {
const withStatus = sessions.map(s => ({ ...s, status: statuses[s.branch] }))
console.log(JSON.stringify(withStatus, null, 2))
return
}
const icons: Record<string, string> = { idle: `${dim}${reset}`, active: `${cyan}${reset}`, dirty: `${yellow}${reset}`, saved: `${green}${reset}` }
const branchColors: Record<string, string> = { idle: dim, active: cyan, dirty: yellow, saved: green }
const styleDefs: [string, string, string][] = [
["idle", dim, "◯"], ["active", cyan, "◎"], ["dirty", yellow, "◐"],
["saved", green, "●"], ["review", magenta, "⦿"],
]
const styles = Object.fromEntries(
styleDefs.map(([k, c, ch]) => [k, { icon: `${c}${ch}${reset}`, color: c }])
)
const branchWidth = Math.max(6, ...sessions.map((s) => s.branch.length))
const cols = process.stdout.columns || 80
const prefixWidth = branchWidth + 4
@ -71,15 +87,14 @@ export async function action(opts: { json?: boolean }) {
for (const s of sessions) {
const prompt = (s.prompt ?? "").split("\n")[0]
const status = statuses[s.branch]
const icon = icons[status]
const bc = branchColors[status]
const status = statuses[s.branch] ?? "idle"
const { icon, color: bc } = styles[status]
const maxPrompt = cols - prefixWidth
const truncated = maxPrompt > 3 && prompt.length > maxPrompt ? prompt.slice(0, maxPrompt - 3) + "..." : prompt
console.log(`${icon} ${bc}${s.branch.padEnd(branchWidth)}${reset} ${dim}${truncated}${reset}`)
}
console.log(`\n${dim}◯ idle${reset} · ${cyan}◎ active${reset} · ${yellow}◐ unsaved${reset} · ${green}● saved${reset}`)
console.log(`\n${dim}◯ idle${reset} · ${cyan}◎ active${reset} · ${yellow}◐ unsaved${reset} · ${green}● saved${reset} · ${magenta}⦿ review${reset}`)
if ((await vm.status()) !== "running") {
console.log(`\n${red}VM is not running.${reset}`)

View File

@ -1,9 +1,10 @@
import * as vm from "../vm.ts"
import * as state from "../state.ts"
import { spinner } from "../spinner.ts"
import { requireSession, saveChanges } from "./helpers.ts"
export async function action(branch: string, extra: string | undefined, opts: { print?: boolean }) {
const { session } = await requireSession(branch)
const { root, session } = await requireSession(branch)
const spin = spinner("Starting container", branch)
await vm.ensure((msg) => { spin.text = msg })
@ -67,15 +68,26 @@ Your thoughts, in brief.
`
if (extra) prompt += "\n\n" + extra
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 })
}
session.in_review = true
await state.setSession(root, session)
await saveChanges(session.worktree, session.branch)
try {
if (opts.print) {
spin.text = "Running review…"
const result = await vm.claude(session.worktree, { print: prompt })
if (result.output) process.stdout.write(result.output + "\n")
} else {
spin.succeed("Session ready")
await vm.claude(session.worktree, { prompt })
}
} finally {
spin.stop()
// Load fresh session to avoid clobbering changes made during the review
const fresh = await state.getSession(root, session.branch)
if (fresh) {
fresh.in_review = false
await state.setSession(root, fresh).catch(() => {})
}
if (!opts.print) await saveChanges(session.worktree, session.branch).catch(() => {})
}
}

View File

@ -5,6 +5,7 @@ export const dim = "\x1b[2m"
export const green = "\x1b[32m"
export const yellow = "\x1b[33m"
export const red = "\x1b[31m"
export const magenta = "\x1b[35m"
export const cyan = "\x1b[36m"
// ── Formatted output ────────────────────────────────────────────────

View File

@ -7,6 +7,7 @@ export interface Session {
worktree: string
created_at: string
prompt?: string
in_review?: boolean
}
export interface State {