fix(worktree): detach kept worktree so branch_per=run survives a kept failure (#138)#152
Conversation
… failure Under branch_per=run + keep_failed=true the first story to end deferred left its worktree checked out on the single shared run branch. git refuses to check out a branch already held by another worktree, so every subsequent story's open_unit_workspace failed and insta-deferred with zero dev activity — one kept failure turned an N-story run into a 1-story run (#138). close_unit_workspace now detaches the kept worktree's HEAD when the run branch is shared (new detach_kept flag, wired only at the DEFER call site), freeing the branch name for the next story while preserving the working tree, uncommitted changes, and the branch ref for inspection. New verify.checkout_detach helper. Best effort: on failure the existing worktree-open-failed defer still surfaces the collision (no regression). The escalate-and-pause callers deliberately do not detach: that path halts the run rather than continuing, so no sibling is dispatched while the worktree holds the branch, and resume frees it via the resume-restart discard before any mount. Added a regression guard proving the escalation pauses without dispatching the next unit.
WalkthroughChangesKept worktree branch reuse
Estimated code review effort: 3 (Moderate) | ~20 minutes Possibly related issues
Possibly related PRs
Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
@CodeRabbit review |
✅ Action performedReview finished.
|
There was a problem hiding this comment.
🧹 Nitpick comments (1)
tests/test_engine_worktree.py (1)
352-355: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueConsider iterable unpacking instead of list concatenation.
As indicated by static analysis tools, you can use iterable unpacking here for slightly cleaner and more idiomatic list construction.
♻️ Proposed refactor
- script = _defer_script(project, "1-1-a") + [ - wt_dev_effect(project, "1-2-b"), - wt_review_effect(project, "1-2-b", clean=True), - ] + script = [ + *_defer_script(project, "1-1-a"), + wt_dev_effect(project, "1-2-b"), + wt_review_effect(project, "1-2-b", clean=True), + ]🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@tests/test_engine_worktree.py` around lines 352 - 355, Update the script construction in the _defer_script test setup to use iterable unpacking for the returned entries instead of list concatenation, while preserving the existing wt_dev_effect and wt_review_effect ordering and values.Source: Linters/SAST tools
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@tests/test_engine_worktree.py`:
- Around line 352-355: Update the script construction in the _defer_script test
setup to use iterable unpacking for the returned entries instead of list
concatenation, while preserving the existing wt_dev_effect and wt_review_effect
ordering and values.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: ab6f57c5-3f73-4fa1-acf1-26866f6bd92b
📒 Files selected for processing (6)
CHANGELOG.mdsrc/bmad_loop/engine.pysrc/bmad_loop/verify.pysrc/bmad_loop/workspace.pytests/test_engine_worktree.pytests/test_verify_worktree.py
|
@coderabbitai Thanks for the review. Declining the single RUF005 nitpick (
Leaving the concatenation as-is. |
|
Tip For best results, initiate chat on the files or code changes.
ʅ(◔◇◔)ʃ ✏️ Learnings added
|
Fixes #138.
Problem
Under
branch_per="run"+keep_failed=true, the first story to end deferred has its worktree preserved byclose_unit_workspace— left checked out on the single shared run branch. git refuses to check out a branch already held by another worktree, so every subsequent story'sopen_unit_workspacefails and is deferred with zero dev activity (attempt=0, no sessions, 0 tokens). One kept failure turns an N-story run into a 1-story run, and the top-line summary reports "0 done, N deferred" — conflating 1 attempted-and-deferred story with N−1 never attempted.Confirmed end-to-end:
close_unit_workspace's keep-path (workspace.py) had nobranch_perawareness;unit_branch_namereturns the sharedbmad-loop/<run_id>for every unit; the next unit'sworktree_add(create=False)raisesverify.GitError, which_run_isolatedcatches and defers (worktree-open-failed). A deferred unit returns normally, so_loopdrains every remaining unit into the same collision.Fix (issue's option 1 — auto-detach, "minimal")
When
close_unit_workspacepreserves a failed worktree underbranch_per="run", it now runsgit checkout --detachin it. This frees the shared branch name for the next story while preserving everything inspection needs — the working tree, uncommitted changes, and the branch ref (still at the kept commit). Subsequent stories mount the run branch normally and get genuine attempts.Why detach over pausing (issue's option 2): unlike the #126 env-fault (unfixable by the tool), this collision is tool-fixable in one git command — pausing would just make the operator run that exact command before a resume helps. Detaching restores the intended
branch_per=runcontinuation and degrades safely: if detach ever fails, the existingworktree-open-faileddefer still surfaces the collision (no regression).Changes
verify.py— newcheckout_detach(repo)helper (git checkout --detach), mirroringcheckout_branch.workspace.py—close_unit_workspacegainsdetach_kept: bool; on thekeep_failedpath it detaches the kept worktree (best-effort, swallowsGitError).engine.py— passesdetach_kept=scm.branch_per == "run"only at the DEFER call site. A boolean flag (notbranch_per) lets the two callers that must not detach omit it honestly.Why the other two
close_unit_workspacecallers don't detach_merge_local: removes the worktree anyway → branch already freed._keep_branch_and_escalate: keeps a DONE-but-merge-conflict branch for manual merge and immediately pauses the run. That path can't cascade — it halts before any sibling is dispatched, and on resume the re-armed unit's worktree is torn down by the resume-restartdiscard_worktreebefore any mount. A regression test (test_branch_per_run_escalation_pauses_without_dispatching_next_unit) locks this in.Tests
test_checkout_detach_frees_branch(test_verify_worktree.py) — detach frees the branch for a sibling mount while preserving the ref + dirty tree.test_branch_per_run_kept_failure_detaches_so_next_unit_runs(test_engine_worktree.py) — rewritten from the old test that encoded the bug (deferred==2, done==0) to the fixed behavior (deferred==1, done==1, noworktree-open-failed, kept worktree detached).test_branch_per_run_escalation_pauses_without_dispatching_next_unit— escalate-path scoping guard.Full suite green (2272 passed, 1 skipped); trunk clean.
Summary by CodeRabbit
branch_per=runandkeep_failed=true, preventing kept failed worktrees from blocking subsequent stories.