feat(review): escalate deliberate sprint-status regressions at review-verify - #339
Conversation
…-verify The orchestrator advances sprint-status to done at dev time (its sole advance site, asserted by verify_dev, and advance never regresses). A review session that judges the story unfinished and writes the board back therefore contradicts a state the orchestrator established — and nothing in the review loop re-advances it, so every remaining cycle re-read the same verify failure and the story ended deferred with its work rolled back. verify_review now takes a kw-only sprint_reached_done flag (passed only by Engine._verify_review, as not _dev_review_enabled()) and turns that regression into a CRITICAL VerifyOutcome carrying a new contradiction flag, routed to the existing not-retryable -> escalate path. The budget-exhaustion rescue site captures its outcome so a contradiction there escalates rather than deferring under a reason naming neither side. Both review-verify-failed emit sites carry contradiction=. Keys on sprint-status only: the spec's own frontmatter legitimately cycles while a review patches, and status: blocked stays the sanctioned hand-back channel. Conservative on uncertainty — no launch flag, a status outside STATUS_ORDER, or a missing board entry all fall back to the legacy retry. New knob [review] on_status_contradiction defaults to escalate; retry restores the released behavior verbatim. Review prompts are deliberately untouched: prohibiting the revert would make a correct reviewer comply, and the story would commit without sign-off — the contradiction becomes invisible instead of resolved. Closes #334
|
Warning Review limit reached
Next review available in: 32 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (11)
✨ Finishing Touches🧪 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 |
Greptile SummaryThis PR fixes a livelock (#334) where a review session that writes
Confidence Score: 5/5Safe to merge — all three primary escalating paths are correctly wired, the new knob defaults conservatively, and the fix is guarded against every uncertainty the PR description enumerates. The contradiction detection is conservative by design: missing launch flag, unknown board token, None status, and the retry knob all fall through to the existing retry path unchanged. _escalate always raises RunPaused so no affected code path falls through silently. The four new engine tests include a correctly-described ablation table and cover the rescue-gate case that was previously an unaddressed gap. The only finding is an observability gap in the timeout-salvage journal entry — a very narrow path requiring both a review timeout and a board regression simultaneously — with no impact on correctness. Files Needing Attention: src/bmad_loop/engine.py around the _salvage_review_timeout journal append (line 1621) — the contradiction field is missing there, unlike the three paths the PR explicitly covers.
|
| Filename | Overview |
|---|---|
| src/bmad_loop/verify.py | Adds sprint_reached_done kw-only param to verify_review and new _is_signoff_regression helper; logic is conservative on every uncertainty (None status, unknown token, missing flag, retry knob all fall through to ordinary retry) |
| src/bmad_loop/engine.py | Three call-site changes: _verify_review wrapper passes sprint_reached_done, both in-loop journal entries gain contradiction, and the rescue gate now captures the outcome and escalates on contradiction before falling through to the defer |
| src/bmad_loop/model.py | Adds contradiction: bool = False field to VerifyOutcome and threads it through VerifyOutcome.escalate(); correctly defaults to False so existing callers are unaffected |
| src/bmad_loop/policy.py | New REVIEW_ON_STATUS_CONTRADICTION_MODES constant, on_status_contradiction field on ReviewPolicy, parse + validation in loads, and POLICY_TEMPLATE entry — consistent with the on_timeout precedent |
| tests/test_engine.py | Four targeted engine tests: escalates on regression (default), legacy defer with retry mode, ordinary fixable-retry not swallowed, and rescue-gate escalation — all with described ablation results |
| tests/test_verify.py | Four unit tests cover escalation, retry-mode legacy path, missing launch flag, and both unrecognized-status cases (absent story entry and hand-edited token) |
| tests/test_policy.py | Validates default, all valid modes, template round-trip, and invalid-value rejection for the new knob |
| tests/test_settings_schema.py | Adds REVIEW_ON_STATUS_CONTRADICTION_MODES to the schema validator's OPTIONS_ENUM, ensuring the settings TUI and schema test stay in lockstep |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[review session completes] --> B{spec status == done?}
B -- No --> C[retry / defer as before]
B -- Yes --> D{sprint status == done?}
D -- Yes --> E[verify commands]
E -- pass --> F[commit / clean=True]
E -- fail --> G[fixable retry or escalate]
D -- No --> H{_is_signoff_regression?}
H -- "No: no launch flag / retry mode / unknown token / None" --> I[VerifyOutcome.retry]
H -- "Yes: sprint_reached_done=True, escalate mode, known token < done" --> J["VerifyOutcome.escalate contradiction=True"]
J --> K{call site}
K -- in-loop path --> L["journal review-verify-failed contradiction=True → _escalate → RunPaused"]
K -- rescue gate --> M["journal review-verify-failed contradiction=True → _escalate → RunPaused"]
K -- skip-review-and-commit --> N["journal review-verify-failed contradiction=True → _escalate → RunPaused"]
K -- timeout salvage --> O["journal review-timeout-salvage-failed (no contradiction field) → _escalate → RunPaused"]
Reviews (2): Last reviewed commit: "fix(review): journal the rescue-gate con..." | Re-trigger Greptile
…y-failed The budget-exhaustion rescue escalated on a revoked sign-off but emitted only story-escalated, so a consumer keying on the new contradiction flag saw two of the three escalating paths. Emit the same kind there. The non-contradiction arm keeps its existing silence — its story is told by the defer reason. Greptile P2 on #339.
|
Addressed the Greptile P2: the rescue-gate escalation now emits Ablated: dropping the new append fails the rescue-gate test's journal assertion. |
Closes #334
The livelock
The orchestrator — not the dev session — advances
sprint-statustodoneat dev time (engine.py:2080, its solesprint_advancesite;verify_devthen asserts the write landed, andadvancenever regresses). A review session that judges the story unfinished and writes the board back to an earlier stage is doing the right thing, but nothing in the review loop re-advances it. Soverify_review'ssprint != "done"check returned a non-fixable retry, the loopcontinued, and every remaining cycle re-read the identical failure — untilmax_review_cyclesran out and the story deferred with its work rolled back.Reported by Wojtek Diset against 0.9.0: three review passes burned on a structural disagreement, then a rollback onto an
attempt-preserveref.The fix
verify_reviewgains a kw-onlysprint_reached_done: bool = False. OnlyEngine._verify_reviewpasses it (not self._dev_review_enabled()— exactly the case where_post_dev_state_synctargeteddone); the sweep and stories gates are separate functions and are untouched. When the flag is set, the knob isescalate, and the fresh sprint status is inSTATUS_ORDERat an index belowdone, the gate returnsVerifyOutcome.escalate(..., contradiction=True)— a reason naming both sides, both ways out, and the opt-out knob. That flows into the existingnot outcome.retryable → self._escalaterouting at both call sites, so the run pauses andpaused_reasoncarries the text tobmad-loop resolve.One site needed an edit: the budget-exhaustion rescue (
engine.py:1528) testedself._verify_review(task).okinline. When every cycle recommends its own follow-up the in-loop verify never runs, so the rescue is the first read of the board — a contradiction there would have fallen through to thedid not convergedefer that rolls the work back naming neither side. It now captures the outcome and escalates oncontradiction.New knob
[review] on_status_contradiction = "escalate" (default) | "retry", following the #271on_timeouttemplate (constant,ReviewPolicyfield, parse + validation,POLICY_TEMPLATE,core.toml,OPTIONS_ENUM). Defaultescalatebecause the released behavior is the defect — it burns cycles and then discards the work;retryreproduces it verbatim for anyone who wants it back.Deliberate scope boundaries
in-review/in-progress) while a review patches; the board is orchestrator-owned truth.status: blockedremains the sanctioned reviewer hand-back channel and pauses before this gate — no overlap.None), or a token outsideSTATUS_ORDER(hand-edited or future board) all fall back to the legacy retry. A wrong escalation halts an otherwise healthy run.escalation.py,resolve.py,sweep.py,stories_engine.py, or the TUI. The journal keeps thereview-verify-failedkind and only gains acontradiction=field, so the TUI's substring colorizer is unaffected.Known limitation (documented, out of scope)
A review that regresses both the spec status and the sprint board never reaches
_verify_review— the loop'sstatus == "done"gate atengine.py:1462short-circuits first — so it still lands in the non-terminal-status exhaustion defer.Tests
test_policy.py(default / parse / invalid / template),test_verify.py×4 (escalates ·retry-mode legacy · no launch flag · missing-or-unknown token),test_engine.py×4 (sign-off regression escalates in 2 scripted sessions ·retry-mode 3-cycle legacy defer + rollback · a non-regression verify failure keeps its fixable-retry routing · rescue-site contradiction withmax_followup_reviews=5escalates instead of deferring).Three ablations run before trusting them (repo rule):
ScriptExhausted: no scripted result for session 1-1-a-review-2(the ablated engine asks for the review cycle the fix prevents)retry-mode testsSTATUS_ORDERguardsFull suite
3363 passed, 24 skipped;trunk checkclean;pyright@1.1.4110 errors.