Repository navigation
Conversation
|
@Stener1 is attempting to deploy a commit to the seanbrydonio's projects Team on Vercel. A member of the Team first needs to authorize it. |
|
Could a maintainer add |
There was a problem hiding this comment.
All reported issues were addressed across 41 files
Reply with feedback, questions, or to request a fix.
View guided diff | Re-trigger cubic
302c509 to
2575aac
Compare
|
Rebased onto current
Codex's review of the fixes found two more (a cancel lost across a relaunch, and an archive failure arriving before its 202); both are fixed in the last commit. The PR body has the new evidence, including |
…199) Archive… still asks, but pressing Archive closes the dialog at once. The worktree shows Archiving… and goes when its box gets to it, through the same per-box queue Remove worktree uses, now carrying the kind. The toasts are today's: Archived (branch kept), Archiving (its script is running), and a warning for uncommitted work. Bulk archive in the Worktrees table skips a worktree already on its way out, so its box never gets a second request for it. The mock takes ?slowremove=MS to make a box slow to remove a worktree, and the verify skill gains a worktree-archive scenario and feature file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Confirming Stop N agents? (and Clean up…) now closes the dialog at once.
The stopping, and the archiving when it was ticked, goes on in a tray in
the bottom-right corner, above the status bar and the loops panel: one
row per agent, grouped by box, with progress in the title ("Stopping 17
agents · 9/17"). It folds to one line (remembered), Stop the rest sends
nothing more, and a clean run sums itself up ("17 stopped, 15 archived"),
shows the toast it always did and fades. If something was kept or failed
it stays as a report of those rows until you dismiss it. Jobs live in an
app-level store, so they carry on when you leave the board.
The sidebar's Archive… shows up in the tray too, as a one-row job.
Concurrency and the archive safety rules are unchanged; bulk archives now
wait in the same per-box line as every other removal. The mock gains
?slowstop=MS to make stops slow.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The dashboard-stop-archive scenario now shows the dialog gone at once, the tray mid-run, folded, while another view is open, its report, a clean run fading and Stop the rest. worktree-archive checks the one-row job. The feature files say how the tray behaves, and the changelog moves the archive bullet back under Unreleased. The tray lifts above a page's floating bar so it never hides the board's selection buttons, a long summary wraps, and a one-row job drops its count. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dashboard-stop-live makes its own worktrees and agents on dev-box.sh's box, stops three with archiving ticked, and checks the tray's report against git worktree list and git branch there; then it archives a fourth from the sidebar. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
keepsWork and the archive outcome messages live in lib/removing.ts, so the stop job, the single archive and the Worktrees table share them, and the stop job no longer takes keepsWork as a dep. Every archive through queueRemoval is a tray job, so the tray flag goes. plural moves to lib/format.ts. The tray uses Button for Fold and Dismiss, one icon per outcome and per row state, and memoised cards and rows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…be held back From the review: an archive waiting its turn on a box now reads the box's agents again when its turn comes, so one started there meanwhile keeps the worktree. Each worktree is marked as it is queued, so Archive… from the sidebar can't ask for it a second time, and one already on its way out is skipped. Rows waiting to archive count as unsent, so Stop the rest stays offered until they go. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ds it back Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… halt it The board's bulk Stop (and archiving where it's safe), the Worktrees table's Stop sessions and Archive, and the sidebar's Stop everything are now one stop run per box with runs: a new run template with two leaves, stop_session and archive_worktree, that keep the app's safety rules (never forced, branch kept, dirty / another agent / a failed stop keep it, before:-gates honoured) and wait for the archive script. The app follows each run; a row fails only when the box says so, and the job comes back to the tray after a relaunch. dropWorktree joins a removal already in flight rather than running the archive script twice. Boxes without runs keep the app's own loop. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… and keeps a worktree an agent started in meanwhile Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…it, and a lost one fails its rows in the table Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ent starts unseen as one goes A stop run's archive step waits its turn on the box (archiveTurn) before it looks the worktree up, so archive scripts from separate runs never run side by side, and one archived meanwhile is found gone. The last look for agents and the removal hold a lock that session starts take for reading (removeAfter, startUnseen): an agent asked for in between waits, then finds its folder gone. A session listing that fails now fails the step instead of passing for "another agent". A worktree is checked for uncommitted work before it counts as on its way out, so a second ask for a dirty one is refused rather than answered 202. The dedup test waits for worktree.archive.started and quotes its temp path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ive… is the box's to finish A bulk Stop is kept (berth.jobs.stopRuns) before its start is sent, with each box's params. A box that doesn't answer is asked again under the same idempotency key until it does, its rows "Waiting for BOX", instead of failing them after three tries; after a relaunch an unanswered share is asked for again. Stop the rest meanwhile looks the run up by its group (GET runs?group=): one the box took is followed and cancelled, and with none the rows read Not stopped. A run the box no longer has lets go of its worktrees' Archiving… marks. Archive… from the sidebar, zen and the launcher is a one-worktree stop run on a box with runs: its sessions stop, it waits the box's own turn, it finishes while the laptop sleeps, and the tray's row ends only when the worktree is gone or kept. Elsewhere the app's line for the box (and the board's own loop) holds until the box says the archive script finished (archiveEnded), so scripts on one box never overlap and a row no longer reads done while its script runs. The mock lists runs by group, answers a repeated key with the run it has, ends a stop run's maps with it, and fails an archive step whose session listing fails. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…al, the tray hidden takes no room runPool walks its queue by length, so an undefined item runs too. team.ts drops its copy of plural for format.ts's. The jobs tray, hidden under Settings, sets --berth-jobs-h to 0 instead of its gap. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the runs guide and the verify skill The live scenario no longer expects the 400 a bulk Stop never sends: it reads the box's stop runs back instead, for the bulk Stop and the sidebar's Archive…. The feature map says how a start that goes unanswered is handled, that jobs are kept 24 hours at most, that a run joining an archive reads Archived, that a bulk Stop still stops agents whose worktree is kept, the review/ branch exception, and the real queueRemoval call. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he archive scenarios expect the box to finish The mock plays a box's archive line across its stop runs, as archiveTurn does. worktree-archive expects Archive… on devl and gpu to be a stop run there: judge-v2 waits in gpu's line, qa-deck's row stays Archiving… until its script is done, and one toast says Archived. Toasts are found by role, so a second one for the same archive fails. dashboard-stop-archive's report reads 2 archived, since the app's loop now waits for the script. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…a real box dashboard-stop-unanswered kills the dev box's berthd as Stop is pressed and starts it again 20 s later: no row fails meanwhile, and the box ends with one stop run for the job, followed to its end, with git agreeing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…at fails fast isn't read as archived From Codex's review. Stop the rest pressed while a box answers nothing is kept with the job (onCancel), so after a relaunch the job comes back cancelled: a share whose start went unanswered is only looked up by its group, its run cancelled if the box took it, and never sent again. removeWorktreeOnBox notes how an archive ends from before it sends it, so a worktree.archive.failed that beats the 202 still fails the tray's row. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2575aac to
38ab82b
Compare
|
Rebased the stack (#175,
Re-run on the new head: |
… be finished git worktree remove ran under the 60 s limit every git call has. A big worktree on a busy box took longer, git was killed mid-delete and said nothing, so the row read "Couldn't archive: git worktree remove:" and the folder was left without its .git, which git then refused to remove again. - git worktree remove gets 10 minutes (BERTH_WORKTREE_REMOVE_TIMEOUT sets another), and a failure with no output says how it ended: timed out, stopped by a signal, cancelled, or its exit status. - A worktree git lists as prunable is finished on archive: what's left of its folder, then git's record of that one worktree (never git worktree prune, which would drop every half-removed one's record), branch kept. Only when git, asked through the record's HEAD and index, sees nothing but files the removal already deleted, and no repository is inside. One whose folder is gone is pruned the same way. Its archive script isn't run again. - A removal's last look for agents holds only its own folder, so a removal git takes minutes over doesn't hold up session starts elsewhere. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dashboard-stop-interrupted-live restarts the dev box with a git that deletes one worktree's .git and then hangs, and a 5 s removal budget. A bulk Stop's row must say it timed out, and Archive... must then finish the half-removed worktree, keeping its branch. VERIFY_BERTHD runs it on an older build for a before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Whether a worktree still has its .git is looked at each time the list is read, rather than taken from git's "prunable" line, so a listing read before the .git went (a cached one) doesn't show it as live and send its archive back to git worktree remove, which refuses it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
8 issues found across 15 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="docs/changelog.mdx">
<violation number="1" location="docs/changelog.mdx:48">
P3: "as any archive does" overstates the branch behavior: a review worktree's `review/` branch is removed by every Archive. Name that exception here, as the runs guide does.</violation>
</file>
<file name="docs/concepts/projects-and-worktrees.mdx">
<violation number="1" location="docs/concepts/projects-and-worktrees.mdx:116">
P2: This overstates the recovery. Archiving a half-removed worktree only finishes it when it holds no unsaved work and no nested repository; otherwise it is refused unless forced. Say so here, and note that a review worktree's `review/` branch is removed.</violation>
</file>
<file name="internal/box/worktreeremove_test.go">
<violation number="1" location="internal/box/worktreeremove_test.go:35">
P3: Setup errors are discarded in `worktreeIn` and several tests here. If one of these writes or removes fails, the test reports a misleading refusal or a later assertion instead of the real setup failure. Check each one with `t.Fatal(err)`, as the rest of the file does.</violation>
<violation number="2" location="internal/box/worktreeremove_test.go:163">
P2: This check passes vacuously if `wt.Path` never appears in `parseWorktrees`'s output. Assert that the worktree was found in the listing, and that it reads as prunable.</violation>
</file>
<file name="internal/box/lastlook.go">
<violation number="1" location="internal/box/lastlook.go:23">
P3: The doc comment says the hold lasts until the folder is gone. The release actually runs whenever `removeAfter` returns, including on a failed removal while the folder still exists. Reword the comment to match.</violation>
</file>
<file name="internal/box/runstop.go">
<violation number="1" location="internal/box/runstop.go:98">
P2: (Based on your team's feedback about clearing pins on worktree removal.)
This path removes the gone worktree's record without the bookkeeping `dropWorktreeIf` does on removal. No `worktree.removed` event is published, so the app's `worktreeGone` never unpins it or clears its removing state. The ports for that path are also never released. Publish the event and release the ports here, as `removed()` does.</violation>
</file>
<file name="internal/box/runstop_test.go">
<violation number="1" location="internal/box/runstop_test.go:381">
P3: The bystander session start is called synchronously while the removal is held. If sessions elsewhere ever block, this test hangs instead of failing. Run it in a goroutine and wait with a `select` timeout, as the `latecomer` check below already does.</violation>
</file>
<file name="internal/box/locations.go">
<violation number="1" location="internal/box/locations.go:794">
P2: When a prunable worktree’s record is missing or `git status` fails, this returns false; removal then stops its services before `RemoveWorktree` refuses to proceed. Propagate an explicit check error before teardown.</violation>
</file>
Reply with feedback, questions, or to request a fix.
View guided diff | Re-trigger cubic
| to remove the folder (`BERTH_WORKTREE_REMOVE_TIMEOUT` in berthd's | ||
| environment sets another limit). A removal stopped halfway leaves a | ||
| folder git no longer counts as a worktree; archiving it again removes | ||
| what's left, and keeps its branch. Both scripts come from |
There was a problem hiding this comment.
P2: This overstates the recovery. Archiving a half-removed worktree only finishes it when it holds no unsaved work and no nested repository; otherwise it is refused unless forced. Say so here, and note that a review worktree's review/ branch is removed.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At docs/concepts/projects-and-worktrees.mdx, line 116:
<comment>This overstates the recovery. Archiving a half-removed worktree only finishes it when it holds no unsaved work and no nested repository; otherwise it is refused unless forced. Say so here, and note that a review worktree's `review/` branch is removed.</comment>
<file context>
@@ -109,7 +109,11 @@ name moves to it.
+to remove the folder (`BERTH_WORKTREE_REMOVE_TIMEOUT` in berthd's
+environment sets another limit). A removal stopped halfway leaves a
+folder git no longer counts as a worktree; archiving it again removes
+what's left, and keeps its branch. Both scripts come from
[`.berth/config.json`](/guides/project-config), a [kit](/guides/kits), or the
box's own config for the location (`berth location scripts`). A
</file context>
| what's left, and keeps its branch. Both scripts come from | |
| what's left unless it holds unsaved work or a nested repository (then `--force`, or a fix by hand), and keeps its branch. Both scripts come from |
| for _, w := range parseWorktrees(out, repo) { | ||
| if w.Path == wt.Path && !w.Prunable { | ||
| t.Fatalf("%s reads as a live worktree from an older listing", w.Name) | ||
| } | ||
| if w.Main && w.Prunable { | ||
| t.Fatal("the main checkout reads as dropped") | ||
| } | ||
| } |
There was a problem hiding this comment.
P2: This check passes vacuously if wt.Path never appears in parseWorktrees's output. Assert that the worktree was found in the listing, and that it reads as prunable.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At internal/box/worktreeremove_test.go, line 163:
<comment>This check passes vacuously if `wt.Path` never appears in `parseWorktrees`'s output. Assert that the worktree was found in the listing, and that it reads as prunable.</comment>
<file context>
@@ -0,0 +1,267 @@
+ if err := os.Remove(filepath.Join(wt.Path, ".git")); err != nil {
+ t.Fatal(err)
+ }
+ for _, w := range parseWorktrees(out, repo) {
+ if w.Path == wt.Path && !w.Prunable {
+ t.Fatalf("%s reads as a live worktree from an older listing", w.Name)
</file context>
| for _, w := range parseWorktrees(out, repo) { | |
| if w.Path == wt.Path && !w.Prunable { | |
| t.Fatalf("%s reads as a live worktree from an older listing", w.Name) | |
| } | |
| if w.Main && w.Prunable { | |
| t.Fatal("the main checkout reads as dropped") | |
| } | |
| } | |
| found := false | |
| for _, w := range parseWorktrees(out, repo) { | |
| if w.Main && w.Prunable { | |
| t.Fatal("the main checkout reads as dropped") | |
| } | |
| if w.Path == wt.Path { | |
| found = true | |
| if !w.Prunable { | |
| t.Fatalf("%s reads as a live worktree from an older listing", w.Name) | |
| } | |
| } | |
| } | |
| if !found { | |
| t.Fatalf("%s is missing from the listing, so nothing was checked", wt.Name) | |
| } |
| if err := b.Locations.RemoveWorktree(ctx, location, name, false); err != nil { | ||
| return fail(err) | ||
| } |
There was a problem hiding this comment.
P2:
(Based on your team's feedback about clearing pins on worktree removal.)
This path removes the gone worktree's record without the bookkeeping dropWorktreeIf does on removal. No worktree.removed event is published, so the app's worktreeGone never unpins it or clears its removing state. The ports for that path are also never released. Publish the event and release the ports here, as removed() does.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At internal/box/runstop.go, line 98:
<comment>
(Based on your team's feedback about clearing pins on worktree removal.)
This path removes the gone worktree's record without the bookkeeping `dropWorktreeIf` does on removal. No `worktree.removed` event is published, so the app's `worktreeGone` never unpins it or clears its removing state. The ports for that path are also never released. Publish the event and release the ports here, as `removed()` does.</comment>
<file context>
@@ -91,6 +91,14 @@ func (h *runHost) archiveWorktree(ctx context.Context, x *runs.StepCtx) runs.Res
+ // halfway, then the rest deleted): what git kept goes too, so a
+ // worktree can be made there again.
+ if g, ok := goneWorktree(ctx, loc, name); ok && (path == "" || g.Path == path) {
+ if err := b.Locations.RemoveWorktree(ctx, location, name, false); err != nil {
+ return fail(err)
+ }
</file context>
| if err := b.Locations.RemoveWorktree(ctx, location, name, false); err != nil { | |
| return fail(err) | |
| } | |
| if err := b.Locations.RemoveWorktree(ctx, location, name, false); err != nil { | |
| return fail(err) | |
| } | |
| b.Locations.Ports.Release(g.Path) | |
| b.Events.Publish(events.Event{Type: "worktree.removed", Box: b.Name, Origin: origOf(x), Data: map[string]any{"location": loc.Name, "name": name, "path": g.Path}}) |
| ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) | ||
| defer cancel() | ||
| record, err := worktreeRecord(ctx, repo, dir) | ||
| return err == nil && leftoverWork(ctx, record, dir) == "contains modified or untracked files" |
There was a problem hiding this comment.
P2: When a prunable worktree’s record is missing or git status fails, this returns false; removal then stops its services before RemoveWorktree refuses to proceed. Propagate an explicit check error before teardown.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At internal/box/locations.go, line 794:
<comment>When a prunable worktree’s record is missing or `git status` fails, this returns false; removal then stops its services before `RemoveWorktree` refuses to proceed. Propagate an explicit check error before teardown.</comment>
<file context>
@@ -540,17 +729,70 @@ func unquoteGit(v string) string {
+ ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
+ defer cancel()
+ record, err := worktreeRecord(ctx, repo, dir)
+ return err == nil && leftoverWork(ctx, record, dir) == "contains modified or untracked files"
+ }
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
</file context>
| still fails, the row says how: timed out, stopped, or failed with no | ||
| output. Archiving a worktree that git has already let go of (its `.git` | ||
| is gone, or its whole folder) now removes what's left and keeps its | ||
| branch, as any archive does. |
There was a problem hiding this comment.
P3: "as any archive does" overstates the branch behavior: a review worktree's review/ branch is removed by every Archive. Name that exception here, as the runs guide does.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At docs/changelog.mdx, line 48:
<comment>"as any archive does" overstates the branch behavior: a review worktree's `review/` branch is removed by every Archive. Name that exception here, as the runs guide does.</comment>
<file context>
@@ -36,6 +36,16 @@ again.
+ still fails, the row says how: timed out, stopped, or failed with no
+ output. Archiving a worktree that git has already let go of (its `.git`
+ is gone, or its whole folder) now removes what's left and keeps its
+ branch, as any archive does.
## v0.3.31
</file context>
| branch, as any archive does. | |
| branch (a review worktree's `review/` branch goes, as with every archive). |
| t.Helper() | ||
| repo := gitRepo(t) | ||
| // What a removal stopped halfway leaves is mostly what git ignores. | ||
| os.WriteFile(filepath.Join(repo, ".git", "info", "exclude"), []byte("node_modules\n"), 0o644) |
There was a problem hiding this comment.
P3: Setup errors are discarded in worktreeIn and several tests here. If one of these writes or removes fails, the test reports a misleading refusal or a later assertion instead of the real setup failure. Check each one with t.Fatal(err), as the rest of the file does.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At internal/box/worktreeremove_test.go, line 35:
<comment>Setup errors are discarded in `worktreeIn` and several tests here. If one of these writes or removes fails, the test reports a misleading refusal or a later assertion instead of the real setup failure. Check each one with `t.Fatal(err)`, as the rest of the file does.</comment>
<file context>
@@ -0,0 +1,267 @@
+ t.Helper()
+ repo := gitRepo(t)
+ // What a removal stopped halfway leaves is mostly what git ignores.
+ os.WriteFile(filepath.Join(repo, ".git", "info", "exclude"), []byte("node_modules\n"), 0o644)
+ l := NewLocations(filepath.Join(t.TempDir(), "locations.json"))
+ if _, err := l.Add(context.Background(), "cal", repo); err != nil {
</file context>
| os.WriteFile(filepath.Join(repo, ".git", "info", "exclude"), []byte("node_modules\n"), 0o644) | |
| if err := os.WriteFile(filepath.Join(repo, ".git", "info", "exclude"), []byte("node_modules\n"), 0o644); err != nil { | |
| t.Fatal(err) | |
| } |
| starting []string | ||
| } | ||
|
|
||
| // remove holds dir for a removal, once no session is starting in it or |
There was a problem hiding this comment.
P3: The doc comment says the hold lasts until the folder is gone. The release actually runs whenever removeAfter returns, including on a failed removal while the folder still exists. Reword the comment to match.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At internal/box/lastlook.go, line 23:
<comment>The doc comment says the hold lasts until the folder is gone. The release actually runs whenever `removeAfter` returns, including on a failed removal while the folder still exists. Reword the comment to match.</comment>
<file context>
@@ -0,0 +1,71 @@
+ starting []string
+}
+
+// remove holds dir for a removal, once no session is starting in it or
+// under it and no other removal holds a folder that overlaps it.
+func (g *lookGuard) remove(dir string) (release func()) {
</file context>
| <-looking | ||
| // A session anywhere else starts meanwhile: a removal git takes | ||
| // minutes over holds up only its own folder. | ||
| if st := f.do("POST", "/v1/sessions", SessionRequest{Name: "bystander", Location: "cal/elsewhere", Command: "claude"}, nil); st != 200 { |
There was a problem hiding this comment.
P3: The bystander session start is called synchronously while the removal is held. If sessions elsewhere ever block, this test hangs instead of failing. Run it in a goroutine and wait with a select timeout, as the latecomer check below already does.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At internal/box/runstop_test.go, line 381:
<comment>The bystander session start is called synchronously while the removal is held. If sessions elsewhere ever block, this test hangs instead of failing. Run it in a goroutine and wait with a `select` timeout, as the `latecomer` check below already does.</comment>
<file context>
@@ -306,17 +365,22 @@ func TestStopRunsOnABoxArchiveOneAtATime(t *testing.T) {
<-looking
+ // A session anywhere else starts meanwhile: a removal git takes
+ // minutes over holds up only its own folder.
+ if st := f.do("POST", "/v1/sessions", SessionRequest{Name: "bystander", Location: "cal/elsewhere", Command: "claude"}, nil); st != 200 {
+ t.Fatalf("a session in another worktree during the last look: %d", st)
+ }
</file context>
| if st := f.do("POST", "/v1/sessions", SessionRequest{Name: "bystander", Location: "cal/elsewhere", Command: "claude"}, nil); st != 200 { | |
| bystander := make(chan int, 1) | |
| go func() { | |
| bystander <- f.do("POST", "/v1/sessions", SessionRequest{Name: "bystander", Location: "cal/elsewhere", Command: "claude"}, nil) | |
| }() | |
| select { | |
| case st := <-bystander: | |
| if st != 200 { | |
| t.Errorf("a session in another worktree during the last look: %d", st) | |
| } | |
| case <-time.After(5 * time.Second): | |
| t.Error("a session in another worktree waited for the last look") | |
| } |
…still be finished A removal stopped halfway can have deleted .gitignore files before what they ignore, so node_modules read as new files and every such worktree was kept as having uncommitted changes. The deleted .gitignore files are put back as committed, from the worktree's own index, and git is asked again, so ignore rules apply as git worktree remove would see them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… real one did Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…it afterwards Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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
stoprun, and so does the sidebar's single Archive…: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.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).playStop), in each box's archive line.?norunsgives the old path.fix(box): an archive that fails says why…).git worktree removeran under the 60 s limit that every git call has. On a busy box a big worktree took longer: git was killed mid-delete without a word, so the row readCouldn't archive: git worktree remove:, and the folder was left without its.git, which git then refused to remove again (validation failed … .git does not exist). Now:BERTH_WORKTREE_REMOVE_TIMEOUTsets another limit), and a failure with no output says how it ended: timed out, stopped by a signal, cancelled, or its exit status (gitFailed);finishRemoval). What's left of its folder goes, then git's record of that one worktree, nevergit worktree prune, which would drop every half-removed worktree's record and orphan their folders. The branch is kept. It only does this when git, asked through the record's HEAD and index, sees nothing but files the removal already deleted, and no repository is inside the folder. Any.gitignorethe removal deleted is put back as committed first, since otherwisenode_modulesreads as new files; otherwise it'sKept: uncommitted changes, and Remove anyway still forces. A worktree whose folder is gone is pruned the same way. Its archive script isn't run again;.gitis checked each time the list is read, not taken from git'sprunableline, so a cached listing (fix(box): listing projects runs git only when a repository changed #195) can't send such an archive back togit worktree remove;lookGuard, which replaces the box-widelastLookRWMutex), so a removal that takes minutes doesn't hold up session starts anywhere else on the box.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 removal git doesn't finish in time (new
dashboard-stop-interrupted-live, a real dev box, synthetic acme-shop). The box is restarted with a git on its PATH that, forverify-slowonly, deletes the worktree's.gitand.gitignoreand then hangs (with anode_modulesonly that.gitignoreignored), as a big worktree'sgit worktree removeon a loaded box did. Thenverify-slowandverify-okare bulk-stopped with archiving ticked.VERIFY_BERTHD=this branch's previous head): the archive step failed after 1m0.283s, the 60 s git limit, with the error"git worktree remove: ". The tray row ended at the colon.git worktree listshowedverify-slowasprunablewith its files still there, and archiving it again failed for good:git worktree remove: fatal: validation failed, cannot remove working tree: '…/acme-shop-verify-slow/.git' does not exist.BERTH_WORKTREE_REMOVE_TIMEOUT=5s):The scenario allows the console's 404 for
GET sessions/<stopped agent>/screen: the board's card asks for a just-stopped agent's screen once. That happens without this change too, and each failed request is logged as a step.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.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.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 listsStopping 3 agents: succeededandArchive verify-solo: succeeded. No request is refused any more: the dirty worktree is kept inside the 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.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-flakeat 5.5 s,Archived judge-v2at 10.0 s). qa-deck's row staysArchiving…until its script is done, then shows one toast.Tests:
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.worktree.archive.startedand quotes its path.TestAWorktreeRemoveThatFailsWithNoOutputSaysWhyandTestAWorktreeRemoveThatTimesOutSaysSo, with a fake git on PATH;TestAStopRunSaysWhyARemovalFailedthrough a stop run.TestAHalfRemovedWorktreeIsFinished: folder and record gone, branch kept, another half-removed worktree left alone; a live dirty worktree and the main checkout are still refused.TestAHalfRemovedWorktreeWithWorkInItIsKept(a changed or an added file: kept in git's words; Remove anyway deletes it) andTestAHalfRemovedWorktreeHoldingARepositoryIsKept.TestAWorktreeWhoseFolderIsGoneIsPruned(a worktree can be made at that path again) andTestAWorktreeGitStillHasIsNeverDeletedByHand.TestAStopRunFinishesAHalfRemovedWorktree: half-removed →Archived, folder gone →already goneand pruned, one with work in it →Kept: uncommitted changes; the archive script never runs again.TestAHalfRemovedWorktreeThatLostItsGitignore:.gitignorefiles deleted beforenode_modules→ finished; with a really new file as well → kept.TestAListingFromBeforeTheGitWentShowsItDropped: a listing read before the.gitwent still shows the worktree as dropped.TestNoAgentStartsUnseenAsAWorktreeGoesnow also starts a session in another worktree during the last look.kept-reason(a timeout, a signal or a silent failure is a failure, not kept) andstop-job(the box's words reach the row unchanged).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. On the interrupted-removal head it failed onlyTestDetach(it fails on main's tree too). Under load, earlier runs also failedTestForwardReachesTheBoxAndSurvivesAnAgentRestart,TestATeamSetupResumesAfterBerthdRestartsandTestSleepDurable; each passes 3/3 alone, on this branch and on its previous head.stop-job.test.ts:runPoolwith anundefineditem, andunmarked.pnpm test: 636 pass.dashboard-stop-archive.spec.tsandworktree-remove.spec.tspass, 11/11.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.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
git worktree removemay take up to 10 minutes instead of 60 s, and a worktree git dropped the link to is finished by the box itself (its folder, then its record under.git/worktrees/), only when nothing in it is work. Branches are always kept.🤖 Generated with Claude Code