Skip to content

fix(engine): session-end durability + raw KeyboardInterrupt handling#62

Merged
pbean merged 2 commits into
bmad-code-org:mainfrom
dracic:fix/gh-57-session-end-durability
Jul 4, 2026
Merged

fix(engine): session-end durability + raw KeyboardInterrupt handling#62
pbean merged 2 commits into
bmad-code-org:mainfrom
dracic:fix/gh-57-session-end-durability

Conversation

@dracic

@dracic dracic commented Jul 4, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Session-end durability barrier: _run_session records the completed SessionRecord without usage and saves immediately; usage is folded in afterwards via the new StoryTask.attach_session_usage and a second save lands before post_session fires. A host kill during usage read, hooks, or follow-up verification can no longer lose a completed session. Usage stays best-effort metadata, never a durability gate.
  • Resume continuation: SessionRecord now persists the session's result_json, and _finish_inflight continues from a durably-completed dev/review session into the existing verify/decide pipeline (journaled resume-verify) instead of re-driving finished work through resume-restart rollback. Incomplete evidence (record missing / not completed / no result) falls through to today's restart path unchanged. Checked against Hardening: reconcile seam is unreachable for stalled sessions — a finished-but-lost-Stop session cannot be recovered as completed #61 first — different mechanism (that is the live-run stalled-verdict reconcile seam; this is the resume path acting on durably-recorded completed evidence).
  • Raw KeyboardInterrupt branch in the run loop, mirroring RunStopped: kill the agent session, persist a controlled stop (run-stop with reason="KeyboardInterrupt"), re-raise when nested. The branch latches _stopping and guards kill_session so a second interrupt or a stop signal landing mid-teardown still records the stop.
  • Per the issue's non-goals, none of the SESSION_END_TRACE_FILE/trace_run diagnostic scaffolding from the reference patches is ported.

Tests

  • Durability proven at the moment post_session fires (spied _emit loads state from disk); read_usage raising leaves the completed session persisted with usage=None.
  • attach_session_usage unit matrix: fold into record + totals, KeyError on unknown task, no-op on None, no double-count.
  • KeyboardInterrupt: controlled stop journaled with reason, session killed, nested re-raise.
  • Resume continuation: dev and review continuations complete the story with zero re-run sessions and no rollback; incomplete-evidence cases (stalled record, legacy record without result_json) still take resume-restart; SessionRecord.result_json serialization round-trip incl. legacy dicts.
  • Full suite: 1388 passed; only the known pre-existing win32 environmental failures in test_module_skills_sync.py remain. black/ruff/isort/bandit clean at trunk-pinned versions.

Closes #57

Summary by CodeRabbit

  • New Features

    • Improved resume behavior so completed sessions can continue from the right step after an interruption, without redoing finished work.
    • Added support for preserving session results and usage details across saves and reloads.
  • Bug Fixes

    • Controlled raw keyboard interruptions now stop runs cleanly instead of being treated like crashes.
    • Session progress is saved more reliably before follow-up processing, helping protect completed work during unexpected shutdowns.

Record the session without usage, save immediately, then fold usage in
via StoryTask.attach_session_usage — a host kill during usage read,
hooks, or follow-up verification can no longer lose a completed session,
and usage stays best-effort metadata rather than a durability gate.

Make the durable record actionable on resume: SessionRecord now carries
the session's result_json, and _finish_inflight continues from a
durably-completed dev/review session into the normal verify/decide
pipeline (journaled as resume-verify) instead of rolling finished work
back through resume-restart. Incomplete evidence — record missing, not
completed, or without a result — falls through to today's restart path.

Also add an explicit KeyboardInterrupt branch to the run loop (Windows
console-ctrl events can surface as a raw KeyboardInterrupt without
routing through the installed signal handler): tear down the agent
session, persist a controlled stop, and re-raise when nested. The branch
latches _stopping and guards kill_session so a second interrupt or a
stop signal landing mid-teardown still records the stop.
@coderabbitai

coderabbitai Bot commented Jul 4, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

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

Next review available in: 33 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: 38d4fe12-8e90-426d-8ac4-c2105b5e61ef

📥 Commits

Reviewing files that changed from the base of the PR and between 2334bdb and 69e2db9.

📒 Files selected for processing (2)
  • src/bmad_loop/engine.py
  • tests/test_engine.py

Walkthrough

This PR persists completed adapter sessions to disk before running post-session hooks, adds a result_json field to SessionRecord with a new attach_session_usage helper, introduces controlled handling of raw KeyboardInterrupt in the run loop, and extends resume logic to reuse durably recorded, complete session results instead of re-running or rolling back work.

Changes

Session durability and resume

