Skip to content

fix(cleanup,mux): honest kill outcomes (#435) and the dropped version diagnostic (#428) - #524

Open
dracic wants to merge 3 commits into
bmad-code-org:mainfrom
dracic:fix/435-428-kill-verdict-version-diagnostic
Open

fix(cleanup,mux): honest kill outcomes (#435) and the dropped version diagnostic (#428)#524
dracic wants to merge 3 commits into
bmad-code-org:mainfrom
dracic:fix/435-428-kill-verdict-version-diagnostic

Conversation

@dracic

@dracic dracic commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Two operator-visible reports that were optimistic to the point of being wrong. Paired because both are "adapter seam + operator-visible output" — one pass, one review.

#435cleanup reported a surviving ctl window as removed

kill_window is best-effort and reports nothing, so the prune counted every attempted kill as a removal: in the text summary, the TUI toast, and cleanup --json.

The prune now kills every candidate, takes one list_window_ids(CTL_SESSION), and partitions by membership into removed / survived / unverifiable.

Deviation from the issue's sketch, deliberate. #435 proposes threading a bool | tri-state through kill_window across every backend. That cannot work: a backend cannot verify a kill from kill_window(target) alone — a bare @N carries no session, and every liveness listing is session-scoped — so all backends except psmux (which already does this internally for its own key cleanup) would be forced to answer "unverifiable". The ctl prune is the one caller that both needs the answer and already holds the session, and one post-kill listing there covers every backend uniformly at one extra round trip per prune instead of one per window. No seam signature changes.

ctl_windows.removed now means verifiably gone, which is a meaning change, so CLEANUP_SCHEMA_VERSION is 2. Text mode marks the stdout count and names the two non-removed arms on stderr, still at exit 0. Survivors are retried by the next cleanup, as before.

sessions.removed is untouched and still an attempted kill — kill_session is silent in exactly the way kill_window is. The docstring now says so rather than implying the sessions half was already trustworthy.

#428 — a crashed version probe was indistinguishable from "reports no version"

version() keeps its None contract (observation may degrade). A new non-abstract version_error() carries the diagnostic it dropped, and bmad-loop mux prints it as a whitespace-collapsed warning: on stderr below the table.

UnicodeError joins the catch: _run decodes with the locale codec and the strict handler on POSIX, so an undecodable -V raised a ValueError outside the SubprocessError/OSError family and escaped as a raw crash — a corrupt install, exactly what the diagnostic exists for.

The read is deliberately not gated on available(): psmux's availability probe calls its own version(), so an AV-blocked binary reads available=False — the row that most needs an explanation is the one where availability already failed.

Second commit

cmd_cleanup had no MultiplexerError guard while the TUI twin has had one all along. The sessions are already killed by the time the raiser-side ctl half runs, so a raise reached main()'s backstop and left stdout empty — under --json that destroys the record of those kills. Pre-existing; surfaced by review, and it matters more now that the window half of the document is worth trusting.

Notes for the reviewer

Two ceilings are documented rather than fixed:

  • BaseTmuxBackend.list_window_ids folds every nonzero exit to [], not just a vanished session, so a server that errors while its windows live still reports them removed. Strictly less optimistic than before this change (which reported everything removed unconditionally), and narrowing that sentinel changes window_alive — the engine's liveness probe — repo-wide. Tracked separately.
  • The verdict is a membership test pairing list_windows' window_id column against list_window_ids — a third symmetry the seam did not state. It now does, in the list_windows docstring, because a backend qualifying one side and not the other reports every killed window as gone, silently and optimistically.

Verification

Full suite, pyright, and trunk check clean against the local baseline. The new negative assertions were ablated: deleting the candidate guard, the survivor partition, the availability-independent diagnostic read, the session argument of the liveness probe, and the MultiplexerError guard each makes the owning test fail. tests/test_psmux_live.py gains a suppressed-kill survivor leg — the half that actually pins the qualified-id symmetry against a real server — and a portable twin lands in tests/test_psmux_backend.py so CI holds it too. Zero LLM tokens consumed by any test.

Closes #435
Closes #428

Summary by CodeRabbit

  • New Features

    • Cleanup now distinguishes verified removals, surviving windows, and unverifiable outcomes.
    • JSON cleanup reports use schema version 2 with separate outcome categories.
    • bmad-loop mux displays warnings when backend version checks fail.
  • Bug Fixes

    • Cleanup continues reporting session results when window pruning encounters errors.
    • Version-probe diagnostics are preserved, including decoding failures.
    • Cleanup summaries now accurately count only verified removals.

dracic added 2 commits August 10, 2026 19:36
…ed version diagnostic

Two operator-visible reports were optimistic to the point of being wrong.

`cleanup` counted every ctl window a kill was *attempted* on as removed — in the
text summary, the TUI toast and `--json` — because `kill_window` is best-effort
and reports nothing. The prune now takes one liveness listing after its kills and
partitions the result into removed / survived / unverifiable, the shape
`prune_sessions` already returns for sessions. The verdict is taken in the prune
rather than pushed into the seam: a backend cannot verify a kill from
`kill_window(target)` alone (a bare `@N` carries no session, and every liveness
listing is session-scoped), and the prune is the one caller that both needs the
answer and already holds the session. `ctl_windows.removed` now means verifiably
gone, so CLEANUP_SCHEMA_VERSION moves to 2.

`version()` collapsed a crashing probe — corrupt install, AV-blocked exe, hung
server — to the same None a binary reporting no version returns, and the probe's
stderr was gone. It keeps that contract; a new non-abstract `version_error()`
carries the dropped diagnostic, and `bmad-loop mux` prints it beside the `-` in
the VERSION column. UnicodeError joins the catch: strict POSIX decoding makes an
undecodable `-V` raise outside the SubprocessError family, which is exactly the
corrupt-binary case the diagnostic exists for.

Closes bmad-code-org#435
Closes bmad-code-org#428
…e raises

`cmd_cleanup` kills the agent sessions first, then prunes ctl windows — and the
second half is raiser-side (its candidate scan probes `has_session`). An
unguarded raise reached `main()`'s backstop, which prints an error and returns 1
with stdout empty. Under `--json` that destroys the record of the kills that
already happened: a consumer cannot tell "killed nothing" from "killed three and
lost the receipt".

The repair had already succeeded; only the observation failed, and observation
degrades. Guarded like the TUI worker has been all along, naming the failure on
stderr and reporting three empty window lists — the honest answer, since the
raise comes from the candidate scan and no window was killed or even chosen.

Pre-existing, surfaced reviewing bmad-code-org#435: that change made the window half of the
document worth trusting, which is what makes losing the whole document matter.
@coderabbitai

coderabbitai Bot commented Aug 10, 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: 51 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

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 Plus

Run ID: 8810d22a-40d9-4207-8fed-0efae735fd6b

📥 Commits

Reviewing files that changed from the base of the PR and between 7daccee and 8e824c0.

📒 Files selected for processing (1)
  • CHANGELOG.md

Walkthrough

Changes

Cleanup and multiplexer reporting

Layer / File(s) Summary
Version-probe diagnostics
src/bmad_loop/adapters/multiplexer.py, src/bmad_loop/adapters/tmux_base.py, src/bmad_loop/cli.py, tests/test_backend_registry.py, tests/test_multiplexer.py, tests/test_cli.py
Version probes preserve failure diagnostics. bmad-loop mux reports collapsed warnings on stderr.
Verified cleanup outcomes
src/bmad_loop/tui/launch.py, src/bmad_loop/documents.py, src/bmad_loop/adapters/multiplexer.py
Control windows now return and report removed, survived, and unverifiable results. Cleanup JSON uses schema version 2.
Cleanup integration and validation
src/bmad_loop/cli.py, src/bmad_loop/tui/app.py, tests/test_tui_launch.py, tests/test_tui_app.py, tests/test_psmux_backend.py, tests/test_psmux_live.py
CLI and TUI cleanup preserve session results, report non-removed windows separately, and verify stable window IDs and post-kill liveness.
Documentation
CHANGELOG.md, docs/FEATURES.md
Documentation describes the new cleanup JSON fields, retry and dry-run behavior, and version-probe warnings.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant CleanupCLI
  participant prune_ctl_windows
  participant Multiplexer
  participant cleanup_document
  CleanupCLI->>prune_ctl_windows: prune candidate control windows
  prune_ctl_windows->>Multiplexer: kill candidate windows
  prune_ctl_windows->>Multiplexer: list window IDs once
  Multiplexer-->>prune_ctl_windows: remaining IDs or verification failure
  prune_ctl_windows-->>CleanupCLI: removed, survived, unverifiable
  CleanupCLI->>cleanup_document: build schema version 2 document
  cleanup_document-->>CleanupCLI: structured cleanup JSON
Loading

Possibly related PRs

Suggested reviewers: pbean

Poem

I’m a rabbit with windows to prune,
Three neat result lists now bloom.
Crashed probes leave a warning behind,
Survivors stay clearly defined.
Verified removals count with care—
Hop through the cleanup everywhere!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 42.03% 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 identifies both main fixes: honest cleanup outcomes and preserved multiplexer version diagnostics.
Linked Issues check ✅ Passed The changes satisfy cleanup verification and schema requirements [#435] and preserve and expose version-probe diagnostics [#428].
Out of Scope Changes check ✅ Passed The code, documentation, and tests remain focused on the two linked issue objectives and related cleanup error 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.

@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

Caution

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

⚠️ Outside diff range comments (1)
tests/test_tui_launch.py (1)

373-445: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Use the shared project sandbox fixture.

Lines 390-395 create .bmad-loop state directly under tmp_path. Change _ctl_prune_fake and its callers to use the project fixture and project.project. This keeps the tests aligned with the repository sandbox contract.

As per coding guidelines, “Use the project conftest sandbox fixture rather than hand-rolled temporary repositories or the template repository.”

🤖 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_tui_launch.py` around lines 373 - 445, Update _ctl_prune_fake and
every caller to accept and use the shared project sandbox fixture instead of
tmp_path. Use project.project for the project path, and create the run state
beneath that path while preserving the existing fake tmux behavior and
assertions.

Source: Coding guidelines

🧹 Nitpick comments (1)
CHANGELOG.md (1)

163-177: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Reduce these entries to terse imperative changelog items.

These bullets contain detailed implementation rationale and schema semantics. Keep a short imperative summary in CHANGELOG.md. Keep the full contract details in docs/FEATURES.md.

As per coding guidelines, “Write CHANGELOG entries under Unreleased; keep them terse, scannable, and imperative.”

🤖 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 `@CHANGELOG.md` around lines 163 - 177, Replace the two detailed Unreleased
changelog bullets with terse, scannable imperative summaries covering the
cleanup reporting fix and version-probe error distinction. Remove implementation
rationale and schema/contract details from CHANGELOG.md, while preserving those
details in docs/FEATURES.md.

Source: Coding guidelines

🤖 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/tui/launch.py`:
- Around line 345-351: Update BaseTmuxBackend.list_window_ids to raise
MultiplexerError for nonzero list-windows results unless it verifies that the
target session is gone, rather than returning an empty set. In the cleanup flow
around mux.list_window_ids(CTL_SESSION), classify this typed failure as
unverifiable instead of marking all candidates removed. Add a regression test
covering a nonzero listing failure while the session remains live.

---

Outside diff comments:
In `@tests/test_tui_launch.py`:
- Around line 373-445: Update _ctl_prune_fake and every caller to accept and use
the shared project sandbox fixture instead of tmp_path. Use project.project for
the project path, and create the run state beneath that path while preserving
the existing fake tmux behavior and assertions.

---

Nitpick comments:
In `@CHANGELOG.md`:
- Around line 163-177: Replace the two detailed Unreleased changelog bullets
with terse, scannable imperative summaries covering the cleanup reporting fix
and version-probe error distinction. Remove implementation rationale and
schema/contract details from CHANGELOG.md, while preserving those details in
docs/FEATURES.md.
🪄 Autofix

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 Plus

Run ID: aa62f217-5010-45e7-aab3-def929fee172

📥 Commits

Reviewing files that changed from the base of the PR and between 99fa36e and 7daccee.

📒 Files selected for processing (15)
  • CHANGELOG.md
  • docs/FEATURES.md
  • src/bmad_loop/adapters/multiplexer.py
  • src/bmad_loop/adapters/tmux_base.py
  • src/bmad_loop/cli.py
  • src/bmad_loop/documents.py
  • src/bmad_loop/tui/app.py
  • src/bmad_loop/tui/launch.py
  • tests/test_backend_registry.py
  • tests/test_cli.py
  • tests/test_multiplexer.py
  • tests/test_psmux_backend.py
  • tests/test_psmux_live.py
  • tests/test_tui_app.py
  • tests/test_tui_launch.py

Comment on lines +345 to +351
live = set(mux.list_window_ids(CTL_SESSION))
except MultiplexerError:
# The kills may well have landed; nothing here can say so. Claiming the
# optimistic half is exactly the bug — the next cleanup pass retries.
return [], [], [name for _win_id, name in candidates]
removed = [name for win_id, name in candidates if win_id not in live]
survived = [name for win_id, name in candidates if win_id in live]

@coderabbitai coderabbitai Bot Aug 10, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Do not treat every nonzero liveness listing as a verified removal.

Line 345 can receive [] when BaseTmuxBackend.list_window_ids() gets any nonzero list-windows exit. A server-side failure can leave CTL_SESSION and its windows alive. Lines 350-351 then classify every candidate as removed.

Make the multiplexer seam raise MultiplexerError unless it can confirm that the session is gone. Classify that typed failure as unverifiable. Add a regression test for a nonzero listing while the session remains live.

As per coding guidelines, “At I/O boundaries, fail loudly: use typed escalation rather than bare except; observation may degrade, but repair writes must raise.”

🤖 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/tui/launch.py` around lines 345 - 351, Update
BaseTmuxBackend.list_window_ids to raise MultiplexerError for nonzero
list-windows results unless it verifies that the target session is gone, rather
than returning an empty set. In the cleanup flow around
mux.list_window_ids(CTL_SESSION), classify this typed failure as unverifiable
instead of marking all candidates removed. Add a regression test covering a
nonzero listing failure while the session remains live.

Source: Coding guidelines

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Correct diagnosis, and it is the one thing in this area I deliberately did not fix here. Tracked separately rather than declined.

BaseTmuxBackend.list_window_ids folds every nonzero exit to [], so yes — a server-side failure with the windows still alive reports them removed. The PR description calls this out as a known ceiling and the prune_ctl_windows docstring names it at the call site.

Two reasons it is not in this PR:

It is not a regression. Before this change the prune reported every attempted kill as removed, unconditionally. The rc≠0 path is now the only remaining way to get an over-optimistic answer, where previously it was the guaranteed answer. This PR strictly narrows the hole; it does not open it.

The suggested fix is not local to cleanup. list_window_ids backs window_alive, which is the engine's liveness probe. Today a vanished session answers [] → "dead", and the engine's crash/recovery paths are built on that. Making nonzero exits raise flips those to "unknowable" repo-wide, which is a behavior change to run supervision reached through a cleanup-reporting fix. It also needs a decision this PR has no basis to make: which stderr signatures count as proved gone versus a real fault. tmux's "session not found" and "no server running" are both nonzero and both genuinely mean gone.

So it needs its own change with its own blast-radius pass over the engine, not a rider here. Filing it as an issue with the ledger entry's reasoning.

One correction to the suggested classification: routing this to unverifiable is right for the fault case, but a session that really did die with its last window must stay removed. Pessimism there invents a phantom survivor that every subsequent cleanup re-reports and never clears — which is the same class of dishonest report from the other side. That distinction is exactly what the seam cannot currently make, and why the fix is a seam change rather than a caller change.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@dracic, agreed. The limitation is real, but it predates this PR and requires a seam-level behavior decision with engine-wide review.

prune_ctl_windows() correctly improves the report for failures that raise MultiplexerError. It cannot distinguish a confirmed empty session from a failed list_window_ids() call that returns []. Treating all empty results as unverifiable would also misreport successful removal of the final window.

I am withdrawing this finding for this PR. A follow-up issue should define confirmed-session-absence handling and evaluate the window_alive and recovery call paths before changing BaseTmuxBackend.list_window_ids().

🐇🔍


✏️ Learnings added
Learnt from: dracic
Repo: bmad-code-org/bmad-loop PR: 524
File: src/bmad_loop/tui/launch.py:345-351
Timestamp: 2026-08-10T18:00:51.731Z
Learning: In `src/bmad_loop/adapters/tmux_base.py`, `BaseTmuxBackend.list_window_ids()` currently maps every nonzero `list-windows` exit to `[]`. This behavior is also used by `window_alive` and engine crash/recovery logic. A future change must distinguish a confirmed absent tmux session, such as “session not found” or “no server running”, from a transport or server fault. Review all engine liveness consumers before changing this seam.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

You are interacting with an AI system.

Comment thread tests/test_tui_launch.py

def test_prune_ctl_windows(monkeypatch, tmp_path: Path):
def _ctl_prune_fake(
monkeypatch, tmp_path: Path, *, kill: str = "lands"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Replying here to CodeRabbit's outside-diff finding on this helper ("use the shared project sandbox fixture instead of tmp_path"). Declining, with the evidence:

The flagged setup is not new code. git diff main...HEAD shows no +/- lines for the .bmad-loop/runs/... / write_pid / project_tag block — git matched it as unchanged context. It was already in test_prune_ctl_windows; this PR extracted the surrounding test into a helper so four verdict cases could share it, and carried that block along verbatim.

tmp_path is this file's convention, not an oversight. tests/test_tui_launch.py references tmp_path 63 times and the project fixture 0 times. Converting only the five prune tests would leave one file with two sandbox conventions and no way to tell from a test which one applies.

The fixture is also the wrong tool for these tests. project copies a session-scoped template repo per test; what the prune needs is one state.json and a pid, so that a single window reads live and the rest do not. Everything else these tests touch is a faked tmux transport. Paying a repo copy per case buys no additional coverage.

The AGENTS.md rule the guideline comes from is aimed at hand-rolled repos — tests that need real git state and would otherwise reach for the template. These are transport tests with a run directory. If the file should move to project wholesale, that is a worthwhile separate cleanup of all 63 sites; doing five of them inside a cleanup-reporting fix would make the inconsistency permanent rather than fix it.

Comment thread CHANGELOG.md
- **`cleanup` no longer reports a surviving ctl window as removed (#435).** Killing a window is
best-effort and reports nothing, so the prune counted every _attempted_ kill as a removal. It now
verifies with one liveness listing and partitions into removed / survived / unverifiable.
`ctl_windows.removed` means _verifiably gone_ and gains `survived` / `unverifiable` siblings, so

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Applied, partly — the #435 entry was 9 lines against a median of 6 across the 113 entries under Unreleased; trimmed to 6 in 8e824c0 by cutting the prune_sessions comparison and the --dry-run clause, both of which docs/FEATURES.md and the cleanup_document docstring already carry.

Kept deliberately, because a changelog reader who does not open FEATURES.md still needs them:

  • CLEANUP_SCHEMA_VERSION is 2 — a consumer pinning the version has to see the bump here.
  • still at exit 0 — the count on stdout got smaller and the difference goes to stderr; a wrapper checking $? sees no change and would otherwise have no signal that anything moved.
  • sessions.removed is untouched and still an attempted kill — without it, "cleanup now verifies removals" reads as covering both halves of the document when it covers one.

The #428 entry is 6 lines, exactly the file's median, so it is left as is.

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.

cleanup --json can report a surviving ctl window as removed version() probe crash is indistinguishable from "reports no version"

1 participant