Skip to content

fix(adapters): seam-owned psmux option-key namespace, kill-order containment - #434

Open
dracic wants to merge 2 commits into
bmad-code-org:mainfrom
dracic:fix/313-psmux-option-channel-safety
Open

fix(adapters): seam-owned psmux option-key namespace, kill-order containment#434
dracic wants to merge 2 commits into
bmad-code-org:mainfrom
dracic:fix/313-psmux-option-channel-safety

Conversation

@dracic

@dracic dracic commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Hardens the psmux option channel's safety core — the three ceilings accepted when the channel landed (#310):

  • Seam-owned key namespace. Keys now carry a __blw@ marker (@bmad_project__blw@3, was @bmad_project_@3), so the generic cleanup sweeps can no longer delete a hand-written user option like @theme_@3 when window @3 dies. Colliding now requires deliberately imitating the seam's marker.
  • Kill-order containment. kill_window kills first and frees the window's keys only after a liveness listing proves the kill landed. A failed kill leaves the live window its project tag and return key; keys stranded by a cleanup failure are reclaimed by the launch-time orphan sweep. Every silent key-cleanup failure now warns.
  • Ctl windows resolved by id. The launcher's ctl-window consumers (attach return-pane stamps, per-run window kill) resolve the stable window id once and replay it instead of re-resolving a first-match name per verb, so duplicate-named ctl windows cannot take another window's option write or kill (ctl_window/select_ctl_window fold into ctl_window_id/select_ctl_window_id).

Cross-project prune isolation is proven live: a Windows-local zero-token gate (tests/test_psmux_live.py) runs two projects' parked windows on one real psmux server and asserts a prune in one leaves the other's window, tag, and foreign options untouched.

Note for dev-build users: keys minted by the unreleased pre-marker build read as foreign and are never swept — restart the ctl psmux server after upgrading.

Closes #313

Summary by CodeRabbit

  • Bug Fixes

    • Improved window handling when names are duplicated by using stable window identifiers.
    • Prevented accidental removal of options or windows when cleanup cannot be verified.
    • Improved isolation when pruning project windows and preserved unrelated project data.
    • Added warnings and safer retry behavior for failed cleanup operations.
  • Documentation

    • Updated psmux option-key documentation and examples to reflect the safer naming format.
    • Documented additional live end-to-end coverage for Windows environments.