Layer / File(s) Summary
SessionRecord result_json and usage attachment
src/bmad_loop/model.py, tests/test_model.py
SessionRecord gains a result_json field serialized via to_dict/from_dict; StoryTask.attach_session_usage folds TokenUsage into the matching record and totals, raising KeyError for unknown task ids and no-op on None.
Durable session save in _run_session
src/bmad_loop/engine.py, tests/test_engine.py
_run_session records result_json, saves state before reading usage and emitting post_session, attaches usage, journals session-end, then saves again; tests confirm persistence even when usage reading raises.
KeyboardInterrupt handling in run loop
src/bmad_loop/engine.py, tests/test_engine.py
Engine.run catches raw KeyboardInterrupt, tears down the session, re-raises for nested runs, and otherwise journals run-stop and marks state stopped.
Resume from recorded session results
src/bmad_loop/engine.py, tests/test_engine.py
_resumable_session finds a completed session with result_json; _finish_inflight, _drive_story, _dev_phase, and _review_and_commit accept an optional resume result to skip re-running the adapter session and continue verify/decision logic, falling back to resume-restart when incomplete.

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

Sequence Diagram(s)

sequenceDiagram
  participant Engine
  participant SessionRecord
  participant Disk
  participant PostSessionHooks
  Engine->>SessionRecord: record_session(result_json)
  Engine->>Disk: _save()
  Engine->>Engine: read_usage
  Engine->>SessionRecord: attach_session_usage
  Engine->>Disk: _save()
  Engine->>PostSessionHooks: emit post_session
Loading
sequenceDiagram
  participant Engine
  participant ResumableSession
  participant DevPhase
  participant ReviewAndCommit
  participant VerifyDecision
  Engine->>ResumableSession: scan task.sessions for completed result_json
  ResumableSession-->>Engine: role, SessionResult
  Engine->>DevPhase: _dev_phase(resume_result)
  Engine->>ReviewAndCommit: _review_and_commit(resume_result)
  DevPhase->>VerifyDecision: continue verify/decide
  ReviewAndCommit->>VerifyDecision: continue verify/decide
Loading

Related issues: #57 (Completed dev session not persisted before post_session hooks/verification)

Suggested labels: bug, engine, durability

Suggested reviewers: none identified from summaries

Poem

A rabbit hops through kill-switch fright,
Saves each session before taking flight. 🐇
No more work lost mid-hook or pause,
Resume picks up without a rollback cause.
KeyboardInterrupts now tucked in bed,
Journal entries neatly said. 📓

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes implement the durability barrier, post-save usage attachment, resume-from-complete behavior, and explicit KeyboardInterrupt handling required by #57.
Out of Scope Changes check ✅ Passed The added resume helpers and tests stay within the issue scope and support the durability/interrupt fixes without introducing unrelated behavior.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main changes: session-end durability and raw KeyboardInterrupt handling.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@augmentcode

augmentcode Bot commented Jul 4, 2026

Copy link
Copy Markdown
🤖 Augment PR Summary

Summary: This PR improves run/session durability and resume behavior by ensuring completed sessions are persisted before best-effort metadata and hooks run.

Changes:

  • Adds a durability barrier in _run_session: persist the completed SessionRecord (now including result_json) before reading usage and before post_session hooks.
  • Introduces StoryTask.attach_session_usage to fold token usage into the most recent matching session record after the initial save.
  • Extends SessionRecord serialization to persist result_json so resume can act on already-completed sessions.
  • Adds resume continuation logic (_resumable_session + resume-verify journaling) to continue verify/decide after a host kill during the post-session window instead of rolling back via resume-restart.
  • Handles raw KeyboardInterrupt in the main run loop by killing the agent session and journaling a controlled run-stop with reason.
  • Adds tests covering durability at post_session, usage-read failure behavior, KeyboardInterrupt stop recording, and resume continuation for dev/review sessions.

Technical Notes: Usage is treated as best-effort metadata (not a durability gate); completed-session evidence is preserved to support reliable resume/continuation.

🤖 Was this summary useful? React with 👍 or 👎

@augmentcode augmentcode 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.

Review completed. 2 suggestions posted.

Fix All in Augment

Comment augment review to trigger a new review at any time.

Comment thread src/bmad_loop/engine.py Outdated
Comment thread src/bmad_loop/engine.py

@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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/bmad_loop/engine.py (2)

1363-1387: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Skip baseline/attempt reset on resumed dev results.

_dev_phase re-captures baseline_commit/baseline_untracked and bumps task.attempt even when resume_result is the already-finished session being replayed through verify/decide. That shifts the squash/rollback baseline to the post-session tree and advances the sequence number before the recorded result is processed. Guard both updates behind resume_result is None.

