feat(box): a bulk Stop runs on the box, so closing the laptop doesn't halt it
Stacked on #175 (archive in the background) and on the background-jobs tray branch (Stener1:background-jobs-tray, not opened upstream yet). Both are rebased onto current main. The diff includes their commits until they land: the tray is the six commits after #175's, and this PR's own work is the ten commits from feat(box): a bulk Stop runs on the box… on.
Summary
Closing the laptop mid bulk Stop used to halt it. The app ran the whole sequence, so in-flight and queued requests failed on wake, and agents ended up stopped but not archived. Now each box with runs does its share itself, as one durable stop run, and so does the sidebar's single Archive…:
runStopJob (app) box (internal/box)
split rows by box
keep the job (berth.jobs.stopRuns) ── before anything is sent
box has runs + online ──► POST /v1/runs {template: stop, group, idem_key}
no answer ─► ask again, same key, until it answers (rows: Waiting for BOX)
map stop_session ×N (3 at a time, session.stop gate)
map archive_worktree (one at a time)
archiveTurn: one archive at a time on the box, across runs
live agent there? → Kept: …
uncommitted work? → Kept: uncommitted changes
worktree.remove gate
dropWorktreeIf(no force, branch kept)
archive script, then under lastLook:
re-check agents, remove (no session starts in between)
follow GET /v1/runs/{id} ──► rows (no answer = ask again; only the box fails a row)
"Stop the rest" ──► cancel the run; a start still unanswered is looked up by group (GET /v1/runs?group=)
and cancelled if the box took it; the cancel is kept across a relaunch
else: the app's own loop (older boxes, offline at start, a 4xx on start),
each box's line held until the box says an archive script finished
Archive… (sidebar, zen, launcher)
box has runs ──► one-worktree stop run: its sessions stop, then archive_worktree as above
else ──► the app's per-box line, held until worktree.removed / worktree.archive.failed
- Box: the
stoptemplate andstop_session/archive_worktreeleaves (runstop.go).dropWorktreegets an in-flight guard, so a second ask (a resumed run, a duplicate run, a second DELETE) joins the first instead of running the archive script again. The dirty check comes before that guard, so a second ask for a dirty worktree is refused, not answered 202.archiveTurnserializes archives across a box's stop runs.removeAfter/startUnseenclose the check-then-remove race: no session starts between the last look for agents and the folder going. If berthd stops mid-archive, the step stays unfinished in the journal for the resumed run. - App:
lib/stop-job.tskeeps the job before it sends, asks an unanswered box again under one key until it answers, and looks a run up by its group when Stop the rest comes first. A job comes back to the tray after a relaunch (for at most 24 h), cancelled if it was. The Worktrees table's Stop sessions and Archive, the sidebar's Stop everything in … and its Archive… use the same run (lib/stop-on-box.ts,archiveOnBox). - The mock plays stop runs out against its own fixtures (
playStop), in each box's archive line.?norunsgives the old path. - Docs: the
stoptemplate inguides/runs.mdx, the new step kinds, changelog bullets, and the verify skill's feature map and scenarios.
Evidence
All on the final head, with the verify-berthd skill.
A start the box never answers (new dashboard-stop-unanswered, a real dev box, synthetic acme-shop). berthd is killed (SIGKILL) as Stop is pressed and started again 20 s later.
- Before:
startStopRungave up after three asks (about 6 s) and failed the rows,Failed: devq is offline, though the box might have the run. That was the old unit testa box that never answers the start fails its rows. - After: no row fails, the start is asked again once the box is back, and the box ends with one run for the job:
- with the box down the rows say: verify-mute-gp4k-a Waiting for devq | verify-mute-gp4k-b Waiting for devq
- after 20 s down, no row failed
- one stop run on the box for it (group stop-mvqsgss9yzpz): r_mvqshg2697ke succeeded
- git on the box agrees: both worktrees gone, both branches kept
| Box down: rows wait | Box back: done |
|---|---|
![]() | ![]() |
Bulk Stop and sidebar Archive… on a real box (dashboard-stop-live): the report reads 3 stopped, 2 archived, 1 kept; git agrees; the box lists Stopping 3 agents: succeeded and Archive verify-solo: succeeded. No request is refused any more: the dirty worktree is kept inside the run.
| Report | Archive… as a stop run |
|---|---|
![]() | ![]() |
The lid closing (dashboard-stop-lid): the laptop agent was paused for 25 s mid-archive, and on wake the tray read 5 stopped, 3 archived, 2 kept. A reload mid-run brought the job back, ending 2 stopped, 2 archived. The Worktrees table's Stop sessions and Archive each ran as a stop run.
| After waking | Reattached, done |
|---|---|
![]() | ![]() |
One archive at a time per box (mock, worktree-archive): Archive… on gpu/ci-flake, then judge-v2 at once. Both are stop runs on gpu, and judge-v2 waits in gpu's line (Archived ci-flake at 5.5 s, Archived judge-v2 at 10.0 s). qa-deck's row stays Archiving… until its script is done, then shows one toast.
| Both archiving | Script running | Dirty: kept |
|---|---|---|
![]() | ![]() | ![]() |
Tests:
- Go, new:
TestStopRunsOnABoxArchiveOneAtATime: two runs' scripts logstart end start end.TestNoAgentStartsUnseenAsAWorktreeGoes: a session asked for during the last look waits, then gets no folder.TestAWorktreeAskedAgainIsCheckedForWorkFirst: dirty while the first archive runs, so the second DELETE is refused.- The dedup test now waits for
worktree.archive.startedand quotes its path.
- Go, the full suite:
go vet ./... && go test -race ./...fails only in tests that fail on main on this machine under load (TestTheCatalogIsCapped,TestReviewRoutesOverTheWire,TestDetach,TestServicesAreCounted);TestReviewRoutesOverTheWirepasses 3/3 alone. - Unit, new in
stop-job.test.ts:- an unanswered start is asked until answered
- Stop the rest while unanswered: a run the box took is cancelled, and with none nothing is done
- kept before sending, then asked again after a relaunch
- a cancel while out of reach is kept: after a relaunch it is only looked up and cancelled, never started (both with and without the box having taken it)
- a 404'd run clears its Archiving… marks
- an archive script holds its box's line
- Unit, elsewhere:
runPoolwith anundefineditem, andunmarked.pnpm test: 636 pass. - E2E:
dashboard-stop-archive.spec.tsandworktree-remove.spec.tspass, 11/11. - These pass:
typecheck:plugins,check:titles,check:type,check:grid,check:csp,check:themes,tsc -p e2eandpnpm build.cargo checkwas not run: GTK dev libs are missing on this machine, which is unrelated. - The 29 review threads from cubic are each answered with the commit that addresses them. Codex review and adversarial review ran on the fixes; their findings (a cancel lost across a relaunch, and an archive failure that beats the 202) are fixed in the last commit.
Merge Danger
Door: two-way
On the box it's additive: a new template, two step kinds, an in-flight guard, an archive line across stop runs, and a lock held only from a removal's last look for agents until the folder goes. Old clients are unaffected, and new clients fall back on boxes without runs. Reverting the PR restores the app's loop.
Blast Radius: bulk-stop and archive
- Where it reaches: on boxes with runs, the board's bulk Stop / Clean up, the Worktrees table's Stop sessions and Archive, the sidebar's Stop everything and its single Archive… all go through the box.
- What changes there:
- A row reads Archived once the script is done, not Archiving while it runs.
- Archive stops the worktree's sessions first, as archiving always ended them.
- A worktree is kept if an agent starts there during the script.
- A bulk Stop row waits for an unreachable box instead of failing.
- A session start can wait up to one
git worktree removewhile a stop run removes a worktree.
- On boxes without runs: the app holds a box's line until an archive script finishes, so queued archives there wait longer, but never overlap.
🤖 Generated with Claude Code








