Skip to content

fix(sweep): re-drive in-flight bundles by identity on resume#112

Merged
pbean merged 1 commit into
mainfrom
fix/sweep-inflight-redrive-94
Jul 10, 2026
Merged

fix(sweep): re-drive in-flight bundles by identity on resume#112
pbean merged 1 commit into
mainfrom
fix/sweep-inflight-redrive-94

Conversation

@pbean

@pbean pbean commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

Closes #94

The bug

The base Engine._loop opens with _finish_inflight() (engine.py:1103), which re-drives every non-terminal task by task identity. SweepEngine._loop overrides _loop wholesale and never did this: its only recovery arm lived inside _run_bundle, reachable only once a cycle re-derived the bundle key from the current triage plan.

So a bundle re-armed by bmad-loop resolve (rearm_escalationPENDING, rearmed=True) survived a resume only because the cached triage.json reloaded and re-emitted the same bundle name → the same _bundle_key. Lose that cache and a fresh triage session partitions the open ids under new names, and the re-armed PENDING task is silently orphaned — the human's resolution is dropped.

The "corrupt cache" leg was unreachable. _read_json (sweep.py:30) is a bare json.loads, so a truncated triage.json raised JSONDecodeError straight out of _ensure_triage → escaped _loopengine.run()'s except Exception → whole-run crash.txt. Only the missing-file leg reached the fresh-triage fallback.

The fix

_finish_inflight_bundles() runs at the top of _loop, before the ledger is read, and re-drives every non-terminal dw* task under its own persisted story_key — never a key recomputed from the current sweep_cycle, so a cycle-1 re-arm resumed much later still re-drives correctly.

Why a double-drive is unrepresentable

Case Outcome
Happy reload Recovered task is terminal by _cycle time → _run_bundle's terminal-return.
Fresh triage Recovery closed the ids, so they left open_now; validate_triage(result, open_now) rejects any fresh plan referencing them. A fresh triage literally cannot re-bundle them.
Recovered re-drive DEFERs Ids stay open, task DEFERRED → the existing failed_ids filter drops the fresh bundle.
The recovered bundle was the only open work Ids close → sweep-nothing-open, no triage session at all.

A still-escalated, un-rearmed bundle is terminal (TERMINAL_PHASES includes ESCALATED) and stays untouched — pinned by the existing test at tests/test_sweep.py:983 and by a new guard test.

