Skip to content

refactor(restore): one latch validator, one path normalizer, one exclusion site#107

Merged
pbean merged 3 commits into
mainfrom
fix/restore-seam-cleanup-91-84
Jul 9, 2026
Merged

refactor(restore): one latch validator, one path normalizer, one exclusion site#107
pbean merged 3 commits into
mainfrom
fix/restore-seam-cleanup-91-84

Conversation

@pbean

@pbean pbean commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

Closes #91, Closes #84

The last of the four PRs bundling the open issues from the PR #86 final review (after #103, #105, #106). Both issues here are non-bug cleanups that review raised: #91 is the restore-seam duplication it flagged across runs/cli/engine/verify, #84 is the test-fidelity gap found while writing PR #77's A5 regression test. Behavior-neutral by intent, with one deliberate exception noted below.

#91(a) — one latch validator

runs.validate_restore_latch now holds every patch-restore precondition: the sentinel wedge and spec-less escalation (previously in rearm_escalation) and the worktree-isolation rejection (previously only in cli._resolve_restore_patch).

The one intentional behavior change: rearm_escalation now rejects a worktree-isolation restore as well. That split was the actual bug in #91 — any non-CLI caller (TUI restore parity, a script) could latch a patch the re-drive can never honor, because it discards the unit's worktree and re-mounts a fresh one. The CLI still passes worktree_isolation from the live policy, the one input run state cannot carry, so a policy edit between escalation and resolve can't skew the guard.

Path resolution and spec_within_roots containment stay CLI-side — they need --project and the loaded bmad config.

#91(b) — one path normalizer

