Skip to content

feat(adapters): post-kill reconcile rescues finished stalled/timeout sessions#95

Open
pbean wants to merge 7 commits into
mainfrom
feat/61-post-kill-reconcile
Open

feat(adapters): post-kill reconcile rescues finished stalled/timeout sessions#95
pbean wants to merge 7 commits into
mainfrom
feat/61-post-kill-reconcile

Conversation

@pbean

@pbean pbean commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

Fixes #61. Fixes #96. Refs #48, #53, #97.

Scope

This PR carries two fixes. The second was surfaced by review of the first, and is tracked separately as #96.

  1. Hardening: reconcile seam is unreachable for stalled sessions — a finished-but-lost-Stop session cannot be recovered as completed #61 — the post-kill rescue. A finished session whose final Stop was lost loses its work. Everything under "Problem" and "Approach" below.
  2. A terminal artifact truncated mid-write crashes the whole run, not just the session #96 — a corrupt terminal artifact crashes the whole run. Pre-existing, and reachable from the ordinary Stop and crash paths independently of this PR.

They are coupled, which is why they ship together. _post_kill_reconcile is the one code path that reads a spec immediately after run()'s finally: self.kill(handle) — precisely the moment a spec the CLI was mid-write is truncated, possibly through a multi-byte UTF-8 sequence. The new hook therefore makes a latent decode crash a routine, expected fault rather than an anomaly. Two commits, at two layers:

  • 7b2af13 contains it at the call site: _synth_result is wrapped in except (OSError, UnicodeDecodeError) -> return result. The rescue keeps its verdict on any read fault. This is the load-bearing invariant — a best-effort rescue must never escalate a clean stall/timeout into an exception — and it does not depend on any distant module's catch clause.
  • 1f8918e fixes the source (A terminal artifact truncated mid-write crashes the whole run, not just the session #96): find_result_artifact skips an undecodable candidate (it cannot be shown to carry a terminal section), and synthesize_result's bare read_text degrades to "". This also closes the pre-existing crash on the Stop/crash paths, which is the failure mode that actually occurs here.

Left deliberately unfixed and filed as #97: verify.read_frontmatter guards UnicodeDecodeError but not OSError, so a permission fault or a TOCTOU delete still escapes on the Stop path. See the Judgment calls below.

Problem

A dev/review session that genuinely finished (terminal spec on disk) but whose final Stop hook event was lost, and which ignores stall nudges, ends stalled with result_json=None — and nothing ever re-reads the on-disk spec after the window dies. The reconcile seam (_reconcile_generic_terminal_status) is completed-gated and structurally unreachable; decide_dev routes the stall to RETRY → _rollback_or_pause, pausing or discarding finished work.

Scope finding beyond the issue text: the grace-expiry stall only arms inside the Stop handler, so total hook loss (hook misconfiguration, events-dir write failure — the failure modes #61 names as the likely tail) never produces stalled at all: it idles to timeout, which performs no artifact check whatsoever. The fix therefore covers both stalled and timeout.

Approach: adapter-side post-kill hook (not the engine-side sketch in #61)

The re-scan needs adapter-only state — the launch floor (SessionHandle.launched_ns), rebased artifact dirs, and the stories-mode dispatch — none of which reaches the engine (SessionResult carries none of it). So:

This preserves the #48/#53 invariant (an artifact under a live window stays advisory; trust is re-established only by window death), covers dev and review uniformly (both roles run GenericDevAdapter under bmad-dev-auto), and the engine's completed path (reconcile → state sync → verify) runs unchanged — a bogus rescue still faces full deterministic verify and degrades into an ordinary verify-failed retry.

Rescued results carry post_kill_reconciled: true, and the engine journals session-rescued-post-kill so a rescue is distinguishable from a live completion in forensics.

Judgment calls (flagging for review)

  • timeout included — widens Hardening: reconcile seam is unreachable for stalled sessions — a finished-but-lost-Stop session cannot be recovered as completed #61's literal "stalled only" wording; justified by the arming analysis above.
  • Stories plan-halt terminal included — widens the literal "terminal-done" wording; it is a successful terminal with a real plan to preserve.
  • blocked never rescued — no finished work to preserve; blocked-plus-nudge-unresponsive is weak evidence; the default passive path (rollback_on_failure off) already pauses with the tree intact.
  • status_consistent semantics: "no active disagreement" — blank frontmatter + prose done rescues (exactly what a delivered Stop would have synthesized; the engine reconcile repairs the lag). Pinned by a test.
  • Names: breadcrumb key post_kill_reconciled, journal kind session-rescued-post-kill.
  • crashed untouched — its accept_result=True scan already ran at verdict time.
  • read_frontmatter left raising on OSError (read_frontmatter: an OSError on the Stop path escapes eight status gates #97) — widening it would make a genuine disk/permission fault degrade silently to "status unknown → clean retry" across ~12 status gates in verify, engine, sweep, and stories, trading loudness for resilience. A missing status and an unreadable disk are different conditions; only the first is safe to treat as "not finished yet". verify.py is untouched by this PR, stories.resolve_story_spec keeps its own except (OSError, UnicodeDecodeError) (still load-bearing), and the post-kill hook is guarded against it regardless.
  • The repair path still raises on purposereset_spec_status and strip_auto_run_result keep the opposite contract from the read-back: silently skipping a rewrite the caller believes succeeded would leave the re-opened spec carrying its stale terminal section, the exact state that makes the re-driven session's first save read as a result.

Commits

  1. refactor(adapters): read-back returns full SynthResult (both call sites previously dropped status_consistent); _result_json stays a dict-returning wrapper — zero behavior change.
  2. feat(adapters): run() restructure + hook + override + 16 adapter tests, including a real-tmux E2E of the total-hook-loss → timeout → rescue path.
  3. feat(engine): journal breadcrumb + engine-level flow-through test.
  4. fix(adapters) (7b2af13): _post_kill_reconcile never raises on a corrupt artifact — the call-site guard.
  5. fix(devcontract) (1f8918e): unreadable artifacts degrade to no-result on the read-back — the source fix (A terminal artifact truncated mid-write crashes the whole run, not just the session #96).
  6. docs(changelog): post-kill rescue + corrupt-artifact hardening.

Testing

  • 1732 passed, 1 skipped (full suite).
  • New tests cover: rescue (stalled + timeout, scan + stories + plan-halt legs, blank-frontmatter parity), every keep-verdict path (window alive after kill, probe transport error, inconsistent status, blocked, no artifact, pre-launch-floor artifact, completed/crashed untouched), kill-before-probe ordering through run(), exception path never reconciles, and a real-tmux end-to-end rescue.
  • Corrupt-artifact coverage: non-UTF-8 spec on the scan read-back, on the bmad-dev-auto-result-*.md fallback-marker leg, on the stories leg, and the _post_kill_reconcile keep-verdict guard.
  • test_dev_grace_result_does_not_complete_while_window_alive (the fix(adapters): complete dev sessions only on Stop or window death #53 invariant test) intentionally unchanged — the live-window stall verdict still stands at verdict time.
  • A terminal artifact truncated mid-write crashes the whole run, not just the session #96 verified end-to-end against the real Engine.run(): pre-fix the run ends crashed=True / crash_error="UnicodeDecodeError: ... 0xff" with the task pinned in DEV_RUNNING and no SessionRecord; post-fix crashed=False, task DEFERRED via the ordinary retry-exhausted path, session records persisted.
  • Full trunk check clean.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added post-kill reconciliation to rescue some stalled or timeout sessions after the final stop signal is missed.
    • Enhanced story-mode result synthesis from saved artifacts.
  • Bug Fixes

    • Reduced false stalls by re-checking terminal state after termination before upgrading outcomes.
    • Prevented failures from corrupt or truncated (non-UTF-8) terminal artifacts; treated unreadable specs as “no result yet” while keeping best-effort verdicts.
    • Added a dedicated journal entry (session-rescued-post-kill) for rescued runs.
  • Tests

    • Added scenarios covering rescue, timeout/no-rescue behavior, journal verification, and corrupt-artifact handling.

pbean added 3 commits July 8, 2026 21:04
Extract _synth_result/_stories_synth_result returning the full
devcontract.SynthResult; _result_json stays a thin dict-returning wrapper
so every existing caller and test is unchanged. No behavior change —
groundwork for #61, whose post-kill reconcile needs status_consistent,
which both call sites currently drop.
…sessions (#61)

A session that wrote its terminal spec but lost its final Stop ends
stalled (nudge-unresponsive under a live window, artifact advisory per
the #48/#53 invariant); with total hook loss (misconfig, events-dir
write failure) no event ever arrives, the stall grace never arms, and
it exits timeout with no artifact check at all. Either way the finished
work was discarded solely because the window was alive to distrust.

run()'s finally-kill settles that. New _post_kill_reconcile hook on the
adapter base (identity by default) runs after the kill on the normal
return path only; GenericDevAdapter overrides it for result-less
stalled/timeout verdicts: re-probe liveness (alive or unknowable ->
verdict stands; unknown is not dead), then re-run the same read-back a
delivered Stop would have run. Rescue requires a self-consistent
(status_consistent) successful terminal -- done, or the stories
plan-halt leg -- with no escalations; blocked is never rescued (no
finished work to preserve). Rescued results carry
post_kill_reconciled=true and still face the engine's full
deterministic verify downstream.

Covers dev and review uniformly (both roles run GenericDevAdapter under
bmad-dev-auto). The gate is deliberately stricter than the crash path's
accept-any-terminal; crashed/completed verdicts already read their
artifact at verdict time and are untouched.
A rescued session is otherwise indistinguishable from a live completion
in the journal; append session-rescued-post-kill when the adapter's
result carries the post_kill_reconciled marker, so a human debugging a
run can see the stall/timeout was rescued rather than never happening.
@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: 13 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: 49b7635f-1fde-43df-80e7-4add0a6087dc

📥 Commits

Reviewing files that changed from the base of the PR and between 37a01f1 and d07b9a4.

📒 Files selected for processing (9)
  • CHANGELOG.md
  • src/bmad_loop/adapters/base.py
  • src/bmad_loop/adapters/generic.py
  • src/bmad_loop/devcontract.py
  • src/bmad_loop/engine.py
  • tests/test_devcontract.py
  • tests/test_engine.py
  • tests/test_generic_tmux.py
  • tests/test_stories_e2e.py

Walkthrough

This PR adds post-kill reconciliation for stalled and timeout sessions, hardens terminal artifact parsing against unreadable UTF-8, records rescued sessions in the engine, and expands coverage for rescue, liveness, and decode-failure paths.

Changes

Post-kill reconcile feature

Layer / File(s) Summary
Result synthesis hardening
src/bmad_loop/devcontract.py, src/bmad_loop/adapters/generic.py
GenericDevAdapter now routes result readback through _synth_result(...) and _stories_synth_result(...), while devcontract treats unreadable specs and candidate artifacts as non-parseable terminal results instead of raising.
Post-kill reconcile flow
src/bmad_loop/adapters/base.py, src/bmad_loop/adapters/generic.py, src/bmad_loop/engine.py
run() kills the session before a new _post_kill_reconcile(...) hook runs, the generic adapter can upgrade eligible stalled/timeout results after re-synthesis, and the engine records session-rescued-post-kill when reconciliation marks the result.
Tests and release notes
tests/test_engine.py, tests/test_generic_tmux.py, tests/test_devcontract.py, tests/test_stories_e2e.py, CHANGELOG.md
Adds coverage for rescued results, non-rescued paths, unreadable UTF-8 artifacts, run-ordering, tmux integration, and updates the changelog and stories-mode docstring.

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

Possibly related PRs

  • bmad-code-org/bmad-loop#53: Both PRs change the generic adapter’s terminal-result acceptance and synthesis behavior around completion signals and tmux window death.
  • bmad-code-org/bmad-loop#65: Both PRs touch devcontract.synthesize_result semantics that feed the adapter’s terminal reconciliation path.
  • bmad-code-org/bmad-loop#77: Both PRs modify stories-mode result synthesis in GenericDevAdapter.

Poem

I hopped past stalls and UTF-8 fuss,
found a rescued session waiting for us.
The Stop went missing, yet work stayed bright,
so I marked the journal and bounced to light. 🐰

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning It also adds UTF-8/decode-hardening and timeout-specific rescue behavior that aren't called for by #61. If intentional, document or split the extra hardening and timeout work into a separate issue; otherwise trim it to the stalled-session recovery scope.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR implements the requested post-kill rescue flow, rescanning after kill and promoting consistent terminal done results to completed.
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 summarizes the main adapter change: post-kill reconciliation rescuing stalled or timeout sessions.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/61-post-kill-reconcile

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.

Actionable comments posted: 1

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

Inline comments:
In `@src/bmad_loop/adapters/generic.py`:
- Around line 606-608: The post-kill reconcile path in `_post_kill_reconcile()`
should not let unreadable terminal artifacts crash the session. Wrap the
`_synth_result(handle, spec, wait=False)` call in the `GenericAdapter` flow so
`UnicodeDecodeError` and `OSError` are caught, and on failure return the
original `result` just like the existing `sr is None` / inconsistent-status
branch. Keep the fix localized to the `_post_kill_reconcile` / `_synth_result`
call site so corrupt artifacts preserve the original verdict instead of raising.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f042b815-73bf-4b3a-8bc8-6ae08d8b030a

📥 Commits

Reviewing files that changed from the base of the PR and between 37a01f1 and d58fc26.

📒 Files selected for processing (6)
  • src/bmad_loop/adapters/base.py
  • src/bmad_loop/adapters/generic.py
  • src/bmad_loop/engine.py
  • tests/test_engine.py
  • tests/test_generic_tmux.py
  • tests/test_stories_e2e.py

Comment thread src/bmad_loop/adapters/generic.py Outdated
pbean added 2 commits July 8, 2026 21:57
…#61)

The rescue hook re-reads the on-disk spec immediately after run()'s
finally-kill. That is precisely the moment a spec the CLI was mid-write is
truncated -- possibly through a multi-byte UTF-8 sequence -- so a decode or
I/O fault on that read is the expected case, not an anomaly.

Nothing contained it. `adapter.run()` is called bare from `_run_session`, and
no frame between it and `Engine.run()`'s broad `except Exception` catches: an
escaping read error marked the whole RUN crashed, wrote crash.txt, left the
task in DEV_RUNNING with no SessionRecord (both `record_session` and `_save`
sit after the raise), and abandoned every remaining story. A best-effort
rescue must never make things worse than the verdict it was handed.

Guard the read-back call and keep the original stalled/timeout verdict, like
every other keep-verdict branch. Both exception types are named because
UnicodeDecodeError is a ValueError, not an OSError -- the same trap
verify.read_frontmatter already documents.

Covered on all three read-back legs: the mtime-scan `spec-*.md` (raises from
find_result_artifact), the name-matched `bmad-dev-auto-result-*.md` fallback
marker (raises from synthesize_result, and is what a `timeout` verdict hits
having read nothing at all), and stories mode. Plus a monkeypatched test that
pins the guard itself, independent of devcontract's internals.
…ad-back

The call-site guard in _post_kill_reconcile contains the rescue hook, but the
same two reads are reached from the Stop and crash paths via _result_json,
where an unreadable artifact still crashed the run. Fix the source.

Two distinct sites, both reachable from a spec truncated mid-write:

- find_result_artifact reads each candidate to check for a real (non-fenced)
  terminal section, guarded by `except OSError`. UnicodeDecodeError is a
  ValueError, so a torn multi-byte sequence sailed straight through -- the
  exact trap verify.read_frontmatter's comment already warns about. A
  candidate that cannot be shown to carry a terminal section does not qualify;
  skip it like any other unreadable file.

- synthesize_result's second read was unguarded entirely. It is reached with a
  file the finder never read: the `bmad-dev-auto-result-*.md` fallback marker
  is matched by NAME. Route it through a helper that degrades to "".

An unreadable spec carries no parseable result section, so it now reads
exactly like a spec that has not terminated yet -- the caller waits, nudges,
or keeps its verdict. The repair path keeps the opposite contract on purpose:
reset_spec_status and strip_auto_run_result still raise, because silently
skipping a rewrite leaves the spec in a state the caller believes it fixed.
@pbean

pbean commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator Author

Addressing the Out of Scope Changes pre-merge warning. It flagged two things; they resolved differently.

The timeout rescue was already in scope, and already justified. The body's "Scope finding beyond the issue text" paragraph carries the argument: the grace-expiry stall only arms inside the Stop handler, so total hook loss — hook misconfiguration, events-dir write failure, the failure modes #61 names as its likely tail — never produces stalled at all. It idles to timeout, which performs no artifact check whatsoever. A stalled-only fix would miss the very case the issue calls out. That reasoning now sits under an explicit ## Scope heading rather than buried mid-Problem, and timeout remains flagged under Judgment calls.

The decode hardening was genuinely unscoped, and now has a scope of its own. Split out as #96 and declared via Fixes #96 — the PR now closes two issues rather than smuggling one. Worth separating the two commits, because only one of them was ever out of scope:

I did not split 1f8918e into a separate PR: it edits test helpers introduced earlier in this branch, so extraction buys scope tidiness at the cost of a rebase and a double CI run, while deferring a crash fix. Documenting + linking is the resolution your check offers first, and it's the cheaper correct one here.

One more thing, filed rather than fixed: #97. verify.read_frontmatter guards UnicodeDecodeError but not OSError, so a permission fault or a TOCTOU delete between is_file() and read_text() still escapes at eight call sites (engine.py:1829/1865/1897, sweep.py:1051, verify.py:1087/1368/1389/1404, devcontract.py:227). stories.resolve_story_spec is the sole guarded caller and is load-bearing as a result. Widening read_frontmatter would be the wrong fix — it would degrade a genuine disk fault to a silent "status unknown → clean retry" across ~12 status gates. A missing status and an unreadable disk are different conditions; only the first is safe to read as "not finished yet." verify.py stays untouched by this PR, and the post-kill hook is guarded against OSError regardless.

No src/ changes since your last verification — the only new commit is d07b9a4, which appends closes #96 to the CHANGELOG bullet. Full suite green at 1732 passed, 1 skipped; trunk check clean.

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown
🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
git log --oneline -5
git show d07b9a4 --stat 2>/dev/null | head -30
git show d07b9a4 2>/dev/null | head -60

Length of output: 1996


@pbean Confirmed — d07b9a4 is CHANGELOG-only, appending (#95, closes #96) to the existing bullet. No other src/ changes since the last verification.

The scope resolution here is sound:

  • timeout rescue staying in-scope is well-argued — if the grace-expiry stall check only fires inside the Stop handler, then total hook loss skips stalled entirely and lands on timeout, which is exactly the failure mode #61 describes. Documenting that under an explicit ## Scope heading (rather than leaving it implicit) is the right call, and flagging it under Judgment calls keeps it visible for reviewers who disagree.
  • Splitting the decode-hardening rationale into Fixes A terminal artifact truncated mid-write crashes the whole run, not just the session #96`` while keeping 1f8918e in this branch is a reasonable trade-off — the commit is small, touches shared test helpers already introduced in this branch, and the alternative (rebase + duplicate CI) doesn't buy much given the fix is a strict crash-avoidance improvement reachable from Stop/crash paths regardless of this PR's hook.
  • Filing #97 rather than widening read_frontmatter is the correct boundary — collapsing OSError into the same "not finished yet" bucket as a missing status would be a real regression across the status gates you listed, not a hardening.

Triggering the review now.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@pbean

pbean commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator Author

@coderabbitai full review

(The pre-merge Out of Scope Changes block still carries its pre-#96 wording verbatim — the incremental pass refreshed the comment without recomputing the check against the now-linked issues. Forcing a full pass so it re-evaluates Fixes #61. Fixes #96. No code has changed since your last verification; d07b9a4 is CHANGELOG-only.)

@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown

@pbean: Understood — triggering a full review pass so the pre-merge Out of Scope Changes check re-evaluates against Fixes #61. Fixes #96. rather than reusing the stale pre-#96 wording. Since no src/ changes have landed since the last verification (d07b9a4 is CHANGELOG-only), this should just refresh the check's framing without surfacing new findings.

✅ Action performed

Full review finished.


Your plan includes PR reviews subject to rate limits. More reviews will be available in 13 minutes.

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