🤖 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 `@src/bmad_loop/engine.py` around lines 1363 - 1387, In _dev_phase, the
baseline and attempt counters are being reset even when replaying an
already-finished resumed session via resume_result, which shifts the
rollback/squash reference and advances the sequence unexpectedly. Update
_dev_phase so task.baseline_commit, task.baseline_untracked, and the
task.attempt increment only happen for fresh dev runs, and skip those updates
when resume_result is provided before the verify/decide flow consumes it.

1503-1520: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Preserve review_cycle when replaying a resumed review result.

resume_result already belongs to the current cycle, so incrementing first makes the replayed pass look like the next one. That mislabels the journal/session IDs and burns one review-budget slot early on every crash-resume.

🐛 Proposed fix
         while task.review_cycle < self.policy.limits.max_review_cycles:
-            task.review_cycle += 1
+            if resume_result is None:
+                task.review_cycle += 1
             refileable_followup = False  # only a completed pass this cycle can set it
             advance(task, Phase.REVIEW_RUNNING)
             self._save()
🤖 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 `@src/bmad_loop/engine.py` around lines 1503 - 1520, In the review loop inside
engine.py, the current logic increments task.review_cycle before checking
resume_result, which makes a replayed resumed review appear as the next cycle
and shifts session/journal IDs. Update the control flow so a resumed result is
processed under the existing cycle value, and only increment review_cycle for a
newly run review session. Use the surrounding while loop, resume_result
handling, and _run_session call as the anchor points for the fix.
🧹 Nitpick comments (1)
tests/test_engine.py (1)

179-183: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Prefer journal.entries() over manual JSONL parsing for consistency.

Other tests use resumed.journal.entries(); this test hand-rolls the same parsing via json.loads over raw lines, duplicating Journal.entries()'s logic.

♻️ Proposed simplification
-    entries = [
-        json.loads(line) for line in (engine.run_dir / "journal.jsonl").read_text().splitlines()
-    ]
+    entries = engine.journal.entries()
     stops = [e for e in entries if e["kind"] == "run-stop"]