verify.resolve_restore_path(raw, root) replaces the four copies of the maybe-relative→absolute join (engine._restore_patch vs workspace.root, verify.verify_dev_exclude_relpaths vs paths.project, cli._resolve_restore_patch vs project, and the fourth #90 added in runs._stale_restore_residue). Each consumer resolves against the base it actually reads the tree from, so root stays caller-supplied. The relative branch is kept — model.StoryTask.restore_patch promises repo-relative-or-absolute — and documented as CLI-latched-absolute in practice. The CLI keeps its own .resolve() on top, since it is the one consumer feeding a containment check.

#91(c) — vacuous phase guard

engine._restore_patch's if task.phase == Phase.DEV_RUNNING was always true at its sole call site (_dev_phase advances to DEV_RUNNING immediately before dispatch), and the comment's PENDING case couldn't survive _escalate anyway. Replaced with an unconditional advance + call-site-invariant comment.

#91(d) — exclusion derived at the gate

_verify_shared_gates' proof_exclude becomes extra_exclude: the gate now derives verify_dev_exclude_relpaths(paths, spec_path, task.restore_patch) from the task it already receives, and each mode passes only what it adds — () for sprint and bundle, the story record + manifest for stories. Threading the restore patch in from three call sites left a default-None foot-gun for a future fourth mode, which would silently let a restore re-drive pass proof-of-work on the patch file's mere presence. extra_exclude=None still skips the gate outright for the plan-halt leg, and #88's baseline_untracked=task.baseline_untracked argument is preserved verbatim.

#91(e) — test dedup

The three _escalated_run copies were not near-identical (different defaults, different return tuples, one unique kwarg each), so conftest gets a parameterized superset builder returning an EscalatedRun record; each module keeps a thin adapter for its own shape. Every field a caller relied on is still reachable — nothing is weakened down to a lowest common denominator. The E2E scaffolds loop install.DEV_BASE_SKILLS (and the STORIES_PROBE_* constants) instead of hand-rolling the skill list three times, so a newly required skill fails them loudly. conftest.dev_effect absorbs test_engine._restoring_dev_effect via a seen hook + write_src=False flag.

#84make_engine persists the launching scope

tests/test_stories_engine.make_engine now seeds max_stories/story_filter/epic_filter on the RunState it builds, mirroring cli.cmd_run. resume rebuilds the cap and filters from run state alone, so the old helper made every resume_engine test silently run uncapped unless the author remembered engine.state.max_stories = N. The workaround in test_max_stories_survives_a_checkpoint_pause_resume is dropped and a round-trip test pins the scope surviving unseeded.

Verification

  • .venv/bin/python -m pytest1950 passed, 1 skipped (5 new tests; main was 1945 + 1).
  • Full trunk check (no filter) — clean.
  • Every test-dedup diff reviewed hunk by hunk for silently weakened assertions. One was caught in the act: an edit initially truncated test_rearm_rejects_restore_patch_without_a_spec_file's tail assertions; restored and re-verified.

New coverage: the stories gate excludes a latched restore patch (guards the #91(d) derive), resolve_restore_path join/no-normalize semantics, rearm_escalation rejecting a worktree-executed task's latch, validate_restore_latch's clean-pass + live-policy legs, and the #84 resume round-trip.

Summary by CodeRabbit

  • Bug Fixes

    • Improved restore-patch handling so restore paths are resolved more consistently and worktree-isolated runs are rejected when restore isn’t supported.
    • Strengthened validation around escalation re-arming and restore flows to prevent invalid or stale patch reuse.
    • Resume behavior now preserves story selection limits and filters more reliably across runs.
  • Documentation

    • Updated the changelog with the latest restore, escalation, and test-fixture improvements.

…usion site

The patch-restore seam grew four copies of the same maybe-relative→absolute
join and split its latch preconditions across runs.rearm_escalation (sentinel
wedge, spec-less escalation) and cli._resolve_restore_patch (worktree
isolation), so a programmatic re-arm could latch a patch the re-drive can
never honor.

- runs.validate_restore_latch: the single precondition seam, called by both
  entry points. rearm_escalation now rejects a worktree-isolation restore too;
  the CLI still passes the live policy's isolation mode, the one input run
  state cannot carry.
- verify.resolve_restore_path: replaces the copies in engine._restore_patch,
  verify.verify_dev_exclude_relpaths, cli._resolve_restore_patch and
  runs._stale_restore_residue. The CLI keeps its own .resolve() for the
  containment check.
- engine._restore_patch: the DEV_RUNNING phase guard was vacuous at its sole
  call site; unconditional advance + call-site-invariant comment.
- _verify_shared_gates: proof_exclude -> extra_exclude, with the restore-patch
  exclusion derived from the task the gate already receives. None still skips
  the gate for the plan-halt leg; #88's baseline_untracked argument unchanged.

Test hygiene: the three _escalated_run copies collapse into one parameterized
conftest builder (superset, not lowest-common-denominator, so no fixture's
assertions are weakened); the E2E scaffolds loop install.DEV_BASE_SKILLS;
conftest's dev_effect absorbs _restoring_dev_effect via a seen-hook +
skip-write flag; make_engine seeds max_stories/story_filter/epic_filter on
RunState like cmd_run does, so resume tests stop silently running uncapped.

Closes #91, Closes #84
@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@pbean, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 2 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: cb741b91-2bac-4ece-8d05-fcb3a8ad4f46

📥 Commits

Reviewing files that changed from the base of the PR and between 07f1aa1 and afbc626.

📒 Files selected for processing (5)
  • CHANGELOG.md
  • src/bmad_loop/data/skills/bmad-loop-resolve/SKILL.md
  • src/bmad_loop/resolve.py
  • tests/test_resolve.py
  • tests/test_stories_engine.py

Walkthrough

Restore-patch validation logic is consolidated into shared helpers (validate_restore_latch in runs.py, resolve_restore_path in verify.py) used by cli.py, engine.py, and runs.py, replacing duplicated inline checks. Separately, test fixtures (escalated_run, install_dev_base_skills) are added to conftest.py and adopted across test modules, and make_engine now persists RunState launch-scope fields for resume.

Changes

Restore-Patch Latch Validation Consolidation

Layer / File(s) Summary
validate_restore_latch helper and rearm_escalation wiring
src/bmad_loop/runs.py, CHANGELOG.md
New validate_restore_latch checks sentinel/spec/worktree-isolation preconditions; rearm_escalation and _stale_restore_residue are updated to use it and the new resolve_restore_path helper respectively.
verify.py resolve_restore_path and shared gate refactor
src/bmad_loop/verify.py
Adds resolve_restore_path, updates verify_dev_exclude_relpaths to use it, and reshapes _verify_shared_gates to accept extra_exclude, updating call sites in verify_dev, verify_dev_bundle, and verify_dev_stories.
cli.py _resolve_restore_patch and cmd_resolve wiring
src/bmad_loop/cli.py
_resolve_restore_patch now takes state, validates via validate_restore_latch, and resolves paths via resolve_restore_path; both cmd_resolve call sites pass the new argument.
engine.py _restore_patch path resolution and phase advance
src/bmad_loop/engine.py
Engine._restore_patch uses resolve_restore_path and unconditionally advances to Phase.DEV_VERIFY on patch-apply failure.
Restore-latch and resolve_restore_path test coverage
tests/test_resolve.py, tests/test_verify.py
Adds tests for worktree-isolation rejection, validate_restore_latch outcomes, stories-mode proof-of-work exclusion, and resolve_restore_path normalization.

Test Fixture Consolidation and Scope Persistence

Layer / File(s) Summary
conftest escalated_run, dev_effect, and skill stub helpers
tests/conftest.py
Adds EscalatedRun/escalated_run factory, _write_skill_stubs/install_dev_base_skills, and extends dev_effect with seen/write_src parameters.
Test modules delegate to conftest.escalated_run
tests/test_cli.py, tests/test_engine.py, tests/test_resolve.py, tests/test_runs.py
Local _escalated_run helpers now delegate to conftest.escalated_run/dev_effect instead of manually constructing state objects.
test_stories_e2e scaffolds use install_dev_base_skills
tests/test_stories_e2e.py
Manual skill-stub tree creation in three scaffold helpers is replaced with install_dev_base_skills(root, folder_id=True).
make_engine persists launching scope for resume
tests/test_stories_engine.py
make_engine seeds max_stories/story_filter/epic_filter on RunState; a new regression test verifies these persist across resume.

Estimated code review effort: 3 (Moderate) | ~30 minutes

Possibly related issues

Possibly related PRs

Poem

A rabbit hops through patch and latch, 🐇
One validator, one true path to match,
Skills stubbed once, scope carried home,
No more duplicate joins to roam,
Hop, resolve, and verify — clean as a carrot patch! 🥕

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main restore refactor: centralized latch validation, path normalization, and proof-of-work exclusion handling.
Docstring Coverage ✅ Passed Docstring coverage is 87.10% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/restore-seam-cleanup-91-84

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
tests/test_stories_engine.py (1)

1037-1056: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Solid regression test for scope persistence.

The test correctly validates the round-trip through save_state/load_state and the resumed engine's internal filter derivation. One minor gap: resumed.state.story_filter and resumed.state.epic_filter are only checked on the reloaded state (lines 1048–1049), not on the resumed engine's state. Adding those assertions would confirm resume_engine doesn't mutate scope fields during construction.

Optional: add resumed-state assertions for story_filter and epic_filter
     resumed, _ = resume_engine(project, engine, [])  # no manual seeding anywhere
     assert resumed.state.max_stories == 2
+    assert resumed.state.story_filter == "1"
+    assert resumed.state.epic_filter == 7
     # StoriesEngine parks `--story` in its own id filter (a flat list has no E-S refs)
     assert resumed._story_id_filter == "1"
🤖 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_stories_engine.py` around lines 1037 - 1056, The resume regression
test in test_make_engine_persists_the_launching_scope_for_resume only checks
story_filter and epic_filter on the reloaded state, not on the resumed engine
itself. Update the test to also assert resumed.state.story_filter and
resumed.state.epic_filter after calling resume_engine, using the existing
make_engine, load_state, and resume_engine symbols to verify those scope fields
are preserved through construction.
🤖 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_stories_engine.py`:
- Around line 1037-1056: The resume regression test in
test_make_engine_persists_the_launching_scope_for_resume only checks
story_filter and epic_filter on the reloaded state, not on the resumed engine
itself. Update the test to also assert resumed.state.story_filter and
resumed.state.epic_filter after calling resume_engine, using the existing
make_engine, load_state, and resume_engine symbols to verify those scope fields
are preserved through construction.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 120f3416-ef33-44d4-ba99-d21e789a1062

📥 Commits

Reviewing files that changed from the base of the PR and between 4f0a308 and 07f1aa1.

📒 Files selected for processing (13)
  • CHANGELOG.md
  • src/bmad_loop/cli.py
  • src/bmad_loop/engine.py
  • src/bmad_loop/runs.py
  • src/bmad_loop/verify.py
  • tests/conftest.py
  • tests/test_cli.py
  • tests/test_engine.py
  • tests/test_resolve.py
  • tests/test_runs.py
  • tests/test_stories_e2e.py
  • tests/test_stories_engine.py
  • tests/test_verify.py

…eloaded state

StoriesEngine nulls the story_filter/epic_filter constructor kwargs (a flat
list has no E-S refs), so assert construction leaves RunState's copies intact
— the base Engine parks the filters on itself, never back onto state.
@pbean

pbean commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks — applied in 0130b18. The suggestion was worth more than a nitpick: StoriesEngine.__init__ explicitly nulls the story_filter/epic_filter constructor kwargs (a flat story list has no E-S refs), so a reader could reasonably worry that construction clobbers the durable scope on RunState too. It doesn't — the base Engine parks the filters on self, never back onto self.state — and now the test pins that instead of leaving it to inspection.

Both suggested assertions added, plus a comment naming the invariant. Full suite still 1950 passed / 1 skipped; trunk check clean.

build_context's flag was a residual partial copy of the precondition set —
worktree-only — so a spec-less or sentinel-wedged escalation told the agent a
restore was available, and the recorded restore_patch would only be rejected
AFTER the interactive session (the exact waste the pre-session validation leg
exists to prevent). Derive it from validate_restore_latch instead, extend the
signal test with the two missing legs, and generalize the skill doc's
false-reason wording.
@pbean pbean merged commit ad90b82 into main Jul 9, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant