From 71c958362ccf84f58d0b8fcc7dee4a9f99c0b8ee Mon Sep 17 00:00:00 2001 From: pbean Date: Tue, 14 Jul 2026 13:01:40 -0700 Subject: [PATCH 1/2] feat(mux): out-of-tree backend discovery via bmad_loop.mux_backends entry points MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An adapter package co-installed with bmad-loop (uv tool install --with / same-venv pip) now registers its backend with no config step: _select() and detect_multiplexers() scan the bmad_loop.mux_backends entry-point group after the builtin load and import each advertised module, whose import-time register_multiplexer() call does the rest. Builtins register first, so tmux keeps first-wins on a name collision and default selection is unchanged by installing an adapter. A broken distribution can never break selection: each ep.load() failure (and a failing scan itself) is recorded in _EXTERNAL_ERRORS and surfaced — a warning line under the `bmad-loop mux` table and an advisory note in the validate preflight (external_backend_errors()). The loaded-flag is set up front, unlike _BUILTINS_LOADED: a third-party import failure is not transient, and retrying would re-import (and re-fail) on every selection. fresh_registry parks the scan as already-done (installed adapters must not leak into builtin-selection tests); the discovery tests re-arm it explicitly, and one exercises a real *.dist-info on sys.path to prove the group/value convention against genuine packaging metadata. Docs: 'Shipping out-of-tree' section in porting-to-a-new-os.md (+ authoring- guide cross-link) naming pbean/bmad-loop-adapter-herdr as the reference external adapter. --- CHANGELOG.md | 10 ++ docs/adapter-authoring-guide.md | 7 +- docs/porting-to-a-new-os.md | 24 +++ src/bmad_loop/adapters/multiplexer.py | 51 +++++- src/bmad_loop/cli.py | 23 ++- tests/test_backend_registry.py | 11 ++ tests/test_external_backends.py | 214 ++++++++++++++++++++++++++ 7 files changed, 335 insertions(+), 5 deletions(-) create mode 100644 tests/test_external_backends.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 00dc9a6..a1fbe93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,16 @@ breaking changes may land in a minor release. ### Added +- **Out-of-tree multiplexer backends (`bmad_loop.mux_backends` entry points).** A backend + package installed next to bmad-loop (e.g. `uv tool install bmad-loop --with `) now + registers itself with no config step: before every selection, core imports each module + advertised under the `bmad_loop.mux_backends` entry-point group, whose import-time + `register_multiplexer(...)` call makes the backend selectable exactly like a bundled one + (builtins load first, so default selection is unchanged by installing an adapter). A package + that fails to import can never break selection — the failure is recorded and surfaced as a + `warning:` line by `bmad-loop mux` and a note in the `validate` preflight + (`external_backend_errors()`). + - **Unity modal-dialog guards (`[plugins.unity]`).** A chronically-dirty Unity scene raises modal Editor dialogs ("scene changed on disk", "save changes before closing") that freeze the MCP dispatch loop and stall the whole run. The bundled Unity plugin now defends in depth: it seeds an diff --git a/docs/adapter-authoring-guide.md b/docs/adapter-authoring-guide.md index 7dc3e4c..ad575cd 100644 --- a/docs/adapter-authoring-guide.md +++ b/docs/adapter-authoring-guide.md @@ -58,7 +58,12 @@ gitignored) → the platform default when registered and `available()` → the first available platform match → the historical tmux fallback. `bmad-loop mux` lists every registered backend and the selection; same-platform backends need discriminating `available()` probes (see the -[porting guide](porting-to-a-new-os.md#availability-discriminators-same-platform-backends)). There are two build paths: extend `BaseTmuxBackend` (`adapters/tmux_base.py`) +[porting guide](porting-to-a-new-os.md#availability-discriminators-same-platform-backends)). +An out-of-tree backend package makes its registration run by advertising the +module under the `bmad_loop.mux_backends` entry-point group — core imports it +before every selection, so co-installing the package is the whole setup (see +[the porting guide](porting-to-a-new-os.md#shipping-out-of-tree-the-bmad_loopmux_backends-entry-point); +a broken package degrades to a `bmad-loop mux` warning, never a selection failure). There are two build paths: extend `BaseTmuxBackend` (`adapters/tmux_base.py`) for a tmux-family backend — overriding only its single spawn primitive `_run()` plus the shell-dialect hooks (`_shell_wrap`, `_join_argv`, `_parked_trailer`, `_source_prefix`, `_window_launch` and the `_EXIT_CAPTURE`/`_ECHO`/`_PARK` diff --git a/docs/porting-to-a-new-os.md b/docs/porting-to-a-new-os.md index 8b10080..5d1254a 100644 --- a/docs/porting-to-a-new-os.md +++ b/docs/porting-to-a-new-os.md @@ -71,6 +71,30 @@ backend. `bmad-loop mux` lists every registered backend with its availability, version, and the current selection. (The result is cached — see [Testing a port](#testing-a-port).) +### Shipping out-of-tree: the `bmad_loop.mux_backends` entry point + +How does the registration snippet above ever _run_ when the backend lives in its +own package? Advertise the module in the package's `pyproject.toml`: + +```toml +[project.entry-points."bmad_loop.mux_backends"] +psmux = "my_package.backend" +``` + +Before every selection, core scans that entry-point group and imports each +advertised module (builtins first, so tmux keeps first registration and the +precedence above is unchanged); the module's top-level `register_multiplexer(...)` +call does the rest. Installing the package into bmad-loop's environment — e.g. +`uv tool install bmad-loop --with ` — is the entire setup; no core +edit, no config step. The entry-point _value_ is a bare module path (core only +imports it; the name is just a diagnostic label). + +A package that fails to import can never break selection: the failure is +recorded and reported by `bmad-loop mux` (a `warning:` line under the table) and +the `validate` preflight, and selection proceeds without it. The reference +out-of-tree adapter is +[bmad-loop-adapter-herdr](https://github.com/pbean/bmad-loop-adapter-herdr). + ### Two build paths - **Extend `BaseTmuxBackend`** (`adapters/tmux_base.py`) for a **tmux-family** diff --git a/src/bmad_loop/adapters/multiplexer.py b/src/bmad_loop/adapters/multiplexer.py index e5addbe..e64685c 100644 --- a/src/bmad_loop/adapters/multiplexer.py +++ b/src/bmad_loop/adapters/multiplexer.py @@ -10,8 +10,10 @@ ``TerminalMultiplexer`` is the contract a backend author implements. Operation names mirror today's call sites verbatim so the migration is mechanical. Backends register themselves through :func:`register_multiplexer` (bundled ones from -:func:`_load_builtin_backends`, out-of-tree ones at import time); the process-wide -backend is selected by registry and returned by :func:`get_multiplexer`. +:func:`_load_builtin_backends`; out-of-tree ones at import time, triggered by the +``bmad_loop.mux_backends`` entry-point scan in :func:`_load_external_backends` — +so a pip/uv co-installed adapter package is selectable with no config step); the +process-wide backend is selected by registry and returned by :func:`get_multiplexer`. Selection precedence (issue #87): the ``BMAD_LOOP_MUX_BACKEND`` env var, then the policy ``[mux] backend`` choice (installed once per CLI invocation via @@ -25,6 +27,7 @@ from __future__ import annotations import functools +import importlib.metadata import os import sys from abc import ABC, abstractmethod @@ -303,6 +306,48 @@ def _load_builtin_backends() -> None: _BUILTINS_LOADED = True # set only after a successful import so a transient failure retries +# The entry-point group an out-of-tree backend package advertises its module +# under; importing the module runs its register_multiplexer call. Loader state: +# scanned-once flag + per-entry-point failure reasons for mux/validate to show. +MUX_BACKENDS_GROUP = "bmad_loop.mux_backends" +_EXTERNALS_LOADED = False +_EXTERNAL_ERRORS: dict[str, str] = {} + + +def _load_external_backends() -> None: + """Import every ``bmad_loop.mux_backends`` entry point; each module + self-registers via :func:`register_multiplexer` at import time. Called after + :func:`_load_builtin_backends`, so builtins keep first registration (tmux + stays first-wins on a name collision) and selection precedence is unchanged. + + A broken third-party distribution must never break backend selection: + failures are recorded in ``_EXTERNAL_ERRORS`` (surfaced by ``bmad-loop mux`` + and the ``validate`` preflight via :func:`external_backend_errors`), not + raised. Unlike ``_BUILTINS_LOADED``, the loaded-flag is set up front: a + third-party import failure is not transient, and retrying on every + selection would re-import (and re-fail) each time.""" + global _EXTERNALS_LOADED + if _EXTERNALS_LOADED: + return + _EXTERNALS_LOADED = True + try: + eps = importlib.metadata.entry_points(group=MUX_BACKENDS_GROUP) + except Exception as exc: # noqa: BLE001 — diagnostics path, never crash selection + _EXTERNAL_ERRORS[""] = f"{type(exc).__name__}: {exc}" + return + for ep in eps: + try: + ep.load() # module import runs register_multiplexer(...) + except Exception as exc: # noqa: BLE001 — one bad package must not hide the rest + _EXTERNAL_ERRORS[ep.name] = f"{type(exc).__name__}: {exc}" + + +def external_backend_errors() -> dict[str, str]: + """Entry-point name -> failure reason for every external backend that failed + to load this process (empty when all loaded). For diagnostics surfaces.""" + return dict(_EXTERNAL_ERRORS) + + def configure_multiplexer(name: str | None, *, origin: Path | None = None) -> None: """Install the policy ``[mux] backend`` choice (``None``/``""`` = auto). @@ -359,6 +404,7 @@ def _select() -> tuple[TerminalMultiplexer, str, str]: can't run. A forced name matching nothing is a misconfiguration; never silently fall back to tmux (wrong/unsafe on a non-POSIX host).""" _load_builtin_backends() + _load_external_backends() forced = os.environ.get("BMAD_LOOP_MUX_BACKEND") if forced: factory = _factory_by_name(forced) @@ -442,6 +488,7 @@ def detect_multiplexers() -> list[MuxBackendInfo]: every registered backend, so factories must stay cheap, side-effect-free constructors (true of the tmux family).""" _load_builtin_backends() + _load_external_backends() try: _, selected_name, reason = _select() except MultiplexerError: diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index ba4e534..f729dec 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -156,7 +156,11 @@ def _platform_preflight() -> tuple[list[str], list[str]]: ``sys.platform`` branch to validate. The process host is named so a misselection (e.g. the Windows host picked on Linux) is visible at a glance. """ - from .adapters.multiplexer import detect_multiplexers, get_multiplexer + from .adapters.multiplexer import ( + detect_multiplexers, + external_backend_errors, + get_multiplexer, + ) from .process_host import get_process_host notes: list[str] = [] @@ -196,6 +200,12 @@ def _platform_preflight() -> tuple[list[str], list[str]]: if chosen and chosen.reason in ("env", "policy"): notes.append(f"multiplexer selection {_mux_reason_label(chosen.reason)}") + # Advisory, not a problem: selection already degraded past the broken + # package (a failed external can never be the selected backend), so the + # preflight outcome above is authoritative — this line explains the absence. + for ep_name, reason in sorted(external_backend_errors().items()): + notes.append(f"external mux backend '{ep_name}' failed to load: {reason}") + try: notes.append(f"process host: {type(get_process_host()).__name__}") except Exception as e: # noqa: BLE001 — a bad BMAD_LOOP_PROCESS_HOST must report, not crash @@ -323,7 +333,12 @@ def cmd_mux(args: argparse.Namespace) -> int: """List registered terminal-multiplexer backends and the selection, or persist a machine-scoped choice (`mux set ` / `mux set --clear`) into .bmad-loop/policy.toml. Never prompts — runs are unattended.""" - from .adapters.multiplexer import MultiplexerError, detect_multiplexers, get_multiplexer + from .adapters.multiplexer import ( + MultiplexerError, + detect_multiplexers, + external_backend_errors, + get_multiplexer, + ) project = _project(args) if args.action == "set": @@ -347,6 +362,10 @@ def cmd_mux(args: argparse.Namespace) -> int: widths = [max(len(h), *(len(row[i]) for row in table), 0) for i, h in enumerate(header)] for row in (header, *table): print(" ".join(cell.ljust(w) for cell, w in zip(row, widths)).rstrip()) + # A failed external package is invisible in the table (it never registered), + # so name it here — the one place an operator looks when a backend is missing. + for ep_name, reason in sorted(external_backend_errors().items()): + print(f"warning: external backend '{ep_name}' failed to load: {reason}", file=sys.stderr) print( "override: BMAD_LOOP_MUX_BACKEND env var, or `bmad-loop mux set ` " f"(persists to {POLICY_FILE})" diff --git a/tests/test_backend_registry.py b/tests/test_backend_registry.py index 7c0770a..47ae809 100644 --- a/tests/test_backend_registry.py +++ b/tests/test_backend_registry.py @@ -58,14 +58,25 @@ def fresh_registry(monkeypatch): saved_backends = list(m._BACKENDS) saved_loaded = m._BUILTINS_LOADED saved_configured = m._CONFIGURED + saved_ext_loaded = m._EXTERNALS_LOADED + saved_ext_errors = dict(m._EXTERNAL_ERRORS) m._BACKENDS.clear() m._BUILTINS_LOADED = False m._CONFIGURED = None + # Externals stay OFF by default (the flag reads "already scanned"): these + # tests pin builtin selection, and a real entry-point scan here would let + # whatever adapters happen to be installed on the dev box leak in. The + # discovery tests opt back in by resetting the flag themselves. + m._EXTERNALS_LOADED = True + m._EXTERNAL_ERRORS.clear() m.get_multiplexer.cache_clear() yield m m._BACKENDS[:] = saved_backends m._BUILTINS_LOADED = saved_loaded m._CONFIGURED = saved_configured + m._EXTERNALS_LOADED = saved_ext_loaded + m._EXTERNAL_ERRORS.clear() + m._EXTERNAL_ERRORS.update(saved_ext_errors) m.get_multiplexer.cache_clear() diff --git a/tests/test_external_backends.py b/tests/test_external_backends.py new file mode 100644 index 0000000..bc62a26 --- /dev/null +++ b/tests/test_external_backends.py @@ -0,0 +1,214 @@ +"""External-backend discovery proof (the ``bmad_loop.mux_backends`` entry-point scan). + +An out-of-tree backend package advertises a module under the +``bmad_loop.mux_backends`` entry-point group; ``_load_external_backends`` +imports it (after the builtins, so tmux keeps first registration) and the +module's import-time ``register_multiplexer`` call makes it selectable exactly +like a bundled backend. These tests pin the loader's contract: discovery, +ordering, and — above all — that a broken third-party distribution degrades to +a recorded, surfaced reason and can never break backend selection. + +Entry points are faked by monkeypatching ``importlib.metadata.entry_points`` +through the ``multiplexer`` module's own binding (it imports the module, so the +attribute path is ``m.importlib.metadata``); one test builds a real +``*.dist-info`` on ``sys.path`` to prove the scan works against genuine +packaging metadata, not just our fake. +""" + +from __future__ import annotations + +import sys + +import pytest + +# Reuse the registry-isolation fixture where it lives; importing it into this +# module's namespace is how pytest shares a non-conftest fixture across files. +from test_backend_registry import fresh_registry # noqa: F401 + +from bmad_loop.adapters import multiplexer as m +from bmad_loop.adapters.tmux_backend import TmuxMultiplexer + + +class _FakeEntryPoint: + """Duck-typed stand-in for importlib.metadata.EntryPoint: the loader only + touches ``.name`` and ``.load()``.""" + + def __init__(self, name, load): + self.name = name + self._load = load + + def load(self): + return self._load() + + +@pytest.fixture +def scan_registry(fresh_registry, monkeypatch): # noqa: F811 — fixture, not a redefinition + """fresh_registry with the externals scan re-armed (the base fixture parks it + as already-loaded so installed adapters can't leak into builtin tests). + Yields a hook: call it with fake entry points (or an exception to raise from + the scan itself) and the next selection performs that scan.""" + + def arm(*eps, scan_error: Exception | None = None): + def fake_entry_points(*, group): + assert group == m.MUX_BACKENDS_GROUP + if scan_error is not None: + raise scan_error + return list(eps) + + monkeypatch.setattr(m.importlib.metadata, "entry_points", fake_entry_points) + m._EXTERNALS_LOADED = False + m._EXTERNAL_ERRORS.clear() + m.get_multiplexer.cache_clear() + + yield fresh_registry, arm + + +def test_entry_point_backend_registers_and_is_selectable(scan_registry, monkeypatch): + """The pip-install-and-go path: the entry point's module import registers the + backend; it lists in detect_multiplexers and a forced name selects it.""" + registry, arm = scan_registry + sentinel = object() + + def load(): + registry.register_multiplexer("extmux", lambda p: False, lambda: sentinel) + return None # the loader ignores the return value; import side effect is the contract + + arm(_FakeEntryPoint("extmux", load)) + monkeypatch.setenv("BMAD_LOOP_MUX_BACKEND", "extmux") + registry.get_multiplexer.cache_clear() + assert registry.get_multiplexer() is sentinel + assert registry.external_backend_errors() == {} + monkeypatch.delenv("BMAD_LOOP_MUX_BACKEND") + registry.get_multiplexer.cache_clear() + rows = {r.name: r for r in registry.detect_multiplexers()} + assert "extmux" in rows + + +def test_externals_load_after_builtins(scan_registry): + """Ordering guarantee: builtins register first, so tmux keeps first-wins on a + name collision and POSIX default selection is unchanged by installing an + adapter. The external lands after both builtins in the registry.""" + registry, arm = scan_registry + + def load(): + registry.register_multiplexer("extmux", lambda p: True, lambda: object()) + + arm(_FakeEntryPoint("extmux", load)) + registry._select() + names = [name for name, _, _ in registry._BACKENDS] + assert names.index("tmux") < names.index("extmux") + + +def test_broken_entry_point_degrades_and_is_recorded(scan_registry, monkeypatch): + """A distribution whose import blows up must not break selection: tmux is + still selected, and the failure is recorded for mux/validate to show.""" + registry, arm = scan_registry + + def boom(): + raise ImportError("No module named 'ghost_dependency'") + + arm(_FakeEntryPoint("brokenmux", boom)) + monkeypatch.setattr(sys, "platform", "linux") + backend, name, _reason = registry._select() + assert isinstance(backend, TmuxMultiplexer) and name == "tmux" + errors = registry.external_backend_errors() + assert list(errors) == ["brokenmux"] + assert "ghost_dependency" in errors["brokenmux"] + + +def test_one_broken_package_does_not_hide_the_rest(scan_registry, monkeypatch): + """Per-entry isolation: the loader keeps importing after a failure, so a + working adapter still registers alongside a broken one.""" + registry, arm = scan_registry + sentinel = object() + + def boom(): + raise RuntimeError("half-installed") + + def load(): + registry.register_multiplexer("goodmux", lambda p: False, lambda: sentinel) + + arm(_FakeEntryPoint("brokenmux", boom), _FakeEntryPoint("goodmux", load)) + monkeypatch.setenv("BMAD_LOOP_MUX_BACKEND", "goodmux") + registry.get_multiplexer.cache_clear() + assert registry.get_multiplexer() is sentinel + assert list(registry.external_backend_errors()) == ["brokenmux"] + + +def test_scan_failure_degrades(scan_registry, monkeypatch): + """Even the entry-point enumeration itself blowing up (exotic sys.path / + importlib state) leaves selection working, with the scan failure recorded.""" + registry, arm = scan_registry + arm(scan_error=RuntimeError("metadata index corrupt")) + monkeypatch.setattr(sys, "platform", "linux") + backend, name, _reason = registry._select() + assert isinstance(backend, TmuxMultiplexer) and name == "tmux" + assert "" in registry.external_backend_errors() + + +def test_scan_runs_once_per_process(scan_registry): + """The loaded-flag is set up front: a second selection does not re-scan (a + third-party import failure is not transient; re-importing would re-fail on + every selection).""" + registry, arm = scan_registry + calls = [] + + def load(): + calls.append(1) + + arm(_FakeEntryPoint("extmux", load)) + registry._select() + registry.get_multiplexer.cache_clear() + registry._select() + assert len(calls) == 1 + + +def test_mux_command_surfaces_load_failures(scan_registry, monkeypatch, capsys, tmp_path): + """`bmad-loop mux` names a failed external package — the one place an operator + looks when an installed backend is missing from the table.""" + import argparse + + from bmad_loop import cli + + registry, arm = scan_registry + + def boom(): + raise ImportError("No module named 'ghost_dependency'") + + arm(_FakeEntryPoint("brokenmux", boom)) + monkeypatch.setattr(sys, "platform", "linux") + args = argparse.Namespace(project=tmp_path, action=None, name=None, clear=False, force=False) + assert cli.cmd_mux(args) == 0 + captured = capsys.readouterr() + assert "brokenmux" in captured.err + assert "ghost_dependency" in captured.err + assert "tmux" in captured.out # the table itself still renders + + +def test_real_dist_info_metadata_is_discovered( + fresh_registry, monkeypatch, tmp_path # noqa: F811 — fixture, not a redefinition +): + """End-to-end against genuine packaging metadata: a real ``*.dist-info`` + + module on sys.path is found by the unpatched importlib scan and its import + registers the backend — proving the group name and value convention work + outside our fakes.""" + site = tmp_path / "site" + site.mkdir() + (site / "extmux_backend.py").write_text( + "from bmad_loop.adapters.multiplexer import register_multiplexer\n" + "class _Probe:\n" + " pass\n" + "register_multiplexer('extmux-real', lambda p: False, _Probe)\n", + encoding="utf-8", + ) + dist = site / "extmux-0.1.dist-info" + dist.mkdir() + (dist / "METADATA").write_text("Metadata-Version: 2.1\nName: extmux\nVersion: 0.1\n") + (dist / "entry_points.txt").write_text( + "[bmad_loop.mux_backends]\nextmux = extmux_backend\n", encoding="utf-8" + ) + monkeypatch.syspath_prepend(str(site)) + fresh_registry._EXTERNALS_LOADED = False # re-arm the (real) scan + fresh_registry._select() + assert fresh_registry.external_backend_errors().get("extmux") is None + assert "extmux-real" in [name for name, _, _ in fresh_registry._BACKENDS] From 200d861e42420f4bc70a112627cf2e13af54ea02 Mon Sep 17 00:00:00 2001 From: pbean Date: Tue, 14 Jul 2026 15:17:12 -0700 Subject: [PATCH 2/2] test: discard unused registry unpack in mux-command test (RUF059) --- tests/test_external_backends.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_external_backends.py b/tests/test_external_backends.py index bc62a26..8a23ab2 100644 --- a/tests/test_external_backends.py +++ b/tests/test_external_backends.py @@ -170,7 +170,7 @@ def test_mux_command_surfaces_load_failures(scan_registry, monkeypatch, capsys, from bmad_loop import cli - registry, arm = scan_registry + _registry, arm = scan_registry def boom(): raise ImportError("No module named 'ghost_dependency'")