Also in this PR

  • _recover_inflight_bundle(task) -> bool_run_bundle's recovery arm extracted verbatim; _run_bundle keeps it as its fallback, so the plan-matched path is bit-for-bit unchanged.
  • _ensure_triage reload hardeningjson.JSONDecodeError / OSError / UnicodeDecodeError degrade to a fresh triage. Added an isinstance(cached, dict) guard too: a top-level JSON array would otherwise still crash on rj.get(...) inside validate_triage.
  • _ensure_bundle_intent — the dev prompt consumes only task.bundle_file, and intent.md persists in the run dir, so it is reused as-is, never re-parsed. Only when the file is gone is a degraded intent rebuilt from the task; _write_intent re-attaches the verbatim ledger bodies, which the replacement prose names as the contract. The triage session's authored prose is the one unrecoverable piece.
  • _warn_stranded_bundles() — belt-and-braces invariant in _cycle: a non-terminal dw* task surviving to a cycle is journaled + notified rather than silently dropped (the issue's no-silent-caps fallback). With the recovery pass in place it should be unreachable.
  • New journal events: sweep-inflight-redrive, sweep-inflight-stranded, sweep-intent-regenerated.

Deliberately out of scope

  • _resumable_session is not adopted for bundles. Base _finish_inflight continues a task whose host died in the post-session window by replaying the recorded result (engine.py:1124); _recover_inflight_bundle still restarts it. Lifting that is a resume-fidelity change of its own and wants its own tests.
  • _read_json itself is unchanged. The decisions.json read (sweep.py:~775) keeps its current contract; only _ensure_triage's call site is guarded.

Known acceptable drifts

  • Recovered bundles do not occupy max_bundles slots under the fresh-triage path.
  • pre_bundle / post_bundle for a recovered bundle now fire before pre_sweep_cycle — mirrors base-engine _finish_inflight-before-pick semantics. No test or plugin asserts on the ordering.

Testing

2030 passed, 1 skipped; full trunk check clean.

7 new tests. Verified they fail without the sweep.py change (reverted sweep.py alone, re-ran): the 6 regression tests fail, while test_escalated_unresolved_still_skipped_when_triage_json_lost passes both before and after — it is a guard, not a regression test.

  1. test_rearmed_bundle_redrives_when_triage_json_lost — the headline regression; 2 sessions, no triage.
  2. test_fresh_triage_different_bundle_name_no_double_drive — parametrized over missing / {{{ / {}; fresh plan renames the surviving bundle; exactly 5 sessions, each dw id closed once by the bundle that owned it. The invalid-JSON leg pins the no-crash path.
  3. test_restore_patch_latch_honored_when_triage_json_lost — restore semantics survive the move out of _run_bundle.
  4. test_escalated_unresolved_still_skipped_when_triage_json_lost — escalated bundle untouched; overlapping fresh bundle still dropped via failed_ids.
  5. test_interrupted_bundle_redrives_by_identity_after_triage_loss — non-rearmed mid-dev crash → restart arm, cause="stopped".
  6. test_regenerated_intent_when_bundle_file_missing — regenerated file carries the verbatim ### DW-1 entry and reaches the dev prompt.
  7. test_stranded_bundle_task_warns_loudly — the invariant fires for a ghost dw* task, and not for a terminal bundle or sweep-triage.

The six existing resume pins named in the design were each traced and stay green; I also diffed the test file to confirm no existing test lost its tail (comm on collected test ids: zero disappeared, 7 added).

Summary by CodeRabbit

  • Bug Fixes
    • Resumed sweeps now reliably recover in-flight work using its persisted identity, even when bundle names change.
    • Missing or corrupted triage data no longer causes recovery failures; fresh triage is used when needed.
    • Missing bundle instructions are regenerated automatically during recovery.
    • Unresolved or stranded bundles are preserved, journaled, and clearly reported instead of being silently dropped.
    • Interrupted development and restore workflows now retain their expected recovery behavior.

SweepEngine._loop never had the base engine's _finish_inflight pass. Its only
recovery arm lived inside _run_bundle, reachable only once a cycle re-derived
the bundle key from the *current* triage plan — so a bundle re-armed by
`bmad-loop resolve` survived a resume solely because the cached triage.json
reloaded and re-emitted the same bundle name. Lose that cache and a fresh
triage partitions the ids under new names, silently orphaning the resolution.

_finish_inflight_bundles now runs at the top of the loop, before the ledger is
read, and re-drives every non-terminal dw* task under its own persisted
story_key. Because its ids leave the open set first, validate_triage rejects
any fresh plan that references them — a double-drive is unrepresentable. A
still-escalated bundle is terminal and stays untouched.

The corrupt-triage.json leg of the issue was unreachable: _read_json is a bare
json.loads, so a truncated cache crashed the whole run out of _ensure_triage.
It now degrades to a fresh triage, as does a cache holding a non-object.

Also: _run_bundle's recovery arm is extracted verbatim to
_recover_inflight_bundle and kept as its fallback (the plan-matched path is
bit-for-bit unchanged); a missing bundle intent file is regenerated from the
task; and a bundle surviving to a cycle is journaled + notified, never dropped.

Closes #94
@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 1e25eb92-3904-49f8-ade2-fc2f6bd4f23c

📥 Commits

Reviewing files that changed from the base of the PR and between 41e5421 and f091e78.

📒 Files selected for processing (3)
  • CHANGELOG.md
  • src/bmad_loop/sweep.py
  • tests/test_sweep.py

Walkthrough

Sweep resume now redrives persisted in-flight bundles by story_key, recovers missing intent files, tolerates invalid triage caches, journals stranded tasks, and validates interrupted, escalated, restore-patch, and renamed-bundle scenarios.

Changes

Sweep resume recovery

Layer / File(s) Summary
Persisted bundle redrive
src/bmad_loop/sweep.py
Startup identifies non-terminal dw* tasks by persisted identity, redrives them, reports stranded bundles, and commits recovered ledger changes.
Bundle state and intent recovery
src/bmad_loop/sweep.py
Interrupted bundle state is reset with rollback and restore handling, while missing intent.md files are regenerated from persisted task data.
Triage fallback and recovery validation
src/bmad_loop/sweep.py, tests/test_sweep.py, CHANGELOG.md
Invalid or unreadable triage caches fall back to fresh triage, with tests covering recovery paths and a changelog entry documenting the behavior.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

Poem

I’m a rabbit with bundles tucked tight,
Redriving lost tasks through the night.
Cache crumbs may stray,
Intent grows anew,
And journals keep every hop in sight!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.91% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: recovering in-flight bundles by persisted identity on resume.
Linked Issues check ✅ Passed The PR implements identity-based resume recovery, handles triage reload failures, and warns on stranded bundles as requested in #94.
Out of Scope Changes check ✅ Passed The changes stay within the resume/recovery hardening scope and do not introduce unrelated functionality.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/sweep-inflight-redrive-94

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.

@pbean pbean merged commit 02fa21c into main Jul 10, 2026
8 checks passed
@pbean pbean deleted the fix/sweep-inflight-redrive-94 branch July 10, 2026 02:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

sweep: a triage.json reload failure on resume orphans a re-armed bundle

1 participant