fix(cli): re-stamp the policy snapshot on resume (#189) - #191
Conversation
`policy_snapshot` was stamped only at run creation. `_resume_paused_run`
reloads policy.toml and enforces it — the per-story budget, every
SessionSpec — but left the launch-time snapshot in place, and every
display reads the snapshot: the run summary, `bmad-loop status`, the TUI,
and the `policy` block of the diagnose bundle, which claims to describe
the run that was executed.
Edit `limits.cache_read_weight` between launch and resume and the run
enforced at the new weight while every surface reported the old one,
silently up to 10x apart at the legal extremes (0.0-1.0). A single
`session-end` entry could likewise carry `tokens_weighted` at the
snapshot weight beside `budget_weighted` at the live one.
Resume now re-stamps the whole snapshot and persists it before the engine
starts, restoring the contract README/FEATURES/tui-guide and the TUI
settings screen already documented ("changes apply to new runs and
resumes"). Run scope and mode (source, spec_folder, epic_filter, ...)
stay pinned at launch, so a policy edit still cannot redirect a live run.
This does not weaken the #129 invariant that displays read the snapshot
and never `self.policy` — it removes the divergence at its source rather
than moving displays onto live policy. The stale comments in
Engine.summary and the test docstring that cited the resume gap as their
justification are reworded; the conclusion stands.
`run-resume` entries now carry `cache_read_weight`, `policy_changed`, and
`cache_read_weight_was` when it moved, so per-session totals written
under the old weight stay reconstructible. Scalars only, never policy
keys or values: journal fields are scrubbed with `scrub_json`, not the
key-aware `_scrub_policy` that reduces plugins.settings to plugin ids, so
a dotted key path would ship setting names verbatim in a diagnose dump.
The policy comparison normalizes through JSON: `to_dict()` returns tuples
where the persisted snapshot round-trips lists, so a plain `!=` reports
"changed" on every resume, including untouched ones.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (9)
WalkthroughResume now re-stamps and persists the current policy before engine startup, records weight changes in the journal, and validates consistent weighted totals across resumed, sweep, legacy, and unchanged-policy runs. ChangesResume policy synchronization
Estimated code review effort: 3 (Moderate) | ~20 minutes Sequence Diagram(s)sequenceDiagram
participant ResumeCommand
participant _resume_paused_run
participant policy_mod_load
participant state_json
participant Engine
ResumeCommand->>_resume_paused_run: resume paused run
_resume_paused_run->>policy_mod_load: load current policy.toml
policy_mod_load-->>_resume_paused_run: policy snapshot and cache_read_weight
_resume_paused_run->>state_json: persist policy_snapshot and resume metadata
_resume_paused_run->>Engine: start with updated state and policy
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 |
Closes #189.
The bug
RunState.policy_snapshotis stamped at exactly two places, both at run creation:cmd_runand_start_sweep._resume_paused_runreloadspolicy.toml, hands it to the rebuilt engine, and never re-stamped — so a resumed run carried two policies:self.policy: the per-story budget check in_finish_commit, and every value threaded onto aSessionSpec.RunState.cache_read_weight(): the run summary,bmad-loop status, three TUI sites, thesession-endjournal'stokens_weighted, and the wholepolicyblock of the diagnose bundle — which claims to describe the run that was executed.Edit
limits.cache_read_weightbetween launch and resume and the run enforces at the new weight while every display reports the old one. The legal range is 0.0–1.0, so that is a silent 10x disagreement at the extremes. A singlesession-endentry could likewise carrytokens_weightedat the snapshot weight besidebudget_weightedat the live one.Why this shape
The contract was already documented in four places —
README.md,docs/FEATURES.md,docs/tui-guide.md, and the TUI settings screen all say "changes apply to new runs and resumes." Enforcement honored it; the snapshot did not. That made this a plain bug rather than a semantics choice.The invariant now established:
policy_snapshotis stamped at every engine-process start —run,sweep,resume— and equals thePolicythat process enforces for its whole lifetime.Policyis loaded once per process and frozen, so this is exact.This does not weaken the #129 invariant that displays read the snapshot and never
self.policy. Re-stamping removes the divergence at its source instead of moving displays onto live policy; both invariants now hold at once.Engine.summary's comment and one test docstring cited the resume gap as their justification — that premise is reworded, the conclusion ("do not unify these") stands.Run scope and mode (
source,spec_folder,epic_filter,target_branch, …) stay pinned at launch, so a policy edit still cannot redirect a live run. The snapshot can therefore disagree with those fields; that is correct, and commented as such.Forensics
run-resumeentries gaincache_read_weight(always),policy_changed(bool), andcache_read_weight_was(only when it moved). Re-stamping re-weights the run's whole history, so entries written before the resume would otherwise stop being reconstructible from the newer snapshot — the exact guarantee #129 exists to provide.Scalars only, never policy keys or values. Journal entries are unsanitized at write time, and
diagnostics._scrub_entryscrubs unknown fields withscrub_json, not the key-aware_scrub_policythat reducesplugins.settingsto plugin ids andadapter.env/extra_argsto counts. Since identifiers legally contain dots, a dotted key-path diff would shipplugins.settings.<plugin>.<setting_name>verbatim into a diagnose dump. Verified empirically: the raw snapshot holds the planted secret,_scrub_policyemitssettings: ['tea'], secret absent.A trap worth reviewing closely
Policy.to_dict()returns tuples forverify.commands,extra_argsandplugins.enabled; the persisted snapshot round-trips them back as lists, and() != []. A plain comparison therefore reports "policy changed" on every resume, including one wherepolicy.tomlwas never touched — the common case. The comparison normalizes throughjson.dumps(..., sort_keys=True)on both sides, the same transformsave_stateapplies.test_resume_under_an_unchanged_policy_reports_no_changeresumes twice to pin it.Visible behavior changes
clear_pause()is now durable immediately (the CLI never calledsave_stateat all before; it relied onEngine._save()), sostatusshows a live run right after resume rather than lingering on PAUSED.write_pidruns beforesave_stateso no observer catches a "not paused + dead pid" window, whichtui/data.pyclassifies as INTERRUPTED.Testing
tests/test_cli.py, each confirmed failing before the fix — the primary one failedassert 0.1 == 0.5, andstatusreported260 weightedwhere the budget judged 660. They cover: the stamp being durable beforeengine.run(), the whole tree (not just the weight), the sweep arm, the legacy empty-snapshot case,statusend to end, and both journal cases.trunk check(no filter) clean.diagnoseCLI end to end: post-resume it reports the re-stamped policy block,weighted: 660, and therun-resumescalars intact through scrubbing, with a planted secret absent from the dump.Summary by CodeRabbit
Bug Fixes
Documentation