…ainment (bmad-code-org#313)

Key grammar moves to a seam-owned marker (@opt__blw@N) so both destructive
sweeps match only keys the channel itself minted; kill_window now kills,
verifies the window is gone, then frees keys, retaining them on any
ambiguity with the orphan sweep as backstop; ctl option calls target by
window id instead of first-match names; a Windows-local zero-token live
gate proves cross-project prune isolation on real psmux 3.3.7.

Every silent key-cleanup failure now warns: both sweeps route unsets
through one _free_scoped_key helper (nonzero rc warns), the orphan sweep
warns when the option listing fails, composition tests pin the failure
sequences (ownership readable after a failed kill; stranded keys reclaimed
by the sweep), and the live gate's teardown verifies the session died.
@coderabbitai

coderabbitai Bot commented Aug 2, 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: 37 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 Plus

Run ID: 605f9bd9-936a-4d13-a443-08c2a5e5cc37

📥 Commits

Reviewing files that changed from the base of the PR and between dbb4db5 and 3daade2.

📒 Files selected for processing (2)
  • src/bmad_loop/adapters/psmux_backend.py
  • tests/test_psmux_backend.py

Walkthrough

The psmux backend now uses seam-owned __blw@ option keys and verified cleanup. TUI control-window operations use stable window IDs. Tests cover cleanup failures, duplicate names, cross-project pruning, and live Windows psmux behavior.

Changes

psmux safety and stable window targeting

Layer / File(s) Summary
Scoped keys and verified cleanup
src/bmad_loop/adapters/psmux_backend.py, tests/test_psmux_backend.py
Scoped keys use the __blw@<window-id> marker. Cleanup verifies window termination, preserves keys when state is inconclusive, and reports failures.
Stable control-window IDs
src/bmad_loop/tui/launch.py, src/bmad_loop/tui/app.py, tests/test_tui_launch.py, tests/test_tui_app.py
Control-window lookup, selection, attachment, return-pane recording, and termination use stable window IDs.
Live pruning validation and documentation
tests/test_psmux_live.py, AGENTS.md, docs/multiplexer-backends.md, CHANGELOG.md
Windows live coverage validates project-specific pruning. Documentation describes the test and namespaced option keys.

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

Sequence Diagram(s)

sequenceDiagram
  participant TUI
  participant PsmuxMultiplexer
  participant psmux
  participant ScopedOptions
  TUI->>PsmuxMultiplexer: resolve control window ID
  PsmuxMultiplexer->>psmux: kill window by ID
  PsmuxMultiplexer->>psmux: verify window is gone
  PsmuxMultiplexer->>ScopedOptions: identify __blw@ keys
  PsmuxMultiplexer->>psmux: unset verified keys
Loading

Possibly related PRs

Suggested reviewers: pbean

Poem

A rabbit guards each scoped key,
And IDs guide windows carefully.
Kill, then check; sweep keys with care,
Two projects prune, one stays there.
Live tests hop through Windows bright.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 9.46% 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 describes the psmux namespace and kill-order hardening, which are the primary changes.
Linked Issues check ✅ Passed The changes implement all coding objectives in issue #313, including namespaced keys, safe cleanup, stable IDs, and live prune isolation coverage.
Out of Scope Changes check ✅ Passed The documentation, changelog, implementation, and tests remain focused on the safety and stable-targeting objectives in issue #313.
✨ 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_app.py (1)

2139-2157: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add force_tmux_backend to these two attach tests.

Both tests assert literal tmux argv (["tmux", "switch-client", "-t", "=bmad-loop-ctl"]) through the multiplexer seam, but neither pins the backend. The neighbouring test_attach_prefers_agent_session_without_decision at Line 2183 carries @pytest.mark.usefixtures("force_tmux_backend") with the comment "pin tmux against win32-matching externals". Without the fixture, platform selection can pick psmux on a win32 host and these assertions fail on backend name alone.

As per coding guidelines: "Use the mock adapter for engine tests; use force_tmux_backend when asserting tmux argv through the multiplexer seam."

💚 Proposed fix
+@pytest.mark.usefixtures("force_tmux_backend")  # pin tmux against win32-matching externals
 async def test_attach_targets_ctl_window_when_decision_pending(project, monkeypatch):
+@pytest.mark.usefixtures("force_tmux_backend")  # pin tmux against win32-matching externals
 async def test_attach_falls_back_to_ctl_window(project, monkeypatch):

Also applies to: 2202-2218

🤖 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_app.py` around lines 2139 - 2157, Add the force_tmux_backend
pytest fixture marker to both attach tests, including
test_attach_targets_ctl_window_when_decision_pending and the test covering lines
2202-2218. Keep their existing tmux argv assertions unchanged, matching the
neighboring test_attach_prefers_agent_session_without_decision pattern.

Source: Coding guidelines

🧹 Nitpick comments (1)
src/bmad_loop/adapters/psmux_backend.py (1)

580-589: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider reusing _write_scoped or catching transport errors per key.

_free_scoped_key issues the same set-option -u verb as _write_scoped, but it does not catch subprocess.SubprocessError/OSError. Both callers wrap the loop in a guarded try, so a transport failure on one key aborts the remaining keys of that sweep instead of freeing them. The two unset-and-warn paths also produce different warning text for the same failure.

Two options: delegate to _write_scoped (adds the transport catch and one message shape), or keep the distinct message and add the same catch inside _free_scoped_key so the loop continues.

♻️ Option: catch transport errors per key
     def _free_scoped_key(self, session: str, name: str) -> None:
         # The one unset path both sweeps share. rc-0 is not proof the key is
         # gone on psmux's write side, but a nonzero rc IS proof it is not —
         # silence here would leak a key for the server's life with no signal.
-        proc = self._run(["set-option", "-u", "-t", session, name], check=False)
-        if proc.returncode != 0:
-            print(
-                f"warning: could not free option {name} on {session}: {proc.stderr.strip()}",
-                file=sys.stderr,
-            )
+        try:
+            proc = self._run(["set-option", "-u", "-t", session, name], check=False)
+        except (subprocess.SubprocessError, OSError) as exc:
+            # Per key, so one dead round-trip does not abandon the rest of the sweep.
+            print(f"warning: could not free option {name} on {session}: {exc}", file=sys.stderr)
+            return
+        if proc.returncode != 0:
+            print(
+                f"warning: could not free option {name} on {session}: {proc.stderr.strip()}",
+                file=sys.stderr,
+            )
🤖 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/adapters/psmux_backend.py` around lines 580 - 589, Update
_free_scoped_key to catch subprocess.SubprocessError and OSError around the
unset operation, warning with the transport error and returning so one failed
key does not abort the sweep. Preserve its existing nonzero-return warning
behavior and align transport-failure handling with _write_scoped.
🤖 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 `@tests/test_tui_launch.py`:
- Around line 259-309: Update all four
tests—test_ctl_window_id_matches_run_id_suffix,
test_ctl_window_id_skips_empty_id_rows,
test_kill_ctl_window_kills_by_id_not_first_name_match, and
test_ctl_window_id_no_session_or_tmux—to use the existing force_tmux_backend
fixture, ensuring backend selection is pinned and the multiplexer cache is
cleared. Preserve the local shutil.which override to None in
test_ctl_window_id_no_session_or_tmux after applying the fixture.

---

Outside diff comments:
In `@tests/test_tui_app.py`:
- Around line 2139-2157: Add the force_tmux_backend pytest fixture marker to
both attach tests, including
test_attach_targets_ctl_window_when_decision_pending and the test covering lines
2202-2218. Keep their existing tmux argv assertions unchanged, matching the
neighboring test_attach_prefers_agent_session_without_decision pattern.

---

Nitpick comments:
In `@src/bmad_loop/adapters/psmux_backend.py`:
- Around line 580-589: Update _free_scoped_key to catch
subprocess.SubprocessError and OSError around the unset operation, warning with
the transport error and returning so one failed key does not abort the sweep.
Preserve its existing nonzero-return warning behavior and align
transport-failure handling with _write_scoped.
🪄 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 Plus

Run ID: b42c54a2-aa8d-4c10-b9a4-defbfd42fb0f

📥 Commits

Reviewing files that changed from the base of the PR and between b5e5b7a and dbb4db5.

📒 Files selected for processing (10)
  • AGENTS.md
  • CHANGELOG.md
  • docs/multiplexer-backends.md
  • src/bmad_loop/adapters/psmux_backend.py
  • src/bmad_loop/tui/app.py
  • src/bmad_loop/tui/launch.py
  • tests/test_psmux_backend.py
  • tests/test_psmux_live.py
  • tests/test_tui_app.py
  • tests/test_tui_launch.py

Comment thread tests/test_tui_launch.py
@greptile-apps

greptile-apps Bot commented Aug 2, 2026

Copy link
Copy Markdown

Greptile Summary

This PR hardens three safety gaps in the psmux option channel: a seam-owned key namespace marker (__blw@) prevents cleanup sweeps from accidentally deleting hand-written user options, kill_window now kills before freeing keys (only after a liveness probe confirms the kill landed), and ctl-window consumers resolve the stable window id once and replay it to avoid first-match name ambiguity on duplicate window names.

  • Key namespace (_SCOPE_MARKER = \"__blw@\"): @bmad_project__blw@3 replaces @bmad_project_@3; cleanup sweeps now match the seam-owned marker so any user option not deliberately imitating the marker is left untouched.
  • Kill-order containment: kill_window kills first, then verifies liveness; a failed or unverifiable kill leaves the live window's keys intact and defers cleanup to the launch-time orphan sweep.
  • ctl_windowctl_window_id: resolves the window's stable id once; subsequent select, kill, and set_window_option calls replay it, eliminating the duplicate-named-window hazard.

Confidence Score: 5/5

Safe to merge. All three hardening goals are implemented correctly and consistently, and no new defects were introduced.

The key-namespace change, kill-order flip, and id-resolution refactor are each mechanically sound. _SCOPE_MARKER is derived in one place and all consumers stay in sync with it. Kill-then-verify handles all ambiguous states conservatively. Per-key exception isolation is correct via _write_scoped delegation. Test coverage is thorough with 13 new backend tests plus a live gate.

Files Needing Attention: No files require special attention.

Important Files Changed

Filename Overview
src/bmad_loop/adapters/psmux_backend.py Core backend changes: _SCOPE_MARKER, _KEY_SUFFIX, _free_scoped_key, _sweep_orphan_keys, and kill_window all updated consistently; kill-then-verify logic correctly ordered with conservative fallback on ambiguous liveness probes
src/bmad_loop/tui/launch.py ctl_window renamed to ctl_window_id returning stable window id; empty-id guard prevents blank -t from resolving to current window
src/bmad_loop/tui/app.py action_attach updated to use ctl_window_id / select_ctl_window_id; return_window now passes stable id directly to set_return_pane
tests/test_psmux_backend.py New tests cover kill-then-verify ordering, failed kill key retention, unverifiable liveness fallback, batch isolation in _free_scoped_key, sweep warnings, and old-convention user options surviving sweeps
tests/test_psmux_live.py New Windows-local live gate; skip-guarded; asserts cross-project prune isolation including survival of old-convention user option
tests/test_tui_app.py Monkeypatches updated to ctl_window_id/select_ctl_window_id; stamp assertions updated to bare @n ids
tests/test_tui_launch.py Tests updated for ctl_window_id rename, empty-id skip, duplicate-name kill correctness, and attach_plan return_window as stable id

Sequence Diagram

sequenceDiagram
    participant Caller
    participant kill_window
    participant BaseMux
    participant sweep as _sweep_orphan_keys

    Caller->>kill_window: "kill_window(ctl:@3)"
    kill_window->>kill_window: "_option_scope -> (ctl, 3)"
    kill_window->>BaseMux: super().kill_window
    BaseMux-->>kill_window: kill sent
    kill_window->>BaseMux: list_window_ids(ctl)
    BaseMux-->>kill_window: "[@1,@13] no @3 confirmed dead"
    kill_window->>kill_window: "free keys ending in __blw@3"
    Note over kill_window: _free_scoped_key via _write_scoped per-key warn never raise

    Note over kill_window,sweep: If @3 still in live list
    kill_window-->>Caller: return keys retained for live window

    Note over kill_window,sweep: If liveness list is empty
    kill_window-->>Caller: return keys retained orphan sweep will reclaim

    Caller->>sweep: _sweep_orphan_keys at next parked-window launch
    sweep->>BaseMux: _scoped_options
    sweep->>BaseMux: list_window_ids
    sweep->>sweep: "free __blw@N keys with no live window"
Loading

Reviews (2): Last reviewed commit: "fix(adapters): contain a dead option-fre..." | Re-trigger Greptile

Comment on lines 647 to 648
scope = self._option_scope(target)
if scope is not None:
session, digits = scope
suffix = f"_@{digits}"
try:
for name in self._scoped_options(session) or ():
if name.endswith(suffix):
self._run(["set-option", "-u", "-t", session, name], check=False)
except (subprocess.SubprocessError, OSError):
pass
super().kill_window(target)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Name-target kill skipped when scope resolution raises

_option_scope is called before super().kill_window, so an exception from _window_id_for_name → super().list_windows (e.g., TmuxError, subprocess.SubprocessError) prevents the kill entirely. In the old code, any cleanup exception left the kill intact; here the kill is never sent. The outer try/except at line 670 only guards the cleanup block, not the pre-kill scope lookup. Since every current caller passes a stable id (not a name token), this path is dormant for normal operation — but a future name-target caller or a transient psmux hiccup during name resolution would silently skip the kill.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/bmad_loop/adapters/psmux_backend.py
Line: 647-648

Comment:
**Name-target kill skipped when scope resolution raises**

`_option_scope` is called before `super().kill_window`, so an exception from `_window_id_for_name → super().list_windows` (e.g., `TmuxError`, `subprocess.SubprocessError`) prevents the kill entirely. In the old code, any cleanup exception left the kill intact; here the kill is never sent. The outer `try/except` at line 670 only guards the cleanup block, not the pre-kill scope lookup. Since every current caller passes a stable id (not a name token), this path is dormant for normal operation — but a future name-target caller or a transient psmux hiccup during name resolution would silently skip the kill.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

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.

Not valid — the exception this depends on cannot happen. _option_scope reaches the listing through _window_id_for_name, which calls super().list_windows, and the base swallows the whole transport class itself (adapters/tmux_base.py:354-361):

try:
    probe = self._run(["list-windows", "-t", f"={session}", "-F", fmt], check=False)
except (subprocess.SubprocessError, OSError):
    return []
if probe.returncode != 0:
    return []

So a dead or failing name-resolve returns [], _window_id_for_name answers None, digits is empty, and _option_scope returns None. The kill still goes out — unscoped, with the keys left to the launch-time orphan sweep, which is the same degradation the ambiguous-liveness branch below already takes. Nothing propagates, so TmuxError never enters this path either (the base only raises it from _tmux/has_session, neither of which is on this leg).

I did write the guard you're describing first, then ablated it: with a TimeoutExpired injected into list-windows, kill-window was sent either way — the guard was unreachable, so it came back out rather than landing as dead code.

The reading is fair, though: the scope-before-kill ordering makes it look plausible on sight, and nothing in the file pinned the opposite. 3daade2 adds test_kill_window_sends_the_kill_when_the_scope_lookup_dies to characterize it, so a future change that lets _window_id_for_name propagate turns your hypothetical into a red test.

Comment thread src/bmad_loop/adapters/psmux_backend.py Outdated
_free_scoped_key issued set-option -u without the transport catch every
other write in this channel has, so a dead round-trip on key N escaped to
the sweep's outer guard and left keys N+1..M stranded until the next
launch. Route it through _write_scoped instead: one warn-never-raise body,
one message shape, and the batch continues past a failed key.

Also pin the pre-kill leg of kill_window: the scope lookup runs before the
kill, which reads like a transport failure there could cost the kill, but
the base list_windows swallows to [] so it degrades to an unscoped kill.
@dracic

dracic commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

Review pass addressed in 3daade2. Inline replies on the three anchored threads; the outside-diff one has no thread to reply to, so it is here.

Finding Verdict
_free_scoped_key transport exception aborts the batch (CodeRabbit nitpick + Greptile P2 — same finding) valid, fixed
kill_window scope-before-kill can skip the kill (Greptile P2) not valid, characterized instead
tests/test_tui_launch.py — four tests unpinned (CodeRabbit) not valid, already pinned
tests/test_tui_app.py — attach tests unpinned (CodeRabbit, outside diff) not valid, already pinned

The fix. _free_scoped_key was the one mutating verb in this channel issuing set-option -u without the transport catch, so a dead round-trip on key N escaped to either sweep's outer guard and stranded keys N+1..M until the next launch. It now delegates to _write_scoped — the existing warn-never-raise body — which closes the missing catch and the divergent warning text in the same edit, and makes the method a one-liner. Ablated: the new test fails against the pre-fix code with the outer-guard warning and the second key unattempted.

On the outside-diff finding. All five attach tests in tests/test_tui_app.py already carry @pytest.mark.usefixtures("force_tmux_backend") — lines 2138, 2160, 2183, 2201, 2221 at the reviewed commit, including both tests named in the finding. The neighbour cited as the counter-example is one of the five.

Gates on Windows: 389 passed / 7 skipped across test_psmux_backend, test_tui_launch, test_multiplexer, test_generic_tmux; 141 passed in test_tui_app; pyright and ruff clean.

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.

psmux option channel: seam-owned key namespace, sweep containment, live prune E2E (safety follow-up to #310)

1 participant