🤖 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.py` around lines 179 - 183, The test is manually parsing
journal.jsonl instead of using the shared journal API, which duplicates
Journal.entries() behavior. Update the KeyboardInterrupt assertion in the test
that reads engine.run_dir to use engine.journal.entries() (or the equivalent
journal object already available in the test) and keep the run-stop
filtering/assertion the same. This keeps the test consistent with other cases
and avoids repeating JSONL parsing logic.
🤖 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.

Outside diff comments:
In `@src/bmad_loop/engine.py`:
- Around line 1363-1387: In _dev_phase, the baseline and attempt counters are
being reset even when replaying an already-finished resumed session via
resume_result, which shifts the rollback/squash reference and advances the
sequence unexpectedly. Update _dev_phase so task.baseline_commit,
task.baseline_untracked, and the task.attempt increment only happen for fresh
dev runs, and skip those updates when resume_result is provided before the
verify/decide flow consumes it.
- Around line 1503-1520: In the review loop inside engine.py, the current logic
increments task.review_cycle before checking resume_result, which makes a
replayed resumed review appear as the next cycle and shifts session/journal IDs.
Update the control flow so a resumed result is processed under the existing
cycle value, and only increment review_cycle for a newly run review session. Use
the surrounding while loop, resume_result handling, and _run_session call as the
anchor points for the fix.

---

Nitpick comments:
In `@tests/test_engine.py`:
- Around line 179-183: The test is manually parsing journal.jsonl instead of
using the shared journal API, which duplicates Journal.entries() behavior.
Update the KeyboardInterrupt assertion in the test that reads engine.run_dir to
use engine.journal.entries() (or the equivalent journal object already available
in the test) and keep the run-stop filtering/assertion the same. This keeps the
test consistent with other cases and avoids repeating JSONL parsing logic.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 15adc9d9-787b-4833-8826-15b1af2fe222

📥 Commits

Reviewing files that changed from the base of the PR and between ea01d9e and 2334bdb.

📒 Files selected for processing (4)
  • src/bmad_loop/engine.py
  • src/bmad_loop/model.py
  • tests/test_engine.py
  • tests/test_model.py

…esumed result

A resumed session result replays the attempt/cycle it was recorded
under: advancing the counters or re-capturing the baseline would desync
the session task_id from the durable record (a second host death would
then fall back to restart-rollback of finished work), shift the
rollback/squash reference onto the completed session's own tree, and
burn a review-budget slot per crash-resume. Guard all three behind the
replay so only fresh sessions advance them.
@dracic

dracic commented Jul 4, 2026

Copy link
Copy Markdown
Contributor Author

CodeRabbit's two outside-diff Major findings (baseline/attempt reset on resumed dev results; review_cycle increment on replay) are both fixed in 69e2db9 — the counter advances and the baseline capture are guarded behind resume_result is None, with tests pinning attempt/cycle/baseline stability across the resume. The nitpick is applied too: the KeyboardInterrupt test now uses journal.entries() instead of hand-parsed JSONL.

@pbean pbean left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approving. The session-end durability barrier (record-before-hooks, usage folded in afterward) and the resume-continuation seam are well-built, and the follow-up commit's attempt/cycle/baseline preservation guard closes the obvious re-entrancy hazards a naive replay would open. _resumable_session correctly consumes only adapter-vouched completed evidence, and the single-consume discipline (resume_result nulled after use) holds in both _dev_phase and _review_and_commit.

Three edges I'll clean up maintainer-side in a fast follow-up PR — all NON-BLOCKING, and each only bites the new crash-in-the-post-session-window path this PR introduces, so none regress current behavior:

1. Final-review-cycle replay drops a recorded clean review to defer. _review_and_commit's loop is guarded by while task.review_cycle < self.policy.limits.max_review_cycles:. When a host death lands in the post-session window of the last allowed review cycle, on resume task.review_cycle is already == max_review_cycles, so the replayed continuation evaluates the guard as false and never enters the loop body — the durably-recorded clean review is dropped and the story falls through to defer. The replay branch already skips the review_cycle += 1 increment, so relaxing the guard to also enter when resume_result is not None burns no extra budget.

2. SessionRecord.result_json aliases the dict _reconcile_generic_terminal_status mutates in place. _run_session stores result_json=result.result_json (same object), saves it pre-reconcile, and only later does _dev_phase call reconcile, which mutates that dict in place. A crash between reconcile's spec write and the dev-decision _save() persists a pre-reconcile dict; on replay reconcile early-returns (fm_status == success_status) and never re-folds followup_review_recommended, so task.followup_review_recommended reads stale and the follow-up review can be silently skipped. Fix is a defensive copy at record time plus a heal on reconcile's early-return path (sync status + fold followup from the already-read frontmatter).

3. Triage/sweep and plugin-label sessions persist large, never-consumed result_json (state.json bloat). _run_session now records result_json for every session, but _resumable_session only ever matches dev/review task ids under DEV_RUNNING/REVIEW_RUNNING. role="triage" sweep sessions and labeled plugin-workflow sessions can never be consumed, so their payloads are pure state.json bloat. The same defensive-copy site gates these to None.

I'll open the follow-up right after merge, referencing this PR and #57. Thanks @dracic — merging.

@pbean pbean merged commit 534e118 into bmad-code-org:main Jul 4, 2026
9 checks passed
pbean added a commit that referenced this pull request Jul 4, 2026
Three follow-ups to the session-end durability / resume-continuation work
in #62 (which closed #57). Each only bites the new crash-in-the-post-
session-window replay path, so none regress steady-state behavior.

(a) Final-review-cycle replay dropped a recorded clean review to defer.
    `_review_and_commit`'s loop was guarded by `review_cycle < max`, but a
    host death in the post-session window of the *last* allowed cycle
    resumes with `review_cycle == max`, so the replayed clean pass never
    entered the loop and the story fell through to defer. Relax the guard
    to also enter while a `resume_result` is pending; the replay branch
    already skips the re-increment, so no extra budget is burned.

(b) `SessionRecord.result_json` aliased the dict
    `_reconcile_generic_terminal_status` mutates in place. A crash between
    reconcile's spec write and the dev-decision save persisted a
    pre-reconcile dict; on replay reconcile early-returns and never
    re-folded `followup_review_recommended`, so the follow-up review could
    be silently skipped. Fix: persist a defensive copy at record time, and
    on reconcile's idempotent early-return sync the stale dict from the
    already-read frontmatter (status + followup). Fold followup only when
    the frontmatter carries the key — on the generic path that is the same
    source `devcontract.synthesize_result` reads it from, so a present key
    can never disagree, but an absent one must not clobber the session's
    own result.json value to a phantom False.

(c) Triage/sweep and labeled plugin-workflow sessions persisted large,
    never-consumed `result_json` into state.json (`_resumable_session`
    only ever matches dev/review task ids). The same defensive-copy site
    gates these to None via `resumable = label is None and role in
    ("dev", "review")`.

Tests: final-cycle clean replay -> DONE (zero re-run, no defer); final-
cycle dirty replay -> defer with no extra budget; reconcile early-return
heals a stale resumed dict; record isolation under post-record mutation;
result_json persisted only for resumable roles.

Refs #62, #57

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@dracic dracic deleted the fix/gh-57-session-end-durability branch July 5, 2026 08:59
@pbean pbean mentioned this pull request Jul 6, 2026
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.

[BUG] Completed dev session not persisted before post_session hooks/verification (session-end durability gap)

2 participants