diff --git a/CHANGELOG.md b/CHANGELOG.md index a1fbe93..b2333de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,39 +67,19 @@ breaking changes may land in a minor release. `TerminalMultiplexer` seam instead of living as hand-assembled tmux syntax in core: a new concrete `target(session, window=None)` encoder (overridable per backend, tmux inherits the default and passes it straight through) and a module-level `parse_target()` decoder that - native-id backends reuse instead of re-deriving the grammar (the herdr backend's - `_parse_target` now delegates to it). `runs.py`/`tui/launch.py`/`tui/app.py` format every + native-id backends reuse instead of re-deriving the grammar (the out-of-tree herdr + adapter's `_parse_target` delegates to it). `runs.py`/`tui/launch.py`/`tui/app.py` format every target via `target()` (new `runs.session_target` / `launch.ctl_target` helpers) — output is byte-identical, so no backend or operator behavior changes; the contract is documented in the adapter authoring guide's new "Window targets" section. -- **Herdr TUI-launch surface.** The herdr backend now covers everything `tui/launch.py` drives: - parked orchestrator windows (a typed `exec sh -c '; banner; read; trailer'` recipe, - tmux-identical from the operator's seat, with the return-to-origin target mirrored into a - per-window return file the trailer consumes), tmux-style `=session[:window]` target parsing - across select/kill/option/attach methods, `list_windows` tmux-field mapping - (`window_id`/`window_name`/`@option`), finalized `attach_target_argv` (blocking - `terminal attach` outside herdr, fire-and-forget `tab focus` inside), and `switch_client` - via `tab focus`. The TUI/CLI attach flows and `in_ctl_session` now ask the seam - (`current_pane_id()`/`current_session()`) instead of sniffing `TMUX`, so they work under any - backend; the tmux backend's `current_*` gained the TMUX guard that keeps "not inside" honest. - Remaining herdr degradations: `detach_client` stays a no-op (the post-exit detach return rides - pane-close ending `terminal attach`; only the mid-process hand-back degrades to staying - attached), and `switch_client` has no last-client fallback. -- **Herdr multiplexer backend (opt-in, non-tmux-family).** A new `herdr` transport implements - the `TerminalMultiplexer` seam _fresh_ (subclassing nothing) over - [herdr](https://herdr.dev)'s cross-platform workspace/tab/pane model — Unix socket on POSIX, - named pipe on Windows — a step toward native Windows (#92) plus herdr's agent-status sidebar for - watching runs. Opt in per run with `BMAD_LOOP_MUX_BACKEND=herdr` or persist with - `bmad-loop mux set herdr`; `_PLATFORM_DEFAULTS` is untouched, so tmux stays the POSIX default and - herdr activates on win32 only by first-match until psmux ships. This PR is the **engine run - path**: session→workspace / window→tab mapping, an `exec`-based launch with tmux-identical - window-death, a content-hash-gated polling `pipe_pane` tee (herdr has no tee), JSON-sidecar - session/window options (herdr has none; writes lock-guarded via a new cross-platform - `platform_util.file_lock`), lazy server autostart, and a protocol-version guard - (fail-below / warn-above). Landed as one registration line plus one sanctioned `probe.py` gate - fix — zero portability-guard allowlist changes. The TUI-launch surface landed as a second PR - (previous entry); the remaining degradations are in the backend module docstring's ledger and, - operator-facing, in `docs/multiplexer-backends.md`. +- **Herdr multiplexer backend — shipped out-of-tree.** A complete non-tmux-family + `TerminalMultiplexer` backend for [herdr](https://herdr.dev)'s cross-platform + workspace/tab/pane model was developed in-tree (engine run path #136, TUI-launch surface + #137) and extracted before ever shipping in a release to + [`bmad-loop-adapter-herdr`](https://github.com/pbean/bmad-loop-adapter-herdr), where it + co-installs with bmad-loop and registers through the `bmad_loop.mux_backends` entry-point + discovery above. Core bundles only tmux; herdr's capabilities, remaining degradations, and + operator notes live in the adapter repo's docs. - **Stories mode — a second planning pipeline that drives the loop off a typed `stories.yaml` (folder+id dispatch) instead of `sprint-status.yaml`.** Opt in with `[stories] source = "stories"` + `spec_folder`, or per run with `bmad-loop run --spec ` (overrides policy); `--story` then filters by story id. Each entry dispatches by folder + id — the dev skill creates-or-resumes the story spec at `/stories/-.md` and the orchestrator reads that id-keyed path back deterministically (no shared board to line-edit, no result-artifact mtime-scan). Strictly linear schedule (list order, no `depends_on`); `bmad-loop run --dry-run --spec ` and `bmad-loop status` print the board (id · live disk state · checkpoint markers · title). Sprint mode is unchanged and remains the default. Requires a `bmad-dev-auto` new enough for folder+id dispatch — the run preflight checks and remediates. - **Per-story human checkpoints (stories mode).** Independent `spec_checkpoint` (pause before code to review the plan — dev halts at `ready-for-dev`; approve to implement, or request a replan that resets the spec to `draft`) and `done_checkpoint` (pause after the story commits, skipped when it is the last story); both additive to `gates.mode`. A blocked story escalates + resolves as in sprint mode, with a pre-planning-halt sentinel auto-deleted (a copy preserved under the run dir) on re-arm. - **TUI human-in-the-loop surface for stories mode.** The sprint tree is replaced by a stories board (id · live disk state · spec/done checkpoint markers · title) when a stories-mode run is selected; paused runs carry a per-run pause-kind badge and the run list shows a global _⚑ N need attention_ count; `p` opens the stage-appropriate viewer — plan-checkpoint spec review (Approve & resume / Request replan), story-checkpoint summary card (Continue / Stop), escalation with story context (Resolve / Re-arm & resume), and a gate spec viewer that the existing spec-approval/epic pauses reuse. The start-run modal gains a source select + spec-folder field with a live schedule preview. Every TUI action calls the same code paths as the CLI. @@ -131,14 +111,12 @@ breaking changes may land in a minor release. PATH)`, the TUI notifies `multiplexer backend unavailable — launch/attach disabled` and `launched (control session bmad-loop-ctl)`, and the "attach to … bmad-loop-ctl" hints say _control session_. The TUI-guide troubleshooting table matches. Behavior is unchanged. -- **Docs: operator guide for the herdr backend (`docs/multiplexer-backends.md`).** The user-facing - docs no longer claim tmux is the only multiplexer backend. The new page covers backend selection - (`bmad-loop mux` / `mux set`) and what changes from the operator's seat on herdr — chiefly that - herdr has no CLI detach, so the automatic hand-back after answering an attached sweep decision - becomes a manual `ctrl+b q`; the rest (polled session logs, the `~/.bmad-loop/herdr-state.json` - sidecar, advisory geometry, lazy server autostart, no last-client fallback) is emulated to parity - or cosmetic. README, setup guide, TUI guide, and FEATURES now name both backends and link the - page. +- **Docs: multiplexer backend guide (`docs/multiplexer-backends.md`).** The user-facing docs no + longer claim tmux is the only multiplexer backend. The new page covers backend selection + (`bmad-loop mux` / `mux set`) and how external backends are installed and discovered; + backend-specific operator guidance (what changes from your seat on herdr, its degradations) + moved out with the extraction and lives in each adapter repo's docs. README, setup guide, + TUI guide, and FEATURES name the mechanism and link the page. - **Docs: `followup_review_recommended` is now scored upstream.** BMAD-METHOD#2580 replaced the skill's convergence-prone significance judgment with a severity-weighted score over patched findings and added a fourth default review layer (Intent Alignment Auditor, #2560). README, diff --git a/README.md b/README.md index 8457122..40eb772 100644 --- a/README.md +++ b/README.md @@ -38,8 +38,8 @@ Inspired by the original [bmad-automator](https://github.com/bmad-code-org/bmad- ## Requirements -- **Python 3.11+**, **tmux**, and a supported coding CLI — `claude` by default; `codex`, `gemini`, `copilot`, and `antigravity` (`agy`) via [profiles](#other-coding-clis). -- **Linux or macOS** (or **Windows via WSL**, which _is_ Linux — it runs as-is). tmux is the default of the two bundled terminal-multiplexer backends (the opt-in [herdr](docs/multiplexer-backends.md) is the other), and both sit behind a pluggable **registry** of OS seams (transport, process lifecycle, hook interpreter) with availability-aware selection — env var → persisted `[mux] backend` choice (`bmad-loop mux set `) → platform default (`psmux` on Windows, `tmux` elsewhere) → first available platform match — so a native-Windows backend slots in as new files + a registration line each, with no engine edits — see [Porting bmad-loop to a new OS](docs/porting-to-a-new-os.md). Native Windows is not yet shipped. +- **Python 3.11+**, a **terminal multiplexer** (tmux is the bundled default), and a supported coding CLI — `claude` by default; `codex`, `gemini`, `copilot`, and `antigravity` (`agy`) via [profiles](#other-coding-clis). +- **Linux or macOS** (or **Windows via WSL**, which _is_ Linux — it runs as-is). tmux is the bundled terminal-multiplexer backend (externals like the [herdr adapter](https://github.com/pbean/bmad-loop-adapter-herdr) co-install as packages and self-register — see [Terminal multiplexer backends](docs/multiplexer-backends.md)), and all of it sits behind a pluggable **registry** of OS seams (transport, process lifecycle, hook interpreter) with availability-aware selection — env var → persisted `[mux] backend` choice (`bmad-loop mux set `) → platform default (`psmux` on Windows, `tmux` elsewhere) → first available platform match — so a native-Windows backend slots in as new files + a registration line each, with no engine edits — see [Porting bmad-loop to a new OS](docs/porting-to-a-new-os.md). Native Windows is not yet shipped. - A **BMAD v6 project** (`_bmad/bmm/config.yaml`, a `sprint-status.yaml` from `bmad-sprint-planning`) with the upstream `bmad-dev-auto` skill (and the three review-hunter skills its step-04 invokes inline: `bmad-review-adversarial-general`, `bmad-review-edge-case-hunter`, `bmad-review-verification-gap`) and the bmad-loop skill module from this repo installed (`bmad-loop-resolve`, `bmad-loop-sweep` — see [Installing the skill module](#installing-the-skill-module)). Standard BMAD skills stay untouched. ## Quick start @@ -493,7 +493,7 @@ Each run drives its agents inside a dedicated tmux session, `bmad-loop-` ## Other coding CLIs -One generic driver (`adapters/generic.py`) runs any coding CLI that fits the injection + hook-signal transport; everything CLI-specific lives in a declarative **profile** (`adapters/profile.py`), and the terminal transport itself sits behind a pluggable `TerminalMultiplexer` seam (tmux by default, herdr opt-in — see [Terminal multiplexer backends](docs/multiplexer-backends.md)). Built-in profiles ship as TOML in `bmad_loop/data/profiles/`: +One generic driver (`adapters/generic.py`) runs any coding CLI that fits the injection + hook-signal transport; everything CLI-specific lives in a declarative **profile** (`adapters/profile.py`), and the terminal transport itself sits behind a pluggable `TerminalMultiplexer` seam (tmux bundled; external backends like the herdr adapter install as packages — see [Terminal multiplexer backends](docs/multiplexer-backends.md)). Built-in profiles ship as TOML in `bmad_loop/data/profiles/`: | Profile | Status | Notes | | ------------- | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | diff --git a/docs/FEATURES.md b/docs/FEATURES.md index df74665..36972fa 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -8,23 +8,23 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se ## Capability matrix (feature → problem addressed) -| Capability | What it does | Problem it addresses | -| ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | -| Deterministic control loop | Story selection, retries, gates, completion checks run in plain Python | LLM-as-orchestrator is nondeterministic, hard to debug, and costs tokens for control flow | -| Dual planning pipelines | Same loop from either `sprint-status.yaml` (sprint mode, default) or a typed `stories.yaml` dispatched by folder+id (stories mode, opt-in) | Sprint boards need `bmad-sprint-planning`; a `bmad-spec` Story Breakdown has no board | -| Per-story human checkpoints | Stories-mode `spec_checkpoint` pauses to review the plan before code; `done_checkpoint` pauses after the commit; both independent, both surfaced in the TUI | Coarse run-global gates can't ask for a plan review on _this_ story only | -| Trust-nothing verification | Checks on-disk artifacts (spec status, baseline-commit match, non-empty diff, sprint sync) + runs your test/lint commands before commit | Agents claim success without working code; broken builds slip through | -| Fresh-context adversarial review | Dev and review are separate sessions; review uses 4 parallel layers (Blind Hunter / Edge Case Hunter / Verification Gap / Intent Alignment) | Self-review anchoring bias; implementer marks own work correct | -| Hook-based transport | Coding-agent hooks write structured event files; skills write `result.json` | Brittle terminal pane-scraping | -| Resumable state machine | Every run is on-disk state, resumable after gate/escalation/crash | Long unattended runs lost to interruptions | -| Plateau-defer | Stuck stories are skipped, stashed, and the run continues | One unconvergeable story blocking a whole sprint | -| Typed escalations + resolve workflow | CRITICAL pauses + notifies; interactive resolve agent re-arms the story | Ambiguous specs silently producing wrong code | -| Deferred-work sweeps | Triages an append-only ledger against real code, bundles + executes | Split-off goals and review findings get lost | -| Multi-CLI adapter + profiles | Generic driver runs claude/codex/gemini/copilot/antigravity; per-stage overrides; TOML profiles; transport + process-lifecycle + hook-interpreter behind a pluggable OS-seam registry (tmux default + opt-in herdr; POSIX today) | Vendor lock-in; no way to mix models per stage; future non-tmux/Windows transport | -| Cost-weighted token budgets | Per-story budget counts cache reads at ~0.1x; raw totals displayed | Naive token caps misjudge real cost (cache reads dominate) | -| Non-invasive skill forks | Drives its own `bmad-loop-*` skill forks; reads `sprint-status.yaml` only | Modifying a user's standard BMAD install | -| Read-only TUI + launcher | Live dashboard over run-dir artifacts; launches detached runs | No visibility into what an unattended run is doing | -| Git worktree isolation (opt-in) | Each unit runs in its own worktree/branch (seeded with the adapters' gitignored MCP/CLI configs), merging back into the target locally; failed units kept for inspection | A long unattended run mutating the working tree you're actively using | +| Capability | What it does | Problem it addresses | +| ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | +| Deterministic control loop | Story selection, retries, gates, completion checks run in plain Python | LLM-as-orchestrator is nondeterministic, hard to debug, and costs tokens for control flow | +| Dual planning pipelines | Same loop from either `sprint-status.yaml` (sprint mode, default) or a typed `stories.yaml` dispatched by folder+id (stories mode, opt-in) | Sprint boards need `bmad-sprint-planning`; a `bmad-spec` Story Breakdown has no board | +| Per-story human checkpoints | Stories-mode `spec_checkpoint` pauses to review the plan before code; `done_checkpoint` pauses after the commit; both independent, both surfaced in the TUI | Coarse run-global gates can't ask for a plan review on _this_ story only | +| Trust-nothing verification | Checks on-disk artifacts (spec status, baseline-commit match, non-empty diff, sprint sync) + runs your test/lint commands before commit | Agents claim success without working code; broken builds slip through | +| Fresh-context adversarial review | Dev and review are separate sessions; review uses 4 parallel layers (Blind Hunter / Edge Case Hunter / Verification Gap / Intent Alignment) | Self-review anchoring bias; implementer marks own work correct | +| Hook-based transport | Coding-agent hooks write structured event files; skills write `result.json` | Brittle terminal pane-scraping | +| Resumable state machine | Every run is on-disk state, resumable after gate/escalation/crash | Long unattended runs lost to interruptions | +| Plateau-defer | Stuck stories are skipped, stashed, and the run continues | One unconvergeable story blocking a whole sprint | +| Typed escalations + resolve workflow | CRITICAL pauses + notifies; interactive resolve agent re-arms the story | Ambiguous specs silently producing wrong code | +| Deferred-work sweeps | Triages an append-only ledger against real code, bundles + executes | Split-off goals and review findings get lost | +| Multi-CLI adapter + profiles | Generic driver runs claude/codex/gemini/copilot/antigravity; per-stage overrides; TOML profiles; transport + process-lifecycle + hook-interpreter behind a pluggable OS-seam registry (tmux bundled; external backends via entry points; POSIX today) | Vendor lock-in; no way to mix models per stage; future non-tmux/Windows transport | +| Cost-weighted token budgets | Per-story budget counts cache reads at ~0.1x; raw totals displayed | Naive token caps misjudge real cost (cache reads dominate) | +| Non-invasive skill forks | Drives its own `bmad-loop-*` skill forks; reads `sprint-status.yaml` only | Modifying a user's standard BMAD install | +| Read-only TUI + launcher | Live dashboard over run-dir artifacts; launches detached runs | No visibility into what an unattended run is doing | +| Git worktree isolation (opt-in) | Each unit runs in its own worktree/branch (seeded with the adapters' gitignored MCP/CLI configs), merging back into the target locally; failed units kept for inspection | A long unattended run mutating the working tree you're actively using | --- @@ -125,7 +125,7 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se ### Multi-CLI / multi-agent support -- Generic adapter drives any CLI fitting the injection + hook-signal transport; CLI specifics live in declarative TOML profiles. Two independent axes: the **CLI** (`CodingCLIAdapter` + profile) and the **terminal transport** (`TerminalMultiplexer`) — two backends ship, tmux (default) and the opt-in herdr ([what to expect](multiplexer-backends.md)), behind a pluggable seam so a native-Windows backend can be added without touching the engine (see the [adapter authoring guide](adapter-authoring-guide.md#two-axes-cli-vs-transport)). +- Generic adapter drives any CLI fitting the injection + hook-signal transport; CLI specifics live in declarative TOML profiles. Two independent axes: the **CLI** (`CodingCLIAdapter` + profile) and the **terminal transport** (`TerminalMultiplexer`) — tmux ships bundled, and external backends (e.g. the [herdr adapter](https://github.com/pbean/bmad-loop-adapter-herdr)) co-install as packages that self-register ([how](multiplexer-backends.md)), behind a pluggable seam so a native-Windows backend can be added without touching the engine (see the [adapter authoring guide](adapter-authoring-guide.md#two-axes-cli-vs-transport)). - The OS is abstracted by a **registry of seams**, each selecting an implementation by platform (with a test-override env var) and extended by a single registration line: the terminal multiplexer (`register_multiplexer`, with availability-aware selection: env var → persisted `[mux] backend` via `bmad-loop mux set` → platform default → first available platform match), the process-lifecycle `ProcessHost` (`register_process_host` — `terminate`/`force_kill`/`is_alive`/`identity`), and the hook interpreter (`ProcessHost.hook_interpreter()`); `bmad-loop validate` runs a platform preflight over them. Porting to a new OS is new files + registrations, no core edits — see [Porting bmad-loop to a new OS](porting-to-a-new-os.md). - Supported, E2E-verified: `claude` (reference), `codex` (≥ 0.139), `gemini` (≥ 0.46), `copilot` (GitHub Copilot CLI ≥ 2026-02 — the `copilot` binary, not the VS Code extension; `agentStop` turn-end, `-i` interactive launch, `--allow-all-tools`; pin a capable model — the free GPT-5 mini default is unreliable for multi-step skills). - Experimental, probe-required: `antigravity` (Google's `agy` ≥ 1.0.16) — `-i` interactive launch, `Stop` turn-end hook (flat handler in `.agents/hooks.json`, no SessionStart/SessionEnd), `--dangerously-skip-permissions` for unattended runs; `usage_parser = "none"` until a transcript parser is written. Verify against your `agy` build with `probe-adapter antigravity`. @@ -174,7 +174,7 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se - `bmad-loop init` — install skills, hooks, policy, gitignore. - `bmad-loop validate` — preflight all prerequisites. -- `bmad-loop mux` — list registered terminal-multiplexer backends (platform · availability · version · which is selected and why); `mux set ` persists a machine-scoped choice into policy.toml (`--clear` reverts to auto, `--force` allows a name only registered on the target machine). Shipped backends: `tmux` (default) and `herdr` (opt-in — see [Terminal multiplexer backends](multiplexer-backends.md)). +- `bmad-loop mux` — list registered terminal-multiplexer backends (platform · availability · version · which is selected and why); `mux set ` persists a machine-scoped choice into policy.toml (`--clear` reverts to auto, `--force` allows a name only registered on the target machine). Bundled backend: `tmux`; external backends (e.g. the herdr adapter) register via the `bmad_loop.mux_backends` entry-point group — see [Terminal multiplexer backends](multiplexer-backends.md). - `bmad-loop run` — drive the dev → review → verify → commit loop. - `bmad-loop sweep` — triage + execute open deferred-work entries. - `bmad-loop resume ` — continue a paused/interrupted run. diff --git a/docs/README.md b/docs/README.md index 6231562..caf665c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,7 +8,7 @@ guides below go deeper, roughly in the order you'll need them. - **[Setup guide](setup-guide.md)** — install the tools, pick a CLI, initialize a project, pass preflight, and uninstall. - **[TUI guide](tui-guide.md)** — the dashboard: layout, key bindings, the settings editor, and troubleshooting. - **[Features & functionality](FEATURES.md)** — the full capability matrix and policy reference. -- **[Terminal multiplexer backends](multiplexer-backends.md)** — which backend drives your agent sessions (tmux by default, herdr opt-in), how selection works, and what changes from the operator's seat on herdr. +- **[Terminal multiplexer backends](multiplexer-backends.md)** — which backend drives your agent sessions (tmux bundled; externals like the [herdr adapter](https://github.com/pbean/bmad-loop-adapter-herdr) install as packages), how selection works, and how external backends arrive. ## Extending bmad-loop diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 54fe0ad..f81f414 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -9,7 +9,7 @@ Status legend: **planned** (agreed, not started) · **exploring** (shape still o ## Native Windows multiplexer backend -**Status:** planned · **Foundation:** the full platform-seam series landed (multiplexer registry + `BaseTmuxBackend` + `ProcessHost` + hook interpreter + validate preflight, v0.7.6; availability-aware backend selection + `bmad-loop mux`, #87; original seam v0.7.0) · a first non-tmux-family, native-Windows-capable backend — **herdr** — now ships end-to-end on POSIX (engine run path + TUI-launch surface; the win32 `agent.start` launch path is the remaining follow-up) +**Status:** planned · **Foundation:** the full platform-seam series landed (multiplexer registry + `BaseTmuxBackend` + `ProcessHost` + hook interpreter + validate preflight, v0.7.6; availability-aware backend selection + `bmad-loop mux`, #87; out-of-tree backend discovery via `bmad_loop.mux_backends` entry points; original seam v0.7.0) · a first non-tmux-family, native-Windows-capable backend — **herdr** — ships end-to-end on POSIX as the external [bmad-loop-adapter-herdr](https://github.com/pbean/bmad-loop-adapter-herdr) (the win32 `agent.start` launch path is tracked there) The orchestrator no longer fuses tmux into the engine. All session/window/pane operations go through a single `TerminalMultiplexer` ABC (`src/bmad_loop/adapters/multiplexer.py`), @@ -44,27 +44,34 @@ availability-aware with discriminating `available()` probes (psmux → `which("tmux") and not which("psmux")`) and an explicit `bmad-loop mux set ` tie-break. The third — **herdr** — has **shipped** end-to-end on POSIX (engine run path + -TUI-launch surface; operator guide: -[Terminal multiplexer backends](multiplexer-backends.md)) as a -different-binary-family backend: it implements `TerminalMultiplexer` fresh over herdr's -workspace/tab/pane model rather than subclassing `BaseTmuxBackend`, and probes -`which("herdr")`, so it is pairwise-discriminating against the tmux family by construction -(no `mux set` tie-break needed to tell them apart). It registers with `matches = True` (all -platforms), which keeps the door open for native Windows without disturbing POSIX. On POSIX, -tmux stays the platform default, so herdr activates only on an explicit -`BMAD_LOOP_MUX_BACKEND=herdr` / `bmad-loop mux set herdr`. On **win32**, `_PLATFORM_DEFAULTS` -is **untouched** — psmux remains the declared win32 default — so herdr is picked only by -**first-match** (the fourth precedence step) when it is `available()` and no higher-priority -backend is: once psmux (or tmux-windows) ships and registers for win32, the win32 default -reasserts and outranks herdr's first-match automatically, no code change. herdr's `exec` -launch is POSIX-only for now; the Windows launch path (herdr's `agent.start`) is a follow-up, +TUI-launch surface), and now lives **out-of-tree** as the reference external adapter, +[pbean/bmad-loop-adapter-herdr](https://github.com/pbean/bmad-loop-adapter-herdr) +(developed in-tree in #136/#137, extracted pre-release per core-team direction; it +co-installs with bmad-loop and self-registers via the `bmad_loop.mux_backends` +entry-point group). It is a different-binary-family backend: it implements +`TerminalMultiplexer` fresh over herdr's workspace/tab/pane model rather than +subclassing `BaseTmuxBackend`, and probes `which("herdr")`, so it is +pairwise-discriminating against the tmux family by construction (no `mux set` +tie-break needed to tell them apart). It registers with `matches = True` (all +platforms), which keeps the door open for native Windows without disturbing POSIX. On +POSIX, tmux stays the platform default, so herdr activates only on an explicit +`BMAD_LOOP_MUX_BACKEND=herdr` / `bmad-loop mux set herdr`. On **win32**, +`_PLATFORM_DEFAULTS` is **untouched** — psmux remains the declared win32 default — so +herdr is picked only by **first-match** (the fourth precedence step) when installed and +`available()` and no higher-priority backend is: once psmux (or tmux-windows) ships and +registers for win32, the win32 default reasserts and outranks herdr's first-match +automatically, no code change. herdr's `exec` launch is POSIX-only for now; the Windows +launch path (herdr's `agent.start`) is tracked in +[the adapter repo's issues](https://github.com/pbean/bmad-loop-adapter-herdr/issues), and the Phase-0 characterization must be re-run on a real Windows host before #92 is claimed. The seams are designed so each backend slots in as **new files plus one registration line, with no change to the adapters, `runs.py`, `tui/launch.py`, `probe.py`, `tui/data.py`, or `cli.py`'s `validate`** (`WindowsProcessHost` and its hook interpreter are already in place and registered; herdr proved this end to end — one registration line and one sanctioned -`probe.py` gate fix, zero portability-guard allowlist changes). The end-to-end port path — +`probe.py` gate fix, zero portability-guard allowlist changes; the extraction then proved +the **fully external** path — the same registration arriving via entry-point discovery, +zero core files). The end-to-end port path — both build options, the test-override env vars, and exactly what a native-Windows port costs — is documented in [Porting bmad-loop to a new OS](porting-to-a-new-os.md); the deep transport contract is in the diff --git a/docs/adapter-authoring-guide.md b/docs/adapter-authoring-guide.md index ad575cd..73b93ee 100644 --- a/docs/adapter-authoring-guide.md +++ b/docs/adapter-authoring-guide.md @@ -98,9 +98,11 @@ module-level `parse_target()` — or the backend's own **native id** (whatever y `target()`. tmux consumes the token natively (it coincides with tmux exact-match syntax), so `BaseTmuxBackend` passes it straight through. A native-id backend calls `parse_target()` first — `None` means "already a native id, use as-is", -otherwise resolve `(session, window)` yourself; `herdr_backend._parse_target` is -the worked example (workspace-by-label → tab-by-name → root pane, resolved lazily -at use time). You MAY override `target()` to emit native ids, but the token must +otherwise resolve `(session, window)` yourself; the herdr adapter's +`_parse_target` +([backend.py](https://github.com/pbean/bmad-loop-adapter-herdr/blob/main/src/bmad_loop_adapter_herdr/backend.py)) +is the worked example (workspace-by-label → tab-by-name → root pane, resolved +lazily at use time). You MAY override `target()` to emit native ids, but the token must stay a stable _by-name_ reference: core formats targets ahead of use (a parked window's return target, for one), so eager resolution to a live id goes stale — inheriting the default and resolving lazily is almost always right. @@ -116,9 +118,11 @@ backend. `window_alive` uses `list-windows` membership, not `display-message`, b the fastest way to see exactly what a `new_parked_window` or `session_options` must produce. -For the **implement-fresh** path, `adapters/herdr_backend.py` is the shipped worked -example — a backend over [herdr](https://herdr.dev), a cross-platform, -agent-aware workspace manager whose CLI is a different binary family from tmux. Its +For the **implement-fresh** path, the external herdr adapter +([pbean/bmad-loop-adapter-herdr](https://github.com/pbean/bmad-loop-adapter-herdr), +`src/bmad_loop_adapter_herdr/backend.py`) is the reference worked example — a +backend over [herdr](https://herdr.dev), a cross-platform, agent-aware workspace +manager whose CLI is a different binary family from tmux. Its mapping: a bmad-loop **session** is a herdr **workspace** (label == session name), a **window** is a **tab** (one shell pane, whose `root_pane.pane_id` is the native window id handed back), and the launched command runs via a typed `exec ` @@ -136,7 +140,7 @@ divergence (sidecar options, poller `pipe_pane`, no-op `detach_client`, the atta argv, the advisory geometry, the protocol-version policy) — the reference for what "implement fresh" costs when the host has no tmux-shaped CLI. The operator-facing view — what a herdr _user_ notices and does — is -[Terminal multiplexer backends](multiplexer-backends.md). +[the adapter's operator guide](https://github.com/pbean/bmad-loop-adapter-herdr/blob/main/docs/adapter-multiplexer-herdr.md). The hard part of a new profile isn't the TOML — it's the **facts that live in no doc**: the CLI's exact hook payload shape (field names and casing, whether diff --git a/docs/multiplexer-backends.md b/docs/multiplexer-backends.md index cad739a..586a33e 100644 --- a/docs/multiplexer-backends.md +++ b/docs/multiplexer-backends.md @@ -1,10 +1,11 @@ # Terminal multiplexer backends The orchestrator drives every agent session — and the TUI's launch/attach keys — through -a terminal multiplexer behind a pluggable seam (`TerminalMultiplexer`). Two backends ship -today: **tmux**, the default everywhere except native Windows, and **herdr**, opt-in. -This page is for operators: which backend is running, how to switch, and what changes -from your seat on herdr. Contributors porting a new backend should start with +a terminal multiplexer behind a pluggable seam (`TerminalMultiplexer`). One backend ships +in the box: **tmux**, the default everywhere except native Windows. Additional backends +install as separate packages and register themselves automatically. This page is for +operators: which backend is running, how to switch, and how external backends arrive. +Contributors porting a new backend should start with [Porting bmad-loop to a new OS](porting-to-a-new-os.md). ## Which backend is running, and how to switch @@ -27,62 +28,38 @@ version as part of the preflight. tmux is the reference backend: everything else in these docs — the `bmad-loop-` and `bmad-loop-ctl` session names, the `ctrl-b d` detach chord — describes tmux behavior. -It is required for launching, attaching, and driving runs; pure TUI observation works -without it. +While tmux is the selected backend it is required for launching, attaching, and driving +runs (an external backend brings its own session mechanism instead); pure TUI observation +works without any backend. -## herdr (opt-in) +## External backends -[herdr](https://herdr.dev) is a cross-platform, agent-aware terminal workspace manager — -a background server plus a CLI — whose agent-status sidebar is a natural fit for watching -runs. The backend is opt-in: tmux stays the default on POSIX, and herdr is selected only -when you ask for it. +Every backend beyond tmux is a separate package that you co-install with bmad-loop; it +registers itself through the `bmad_loop.mux_backends` entry-point group, so installation +is the entire setup — the new backend simply appears in `bmad-loop mux`, selectable and +persistable like a bundled one. With bmad-loop installed as a `uv` tool: ```bash -bmad-loop mux set herdr # persist for this machine -BMAD_LOOP_MUX_BACKEND=herdr bmad-loop run # …or force a single invocation +uv tool install "bmad-loop @ git+https://github.com/bmad-code-org/bmad-loop.git" \ + --with "" +bmad-loop mux # the new backend's row appears +bmad-loop mux set # persist for this machine ``` -A bmad-loop **session** becomes a herdr **workspace** (same name) and each **window** a -**tab**. From your seat, runs behave as on tmux: windows close when the agent's process -exits, parked `[bmad-loop exited — press enter]` windows wait for Enter, and the -TUI's Log tab, stall detection, and completion probes all work. One difference is -functional and needs a keypress from you; the rest are cosmetic or invisible. - -### Detach is manual: press `ctrl+b q` - -herdr has no CLI command to detach an attached terminal — detach exists only as herdr's -own keybinding, by default `ctrl+b q` (`ctrl+b ctrl+b` sends a literal `ctrl+b`). So the -one moment where bmad-loop would detach your terminal **for** you becomes manual: - -- **When it bites** — you attached from a plain terminal (or the TUI suspended itself to - attach you) to answer a sweep decision. On tmux, answering prints - `✓ decisions recorded — sweep continues in the background` and your shell or dashboard - comes back by itself. On herdr you see the same message, but your terminal **stays - attached** to the sweep's window. -- **What to do** — press `ctrl+b q`. Nothing is lost or waiting on you: the answer is - already recorded and the sweep runs unattended; the keypress only hands your terminal - back. A suspended TUI resumes the moment the attach exits. -- **What still returns automatically** — everything that rides a window _closing_: when a - parked window's command finishes and you press Enter, the pane closes and the blocking - attach exits on its own, exactly as on tmux. Attaching from **inside** herdr is a tab - switch and behaves like tmux's `switch-client`. - -### Differences you may notice - -| Where | On tmux | On herdr | What to do | -| ------------------------------ | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | -| Session logs (TUI Log tab) | output streams continuously (a tmux tee) | the pane is snapshotted about once a second, and only when its content changes — a static screen doesn't grow the log | nothing — stall detection and completion markers read the same log and work unchanged | -| Backend state | lives in the tmux server | lives in a lock-guarded JSON sidecar, `~/.bmad-loop/herdr-state.json` (override: `BMAD_LOOP_HERDR_STATE`) | leave it alone while runs are live; entries for gone workspaces are pruned automatically | -| Switching back after an attach | falls back to your most recent client | herdr has no "last client" — if the pane you came from is gone, focus stays put | switch tabs yourself | -| Detached window size | honors the requested geometry headless | advisory — a detached pane takes the size of whichever client attaches | nothing; the first attach may briefly reflow | -| Server lifecycle | any tmux command starts the server | bmad-loop starts the herdr server lazily, and only for operations that create or change something | nothing — `bmad-loop mux`, `validate`, and listings never resurrect a stopped server | - -### Current limits - -- **POSIX launches only for now.** The herdr backend launches windows through a POSIX - shell recipe, so it runs on Linux, macOS, and WSL today; the native-Windows launch path - is tracked in [the roadmap](ROADMAP.md#native-windows-multiplexer-backend) (#140). -- **Don't hand-create `bmad-loop-*` workspaces.** herdr allows duplicate workspace labels - (tmux session names are unique); bmad-loop refuses to create a duplicate itself and - always resolves the first match, but a hand-made duplicate can shadow a run's real - workspace. +The reference external backend is +**[bmad-loop-adapter-herdr](https://github.com/pbean/bmad-loop-adapter-herdr)** — +[herdr](https://herdr.dev) is a cross-platform, agent-aware terminal workspace manager +whose agent-status sidebar is a natural fit for watching runs. What changes from your +seat on herdr (one manual detach chord, `ctrl+b q`; polled logs; a JSON state sidecar) is +documented in +[that repo's operator guide](https://github.com/pbean/bmad-loop-adapter-herdr/blob/main/docs/adapter-multiplexer-herdr.md). + +Two operational notes that apply to any external backend: + +- **A broken adapter package never breaks bmad-loop.** If an installed backend fails to + import, selection proceeds without it; `bmad-loop mux` prints a + `warning: external backend '' failed to load: ` line and `validate` notes + the same. The fix is usually reinstalling or upgrading the adapter. +- **`mux set --force` covers late registrations.** A backend that only registers on some + other machine (where the package IS installed) can still be persisted in a shared + workflow with `bmad-loop mux set --force`. diff --git a/docs/porting-to-a-new-os.md b/docs/porting-to-a-new-os.md index 5d1254a..383f550 100644 --- a/docs/porting-to-a-new-os.md +++ b/docs/porting-to-a-new-os.md @@ -112,21 +112,23 @@ out-of-tree adapter is - **Implement `TerminalMultiplexer` fresh** when the host has no tmux-shaped CLI at all (e.g. a ConPTY-based window manager). You implement the full contract directly; `tmux_backend.py` is the reference for what each method must produce. - The shipped worked example is **herdr** (`adapters/herdr_backend.py`) — a - cross-platform, agent-aware workspace manager whose object model - (workspace/tab/pane) and CLI are a different binary family entirely, so it - subclasses nothing and maps the whole contract onto herdr verbs: a bmad-loop - session is a herdr **workspace** (label == session name), a window is a **tab** - (its `root_pane.pane_id` is the native window id), and the launched command runs - via a typed `exec ` so process-exit stays tmux-identical window death. - Where herdr has no analogue for a contract method — options, `pipe_pane`, - the parked-window return hop, detach — it emulates or degrades honestly (a - JSON sidecar for options, a polling tee for `pipe_pane`, a per-window return - file for the parked trailer, a no-op detach); that **degradation ledger** is - the module docstring, and it is the template for what "implement fresh" costs - in practice. (The operator-facing view of those degradations — what a herdr - _user_ notices and does — is - [Terminal multiplexer backends](multiplexer-backends.md).) + The reference worked example is the external **herdr adapter** + ([pbean/bmad-loop-adapter-herdr](https://github.com/pbean/bmad-loop-adapter-herdr), + `src/bmad_loop_adapter_herdr/backend.py`) — a cross-platform, agent-aware + workspace manager whose object model (workspace/tab/pane) and CLI are a + different binary family entirely, so it subclasses nothing and maps the whole + contract onto herdr verbs: a bmad-loop session is a herdr **workspace** (label + == session name), a window is a **tab** (its `root_pane.pane_id` is the native + window id), and the launched command runs via a typed `exec ` so + process-exit stays tmux-identical window death. Where herdr has no analogue + for a contract method — options, `pipe_pane`, the parked-window return hop, + detach — it emulates or degrades honestly (a JSON sidecar for options, a + polling tee for `pipe_pane`, a per-window return file for the parked trailer, + a no-op detach); that **degradation ledger** is the module docstring, and it + is the template for what "implement fresh" costs in practice. (The + operator-facing view of those degradations — what a herdr _user_ notices and + does — is + [the adapter's operator guide](https://github.com/pbean/bmad-loop-adapter-herdr/blob/main/docs/adapter-multiplexer-herdr.md).) `available()` gates whether the backend is usable on the current host (e.g. its binary is on PATH); the optional `version()` feeds the diagnostic dump and the @@ -163,14 +165,14 @@ available when only one actually drives the installed binary. A host with an ambiguous install resolves it explicitly: `bmad-loop mux set `. A backend from a **different binary family** sidesteps this problem entirely. -The shipped `herdr` backend probes `shutil.which("herdr")` — a distinct binary +The external herdr adapter probes `shutil.which("herdr")` — a distinct binary that no tmux-family backend claims — so it is **pairwise-discriminating by construction**: it can never report available on a host where only tmux is installed, and vice versa, without any explicit tie-break. That `available()` must stay a pure PATH lookup (it is called by `detect_multiplexers()` on every -listing) — herdr in particular **never** probes or starts its background server -from `available()`, `version()`, or the constructor; server autostart is lazy, -confined to the mutating operations that actually need it. +listing) — the herdr adapter in particular **never** probes or starts its +background server from `available()`, `version()`, or the constructor; server +autostart is lazy, confined to the mutating operations that actually need it. **Deep contract →** [adapter authoring guide: the transport contract for a backend author](adapter-authoring-guide.md#the-transport-contract-for-a-backend-author). diff --git a/docs/setup-guide.md b/docs/setup-guide.md index 226cac7..658099a 100644 --- a/docs/setup-guide.md +++ b/docs/setup-guide.md @@ -21,10 +21,11 @@ of the README. - **Python 3.11+** and a supported coding CLI (`claude` by default). - **A terminal multiplexer** — the orchestrator drives agent sessions through a terminal - multiplexer: **tmux** is the default backend, and **herdr** ships as an opt-in - alternative (`bmad-loop mux set herdr` — see - [Terminal multiplexer backends](multiplexer-backends.md) for what changes from the - operator's seat, chiefly a manual `ctrl+b q` detach). A backend is required for + multiplexer: **tmux** is the bundled default, and additional backends install as + separate packages that register themselves (e.g. the + [herdr adapter](https://github.com/pbean/bmad-loop-adapter-herdr), then + `bmad-loop mux set herdr` — see + [Terminal multiplexer backends](multiplexer-backends.md)). A backend is required for launching, attaching, and driving runs (pure TUI observation works without one). The multiplexer sits behind a pluggable seam (`TerminalMultiplexer`), so a native-Windows backend can be added later without changing the engine — contributors should start with diff --git a/docs/tui-guide.md b/docs/tui-guide.md index 62f5e19..1a0bbe7 100644 --- a/docs/tui-guide.md +++ b/docs/tui-guide.md @@ -15,7 +15,7 @@ bmad-loop tui # or: bmad-loop tui --project /path/to/project ``` `--project` defaults to the current directory. The selected terminal-multiplexer -backend — tmux by default, herdr opt-in; see +backend — tmux by default; external backends install separately, see [Terminal multiplexer backends](multiplexer-backends.md) — must be on PATH for the launch/attach keys (`r` `s` `e` `a`); pure observation works without it. (WSL counts as Linux, so tmux works there unchanged; native Windows awaits a Windows-capable backend.) @@ -370,7 +370,8 @@ disambiguate the spec. When it has recorded a resolution, the same window prompt `re-arm and resume run ? [y/N]`; answer `y` and it re-arms the story (`escalated → pending`, spec status reset to `ready-for-dev`) and resumes the run in place — a clean rebuild against the corrected spec, then on through the rest -of the sprint. Detach (`Ctrl-b d`; herdr: `ctrl+b q`) to return to the dashboard, which observes the +of the sprint. Detach (`Ctrl-b d` on tmux; other backends have their own chord — +herdr's is `ctrl+b q`) to return to the dashboard, which observes the resumed run like any other. Exiting the agent without recording a resolution leaves the story escalated and the run paused — the safe default. @@ -422,10 +423,10 @@ the run header name the stage so you know which viewer `p` will open. If the TUI itself is running inside tmux, attach uses `switch-client` — the TUI keeps running and you switch back with your usual tmux client commands. Outside tmux, the TUI suspends, runs `tmux attach`, and resumes when you -detach (`ctrl-b d`). On the herdr backend the same two paths apply (a tab switch -inside herdr, a suspended blocking attach outside); the detach chord is `ctrl+b q`, -and one hand-back is manual — see -[Terminal multiplexer backends](multiplexer-backends.md#detach-is-manual-press-ctrlb-q). +detach (`ctrl-b d`). External backends follow the same two paths with their own +chords and caveats — on the herdr adapter the detach chord is `ctrl+b q` and one +hand-back is manual; see +[its operator guide](https://github.com/pbean/bmad-loop-adapter-herdr/blob/main/docs/adapter-multiplexer-herdr.md). ### Answering a sweep decision @@ -439,8 +440,9 @@ toast. Then: 2. Answer the prompt (build / close / keep-open, with the triage recommendation shown). 3. Detach with `ctrl-b d`. (On tmux this step is belt-and-suspenders — answering - already hands your terminal back. On herdr the hand-back cannot be automatic: - press `ctrl+b q`.) + already hands your terminal back. On backends without a detach verb — e.g. + the herdr adapter — the hand-back cannot be automatic: press its detach + chord, `ctrl+b q` on herdr.) The banner clears on the next poll after the sweep journals anything further (the answer is recorded as a `decision:` line in `deferred-work.md`). Sweeps diff --git a/src/bmad_loop/adapters/herdr_backend.py b/src/bmad_loop/adapters/herdr_backend.py deleted file mode 100644 index 2933a7b..0000000 --- a/src/bmad_loop/adapters/herdr_backend.py +++ /dev/null @@ -1,1294 +0,0 @@ -"""Herdr backend for the terminal-multiplexer seam. - -Herdr (https://herdr.dev) is a cross-platform, agent-aware terminal workspace -manager: a headless background server plus a CLI, talking over a Unix domain -socket on POSIX and a **named pipe on Windows**. Implementing the -:class:`~.multiplexer.TerminalMultiplexer` contract on top of it gives bmad-loop a -native-Windows-capable transport *and* herdr's agent-status sidebar for watching -runs — with no core edits (a new backend is a new file plus one registration -line; see ``docs/porting-to-a-new-os.md``). - -Unlike the tmux family, this backend does **not** subclass the tmux base: herdr's -object model and CLI are a different binary family, so it implements the contract -fresh. The mapping (characterized against herdr 0.7.3 / protocol 16 — see the -plan's Phase-0 Findings): - -- bmad-loop session -> herdr **workspace** (label == the session name) -- bmad-loop window -> herdr **tab** (one shell pane); the native window id we - hand back is that tab's ``root_pane.pane_id`` (``w1:p1``-shaped) -- the launched command runs via a typed ``exec `` into that pane - (``pane run`` = type + Enter atomically) so process-exit == pane-close == - tab-close == tmux-identical window death - -All herdr subprocess I/O is funnelled through :class:`_HerdrClient` (the ``_run`` -/ ``_herdr`` / ``_herdr_json`` primitives plus the server lifecycle), so a future -socket transport can replace it without touching :class:`HerdrMultiplexer`. - -**Degradation ledger (PR 1 = run path; PR 2 added the TUI-launch surface):** - -- ``pipe_pane`` has no herdr ``pipe-pane``/tee to hand off to, so it runs a - per-window :class:`_PanePoller` daemon that snapshots ``pane read`` into the - log whenever the pane content changes (content-hash-gated — the CLI - ``revision`` is unusable, it stays 0). This drives the two log consumers a - tmux tee would: ``generic._log_activity_key`` re-arms the dev-stall grace on - log growth, and ``probe`` finds completion markers in the log. -- ``new_parked_window`` types a POSIX ``exec sh -c '; ec=$?; echo - ; read -r; '`` recipe into a fresh tab, tmux-identical from - the operator's seat. The tmux trailer reads the return option live via - ``show-options``; herdr window options live in OUR sidecar, which a one-line - ``sh`` trailer can't query — so the option methods mirror the parked window's - return option into a per-window **return file** the trailer ``cat``\\ s - (resolved to a *tab id* at write time, keeping the trailer a dumb - ``herdr tab focus``). POSIX-only, like the ``exec`` launch (pwsh dialect is - the Windows follow-up). -- ``attach_target_argv`` accepts both target families (native pane id and the - seam-canonical ``=session[:window]`` tokens core formats via - ``TerminalMultiplexer.target`` — see ``_parse_target``): outside herdr it - returns ``["herdr", "terminal", - "attach", ]`` (which blocks, and exits when the pane closes); - inside a herdr pane it returns the fire-and-forget ``["herdr", "tab", - "focus", ]`` — the switch-client move, mirroring tmux's in-``TMUX`` - branch. Raises ``HerdrError`` with operator guidance when unresolvable. -- ``switch_client`` is a ``tab focus`` on the target's tab (focusing a tab in - another workspace flips workspace focus too — verified 0.7.3). herdr has no - "last client" concept, so ``last_fallback`` has nothing to fall back to and - a failed switch is honestly ``False``. -- ``detach_client`` is a no-op — herdr detach is a keybinding, with no CLI - verb. Consequence: the *post-exit* detach return is full-fidelity anyway - (ending the parked source closes the pane, which ends a blocking ``terminal - attach`` client), but the *mid-process* ``RETURN_DETACH`` hand-back - (``launch.return_attached_client`` while a plain-terminal client watches) - degrades to "stay attached until the parked window closes". -- Session/window **options** have no native herdr equivalent, so they live in a - cross-process **sidecar** JSON (``~/.bmad-loop/herdr-state.json``, override - ``BMAD_LOOP_HERDR_STATE``), written via :func:`platform_util.atomic_replace` - with every read-modify-write cycle serialized by an OS advisory lock on a - sibling ``.lock`` file (:func:`platform_util.file_lock`) — the write - serialization the tmux server gave ``set-option`` for free; entries for a - workspace that is gone are pruned on the next enumeration. Window-option keys - are **normalized to the native pane id** (``_parse_target``) so a write by - ``=session:name`` target and a read by native id agree. -- ``new_session`` geometry (cols/lines) is **advisory** — herdr exposes no - absolute headless resize; a detached pane takes an attaching client's size. -- Protocol policy: fail below :data:`SUPPORTED_PROTOCOL`, warn once above. - -See :mod:`.multiplexer` for the contract and the raisers-vs-sentinels split. -""" - -from __future__ import annotations - -import hashlib -import json -import os -import shlex -import shutil -import subprocess -import threading -import time -import warnings -from pathlib import Path - -from .. import platform_util -from .multiplexer import MultiplexerError, TerminalMultiplexer, parse_target - -HERDR_TIMEOUT_S = 30 -# The herdr server wire protocol this backend was written against (0.7.3). Read -# back from `herdr status --json` -> .server.protocol on the first server op. -SUPPORTED_PROTOCOL = 16 -# How long _start_server waits for a freshly spawned `herdr server` to report -# itself running before giving up. Module-level so tests can shrink it. -SERVER_START_TIMEOUT_S = 5.0 -_SERVER_POLL_S = 0.1 - -# pipe_pane poller cadence: how often a _PanePoller re-reads its pane, and how -# many CONSECUTIVE server-answered pane-not-founds retire it (the pane vanished -# on process exit — see Phase-0 O1). Module-level so tests can shrink them. -POLL_INTERVAL_S = 1.0 -POLL_NOT_FOUND_LIMIT = 3 - -# Env var herdr injects into every pane it spawns; its presence is how the -# current_* accessors know this process is running inside a herdr pane. -_HERDR_ENV_MARKER = "HERDR_ENV" - -# Per-window option value (vs a pane id) telling the parked trailer to detach -# the client rather than switch it — the same protocol constant the tmux family -# uses (tmux_base.PARKED_RETURN_DETACH); duplicated so this backend stays free -# of tmux imports. -PARKED_RETURN_DETACH = "detach" - -# Private sidecar window-entry key recording which user option is a parked -# window's return option (new_parked_window writes it; set/unset_window_option -# consult it to mirror that option into the window's return file). "~"-prefixed -# so it can never collide with a tmux-style "@" user option. -_PARKED_RETURN_KEY = "~parked_return_opt" - - -class HerdrError(MultiplexerError): - """A herdr transport op failed — the seam type, so call sites catch it via - :class:`~.multiplexer.MultiplexerError` without importing this backend.""" - - -# --------------------------------------------------------------- sidecar state -# -# herdr has no per-session/per-window user options (tmux's `set-option -t`), so -# the @bmad_project prune tag and the @bmad_return_pane launch marker are kept in -# a small JSON file shared across processes. Keyed by the durable identities -# bmad-loop already uses: session name (== workspace label) and native window id -# (== pane id). Reads tolerate a missing/corrupt file; writes go through -# atomic_replace so a concurrent reader never sees a torn file, and every -# read-modify-write cycle holds an exclusive OS advisory lock on a sibling -# `.lock` file so concurrent writers (engine tagging a new session vs the -# TUI/CLI's prune rewrite) never lose each other's updates — the serialization -# the tmux server gave `set-option` for free. Plain reads stay lock-free. -# -# Residual (deliberate): session_options takes its workspace-liveness snapshot -# OUTSIDE the lock — never hold it across a subprocess call with a 30 s timeout -# — so a session created+tagged between that enumeration and the prune can still -# lose its fresh tag. Consequence is bounded: runs.prunable_sessions falls back -# to run-dir ownership + engine liveness for untagged sessions. - - -def _state_path() -> Path: - override = os.environ.get("BMAD_LOOP_HERDR_STATE") - if override: - return Path(override) - return Path.home() / ".bmad-loop" / "herdr-state.json" - - -def _state_lock(): - """The advisory lock guarding sidecar read-modify-write cycles. Blocking: - holders only do file I/O, so waits are micro-scale (bounded ~10 s on win32 - by msvcrt's built-in retry). Acquisition failure is an ``OSError``, riding - each caller's existing raiser/sentinel split.""" - path = _state_path() - return platform_util.file_lock(path.with_name(path.name + ".lock")) - - -def _load_state() -> dict: - """The sidecar as a ``{"sessions": {...}, "windows": {...}}`` dict. A missing - or unreadable/corrupt file reads as empty — never raises.""" - try: - raw = _state_path().read_text(encoding="utf-8") - data = json.loads(raw) - except (OSError, json.JSONDecodeError): - data = {} - if not isinstance(data, dict): - data = {} - sessions = data.get("sessions") - windows = data.get("windows") - return { - "sessions": sessions if isinstance(sessions, dict) else {}, - "windows": windows if isinstance(windows, dict) else {}, - } - - -def _save_state(state: dict) -> None: - """Persist the sidecar atomically. Propagates ``OSError`` — a *raiser* caller - (set_session_option) wraps it as :class:`HerdrError`; *sentinel* callers - swallow it.""" - path = _state_path() - path.parent.mkdir(parents=True, exist_ok=True) - tmp = path.with_name(f"{path.name}.tmp{os.getpid()}") - try: - tmp.write_text(json.dumps(state, indent=2, sort_keys=True), encoding="utf-8") - platform_util.atomic_replace(tmp, path) - except OSError: - # Don't leave a half-written temp behind on a failed swap. - try: - tmp.unlink() - except OSError: - pass - raise - - -# ------------------------------------------------------------- return files -# -# A parked window's trailing shell must read the CURRENT value of its return -# option at park-exit time. tmux trailers run `show-options` against the live -# server; herdr window options live in the sidecar JSON above, which a one-line -# sh trailer cannot sensibly parse — so the option methods mirror the parked -# return option into a tiny per-window file the trailer `cat`s. The path is -# deterministic from the sidecar location, so the window creator (one process), -# the attach-time writer (another), and the in-window trailer (a third) all -# agree with no coordination. - - -def _return_file(pane_id: str) -> Path: - # ':' is not a legal filename character on Windows, hence the '-' mapping. - state = _state_path() - return state.with_name(f"herdr-return-{pane_id.replace(':', '-')}") - - -def _write_return_file(pane_id: str, content: str) -> None: - """Best-effort: the option write already succeeded, and the mirror only - serves the trailer — a failure here must not fail the (sentinel) caller.""" - path = _return_file(pane_id) - try: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(content + "\n", encoding="utf-8") - except OSError: - pass - - -def _remove_return_file(pane_id: str) -> None: - try: - _return_file(pane_id).unlink() - except OSError: - pass - - -def _parked_source(argv: list[str], pane_id: str) -> str: - """The POSIX sh source a parked window runs. Typed via ``pane run``, so it - must be a single line; it passes through the pane's default shell before - ``exec sh -c`` takes over, so it sticks to quoting every POSIX family - member AND fish accept (single-quoted payloads with the shlex ``'"'"'`` - concatenation dance). - - After the park, the trailer hands an attached client back to its origin: - the return file holds a tab id to focus (one ``tab focus`` also flips - workspace focus — verified 0.7.3) or :data:`PARKED_RETURN_DETACH`, where - doing NOTHING is correct — ending the source closes the pane, and a - ``herdr terminal attach`` client exits when its pane closes (verified - 0.7.3).""" - ret = shlex.quote(str(_return_file(pane_id))) - return ( - f"{shlex.join(argv)}; ec=$?; " - f'echo "[bmad-loop exited $ec — press enter]"; ' - "read -r; " - f"ret=$(cat {ret} 2>/dev/null); rm -f {ret}; " - f'if [ -n "$ret" ] && [ "$ret" != {PARKED_RETURN_DETACH} ]; then ' - f'herdr tab focus "$ret" >/dev/null 2>&1 || true; fi' - ) - - -# ----------------------------------------------------------------- transport -# -# The ONE place herdr is spawned. Everything protocol-specific about the CLI wire -# format lives above this line as argv the multiplexer builds; the client only -# spawns, decodes, and enforces the protocol/server lifecycle. A socket transport -# would reimplement these primitives and leave HerdrMultiplexer untouched. - - -class _HerdrClient: - """Isolates all herdr subprocess I/O behind three spawn primitives plus the - server-lifecycle guard, so a socket transport can swap in later.""" - - #: Output decoding for captured herdr text; None = locale default (POSIX), - #: a Windows leaf could set "utf-8". Mirrors BaseTmuxBackend._ENCODING. - _ENCODING: str | None = None - - def __init__(self) -> None: - # Set once the server is confirmed up AND its protocol checked, so the - # per-op ensure_server() is a cheap no-op for the rest of the run. - self._ensured = False - self._proto_warned = False - - def _run( - self, - argv: list[str], - *, - check: bool = True, - env: dict[str, str] | None = None, - ) -> subprocess.CompletedProcess[str]: - """The ONE place herdr is spawned. ``argv`` are the args after ``herdr``. - - With ``check=True`` a non-zero exit raises :class:`HerdrError`; with - ``check=False`` the completed process is returned as-is so callers can - apply their own tolerant / server-answered-vs-transport handling. A - timeout / missing binary always propagates raw (``TimeoutExpired`` / - ``OSError``) — the seam-honesty guarantee is enforced one level up, in the - helpers and contract methods, exactly as the tmux base does.""" - proc = subprocess.run( - ["herdr", *argv], - capture_output=True, - text=True, - encoding=self._ENCODING, - env=env, - timeout=HERDR_TIMEOUT_S, - ) - if check and proc.returncode != 0: - raise HerdrError(f"herdr {' '.join(argv[:2])} failed: {proc.stderr.strip()}") - return proc - - def _herdr(self, *args: str) -> str: - """Strict spawn: non-zero already raises inside :meth:`_run`; a timeout / - missing binary is trapped here and re-raised as the seam type. Returns the - stripped stdout.""" - try: - return self._run(list(args), check=True).stdout.strip() - except (subprocess.TimeoutExpired, OSError) as exc: - raise HerdrError(f"herdr {args[0] if args else ''} failed: {exc}") from exc - - def _herdr_json(self, *args: str) -> dict: - """Strict spawn of a structured herdr command, returning its ``result`` - object. herdr prints a single-line envelope ``{"id":..,"result":{..}}`` by - default (no ``--json`` flag — passing one errors); a non-JSON body is a - transport/version fault -> :class:`HerdrError`.""" - out = self._herdr(*args) - try: - envelope = json.loads(out) - except json.JSONDecodeError as exc: - raise HerdrError(f"herdr {args[0] if args else ''} returned non-JSON: {out!r}") from exc - result = envelope.get("result") if isinstance(envelope, dict) else None - return result if isinstance(result, dict) else {} - - # ---- server lifecycle - - def ensure_server(self) -> None: - """Idempotently guarantee a running, protocol-compatible server. Called - from the session-creation ops (never from diagnostics like ``list_sessions`` - or ``detect_multiplexers``, which must not spawn a server). No CLI verb - autostarts the server, so this is mandatory before the first mutating op. - - Warm path (server already up) is a SINGLE ``status`` read — the ``_ensured`` - flag then makes every later mutating op skip the probe entirely.""" - if self._ensured: - return - server = self._server() # one status read: gives both running and protocol - if not server.get("running"): - self._start_server() # polls status until running - server = self._server() # re-read for the post-start protocol - self._check_protocol(server) - self._ensured = True - - def _status(self) -> dict: - """Parsed ``herdr status --json`` (rc=0 even when the server is down — a - safe probe). Raises :class:`HerdrError` on a timeout / missing binary.""" - try: - proc = self._run(["status", "--json"], check=False) - except (subprocess.TimeoutExpired, OSError) as exc: - raise HerdrError(f"herdr status failed: {exc}") from exc - try: - data = json.loads(proc.stdout or "{}") - except json.JSONDecodeError as exc: - raise HerdrError(f"herdr status returned non-JSON: {proc.stdout!r}") from exc - return data if isinstance(data, dict) else {} - - def _server(self) -> dict: - server = self._status().get("server") - return server if isinstance(server, dict) else {} - - def _server_running(self) -> bool: - return bool(self._server().get("running")) - - def _start_server(self) -> None: - """Spawn a detached ``herdr server`` and poll until it reports running.""" - try: - subprocess.Popen( # noqa: S603 - fixed argv, no shell - ["herdr", "server"], - stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - **platform_util.detach_kwargs(), - ) - except OSError as exc: - raise HerdrError(f"could not start herdr server: {exc}") from exc - deadline = time.monotonic() + SERVER_START_TIMEOUT_S - while time.monotonic() < deadline: - if self._server_running(): - return - time.sleep(_SERVER_POLL_S) - raise HerdrError(f"herdr server did not report running within {SERVER_START_TIMEOUT_S}s") - - def _check_protocol(self, server: dict) -> None: - """Fail below :data:`SUPPORTED_PROTOCOL`, warn once above. ``server`` is the - ``.server`` object from a ``status --json`` read (``protocol`` is ``null`` - when down — read the SERVER's, not the binary-local ``api schema``).""" - proto = server.get("protocol") - if not isinstance(proto, int): - return # server down / unknown — the following op fails loudly on its own - if proto < SUPPORTED_PROTOCOL: - raise HerdrError( - f"herdr server protocol {proto} < required {SUPPORTED_PROTOCOL}; upgrade herdr" - ) - if proto > SUPPORTED_PROTOCOL and not self._proto_warned: - self._proto_warned = True - warnings.warn( - f"herdr server protocol {proto} newer than tested {SUPPORTED_PROTOCOL}; " - "proceeding but behavior is unverified", - stacklevel=2, - ) - - -def _error_code(proc: subprocess.CompletedProcess[str]) -> str | None: - """The server-answered error ``code`` from a non-zero result, or None when the - failure was transport-level (non-JSON stderr == server down / unreachable). - - herdr's error bodies are ``{"error":{"code","message"},...}`` for most verbs - and a bare ``{"code","message"}`` for ``pane read`` — both handled.""" - try: - payload = json.loads((proc.stderr or "").strip()) - except (json.JSONDecodeError, ValueError): - return None - if not isinstance(payload, dict): - return None - err = payload.get("error", payload) - if isinstance(err, dict) and isinstance(err.get("code"), str): - return err["code"] - return None - - -# ------------------------------------------------------------- pipe_pane poller -# -# herdr has no `pipe-pane`/tee (nor any push stream on the CLI transport), so the -# tmux "append the pane's output to a log file" contract is emulated by polling -# `pane read` and appending a fresh snapshot whenever the pane's content changes. -# Two consumers read that log and MUST see it grow while a session is producing -# output: generic._log_activity_key (mtime,size) re-arms the dev-stall grace, and -# probe scans the log for completion markers. A #85-style no-op would leave the -# log flat and mis-stall a long silent-but-working turn. - -_PANE_GONE = object() # sentinel: `pane read` was answered with pane_not_found - - -class _PanePoller(threading.Thread): - """A daemon thread that tees one herdr pane into a log file by polling. - - Every :data:`POLL_INTERVAL_S` it reads the pane's ``recent-unwrapped`` text - (unwrapped so herdr's narrow default width can't split a marker across lines) - and appends it to the log **only when the content changed** — content-hash - gated, because the CLI ``revision`` is unusable (it stays 0 across both - normal-buffer growth and alt-screen repaints; see the Phase-0 Findings). A - static screen therefore stops growing the log, so a genuinely idle session - can still stall; an actively-repainting one keeps re-arming the grace window. - - Retired by :meth:`stop` (kill_window / kill_session) or, on its own, after - :data:`POLL_NOT_FOUND_LIMIT` consecutive server-answered ``pane_not_found`` - reads — the pane vanished when its process exited (Phase-0 O1). A transport - hiccup (couldn't ask) is neither growth nor death: the tick is skipped and - the not-found streak is left intact, exactly as the wait loop treats a probe - error as "not proof of death".""" - - def __init__( - self, - client: _HerdrClient, - pane_id: str, - log_file: Path, - *, - interval_s: float | None = None, - not_found_limit: int | None = None, - ) -> None: - super().__init__(daemon=True, name=f"herdr-poll-{pane_id}") - self._client = client - self._pane_id = pane_id - self._log_file = Path(log_file) - # Resolve the cadence from the module globals at construction (not as - # signature defaults) so a test can shrink POLL_* before starting one. - self._interval_s = POLL_INTERVAL_S if interval_s is None else interval_s - self._not_found_limit = POLL_NOT_FOUND_LIMIT if not_found_limit is None else not_found_limit - self._stop_event = threading.Event() - self._last_hash: str | None = None - - def stop(self) -> None: - """Signal the thread to exit. Returns immediately: the event wakes the - interval sleep at once, but an in-flight ``pane read`` still finishes - first (the thread is a daemon, so a hung read never blocks shutdown).""" - self._stop_event.set() - - def prime(self) -> bool: - """Do one synchronous read before the thread starts. Returns True (and - logs the first snapshot) if the pane answered with text; False if it is - already gone or unreachable — the caller then declines to spin up a - thread, which is how :meth:`HerdrMultiplexer.pipe_pane` stays tolerant of - a pane that died on launch (probe.py depends on that tolerance).""" - snapshot = self._read_snapshot() - if isinstance(snapshot, str): - self._record(snapshot) - return True - return False - - def run(self) -> None: - not_found = 0 - # wait() returns True the instant stop() fires (-> exit) or False on - # timeout (-> poll). prime() already captured t0, so the first poll is - # one interval in — no immediate re-read. - while not self._stop_event.wait(self._interval_s): - snapshot = self._read_snapshot() - if snapshot is _PANE_GONE: - not_found += 1 - if not_found >= self._not_found_limit: - return - elif isinstance(snapshot, str): - not_found = 0 - self._record(snapshot) - # else: transport hiccup — skip this tick, keep the not-found streak. - - def _read_snapshot(self) -> str | object | None: - """One ``pane read``: the raw pane text (str), :data:`_PANE_GONE` when the - server answered ``pane_not_found``, or None on a transport failure.""" - try: - proc = self._client._run( - ["pane", "read", self._pane_id, "--source", "recent-unwrapped"], - check=False, - ) - except (subprocess.SubprocessError, OSError): - return None - if proc.returncode == 0: - return proc.stdout - if _error_code(proc) == "pane_not_found": - return _PANE_GONE - return None # non-JSON `Error: Os` etc. — unreachable, retry next tick - - def _record(self, text: str) -> None: - digest = hashlib.sha256(text.encode("utf-8", "replace")).hexdigest() - if digest == self._last_hash: - return # unchanged screen: not activity, don't grow the log - self._last_hash = digest - if text.strip(): # a blank repaint isn't worth a log line - self._append(text) - - def _append(self, text: str) -> None: - # Append-only so the log's inode/size grow monotonically (the activity - # signal). A write failure must never crash the tee thread. - if not text.endswith("\n"): - text += "\n" - try: - self._log_file.parent.mkdir(parents=True, exist_ok=True) - with self._log_file.open("a", encoding="utf-8") as handle: - handle.write(text) - except OSError: - pass - - -class HerdrMultiplexer(TerminalMultiplexer): - """herdr backend implementing the full :class:`TerminalMultiplexer` contract. - - The constructor does **no I/O** — ``detect_multiplexers`` instantiates every - registered backend, so ``available()`` (a plain ``shutil.which``) is the only - host probe, and the server is only ever touched by the mutating ops via - :meth:`_HerdrClient.ensure_server`.""" - - def __init__(self) -> None: - self._client = _HerdrClient() - # Live pipe_pane tees, keyed by native window id (== pane id). Mutated - # only from the caller's thread (pipe_pane / kill_*); the poller threads - # never touch it. The lock is defensive hygiene, not a hot path. - self._pollers: dict[str, _PanePoller] = {} - self._pollers_lock = threading.Lock() - - # -------------------------------------------------- enumeration helpers - - def _list_workspaces_strict(self) -> list[dict]: - """Every workspace; raises on ANY failure (transport or server-down). - - This is the liveness-honest enumeration: a successful ``workspace list`` - proves the server answered, so an absent label is honestly "no such - workspace", never "couldn't ask". ``workspace list`` has no not-found - case, so any non-zero exit is a transport failure -> raise.""" - try: - proc = self._client._run(["workspace", "list"], check=False) - except (subprocess.TimeoutExpired, OSError) as exc: - raise HerdrError(f"herdr workspace list failed: {exc}") from exc - if proc.returncode != 0: - raise HerdrError(f"herdr workspace list failed: {proc.stderr.strip()}") - return _envelope_items(proc.stdout, "workspaces", strict=True) - - def _list_workspaces_tolerant(self) -> list[dict] | None: - """Every workspace, or None when the query could not be answered (herdr - missing / server down / transport failure). Used by the diagnostic - sentinels, which must never start a server or raise.""" - if not shutil.which("herdr"): - return None - try: - proc = self._client._run(["workspace", "list"], check=False) - except (subprocess.SubprocessError, OSError): - return None - if proc.returncode != 0: - return None - return _envelope_items(proc.stdout, "workspaces", strict=False) - - def _list_panes_strict(self) -> list[dict]: - """Every pane across all workspaces; raises on failure. Never - ``pane list --workspace `` — that RAISES ``workspace_not_found`` - rather than returning [], so we enumerate all panes and filter in-process.""" - try: - proc = self._client._run(["pane", "list"], check=False) - except (subprocess.TimeoutExpired, OSError) as exc: - raise HerdrError(f"herdr pane list failed: {exc}") from exc - if proc.returncode != 0: - raise HerdrError(f"herdr pane list failed: {proc.stderr.strip()}") - return _envelope_items(proc.stdout, "panes", strict=True) - - def _list_tabs_strict(self) -> list[dict]: - """Every tab across all workspaces; raises on failure. Never - ``tab list --workspace `` — that RAISES ``workspace_not_found`` - (the pane-list trap again); enumerate all tabs and filter in-process.""" - try: - proc = self._client._run(["tab", "list"], check=False) - except (subprocess.TimeoutExpired, OSError) as exc: - raise HerdrError(f"herdr tab list failed: {exc}") from exc - if proc.returncode != 0: - raise HerdrError(f"herdr tab list failed: {proc.stderr.strip()}") - return _envelope_items(proc.stdout, "tabs", strict=True) - - def _list_tabs_tolerant(self) -> list[dict] | None: - """Every tab, or None when the query could not be answered.""" - if not shutil.which("herdr"): - return None - try: - proc = self._client._run(["tab", "list"], check=False) - except (subprocess.SubprocessError, OSError): - return None - if proc.returncode != 0: - return None - return _envelope_items(proc.stdout, "tabs", strict=False) - - def _workspace_row(self, label: str, *, strict: bool) -> dict | None: - """First-match resolution of a session name to its workspace row. herdr - allows DUPLICATE labels (tmux session names are unique; herdr's are not), - so callers must tolerate more than one and take the first.""" - workspaces = self._list_workspaces_strict() if strict else self._list_workspaces_tolerant() - if not workspaces: - return None - for ws in workspaces: - if ws.get("label") == label: - return ws if isinstance(ws.get("workspace_id"), str) else None - return None - - def _workspace_id(self, label: str, *, strict: bool) -> str | None: - row = self._workspace_row(label, strict=strict) - return row["workspace_id"] if row is not None else None - - def _tab_root_pane(self, tab_id: str, *, strict: bool) -> str | None: - """The tab's first pane — bmad-loop windows are single-pane tabs, so this - is the pane that IS the window.""" - try: - panes = self._list_panes_strict() - except MultiplexerError: - if strict: - raise - return None - for pane in panes: - if pane.get("tab_id") == tab_id and isinstance(pane.get("pane_id"), str): - return pane["pane_id"] - return None - - def _parse_target(self, target: str, *, strict: bool) -> str | None: - """Resolve any window target to a native pane id. - - Callers hand this two families: the seam-canonical ``=session[:window]`` - token (decoded by :func:`multiplexer.parse_target`; core formats these - via ``TerminalMultiplexer.target``) and our own native pane id — native - ids contain ``:`` (``w1:p1``) but never lead with ``=``. A native id - passes through untouched, with no server round-trip. For ``=…`` targets: - session → workspace (first-match label), a window name → the tab with - that label, no window → the session-level tab (see - :func:`_session_level_tab`), then that tab's pane. ``strict=True`` - raises :class:`HerdrError` when the target cannot be resolved; - ``strict=False`` returns None (the sentinel callers' quiet failure).""" - parsed = parse_target(target) - if parsed is None: - return target - session, window = parsed - row = self._workspace_row(session, strict=strict) - if row is None: - if strict: - raise HerdrError(f"herdr workspace for session {session!r} not found") - return None - wid = row["workspace_id"] - tabs = self._list_tabs_strict() if strict else self._list_tabs_tolerant() - tabs = [ - t - for t in tabs or [] - if t.get("workspace_id") == wid and isinstance(t.get("tab_id"), str) - ] - if window: - tab = next((t for t in tabs if t.get("label") == window), None) - else: - tab = _session_level_tab(tabs, row.get("active_tab_id")) - if tab is None: - if strict: - raise HerdrError(f"herdr target {target!r} matches no tab") - return None - pane_id = self._tab_root_pane(tab["tab_id"], strict=strict) - if pane_id is None and strict: - raise HerdrError(f"herdr tab {tab['tab_id']!r} for target {target!r} has no pane") - return pane_id - - # ------------------------------------------------------------ sessions - - def has_session(self, name: str) -> bool: - # Ensures the server first: this gates session creation, so a fresh run - # that finds the server down must bring it up to answer authoritatively - # (and to then create the workspace). A transport failure raises. - self._client.ensure_server() - return self._workspace_id(name, strict=True) is not None - - def new_session( - self, name: str, cwd: Path, cols: int | None = None, lines: int | None = None - ) -> None: - # Workspace create auto-spawns a root shell tab, which keeps the workspace - # alive after task tabs close (tmux window-0 role). Geometry is advisory: - # herdr has no absolute headless resize, so cols/lines are ignored here. - self._client.ensure_server() - # herdr allows duplicate labels; guard so a re-armed/resumed run does not - # spawn a second workspace with the same name. - if self._workspace_id(name, strict=True) is not None: - return - self._client._herdr_json( - "workspace", "create", "--label", name, "--cwd", str(cwd), "--no-focus" - ) - - def kill_session(self, name: str) -> None: - # Best-effort teardown: never start a server just to tear one down, and - # tolerate the workspace already being gone. Also retire the session's - # pipe_pane tees so no poller outlives the workspace it was watching. - wid: str | None = None - if shutil.which("herdr"): - try: - wid = self._workspace_id(name, strict=False) - if wid is not None: - self._client._run(["workspace", "close", wid], check=False) - except (subprocess.SubprocessError, OSError): - wid = None - if wid is not None: - self._stop_pollers_for_workspace(wid) - _drop_windows_for_workspace(wid) - _drop_state("sessions", name) - - def list_sessions(self) -> list[str]: - # [] when herdr is missing, no server is running, or the query fails — - # indistinguishable and all "nothing live", as with tmux list-sessions. - workspaces = self._list_workspaces_tolerant() - if workspaces is None: - return [] - return [ws["label"] for ws in workspaces if isinstance(ws.get("label"), str)] - - def session_options(self, option: str) -> dict[str, str]: - # Map live-session name -> sidecar option value. Also prunes sidecar - # entries whose workspace is gone (only when liveness is knowable — a - # None enumeration can't prove a workspace dead, so it prunes nothing). - workspaces = self._list_workspaces_tolerant() - if workspaces is None: - return {} - labels = {ws["label"] for ws in workspaces if isinstance(ws.get("label"), str)} - # Lock-free read for the answer (atomic_replace guarantees a consistent - # snapshot; readers never block on writers)... - sessions = _load_state()["sessions"] - result = { - label: sessions[label][option] - for label in labels - if label in sessions and option in sessions[label] - } - dead = [key for key in sessions if key not in labels] - if dead: - # ...then the prune is a proper locked read-modify-write (re-loaded - # under the lock so it never clobbers a concurrent writer's update). - try: - with _state_lock(): - state = _load_state() - pruned = False - for key in dead: - if state["sessions"].pop(key, None) is not None: - pruned = True - if pruned: - _save_state(state) - except OSError: - pass # best-effort prune - return result - - def set_session_option(self, name: str, option: str, value: str) -> None: - # Raiser: a sidecar write failure is this backend's "transport failure" - # (a lock-acquisition failure counts — never write unguarded). - try: - with _state_lock(): - state = _load_state() - state["sessions"].setdefault(name, {})[option] = value - _save_state(state) - except OSError as exc: - raise HerdrError(f"herdr sidecar write failed: {exc}") from exc - - # ------------------------------------------------------------- windows - - def new_window( - self, session: str, name: str, cwd: Path, env: dict[str, str], command: str - ) -> str: - # A window is a tab with a single shell pane; the native window id we hand - # back is that tab's root pane id. The command (a shlex-joined argv, per - # the contract) is re-split and launched via a typed `exec` so process - # exit == pane close == tab close == tmux-identical death semantics. - self._client.ensure_server() - wid = self._workspace_id(session, strict=True) - if wid is None: - raise HerdrError(f"herdr workspace for session {session!r} not found") - argv: list[str] = ["tab", "create", "--workspace", wid, "--label", name, "--cwd", str(cwd)] - for key, val in env.items(): - argv += ["--env", f"{key}={val}"] - argv.append("--no-focus") - result = self._client._herdr_json(*argv) - pane_id = _root_pane_id(result) - if pane_id is None: - raise HerdrError(f"herdr tab create did not return a root pane id: {result!r}") - self._launch(pane_id, shlex.split(command)) - return pane_id - - def _launch(self, pane_id: str, argv: list[str]) -> None: - # `pane run` types the line and presses Enter atomically. `exec` replaces - # the shell so the process IS the pane; POSIX-only by design (the Windows - # launch path uses agent.start — a Phase-6 follow-up). - self._client._herdr("pane", "run", pane_id, "exec " + shlex.join(argv)) - - def new_parked_window( - self, session: str, name: str, cwd: Path, argv: list[str], return_opt: str - ) -> str: - # A fresh tab whose typed `exec sh -c ''` runs argv, echoes the - # exit banner, parks on a blocking read (the exit status stays - # inspectable), and finally hands an attached client back to its origin - # via the per-window return file (see _parked_source / the module - # docstring's ledger). exec replaces the shell, so finishing the source - # closes the pane — which is also what ends a watching `terminal - # attach` client. - self._client.ensure_server() - wid = self._workspace_id(session, strict=True) - if wid is None: - raise HerdrError(f"herdr workspace for session {session!r} not found") - result = self._client._herdr_json( - "tab", "create", "--workspace", wid, "--label", name, "--cwd", str(cwd), "--no-focus" - ) - pane_id = _root_pane_id(result) - if pane_id is None: - raise HerdrError(f"herdr tab create did not return a root pane id: {result!r}") - # Record which option this window's trailer consumes BEFORE typing the - # recipe, so a set_window_option racing the launch already mirrors into - # the return file. tmux gets create+launch atomically (one new-window - # call); here the tab already exists, so any failure before the recipe - # is typed must roll it back or it lingers as an untracked idle shell - # until the whole workspace is closed. - try: - try: - with _state_lock(): - state = _load_state() - state["windows"].setdefault(pane_id, {})[_PARKED_RETURN_KEY] = return_opt - _save_state(state) - except OSError as exc: - raise HerdrError(f"herdr sidecar write failed: {exc}") from exc - self._client._herdr( - "pane", "run", pane_id, "exec sh -c " + shlex.quote(_parked_source(argv, pane_id)) - ) - except MultiplexerError: - try: - self.kill_window(pane_id) - except MultiplexerError: - pass - raise - return pane_id - - def list_window_ids(self, session: str) -> list[str]: - # The engine's liveness probe: [] means "no windows", and a transport - # failure must RAISE (never []), so a mere server hang can't read as - # "window dead -> crashed". Does NOT ensure_server: a liveness check must - # not resurrect a dead server; an unreachable server is honestly "unknown" - # -> raise. An absent workspace, though, is a knowable "no windows" -> []. - wid = self._workspace_id(session, strict=True) - if wid is None: - return [] - return [ - pane["pane_id"] - for pane in self._list_panes_strict() - if pane.get("workspace_id") == wid and isinstance(pane.get("pane_id"), str) - ] - - def list_windows(self, session: str, fields: list[str]) -> list[tuple[str, ...]]: - # Best-effort metadata (sentinel [] on any failure). The tmux format - # fields tui/launch.py asks for are mapped onto the herdr object model - # (window == tab, native window id == the tab's root pane id): - # window_id/pane_id -> pane_id, window_name -> the owning tab's label, - # session_name -> the session, "@..." user options -> the sidecar. - # Anything unmapped falls back to the pane dict's own herdr-native key. - wid = self._workspace_id(session, strict=False) - if wid is None: - return [] - try: - panes = self._list_panes_strict() - except MultiplexerError: - return [] - tab_labels: dict[str, str] = {} - if "window_name" in fields: - tabs = self._list_tabs_tolerant() - if tabs is None: - return [] # can't answer a requested field -> the [] sentinel - for tab in tabs: - if isinstance(tab.get("tab_id"), str): - label = tab.get("label") - tab_labels[tab["tab_id"]] = label if isinstance(label, str) else "" - options: dict = {} - if any(field.startswith("@") for field in fields): - options = _load_state()["windows"] - rows: list[tuple[str, ...]] = [] - for pane in panes: - if pane.get("workspace_id") != wid: - continue - pane_id = pane.get("pane_id") - pane_id = pane_id if isinstance(pane_id, str) else "" - row: list[str] = [] - for field in fields: - if field in ("window_id", "pane_id"): - row.append(pane_id) - elif field == "window_name": - tab_id = pane.get("tab_id") - row.append(tab_labels.get(tab_id, "") if isinstance(tab_id, str) else "") - elif field == "session_name": - row.append(session) - elif field.startswith("@"): - opts = options.get(pane_id, {}) - value = opts.get(field, "") if isinstance(opts, dict) else "" - row.append(value if isinstance(value, str) else "") - else: - row.append(str(pane.get(field, ""))) - rows.append(tuple(row)) - return rows - - def window_alive(self, session: str, window_id: str) -> bool: - # Single-signal liveness: server-answered pane presence. No linger was - # observed for the exec launch (the pane vanishes on exit), so pane - # presence alone is authoritative. May raise (transport unknowable). - return self._pane_counts_as_live(window_id) - - def _pane_counts_as_live(self, pane_id: str) -> bool: - try: - proc = self._client._run(["pane", "get", pane_id], check=False) - except (subprocess.TimeoutExpired, OSError) as exc: - raise HerdrError(f"herdr pane get failed: {exc}") from exc - if proc.returncode == 0: - try: - data = json.loads(proc.stdout) - except json.JSONDecodeError as exc: - raise HerdrError(f"herdr pane get returned non-JSON: {proc.stdout!r}") from exc - result = data.get("result") if isinstance(data, dict) else None - return isinstance(result, dict) and "pane" in result - # Non-zero: a server-answered pane_not_found is honestly "dead"; a - # non-JSON transport error (server down / bogus socket) is unknowable. - if _error_code(proc) == "pane_not_found": - return False - raise HerdrError(f"herdr pane get failed: {proc.stderr.strip()}") - - def kill_window(self, target: str) -> None: - # Best-effort: `pane close` cascades the now-empty tab closed; tolerate the - # pane already being gone (a CLI that crashes on launch races its window - # down before teardown — probe.py depends on this tolerance). - pane_id = self._parse_target(target, strict=False) - if pane_id is None: - return - self._stop_poller(pane_id) - if shutil.which("herdr"): - try: - self._client._run(["pane", "close", pane_id], check=False) - except (subprocess.SubprocessError, OSError): - pass - _drop_state("windows", pane_id) - _remove_return_file(pane_id) - - def select_window(self, target: str) -> None: - # Best-effort focus: resolve the pane's tab and focus it (herdr has - # `tab focus`, not `pane focus`). Swallow any failure to the no-op sentinel. - if not shutil.which("herdr"): - return - pane_id = self._parse_target(target, strict=False) - if pane_id is None: - return - try: - proc = self._client._run(["pane", "get", pane_id], check=False) - if proc.returncode != 0: - return - pane = json.loads(proc.stdout).get("result", {}).get("pane", {}) - tab_id = pane.get("tab_id") - if isinstance(tab_id, str) and tab_id: - self._client._run(["tab", "focus", tab_id], check=False) - except (subprocess.SubprocessError, OSError, json.JSONDecodeError): - pass - - def set_window_option(self, target: str, option: str, value: str) -> None: - # Best-effort sidecar write (sentinel: swallow OSError to a no-op — a - # lock failure skips the write entirely, never writes unguarded). Keys - # are normalized to the native pane id, so a write by tmux-style - # "=session:name" target (set_return_pane) and a read by native id - # (return_attached_client) agree. - pane_id = self._parse_target(target, strict=False) - if pane_id is None: - return - parked_opt = None - try: - with _state_lock(): - state = _load_state() - opts = state["windows"].setdefault(pane_id, {}) - opts[option] = value - parked_opt = opts.get(_PARKED_RETURN_KEY) - _save_state(state) - except OSError: - return - if parked_opt == option: - self._mirror_return_value(pane_id, value) - - def _mirror_return_value(self, pane_id: str, value: str) -> None: - """Mirror a parked window's return-option value into its return file, in - trailer-actionable form: :data:`PARKED_RETURN_DETACH` verbatim; a pane id - is resolved to its TAB id here, where JSON parsing is cheap, so the sh - trailer stays a dumb ``tab focus``. Best-effort — an unresolvable origin - clears the file instead, so a stale target can't yank focus somewhere - wrong later.""" - if value == PARKED_RETURN_DETACH: - _write_return_file(pane_id, value) - return - tab_id: str | None = None - try: - candidate = self._client._herdr_json("pane", "get", value).get("pane", {}) - if isinstance(candidate, dict) and isinstance(candidate.get("tab_id"), str): - tab_id = candidate["tab_id"] or None - except MultiplexerError: - tab_id = None - if tab_id is None: - _remove_return_file(pane_id) - else: - _write_return_file(pane_id, tab_id) - - def unset_window_option(self, target: str, option: str) -> None: - pane_id = self._parse_target(target, strict=False) - if pane_id is None: - return - parked_opt = None - try: - with _state_lock(): - state = _load_state() - opts = state["windows"].get(pane_id) - if isinstance(opts, dict) and option in opts: - del opts[option] - parked_opt = opts.get(_PARKED_RETURN_KEY) - if not opts: - del state["windows"][pane_id] - _save_state(state) - except OSError: - return - if parked_opt == option: - _remove_return_file(pane_id) - - def show_window_option(self, target: str, option: str) -> str: - # "" reads as "unset" — also the failure sentinel (including a "=..." - # target that no longer resolves). Keys normalized like set_window_option. - pane_id = self._parse_target(target, strict=False) - if pane_id is None: - return "" - opts = _load_state()["windows"].get(pane_id, {}) - value = opts.get(option, "") if isinstance(opts, dict) else "" - return value if isinstance(value, str) else "" - - def pipe_pane(self, window_id: str, log_file: Path) -> None: - # Emulate tmux `pipe-pane` with a per-window polling tee (see _PanePoller). - # Sentinel contract: never raise. A prime() read that fails means the pane - # already died on launch (or the server is unreachable) — mirror tmux - # swallowing that race by simply not starting a tee; the dead window is - # then reported as a crash by wait_for_completion. - if not shutil.which("herdr"): - return None - poller = _PanePoller(self._client, window_id, Path(log_file)) - if not poller.prime(): - return None - with self._pollers_lock: - previous = self._pollers.pop(window_id, None) - self._pollers[window_id] = poller - if previous is not None: # a re-armed window replaces its old tee - previous.stop() - poller.start() - return None - - def _stop_poller(self, window_id: str) -> None: - with self._pollers_lock: - poller = self._pollers.pop(window_id, None) - if poller is not None: - poller.stop() - - def _stop_pollers_for_workspace(self, workspace_id: str) -> None: - # A pane id is `:p`, so the prefix identifies the session's - # tees without re-querying herdr. (A poller whose pane merely vanished - # would also self-retire on not-founds; this just frees it promptly.) - with self._pollers_lock: - doomed = [pid for pid in self._pollers if pid.split(":", 1)[0] == workspace_id] - pollers = [self._pollers.pop(pid) for pid in doomed] - for poller in pollers: - poller.stop() - - def send_text(self, window_id: str, text: str) -> None: - # Literal paste, let the TUI ingest it, then submit — the tmux - # send-text / sleep / Enter ordering, in herdr verbs (Enter is lowercase). - self._client._herdr("pane", "send-text", window_id, text) - time.sleep(0.3) - self._client._herdr("pane", "send-keys", window_id, "enter") - - # ----------------------------------------------------- client / attach - - def attach_target_argv(self, target: str) -> list[str]: - # Resolve either target family (native pane id or a tmux-style - # "=session[:window]" spec — see _parse_target) to its pane, then: - # - inside a herdr pane (HERDR_ENV): nesting a full attach is the - # wrong move, exactly like attach-inside-tmux — return the - # fire-and-forget `tab focus`, the switch-client equivalent; - # - outside: attach the caller's terminal to the pane's terminal_id - # (`herdr terminal attach` blocks, and exits when the pane closes). - try: - pane_id = self._parse_target(target, strict=True) - if pane_id is None: # strict never returns None; belt and braces - raise HerdrError("target resolves to no pane") - result = self._client._herdr_json("pane", "get", pane_id) - except HerdrError as exc: - raise HerdrError( - f"cannot resolve a herdr terminal for {target!r} to attach: {exc}" - ) from exc - pane = result.get("pane") - pane = pane if isinstance(pane, dict) else {} - if os.environ.get(_HERDR_ENV_MARKER) == "1": - tab_id = pane.get("tab_id") - if isinstance(tab_id, str) and tab_id: - return ["herdr", "tab", "focus", tab_id] - raise HerdrError(f"herdr pane {pane_id!r} has no tab to focus") - terminal_id = pane.get("terminal_id") - if not isinstance(terminal_id, str) or not terminal_id: - raise HerdrError(f"herdr pane {pane_id!r} has no terminal to attach") - return ["herdr", "terminal", "attach", terminal_id] - - def current_pane_id(self) -> str | None: - return self._current_from_env("HERDR_PANE_ID") - - def current_window_id(self) -> str | None: - # Our native window id is the tab's root pane id; inside a single-pane - # bmad-loop window the current pane IS that root pane. - return self._current_from_env("HERDR_PANE_ID") - - def current_session(self) -> str | None: - # Resolve the injected workspace id back to its label (the session name); - # best-effort, None when not inside herdr or the label can't be resolved. - if os.environ.get(_HERDR_ENV_MARKER) != "1": - return None - ws_id = os.environ.get("HERDR_WORKSPACE_ID") - if not ws_id: - return None - workspaces = self._list_workspaces_tolerant() or [] - for ws in workspaces: - if ws.get("workspace_id") == ws_id and isinstance(ws.get("label"), str): - return ws["label"] - return None - - def _current_from_env(self, key: str) -> str | None: - if os.environ.get(_HERDR_ENV_MARKER) != "1": - return None - value = os.environ.get(key) - return value or None - - def detach_client(self) -> None: - # herdr detach is a keybinding, with no CLI verb — documented no-op. - return None - - def switch_client(self, target: str, last_fallback: bool = False) -> bool: - # The herdr "switch client" move is a tab focus: focusing a tab also - # flips workspace focus when it lives elsewhere (verified 0.7.3), so one - # verb covers the whole return-to-origin hop. True iff the focus landed. - # herdr has no "last client" concept, so last_fallback has nothing to - # fall back to and a failed switch is honestly False. - pane_id = self._parse_target(target, strict=False) - if pane_id is None: - return False - try: - pane = self._client._herdr_json("pane", "get", pane_id).get("pane", {}) - except MultiplexerError: - return False - tab_id = pane.get("tab_id") if isinstance(pane, dict) else None - if not isinstance(tab_id, str) or not tab_id: - return False - try: - proc = self._client._run(["tab", "focus", tab_id], check=False) - except (subprocess.SubprocessError, OSError): - return False - return proc.returncode == 0 - - def available(self) -> bool: - # A plain PATH probe — NEVER touches the server (detect_multiplexers - # instantiates every backend and must stay side-effect-free). - return shutil.which("herdr") is not None - - def version(self) -> str | None: - if not shutil.which("herdr"): - return None - try: - return self._client._herdr("--version") - except (MultiplexerError, subprocess.SubprocessError, OSError): - return None - - -# --------------------------------------------------------------- parse helpers - - -def _envelope_items(stdout: str, key: str, *, strict: bool) -> list[dict]: - """Extract ``result.`` (a list of dicts) from a herdr JSON envelope. In - strict mode a non-JSON body raises :class:`HerdrError`; otherwise it yields [].""" - try: - data = json.loads(stdout) - except json.JSONDecodeError as exc: - if strict: - raise HerdrError(f"herdr returned non-JSON: {stdout!r}") from exc - return [] - result = data.get("result") if isinstance(data, dict) else None - items = result.get(key) if isinstance(result, dict) else None - if not isinstance(items, list): - return [] - return [item for item in items if isinstance(item, dict)] - - -def _session_level_tab(tabs: list[dict], active_tab_id: object) -> dict | None: - """The tab a session-level (``=session``) target lands on: the workspace's - active tab — except when that is the root shell tab and task tabs exist, - where the NEWEST tab wins. Rationale: ``new_window``/``new_parked_window`` - create tabs ``--no-focus`` (never yanking an attached client's focus), so a - headless agent workspace keeps its root shell active while the window the - caller wants is the newest tab; the ctl session is unaffected because - ``attach_plan`` runs ``select_window`` (a ``tab focus``) first, making the - intended window the active one.""" - if not tabs: - return None - - def num(tab: dict) -> int: - n = tab.get("number") - return n if isinstance(n, int) else 0 - - root = min(tabs, key=num) - active = next((t for t in tabs if t.get("tab_id") == active_tab_id), None) - if active is not None and active.get("tab_id") != root.get("tab_id"): - return active - return max(tabs, key=num) # root active (or unknown): newest tab (== root when alone) - - -def _root_pane_id(result: dict) -> str | None: - root = result.get("root_pane") - if isinstance(root, dict) and isinstance(root.get("pane_id"), str): - return root["pane_id"] - return None - - -def _drop_state(section: str, key: str) -> None: - """Best-effort removal of one sidecar entry (a workspace/window gone). Never - raises — a teardown/kill must not fail on a sidecar hiccup.""" - try: - with _state_lock(): - state = _load_state() - if key in state.get(section, {}): - del state[section][key] - _save_state(state) - except OSError: - pass - - -def _drop_windows_for_workspace(workspace_id: str) -> None: - """Best-effort sidecar + return-file cleanup for every window of a closed - workspace (pane ids are ``:p`` — the same prefix rule the - poller registry uses). Never raises.""" - prefix = workspace_id + ":" - doomed: list[str] = [] - try: - with _state_lock(): - state = _load_state() - doomed = [key for key in state["windows"] if key.startswith(prefix)] - for key in doomed: - del state["windows"][key] - if doomed: - _save_state(state) - except OSError: - return # unknown keys: the trailers' own `rm -f` self-cleans the files - for key in doomed: - _remove_return_file(key) diff --git a/src/bmad_loop/adapters/multiplexer.py b/src/bmad_loop/adapters/multiplexer.py index e64685c..62c4ae1 100644 --- a/src/bmad_loop/adapters/multiplexer.py +++ b/src/bmad_loop/adapters/multiplexer.py @@ -3,9 +3,11 @@ The coding-CLI adapter (:class:`~.base.CodingCLIAdapter`) abstracts *which CLI* to drive and how its prompts/hooks work. This module abstracts the orthogonal **transport** axis: how sessions, windows, and panes are created, observed, and -torn down. Today the only backend is tmux (:class:`~.tmux_backend.TmuxMultiplexer`); -the seam exists so a future non-POSIX backend (an eventual native-Windows "psmux") -can slot in without the rest of the codebase shelling out to ``tmux`` directly. +torn down. The one bundled backend is tmux +(:class:`~.tmux_backend.TmuxMultiplexer`); every other backend lives out-of-tree +(the reference is the herdr adapter, https://github.com/pbean/bmad-loop-adapter-herdr; +an eventual native-Windows "psmux" is the same shape) and slots in without the +rest of the codebase shelling out to ``tmux`` directly. ``TerminalMultiplexer`` is the contract a backend author implements. Operation names mirror today's call sites verbatim so the migration is mechanical. Backends @@ -282,27 +284,22 @@ def register_multiplexer( def _load_builtin_backends() -> None: - """Register the bundled backends. Idempotent and lazy (called from - :func:`get_multiplexer`, not at module import) to stay cycle-safe. Registers - inline rather than via tmux_backend's import side effect so the registry can be - cleared and re-loaded deterministically (a re-import is a no-op once cached) — + """Register the bundled backends — today, tmux alone (every other backend is + out-of-tree and arrives via :func:`_load_external_backends` or a manual + import). Idempotent and lazy (called from :func:`get_multiplexer`, not at + module import) to stay cycle-safe. Registers inline rather than via + tmux_backend's import side effect so the registry can be cleared and + re-loaded deterministically (a re-import is a no-op once cached) — mirroring ``process_host._load_builtin_hosts``.""" global _BUILTINS_LOADED if _BUILTINS_LOADED: return - from .herdr_backend import HerdrMultiplexer from .tmux_backend import TmuxMultiplexer # tmux is the default everywhere except native Windows (no tmux binary there); - # get_multiplexer still falls back to tmux when no backend matches. + # get_multiplexer still falls back to tmux when no backend matches. Builtins + # register before externals, so tmux keeps first-wins on any name collision. register_multiplexer("tmux", lambda platform: platform != "win32", TmuxMultiplexer) - # herdr is a cross-platform, agent-aware backend (a different binary family, so - # it never conflicts with tmux). Registered AFTER tmux so on POSIX the tmux - # platform-default still wins; on native Windows (no tmux match, psmux - # out-of-tree) herdr is the first platform match. Opt-in only — _PLATFORM_DEFAULTS - # stays untouched, so POSIX hosts keep tmux unless BMAD_LOOP_MUX_BACKEND / the - # [mux] backend policy names herdr. - register_multiplexer("herdr", lambda platform: True, HerdrMultiplexer) _BUILTINS_LOADED = True # set only after a successful import so a transient failure retries diff --git a/tests/conftest.py b/tests/conftest.py index f8d8e48..6c8bc0a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -36,15 +36,16 @@ def force_tmux_backend(monkeypatch): """Pin the tmux transport backend by name, regardless of host platform. - Phase 3 registered the cross-platform ``herdr`` backend, which — unlike tmux — - *matches* win32. So on the Windows CI leg ``get_multiplexer()`` no longer - bottom-falls-back to tmux (herdr is now the first win32 match); tests that - assert tmux-specific argv/behaviour *through the seam* would otherwise drive - herdr there. Forcing ``BMAD_LOOP_MUX_BACKEND=tmux`` selects tmux by name (the - env override bypasses the platform predicate and ``available()``), so these - tests stay platform-independent. On POSIX this is a no-op — tmux is already - the default — so the Linux suite is unaffected. The cache is cleared on both - ends so the forced choice takes effect and does not leak to later tests.""" + External backends discovered via the ``bmad_loop.mux_backends`` entry-point + scan may match any platform — the herdr adapter matches win32, where tmux + does not — so on a host with such a package installed ``get_multiplexer()`` + would select it and tests that assert tmux-specific argv/behaviour *through + the seam* would drive the wrong backend. Forcing + ``BMAD_LOOP_MUX_BACKEND=tmux`` selects tmux by name (the env override + bypasses the platform predicate and ``available()``), so these tests stay + environment-independent. On a stock POSIX box this is a no-op — tmux is + already the default. The cache is cleared on both ends so the forced choice + takes effect and does not leak to later tests.""" from bmad_loop.adapters import multiplexer monkeypatch.setenv("BMAD_LOOP_MUX_BACKEND", "tmux") diff --git a/tests/test_backend_registry.py b/tests/test_backend_registry.py index 47ae809..eb79e44 100644 --- a/tests/test_backend_registry.py +++ b/tests/test_backend_registry.py @@ -18,7 +18,6 @@ import pytest from bmad_loop.adapters import multiplexer as m -from bmad_loop.adapters.herdr_backend import HerdrMultiplexer from bmad_loop.adapters.multiplexer import MultiplexerError from bmad_loop.adapters.tmux_backend import TmuxMultiplexer @@ -82,9 +81,9 @@ def fresh_registry(monkeypatch): def test_default_is_tmux(fresh_registry, monkeypatch): """No override, POSIX host → tmux, selected via the loop's platform match (the - builtin registers ``matches=p != 'win32'``). Pin a POSIX platform: herdr is now - registered and *matches win32*, so on a win32 host the auto-default is herdr, - not tmux — this test is specifically about the POSIX default.""" + builtin registers ``matches=p != 'win32'``). Pin a POSIX platform anyway: an + installed external backend may match win32 (the herdr adapter does), so only + the POSIX default is a stable claim — this test is specifically about it.""" monkeypatch.setattr(sys, "platform", "linux") assert isinstance(fresh_registry.get_multiplexer(), TmuxMultiplexer) @@ -278,7 +277,7 @@ def test_configure_multiplexer_clears_cache_only_on_change(fresh_registry): def test_empty_string_configuration_means_auto(fresh_registry, monkeypatch): """configure_multiplexer("") — an unset policy key — must behave exactly like None, not force an empty backend name. Pinned to a POSIX platform so the - auto-default is tmux (on win32 herdr now matches and would auto-select).""" + auto-default is tmux regardless of any installed win32-matching external.""" monkeypatch.setattr(sys, "platform", "linux") fresh_registry.configure_multiplexer("") assert isinstance(fresh_registry.get_multiplexer(), TmuxMultiplexer) @@ -347,88 +346,34 @@ def test_detect_multiplexers_version_crash_keeps_availability(fresh_registry): # --------------------------------------------------------------------------- -# herdr builtin registration (Phase 3). These exercise the REAL builtins -# (tmux + herdr), so they leave `_BUILTINS_LOADED` False and control the outcome -# by monkeypatching `sys.platform` (deterministic on both CI legs) and the shared -# `shutil.which` (both backends' available() probes it). The registration adds -# herdr AFTER tmux with `matches=lambda platform: True`, and does NOT touch -# `_PLATFORM_DEFAULTS`. +# Bundled-registry platform behavior. These exercise the REAL builtin load (they +# leave `_BUILTINS_LOADED` False) and control the outcome by monkeypatching +# `sys.platform` (deterministic on both CI legs) and the shared `shutil.which`. +# The herdr selection facts that used to live here moved with the backend to +# pbean/bmad-loop-adapter-herdr (tests/test_registration.py). def _which_only(*available: str): """`shutil.which` stub: only the named binaries resolve, everything else is - absent. Patches the shared stdlib module, so both tmux_base and herdr_backend - (which each `import shutil`) see it.""" + absent. Patches the shared stdlib module, so every backend's available() + probe sees it.""" names = set(available) return lambda name, *a, **k: (f"/usr/bin/{name}" if name in names else None) -def test_herdr_registered_after_tmux_and_tmux_wins_on_posix(fresh_registry, monkeypatch): - """Both backends available on POSIX: herdr is registered but tmux is the - platform default, so tmux still wins (herdr is opt-in on POSIX).""" - monkeypatch.setattr(sys, "platform", "linux") - monkeypatch.setattr(shutil, "which", _which_only("tmux", "herdr")) - backend, name, reason = fresh_registry._select() - assert isinstance(backend, TmuxMultiplexer) - assert (name, reason) == ("tmux", "platform-default") - - -def test_herdr_first_match_when_tmux_unavailable_on_posix(fresh_registry, monkeypatch): - """A POSIX host with herdr but no tmux binary selects herdr as the first - available platform match — the default (tmux) is unavailable and falls through.""" - monkeypatch.setattr(sys, "platform", "linux") - monkeypatch.setattr(shutil, "which", _which_only("herdr")) # tmux absent - backend, name, reason = fresh_registry._select() - assert isinstance(backend, HerdrMultiplexer) - assert (name, reason) == ("herdr", "first-match") - - -def test_env_override_selects_herdr(fresh_registry, monkeypatch): - """BMAD_LOOP_MUX_BACKEND=herdr forces herdr by name, bypassing the platform - predicate and availability (an explicit choice is trusted, as for any backend).""" - monkeypatch.setenv("BMAD_LOOP_MUX_BACKEND", "herdr") - fresh_registry.get_multiplexer.cache_clear() - assert isinstance(fresh_registry.get_multiplexer(), HerdrMultiplexer) - - -def test_herdr_selected_on_win32_as_first_platform_match(fresh_registry, monkeypatch): - """On native Windows tmux does not match (its `matches` is `p != 'win32'`) and - psmux is out-of-tree, so the cross-platform herdr is the first platform match: - win32 auto-selects herdr with `_PLATFORM_DEFAULTS` untouched.""" - monkeypatch.setattr(sys, "platform", "win32") - monkeypatch.setattr(shutil, "which", _which_only("herdr")) # herdr present, no tmux - backend, name, reason = fresh_registry._select() - assert isinstance(backend, HerdrMultiplexer) - assert (name, reason) == ("herdr", "first-match") - # sanity: _PLATFORM_DEFAULTS still names psmux for win32, not herdr - assert fresh_registry._PLATFORM_DEFAULTS.get("win32") == "psmux" - - -def test_win32_falls_back_to_herdr_even_when_unavailable(fresh_registry, monkeypatch): - """With neither binary installed on win32 (the Windows CI leg), the historical - fallback returns the first platform match regardless of availability — that is - now herdr, not the bottom tmux fallback. This is the selection change the - tmux-argv test fixtures pin `BMAD_LOOP_MUX_BACKEND=tmux` around.""" +def test_win32_bottoms_out_at_tmux_with_no_externals(fresh_registry, monkeypatch): + """On native Windows nothing bundled matches (tmux's `matches` is + `p != 'win32'`, psmux is out-of-tree), so with no external backend installed + `_select` bottoms out at the documented historical fallback: tmux, reported + unavailable by validate. Pins the post-extraction win32 semantics — while + herdr was bundled, its `matches=True` made it the win32 first-match/fallback + instead (that behavior now ships with the adapter, and the + `force_tmux_backend` fixture keeps guarding tmux-argv tests against any + installed external that matches win32).""" monkeypatch.setattr(sys, "platform", "win32") monkeypatch.setattr(shutil, "which", _which_only()) # nothing available backend, name, reason = fresh_registry._select() - assert isinstance(backend, HerdrMultiplexer) - assert (name, reason) == ("herdr", "fallback") - - -def test_detect_multiplexers_lists_herdr_row(fresh_registry, monkeypatch): - """detect_multiplexers() enumerates herdr (a real registered builtin) with its - availability reflecting the host probe. version() is stubbed so no row spawns a - real `herdr --version` / `tmux -V`.""" - monkeypatch.setattr(sys, "platform", "linux") - monkeypatch.setattr(shutil, "which", _which_only("tmux", "herdr")) - monkeypatch.setattr(HerdrMultiplexer, "version", lambda self: "herdr 0.7.3") - monkeypatch.setattr(TmuxMultiplexer, "version", lambda self: "tmux 3.5") - rows = {r.name: r for r in fresh_registry.detect_multiplexers()} - assert {"tmux", "herdr"} <= set(rows) - assert rows["herdr"].matches_platform is True - assert rows["herdr"].available is True - assert rows["herdr"].version == "herdr 0.7.3" - # tmux is the POSIX platform default, so it stays selected — herdr is listed but not chosen - assert rows["tmux"].selected is True - assert rows["herdr"].selected is False and rows["herdr"].reason == "" + assert isinstance(backend, TmuxMultiplexer) + assert (name, reason) == ("tmux", "fallback") + # sanity: _PLATFORM_DEFAULTS still names the out-of-tree psmux for win32 + assert fresh_registry._PLATFORM_DEFAULTS.get("win32") == "psmux" diff --git a/tests/test_cli.py b/tests/test_cli.py index 2b5301f..d637b1e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -499,19 +499,20 @@ def test_attach_nothing_to_attach(project, monkeypatch, capsys): def test_attach_multiplexer_error_surfaces_clean_error(project, monkeypatch, capsys): - # attach_plan reaches the multiplexer (a herdr server round-trip); when that - # raises, main()'s backstop must surface `error: ` + rc 1, never a - # traceback to the parked control pane. + # attach_plan reaches the multiplexer (a server round-trip on server-backed + # backends like the external herdr adapter); when that raises, main()'s + # backstop must surface `error: ` + rc 1, never a traceback to the + # parked control pane. from bmad_loop.adapters.multiplexer import MultiplexerError from bmad_loop.tui import launch def boom(_proj, _rid): - raise MultiplexerError("herdr server not reachable") + raise MultiplexerError("backend server not reachable") _make_run_with_decision(project, run_id="20260101-000000-aaaa") monkeypatch.setattr(launch, "attach_plan", boom) assert cli.main(["attach", "--project", str(project.project), "20260101-000000-aaaa"]) == 1 - assert "error: herdr server not reachable" in capsys.readouterr().err + assert "error: backend server not reachable" in capsys.readouterr().err def test_sweep_dry_run_lists_open_entries(project, capsys): diff --git a/tests/test_generic_tmux.py b/tests/test_generic_tmux.py index 6b4cbff..e5292cc 100644 --- a/tests/test_generic_tmux.py +++ b/tests/test_generic_tmux.py @@ -72,8 +72,8 @@ def test_ensure_session_tags_project(tmp_path, monkeypatch, force_tmux_backend): """A freshly created agent session is stamped with its project so a cleanup in another project never prunes this run. The set-option now flows through the tmux backend, so patch its subprocess seam. ``force_tmux_backend`` pins - tmux on win32, where the default mux would otherwise be herdr (a no-op on - POSIX) — the adapter's default ``mux`` is ``get_multiplexer()``.""" + tmux against any installed win32-matching external backend (a no-op on a + stock POSIX box) — the adapter's default ``mux`` is ``get_multiplexer()``.""" from bmad_loop import runs project = tmp_path diff --git a/tests/test_herdr_backend.py b/tests/test_herdr_backend.py deleted file mode 100644 index e91370f..0000000 --- a/tests/test_herdr_backend.py +++ /dev/null @@ -1,945 +0,0 @@ -"""Conformance tests for the herdr terminal-multiplexer backend. - -No real herdr server: every test drives :class:`HerdrMultiplexer` against a fake -that stands in for ``subprocess.run`` (the ONE spawn seam), maintaining an -in-memory server (workspaces + panes) and recording every argv so we can pin the -exact CLI each contract method emits. Mirrors ``tests/test_multiplexer.py``'s -``_RecordRun`` + seam-honesty patterns for the herdr (non-tmux-family) backend. -""" - -from __future__ import annotations - -import json -import os -import shlex -import subprocess -from pathlib import Path - -import pytest - -from bmad_loop.adapters import herdr_backend -from bmad_loop.adapters.herdr_backend import HerdrError, HerdrMultiplexer -from bmad_loop.adapters.multiplexer import MultiplexerError - -PROJECT_OPTION = "@bmad_project" -RETURN_OPTION = "@bmad_return_pane" - - -# --------------------------------------------------------------- fake transport - - -def _flag(argv: list[str], name: str) -> str: - return argv[argv.index(name) + 1] - - -class FakeHerdr: - """Stand-in for ``subprocess.run(["herdr", ...])``. Holds an in-memory server - (a running flag, a protocol number, workspaces, panes), answers the CLI verbs - this backend uses with herdr's real envelope shapes, and records every argv.""" - - def __init__(self, *, running: bool = True, protocol: int = 16) -> None: - self.running = running - self.protocol = protocol - self.workspaces: list[dict] = [] # {"label","workspace_id","active_tab_id"} - self.tabs: list[dict] = [] # {"tab_id","label","number","workspace_id","focused"} - self.panes: list[dict] = [] # {"pane_id","workspace_id","tab_id","terminal_id"} - self.calls: list[list[str]] = [] - # Scripted `pane read` output per pane: a list of successive raw-text - # screens; each read advances the cursor and sticks on the last entry. - self.pane_reads: dict[str, list[str]] = {} - self._pane_read_idx: dict[str, int] = {} - self._ws_seq = 0 - self._tab_seq: dict[str, int] = {} - self._pane_seq: dict[str, int] = {} - self._term_seq = 0 - - # ---- helpers a test can call to seed state - - def _new_tab(self, wid: str, label: str | None, *, focus: bool) -> dict: - self._tab_seq[wid] = self._tab_seq.get(wid, 0) + 1 - number = self._tab_seq[wid] - tab = { - # real herdr gives an unlabelled tab its NUMBER as the label (a - # workspace's root shell tab is "1" — verified 0.7.3) - "tab_id": f"{wid}:t{number}", - "label": label if label is not None else str(number), - "number": number, - "workspace_id": wid, - "focused": False, - } - self.tabs.append(tab) - if focus: - self._focus_tab(tab) - return tab - - def _focus_tab(self, tab: dict) -> None: - for other in self.tabs: - if other["workspace_id"] == tab["workspace_id"]: - other["focused"] = other is tab - for ws in self.workspaces: - if ws["workspace_id"] == tab["workspace_id"]: - ws["active_tab_id"] = tab["tab_id"] - - def _new_pane(self, wid: str, tab_id: str) -> dict: - self._pane_seq[wid] = self._pane_seq.get(wid, 0) + 1 - self._term_seq += 1 - pane = { - "pane_id": f"{wid}:p{self._pane_seq[wid]}", - "workspace_id": wid, - "tab_id": tab_id, - "terminal_id": f"term{self._term_seq}", - } - self.panes.append(pane) - return pane - - def add_workspace(self, label: str) -> str: - """Create a workspace (with its root shell tab+pane) and return its id.""" - self._ws_seq += 1 - wid = f"w{self._ws_seq}" - self.workspaces.append({"label": label, "workspace_id": wid, "active_tab_id": None}) - tab = self._new_tab(wid, None, focus=True) # the root shell tab, label "1" - self._new_pane(wid, tab["tab_id"]) - return wid - - def set_pane_reads(self, pane_id: str, screens: list[str]) -> None: - """Script the raw-text screens successive ``pane read`` calls return.""" - self.pane_reads[pane_id] = list(screens) - self._pane_read_idx[pane_id] = 0 - - def _pane_read_out(self, pane_id: str) -> str: - screens = self.pane_reads.get(pane_id) - if not screens: - return "" # a live-but-unscripted pane reads as a blank screen - idx = self._pane_read_idx.get(pane_id, 0) - self._pane_read_idx[pane_id] = idx + 1 - return screens[min(idx, len(screens) - 1)] - - # ---- CompletedProcess builders - - @staticmethod - def _cp(cmd, rc: int, out: str = "", err: str = "") -> subprocess.CompletedProcess: - return subprocess.CompletedProcess(cmd, rc, stdout=out, stderr=err) - - def _ok(self, cmd, result: dict) -> subprocess.CompletedProcess: - return self._cp(cmd, 0, out=json.dumps({"id": "cli:1", "result": result})) - - def _server_err(self, cmd, code: str, msg: str) -> subprocess.CompletedProcess: - # herdr's nested error body (bare {"code",...} only for `pane read`). - return self._cp( - cmd, 1, err=json.dumps({"error": {"code": code, "message": msg}, "id": "x"}) - ) - - def _bare_err(self, cmd, code: str, msg: str) -> subprocess.CompletedProcess: - # `pane read` uses herdr's BARE error body (no {"error": ...} envelope). - return self._cp(cmd, 1, err=json.dumps({"code": code, "message": msg})) - - def _down(self, cmd) -> subprocess.CompletedProcess: - # server down / bogus socket: a non-JSON transport error. - return self._cp(cmd, 1, err='Error: Os { code: 2, kind: NotFound, message: "nope" }') - - # ---- subprocess.run replacement - - def __call__(self, cmd, **kwargs) -> subprocess.CompletedProcess: - assert cmd[0] == "herdr" - argv = list(cmd[1:]) - self.calls.append(argv) - return self._dispatch(cmd, argv) - - def _dispatch(self, cmd, argv: list[str]) -> subprocess.CompletedProcess: - if argv == ["--version"]: - return self._cp(cmd, 0, out="herdr 0.7.3") - if argv[:2] == ["status", "--json"]: - server = {"running": self.running, "protocol": self.protocol if self.running else None} - return self._cp(cmd, 0, out=json.dumps({"server": server})) - group = argv[0] if argv else "" - if not self.running and group in {"workspace", "pane", "tab", "agent", "wait"}: - return self._down(cmd) - verb = argv[1] if len(argv) > 1 else "" - if group == "workspace" and verb == "list": - return self._ok(cmd, {"workspaces": self.workspaces}) - if group == "workspace" and verb == "create": - wid = self.add_workspace(_flag(argv, "--label")) - ws = next(w for w in self.workspaces if w["workspace_id"] == wid) - root = next(p for p in self.panes if p["workspace_id"] == wid) - return self._ok(cmd, {"workspace": ws, "root_pane": root}) - if group == "workspace" and verb == "close": - wid = argv[2] - self.workspaces = [w for w in self.workspaces if w["workspace_id"] != wid] - self.tabs = [t for t in self.tabs if t["workspace_id"] != wid] - self.panes = [p for p in self.panes if p["workspace_id"] != wid] - return self._ok(cmd, {"type": "ok"}) - if group == "tab" and verb == "create": - wid = _flag(argv, "--workspace") - label = _flag(argv, "--label") if "--label" in argv else None - tab = self._new_tab(wid, label, focus="--focus" in argv) - root = self._new_pane(wid, tab["tab_id"]) - return self._ok(cmd, {"tab": tab, "root_pane": root}) - if group == "tab" and verb == "list": - # real herdr RAISES workspace_not_found for an absent --workspace - # (never []); the backend must therefore always enumerate bare. - if "--workspace" in argv: - wid = _flag(argv, "--workspace") - if all(w["workspace_id"] != wid for w in self.workspaces): - return self._server_err( - cmd, "workspace_not_found", f"workspace {wid} not found" - ) - return self._ok(cmd, {"tabs": [t for t in self.tabs if t["workspace_id"] == wid]}) - return self._ok(cmd, {"tabs": self.tabs}) - if group == "tab" and verb == "focus": - tab = next((t for t in self.tabs if t["tab_id"] == argv[2]), None) - if tab is None: - return self._server_err(cmd, "tab_not_found", f"tab {argv[2]} not found") - self._focus_tab(tab) - return self._ok(cmd, {"tab": tab, "type": "tab_info"}) - if group == "pane" and verb == "list": - return self._ok(cmd, {"panes": self.panes}) - if group == "pane" and verb == "get": - pane = next((p for p in self.panes if p["pane_id"] == argv[2]), None) - if pane is None: - return self._server_err(cmd, "pane_not_found", f"pane {argv[2]} not found") - return self._ok(cmd, {"pane": pane}) - if group == "pane" and verb == "close": - pane = next((p for p in self.panes if p["pane_id"] == argv[2]), None) - self.panes = [p for p in self.panes if p["pane_id"] != argv[2]] - if pane is not None: # a pane close cascades its now-empty tab closed - tab_id = pane["tab_id"] - if all(p["tab_id"] != tab_id for p in self.panes): - self.tabs = [t for t in self.tabs if t["tab_id"] != tab_id] - for ws in self.workspaces: - if ws.get("active_tab_id") == tab_id: - ws["active_tab_id"] = next( - ( - t["tab_id"] - for t in self.tabs - if t["workspace_id"] == ws["workspace_id"] - ), - None, - ) - return self._ok(cmd, {"type": "ok"}) - if group == "pane" and verb == "read": - pane_id = argv[2] - if not any(p["pane_id"] == pane_id for p in self.panes): - return self._bare_err(cmd, "pane_not_found", f"pane {pane_id} not found") - # pane read prints RAW TEXT, not a JSON envelope. - return self._cp(cmd, 0, out=self._pane_read_out(pane_id)) - if group == "pane" and verb in {"run", "send-text", "send-keys"}: - return self._ok(cmd, {"type": "ok"}) - return self._ok(cmd, {"type": "ok"}) # permissive fallback - - -def install_fake_herdr(monkeypatch, tmp_path) -> FakeHerdr: - """Wire a FakeHerdr in as the transport: patch the spawn seam + binary probe, - redirect the sidecar into ``tmp_path``, and clear the inside-a-pane marker. - Shared with ``test_herdr_poller``'s ``fake`` fixture (which adds its - poll-cadence patches on top) so the two setups can't drift.""" - f = FakeHerdr() - monkeypatch.setattr(herdr_backend.subprocess, "run", f) - monkeypatch.setattr(herdr_backend.shutil, "which", lambda _name: "/usr/bin/herdr") - monkeypatch.setenv("BMAD_LOOP_HERDR_STATE", str(tmp_path / "herdr-state.json")) - monkeypatch.delenv("HERDR_ENV", raising=False) - return f - - -@pytest.fixture -def fake(monkeypatch, tmp_path): - return install_fake_herdr(monkeypatch, tmp_path) - - -def _creates(fake: FakeHerdr, group: str, verb: str) -> list[list[str]]: - return [c for c in fake.calls if c[:2] == [group, verb]] - - -# ------------------------------------------------------------- availability - - -def test_available_and_version(fake): - mux = HerdrMultiplexer() - assert mux.available() is True - assert mux.version() == "herdr 0.7.3" - - -def test_available_false_without_binary(monkeypatch): - monkeypatch.setattr(herdr_backend.shutil, "which", lambda _name: None) - mux = HerdrMultiplexer() - assert mux.available() is False - assert mux.version() is None - - -def test_constructor_does_no_io(monkeypatch): - def boom(*_a, **_k): - raise AssertionError("constructor spawned a subprocess") - - monkeypatch.setattr(herdr_backend.subprocess, "run", boom) - monkeypatch.setattr(herdr_backend.subprocess, "Popen", boom) - monkeypatch.setattr(herdr_backend.shutil, "which", lambda _name: "/usr/bin/herdr") - mux = HerdrMultiplexer() # must not spawn anything - assert mux.available() is True # shutil.which only — never the server - - -# ------------------------------------------------------------- exact argv - - -def test_new_session_creates_workspace_argv(fake): - cwd = str(Path("/work")) # backend stringifies the Path; '\\work' on win32 - HerdrMultiplexer().new_session("bmad-loop-x", Path("/work"), 220, 50) - creates = _creates(fake, "workspace", "create") - assert creates == [ - ["workspace", "create", "--label", "bmad-loop-x", "--cwd", cwd, "--no-focus"] - ] - - -def test_new_session_guards_duplicate_label(fake): - mux = HerdrMultiplexer() - mux.new_session("bmad-loop-x", Path("/work")) - mux.new_session("bmad-loop-x", Path("/work")) # already present -> no second create - assert len(_creates(fake, "workspace", "create")) == 1 - assert len([w for w in fake.workspaces if w["label"] == "bmad-loop-x"]) == 1 - - -def test_new_window_tab_create_and_exec_launch(fake): - cwd = str(Path("/work")) # backend stringifies the Path; '\\work' on win32 - fake.add_workspace("bmad-loop-x") - mux = HerdrMultiplexer() - pane_id = mux.new_window("bmad-loop-x", "win", Path("/work"), {"A": "1", "B": "2"}, "echo hi") - (tab_create,) = _creates(fake, "tab", "create") - assert tab_create == [ - "tab", "create", - "--workspace", "w1", - "--label", "win", - "--cwd", cwd, - "--env", "A=1", - "--env", "B=2", - "--no-focus", - ] # fmt: skip - (pane_run,) = _creates(fake, "pane", "run") - assert pane_run == ["pane", "run", pane_id, "exec echo hi"] - assert pane_id.startswith("w1:") - - -def test_new_window_missing_workspace_raises(fake): - with pytest.raises(HerdrError): - HerdrMultiplexer().new_window("bmad-loop-absent", "win", Path("/w"), {}, "echo hi") - - -def test_new_window_shlex_resplit_roundtrip(fake): - # The contract hands new_window a POSIX shlex-joined argv (generic.build_command - # = " ".join(shlex.quote(a) ...)). The exec launch must re-split it faithfully: - # shlex.split(command) then shlex.join back, so a tricky arg survives intact. - import shlex - - argv = ["claude", "-p", "hello world", "--dangerously-skip", "a&&b"] - command = " ".join(shlex.quote(a) for a in argv) - fake.add_workspace("bmad-loop-x") - HerdrMultiplexer().new_window("bmad-loop-x", "win", Path("/w"), {}, command) - (pane_run,) = _creates(fake, "pane", "run") - launched = pane_run[3] - assert launched == "exec " + shlex.join(argv) - assert shlex.split(launched)[0] == "exec" - assert shlex.split(launched)[1:] == argv - - -def test_send_text_literal_then_sleep_then_enter(fake, monkeypatch): - sleeps: list[tuple[float, int]] = [] - # record the call count at sleep time to pin the ordering: paste, THEN sleep, - # THEN submit. - monkeypatch.setattr(herdr_backend.time, "sleep", lambda s: sleeps.append((s, len(fake.calls)))) - HerdrMultiplexer().send_text("w1:p1", "hello world") - assert fake.calls == [ - ["pane", "send-text", "w1:p1", "hello world"], - ["pane", "send-keys", "w1:p1", "enter"], - ] - assert sleeps == [(0.3, 1)] # exactly one 0.3s sleep, after the paste (1 call so far) - - -# ------------------------------------------------------------- liveness honesty - - -def test_list_window_ids_absent_workspace_returns_empty(fake): - # server reachable (running), label absent -> honest [] (not "couldn't ask"). - assert HerdrMultiplexer().list_window_ids("bmad-loop-x") == [] - - -def test_list_window_ids_filters_by_workspace(fake): - fake.add_workspace("bmad-loop-a") - fake.add_workspace("bmad-loop-b") - fake.add_workspace("bmad-loop-a") # duplicate label -> first-match resolution - mux = HerdrMultiplexer() - ids_a = mux.list_window_ids("bmad-loop-a") - ids_b = mux.list_window_ids("bmad-loop-b") - assert ids_a and all(i.startswith("w1:") for i in ids_a) # first "a" workspace wins - assert ids_b and all(i.startswith("w2:") for i in ids_b) - assert set(ids_a).isdisjoint(ids_b) - - -def test_list_window_ids_raises_when_server_unreachable(fake): - fake.running = False - with pytest.raises(HerdrError): - HerdrMultiplexer().list_window_ids("bmad-loop-x") - - -def test_window_alive_pane_get_outcomes(fake): - fake.add_workspace("bmad-loop-x") - pane_id = fake.panes[-1]["pane_id"] - mux = HerdrMultiplexer() - assert mux.window_alive("bmad-loop-x", pane_id) is True # present - assert mux.window_alive("bmad-loop-x", "w9:p9") is False # pane_not_found (answered) - fake.running = False - with pytest.raises(HerdrError): # Error: Os (unreachable) -> unknowable -> raise - mux.window_alive("bmad-loop-x", pane_id) - - -# ----------------------------------------------------------------- seam honesty -# -# No herdr contract method may leak a raw subprocess.TimeoutExpired / OSError: -# raisers re-raise as the seam type, sentinels degrade to their documented value. -# set_session_option is a sidecar write (no subprocess), so its transport-failure -# mode is an OSError on the file, tested separately below — not here. - - -@pytest.fixture(params=[subprocess.TimeoutExpired(["herdr"], 30), FileNotFoundError("herdr")]) -def boom(request, monkeypatch, tmp_path): - monkeypatch.setattr(herdr_backend.shutil, "which", lambda _name: "/usr/bin/herdr") - - def _boom(*_a, **_k): - raise request.param - - monkeypatch.setattr(herdr_backend.subprocess, "run", _boom) - monkeypatch.setenv("BMAD_LOOP_HERDR_STATE", str(tmp_path / "herdr-state.json")) - monkeypatch.delenv("HERDR_ENV", raising=False) - - -def test_seam_methods_never_leak_raw_subprocess_error(boom, tmp_path): - mux = HerdrMultiplexer() - raisers = [ - lambda: mux.list_window_ids("s"), - lambda: mux.window_alive("s", "w1:p1"), - lambda: mux.has_session("s"), - lambda: mux.new_session("s", tmp_path), - lambda: mux.new_window("s", "n", tmp_path, {}, "cmd"), - lambda: mux.send_text("w1:p1", "hi"), - lambda: mux.new_parked_window("s", "n", tmp_path, ["echo", "hi"], ""), - ] - for call in raisers: - with pytest.raises(MultiplexerError) as excinfo: - call() - assert not isinstance(excinfo.value, subprocess.SubprocessError) - assert not isinstance(excinfo.value, OSError) - - # Sentinel returners degrade to the documented value, never raise. - assert mux.list_windows("s", ["pane_id"]) == [] - assert mux.show_window_option("w1:p1", "opt") == "" - assert mux.switch_client("s") is False - assert mux.switch_client("s", last_fallback=True) is False - assert mux.kill_window("w1:p1") is None - assert mux.kill_session("s") is None - assert mux.select_window("w1:p1") is None - assert mux.set_window_option("w1:p1", "opt", "v") is None - assert mux.unset_window_option("w1:p1", "opt") is None - assert mux.detach_client() is None - assert mux.pipe_pane("w1:p1", tmp_path / "log") is None - assert mux.list_sessions() == [] - assert mux.session_options("opt") == {} - assert mux.version() is None - assert mux.current_pane_id() is None - assert mux.current_window_id() is None - assert mux.current_session() is None - - -# ------------------------------------------------------------------- sidecar - - -def test_session_option_roundtrip(fake): - fake.add_workspace("bmad-loop-x") - mux = HerdrMultiplexer() - mux.set_session_option("bmad-loop-x", PROJECT_OPTION, "/proj") - assert mux.session_options(PROJECT_OPTION) == {"bmad-loop-x": "/proj"} - - -def test_session_option_persists_across_instances(fake): - HerdrMultiplexer().set_session_option("bmad-loop-x", PROJECT_OPTION, "/proj") - fake.add_workspace("bmad-loop-x") - assert HerdrMultiplexer().session_options(PROJECT_OPTION) == {"bmad-loop-x": "/proj"} - - -def test_session_options_prunes_dead_workspace(fake): - fake.add_workspace("bmad-loop-x") - mux = HerdrMultiplexer() - mux.set_session_option("bmad-loop-x", PROJECT_OPTION, "/proj") - assert mux.session_options(PROJECT_OPTION) == {"bmad-loop-x": "/proj"} - - fake.workspaces.clear() # workspace gone out-of-band - fake.panes.clear() - assert mux.session_options(PROJECT_OPTION) == {} - state = json.loads(Path(os.environ["BMAD_LOOP_HERDR_STATE"]).read_text()) - assert "bmad-loop-x" not in state["sessions"] # sidecar entry pruned - - -def test_session_options_no_prune_when_unreachable(fake): - fake.add_workspace("bmad-loop-x") - mux = HerdrMultiplexer() - mux.set_session_option("bmad-loop-x", PROJECT_OPTION, "/proj") - fake.running = False # can't prove anything dead -> prune nothing, return {} - assert mux.session_options(PROJECT_OPTION) == {} - state = json.loads(Path(os.environ["BMAD_LOOP_HERDR_STATE"]).read_text()) - assert state["sessions"]["bmad-loop-x"][PROJECT_OPTION] == "/proj" - - -def test_sidecar_write_is_atomic_no_temp_left(fake, tmp_path): - HerdrMultiplexer().set_session_option("bmad-loop-x", PROJECT_OPTION, "/proj") - target = tmp_path / "herdr-state.json" - assert json.loads(target.read_text())["sessions"]["bmad-loop-x"][PROJECT_OPTION] == "/proj" - assert list(tmp_path.glob("herdr-state.json.tmp*")) == [] # no half-written temp - - -def test_set_session_option_raises_on_write_failure(fake, monkeypatch): - def boom_replace(_tmp, _target): - raise OSError("disk full") - - monkeypatch.setattr(herdr_backend.platform_util, "atomic_replace", boom_replace) - with pytest.raises(HerdrError): - HerdrMultiplexer().set_session_option("bmad-loop-x", PROJECT_OPTION, "/proj") - - -def test_sidecar_cycles_hold_the_advisory_lock(fake, monkeypatch): - """Every read-modify-write cycle runs UNDER the sidecar lock — while - _save_state executes, a non-blocking acquire of the lock file must fail. - Guards the lost-update hole: an unlocked cycle would let a concurrent - writer's update (engine tag vs TUI prune) be silently clobbered.""" - real_save = herdr_backend._save_state - lock_path = Path(os.environ["BMAD_LOOP_HERDR_STATE"] + ".lock") - held: list[bool] = [] - - def probing_save(state): - try: - with herdr_backend.platform_util.file_lock(lock_path, blocking=False): - held.append(False) - except OSError: - held.append(True) - real_save(state) - - monkeypatch.setattr(herdr_backend, "_save_state", probing_save) - fake.add_workspace("bmad-loop-x") - mux = HerdrMultiplexer() - mux.set_session_option("bmad-loop-x", PROJECT_OPTION, "/proj") # raiser cycle - mux.set_window_option("w1:p1", RETURN_OPTION, "w1:p2") # sentinel cycle - mux.unset_window_option("w1:p1", RETURN_OPTION) # sentinel cycle - fake.workspaces.clear() # workspace gone -> next enumeration prunes - fake.panes.clear() - mux.session_options(PROJECT_OPTION) # locked prune cycle - assert held == [True, True, True, True] - - -def test_sidecar_lock_failure_rides_the_raiser_sentinel_split(fake, monkeypatch): - """A lock-acquisition failure is a sidecar-write failure: the raiser wraps - it as HerdrError, the sentinels no-op — and nobody ever writes unguarded.""" - - def no_lock(_path, **_kw): - raise OSError("lock unavailable") - - monkeypatch.setattr(herdr_backend.platform_util, "file_lock", no_lock) - saves: list[dict] = [] - monkeypatch.setattr(herdr_backend, "_save_state", saves.append) - mux = HerdrMultiplexer() - with pytest.raises(HerdrError): - mux.set_session_option("bmad-loop-x", PROJECT_OPTION, "/proj") - mux.set_window_option("w1:p1", RETURN_OPTION, "w1:p2") # sentinel: swallowed - mux.unset_window_option("w1:p1", RETURN_OPTION) # sentinel: swallowed - herdr_backend._drop_state("windows", "w1:p1") # sentinel: swallowed - assert saves == [] # a failed lock skips the write entirely - - -def test_window_option_roundtrip(fake): - mux = HerdrMultiplexer() - assert mux.show_window_option("w1:p1", RETURN_OPTION) == "" - mux.set_window_option("w1:p1", RETURN_OPTION, "w1:p2") - assert mux.show_window_option("w1:p1", RETURN_OPTION) == "w1:p2" - mux.unset_window_option("w1:p1", RETURN_OPTION) - assert mux.show_window_option("w1:p1", RETURN_OPTION) == "" - - -# --------------------------------------------------------------- teardown ops - - -def test_kill_window_closes_pane_and_prunes_sidecar(fake): - fake.add_workspace("bmad-loop-x") - pane = fake.panes[-1] - mux = HerdrMultiplexer() - mux.set_window_option(pane["pane_id"], "opt", "v") - mux.kill_window(pane["pane_id"]) - assert pane not in fake.panes # pane closed (cascades the empty tab) - assert mux.show_window_option(pane["pane_id"], "opt") == "" # sidecar entry gone - - -def test_kill_session_closes_workspace_and_prunes_sidecar(fake): - wid = fake.add_workspace("bmad-loop-x") - mux = HerdrMultiplexer() - mux.set_session_option("bmad-loop-x", PROJECT_OPTION, "/proj") - mux.kill_session("bmad-loop-x") - assert all(w["workspace_id"] != wid for w in fake.workspaces) - state = json.loads(Path(os.environ["BMAD_LOOP_HERDR_STATE"]).read_text()) - assert "bmad-loop-x" not in state["sessions"] - - -def test_kill_session_tolerates_missing_binary(monkeypatch, tmp_path): - monkeypatch.setattr(herdr_backend.shutil, "which", lambda _name: None) - monkeypatch.setenv("BMAD_LOOP_HERDR_STATE", str(tmp_path / "s.json")) - assert HerdrMultiplexer().kill_session("bmad-loop-x") is None # no server op, no raise - - -def test_list_sessions(fake): - fake.add_workspace("bmad-loop-a") - fake.add_workspace("bmad-loop-b") - assert set(HerdrMultiplexer().list_sessions()) == {"bmad-loop-a", "bmad-loop-b"} - - -def test_list_sessions_empty_when_server_down(fake): - fake.running = False - assert HerdrMultiplexer().list_sessions() == [] - - -# ---------------------------------------------------------- server + protocol - - -def test_ensure_server_starts_when_down(fake, monkeypatch): - fake.running = False - popen_calls: list[tuple] = [] - - def fake_popen(argv, **kwargs): - popen_calls.append((argv, kwargs)) - fake.running = True # the spawned server comes up - return object() - - monkeypatch.setattr(herdr_backend.subprocess, "Popen", fake_popen) - mux = HerdrMultiplexer() - mux.new_session("bmad-loop-x", Path("/work")) - assert popen_calls and popen_calls[0][0] == ["herdr", "server"] - # detached spawn (POSIX start_new_session / win32 creationflags) - assert "start_new_session" in popen_calls[0][1] or "creationflags" in popen_calls[0][1] - assert any(w["label"] == "bmad-loop-x" for w in fake.workspaces) - - -def test_ensure_server_is_probed_once(fake): - mux = HerdrMultiplexer() - mux.new_session("bmad-loop-a", Path("/work")) - mux.new_window("bmad-loop-a", "win", Path("/work"), {}, "echo hi") - # the server was confirmed up once, not re-probed before every mutating op - assert len([c for c in fake.calls if c[:2] == ["status", "--json"]]) == 1 - - -def test_protocol_below_supported_raises(fake): - fake.protocol = herdr_backend.SUPPORTED_PROTOCOL - 1 - with pytest.raises(HerdrError): - HerdrMultiplexer().new_session("bmad-loop-x", Path("/work")) - - -def test_protocol_above_supported_warns_but_proceeds(fake): - fake.protocol = herdr_backend.SUPPORTED_PROTOCOL + 1 - mux = HerdrMultiplexer() - with pytest.warns(UserWarning): - mux.new_session("bmad-loop-x", Path("/work")) - assert _creates(fake, "workspace", "create") # created despite the warning - - -# --------------------------------------------------------------- degradations - - -def test_pipe_pane_tolerates_dead_pane_and_detach_noop(fake, tmp_path): - # pipe_pane races a pane that already died on launch: the priming read gets - # pane_not_found, so no tee thread is spun up (tmux swallows the same race). - mux = HerdrMultiplexer() - assert mux.pipe_pane("w9:p9", tmp_path / "log") is None - assert mux._pollers == {} # nothing left running - assert mux.detach_client() is None - - -# ------------------------------------------------- TUI-launch surface (PR 2) - - -def test_new_parked_window_recipe(fake): - fake.add_workspace("bmad-loop-ctl") - mux = HerdrMultiplexer() - pane_id = mux.new_parked_window( - "bmad-loop-ctl", "run-RID", Path("/work"), ["python", "-m", "x"], RETURN_OPTION - ) - (tab_create,) = _creates(fake, "tab", "create") - assert tab_create == [ - "tab", "create", - "--workspace", "w1", - "--label", "run-RID", - "--cwd", str(Path("/work")), - "--no-focus", - ] # fmt: skip - (pane_run,) = _creates(fake, "pane", "run") - assert pane_run[:3] == ["pane", "run", pane_id] - typed = pane_run[3] - # the typed line execs `sh -c ''` (one line, POSIX+fish-safe quoting) - assert typed.startswith("exec sh -c ") - sh_argv = shlex.split(typed[len("exec ") :]) - assert sh_argv[:2] == ["sh", "-c"] - src = sh_argv[2] - assert "\n" not in typed - # recipe shape: argv; exit capture; banner; park; return trailer - assert src.startswith("python -m x; ec=$?; ") - assert "[bmad-loop exited $ec — press enter]" in src - assert "read -r" in src - retfile = herdr_backend._return_file(pane_id) - assert str(retfile) in src # the trailer cats/rms the per-window return file - assert 'herdr tab focus "$ret"' in src - # the sidecar remembers which option this window's trailer consumes - state = json.loads(Path(os.environ["BMAD_LOOP_HERDR_STATE"]).read_text()) - assert state["windows"][pane_id][herdr_backend._PARKED_RETURN_KEY] == RETURN_OPTION - - -def test_new_parked_window_missing_session_raises(fake, tmp_path): - with pytest.raises(HerdrError): - HerdrMultiplexer().new_parked_window("s", "n", tmp_path, ["echo", "hi"], RETURN_OPTION) - - -def test_new_parked_window_rolls_back_tab_when_recipe_typing_fails(fake, monkeypatch): - # tmux gets create+launch atomically (one new-window call); here the tab - # already exists when the recipe-typing `pane run` fails, so the backend - # must close it again — not leave an untracked idle shell in the ctl - # workspace with a stale sidecar entry. - fake.add_workspace("bmad-loop-ctl") - before = {p["pane_id"] for p in fake.panes} - real_dispatch = fake._dispatch - seen: dict[str, str] = {} - - def failing_pane_run(cmd, argv): - if argv[:2] == ["pane", "run"]: - seen["pane_id"] = argv[2] - return fake._server_err(cmd, "internal", "pane run exploded") - return real_dispatch(cmd, argv) - - monkeypatch.setattr(fake, "_dispatch", failing_pane_run) - with pytest.raises(HerdrError): - HerdrMultiplexer().new_parked_window( - "bmad-loop-ctl", "run-RID", Path("/w"), ["x"], RETURN_OPTION - ) - assert seen["pane_id"] not in before # the launch really created a fresh pane - assert {p["pane_id"] for p in fake.panes} == before # ...and rolled it back - assert _creates(fake, "pane", "close") # via an explicit close, not luck - state = json.loads(Path(os.environ["BMAD_LOOP_HERDR_STATE"]).read_text()) - assert seen["pane_id"] not in state["windows"] # sidecar entry pruned too - - -def test_new_parked_window_rolls_back_tab_when_sidecar_write_fails(fake, monkeypatch): - # The sidecar write sits between tab create and recipe typing; when it - # fails there is no sidecar entry to catch the tab later, so the rollback - # is the only thing standing between us and an orphan. - fake.add_workspace("bmad-loop-ctl") - before = {p["pane_id"] for p in fake.panes} - - def no_lock(_path, **_kw): - raise OSError("lock unavailable") - - monkeypatch.setattr(herdr_backend.platform_util, "file_lock", no_lock) - with pytest.raises(HerdrError): - HerdrMultiplexer().new_parked_window( - "bmad-loop-ctl", "run-RID", Path("/w"), ["x"], RETURN_OPTION - ) - assert {p["pane_id"] for p in fake.panes} == before # created tab rolled back - assert _creates(fake, "pane", "close") - assert not _creates(fake, "pane", "run") # recipe never typed into a doomed tab - - -def test_parked_return_option_round_trip(fake): - # cmd_attach / the TUI write the return option by tmux-style name target; - # return_attached_client (inside the window) reads it back by native id — - # the sidecar key is normalized so both agree. The return FILE holds the - # trailer-actionable TAB id of the origin pane, resolved at write time. - fake.add_workspace("bmad-loop-ctl") - fake.add_workspace("userws") - origin = fake.panes[-1] # the TUI's own pane, in the user's workspace - mux = HerdrMultiplexer() - pane_id = mux.new_parked_window("bmad-loop-ctl", "sweep-RID", Path("/w"), ["x"], RETURN_OPTION) - retfile = herdr_backend._return_file(pane_id) - - mux.set_window_option("=bmad-loop-ctl:sweep-RID", RETURN_OPTION, origin["pane_id"]) - assert mux.show_window_option(pane_id, RETURN_OPTION) == origin["pane_id"] - assert retfile.read_text(encoding="utf-8").strip() == origin["tab_id"] - - # RETURN_DETACH is mirrored verbatim (the trailer treats it as "do nothing": - # ending the source closes the pane, which ends a `terminal attach` client) - mux.set_window_option(pane_id, RETURN_OPTION, "detach") - assert retfile.read_text(encoding="utf-8").strip() == "detach" - - # unsetting clears the option AND the file (so the post-exit trailer cannot - # fire a second time after a mid-process return) - mux.unset_window_option(pane_id, RETURN_OPTION) - assert mux.show_window_option(pane_id, RETURN_OPTION) == "" - assert not retfile.exists() - - -def test_parked_return_unresolvable_origin_clears_file(fake): - fake.add_workspace("bmad-loop-ctl") - mux = HerdrMultiplexer() - pane_id = mux.new_parked_window("bmad-loop-ctl", "run-RID", Path("/w"), ["x"], RETURN_OPTION) - mux.set_window_option(pane_id, RETURN_OPTION, "detach") - retfile = herdr_backend._return_file(pane_id) - assert retfile.exists() - # a gone origin pane must not leave a stale focus target behind - mux.set_window_option(pane_id, RETURN_OPTION, "w9:p9") - assert not retfile.exists() - assert mux.show_window_option(pane_id, RETURN_OPTION) == "w9:p9" # option itself kept - - -def test_kill_window_by_name_target_cleans_return_file(fake): - fake.add_workspace("bmad-loop-ctl") - mux = HerdrMultiplexer() - pane_id = mux.new_parked_window("bmad-loop-ctl", "run-RID", Path("/w"), ["x"], RETURN_OPTION) - mux.set_window_option(pane_id, RETURN_OPTION, "detach") - retfile = herdr_backend._return_file(pane_id) - assert retfile.exists() - mux.kill_window("=bmad-loop-ctl:run-RID") # kill_ctl_window passes this form - assert all(p["pane_id"] != pane_id for p in fake.panes) - assert not retfile.exists() - assert mux.show_window_option(pane_id, RETURN_OPTION) == "" - - -def test_kill_session_cleans_window_sidecar_and_return_files(fake): - fake.add_workspace("bmad-loop-ctl") - mux = HerdrMultiplexer() - pane_id = mux.new_parked_window("bmad-loop-ctl", "run-RID", Path("/w"), ["x"], RETURN_OPTION) - mux.set_window_option(pane_id, RETURN_OPTION, "detach") - retfile = herdr_backend._return_file(pane_id) - mux.kill_session("bmad-loop-ctl") - assert not retfile.exists() - state = json.loads(Path(os.environ["BMAD_LOOP_HERDR_STATE"]).read_text()) - assert pane_id not in state["windows"] - - -def test_select_window_by_name_focuses_tab(fake): - fake.add_workspace("bmad-loop-ctl") - mux = HerdrMultiplexer() - pane_id = mux.new_window("bmad-loop-ctl", "run-RID", Path("/w"), {}, "echo hi") - tab_id = next(p["tab_id"] for p in fake.panes if p["pane_id"] == pane_id) - mux.select_window("=bmad-loop-ctl:run-RID") - assert ["tab", "focus", tab_id] in fake.calls - ws = next(w for w in fake.workspaces if w["label"] == "bmad-loop-ctl") - assert ws["active_tab_id"] == tab_id - # unresolvable targets stay quiet no-ops (sentinel) - assert mux.select_window("=bmad-loop-ctl:nope") is None - assert mux.select_window("=absent:x") is None - - -def test_attach_target_argv_native_and_name_targets(fake): - fake.add_workspace("bmad-loop-ctl") - mux = HerdrMultiplexer() - pane_id = mux.new_window("bmad-loop-ctl", "run-RID", Path("/w"), {}, "echo hi") - pane = next(p for p in fake.panes if p["pane_id"] == pane_id) - expected = ["herdr", "terminal", "attach", pane["terminal_id"]] - assert mux.attach_target_argv(pane_id) == expected # native pane id - assert mux.attach_target_argv("=bmad-loop-ctl:run-RID") == expected # by window name - - -def test_attach_target_argv_session_level(fake): - fake.add_workspace("bmad-loop-ctl") - mux = HerdrMultiplexer() - a = mux.new_window("bmad-loop-ctl", "run-a", Path("/w"), {}, "sleep 1") - b = mux.new_window("bmad-loop-ctl", "run-b", Path("/w"), {}, "sleep 1") - term = {p["pane_id"]: p["terminal_id"] for p in fake.panes} - # task tabs are created --no-focus, so the root shell tab is still active: - # a session-level attach prefers the NEWEST task tab over the root shell - assert mux.attach_target_argv("=bmad-loop-ctl")[-1] == term[b] - # after an explicit select (attach_plan runs select_ctl_window first) the - # active tab wins - mux.select_window("=bmad-loop-ctl:run-a") - assert mux.attach_target_argv("=bmad-loop-ctl")[-1] == term[a] - assert a != b - - -def test_attach_target_argv_session_level_single_tab_is_root(fake): - fake.add_workspace("bmad-loop-x") - root = fake.panes[-1] - assert HerdrMultiplexer().attach_target_argv("=bmad-loop-x")[-1] == root["terminal_id"] - - -def test_attach_target_argv_inside_herdr_focuses(fake, monkeypatch): - # inside a herdr pane, nesting a full attach is the wrong move (as with - # attach-inside-tmux): return the fire-and-forget focus argv instead - fake.add_workspace("bmad-loop-ctl") - mux = HerdrMultiplexer() - pane_id = mux.new_window("bmad-loop-ctl", "run-RID", Path("/w"), {}, "echo hi") - tab_id = next(p["tab_id"] for p in fake.panes if p["pane_id"] == pane_id) - monkeypatch.setenv("HERDR_ENV", "1") - assert mux.attach_target_argv("=bmad-loop-ctl:run-RID") == ["herdr", "tab", "focus", tab_id] - - -def test_attach_target_argv_missing_pane_raises(fake): - with pytest.raises(HerdrError): - HerdrMultiplexer().attach_target_argv("w9:p9") - - -def test_attach_target_argv_missing_session_raises_with_guidance(fake): - with pytest.raises(HerdrError) as excinfo: - HerdrMultiplexer().attach_target_argv("=absent") - assert "attach" in str(excinfo.value) - - -def test_switch_client_focuses_origin_tab(fake): - fake.add_workspace("bmad-loop-ctl") - fake.add_workspace("userws") - origin = fake.panes[-1] - mux = HerdrMultiplexer() - assert mux.switch_client(origin["pane_id"]) is True - assert ["tab", "focus", origin["tab_id"]] in fake.calls - # a gone target is honestly False; herdr has no "last client" to fall back to - assert mux.switch_client("w9:p9") is False - assert mux.switch_client("w9:p9", last_fallback=True) is False - - -def test_list_windows_tmux_field_mapping(fake): - fake.add_workspace("bmad-loop-ctl") - mux = HerdrMultiplexer() - pane_id = mux.new_window("bmad-loop-ctl", "run-RID", Path("/w"), {}, "echo hi") - mux.set_window_option(pane_id, PROJECT_OPTION, "/proj") - rows = mux.list_windows("bmad-loop-ctl", ["window_id", "window_name", PROJECT_OPTION]) - by_id = {row[0]: row for row in rows} - assert by_id[pane_id] == (pane_id, "run-RID", "/proj") - root = fake.panes[0]["pane_id"] - assert by_id[root] == (root, "1", "") # the root shell tab: numeric label, untagged - # ctl_window()'s discovery contract: every window comes back with its name - assert {row[1] for row in rows} == {"1", "run-RID"} - - -def test_in_ctl_session_seam_honest_under_herdr(fake, monkeypatch): - # launch.in_ctl_session no longer sniffs TMUX: with herdr selected and the - # herdr pane env present it resolves through the seam. - from bmad_loop.adapters.multiplexer import get_multiplexer - from bmad_loop.tui import launch - - wid = fake.add_workspace(launch.CTL_SESSION) - monkeypatch.delenv("TMUX", raising=False) - monkeypatch.setenv("BMAD_LOOP_MUX_BACKEND", "herdr") - get_multiplexer.cache_clear() - try: - monkeypatch.setenv("HERDR_ENV", "1") - monkeypatch.setenv("HERDR_WORKSPACE_ID", wid) - monkeypatch.setenv("HERDR_PANE_ID", "w1:p1") - assert launch.in_ctl_session() is True - monkeypatch.setenv("HERDR_WORKSPACE_ID", "w9") # some other workspace - assert launch.in_ctl_session() is False - monkeypatch.delenv("HERDR_ENV", raising=False) # not inside herdr at all - assert launch.in_ctl_session() is False - finally: - get_multiplexer.cache_clear() - - -def test_current_accessors_resolve_from_env(fake, monkeypatch): - wid = fake.add_workspace("bmad-loop-x") - monkeypatch.setenv("HERDR_ENV", "1") - monkeypatch.setenv("HERDR_WORKSPACE_ID", wid) - monkeypatch.setenv("HERDR_PANE_ID", "w1:p1") - mux = HerdrMultiplexer() - assert mux.current_session() == "bmad-loop-x" - assert mux.current_pane_id() == "w1:p1" - assert mux.current_window_id() == "w1:p1" - - -def test_current_accessors_none_outside_herdr(fake, monkeypatch): - monkeypatch.delenv("HERDR_ENV", raising=False) - mux = HerdrMultiplexer() - assert mux.current_session() is None - assert mux.current_pane_id() is None - assert mux.current_window_id() is None diff --git a/tests/test_herdr_e2e.py b/tests/test_herdr_e2e.py deleted file mode 100644 index 49086f0..0000000 --- a/tests/test_herdr_e2e.py +++ /dev/null @@ -1,81 +0,0 @@ -"""Stories E2E over the herdr backend, gated on a live herdr install. - -ONE happy-path scenario lifted from ``test_stories_e2e.py``'s fake-claude + -custom-profile-TOML recipe, driven through the REAL ``bmad-loop run`` binary with -the herdr backend forced by env (``BMAD_LOOP_MUX_BACKEND=herdr``) and an isolated -``HERDR_SESSION=bmad-test-`` per test (a private herdr server + socket, with -a guaranteed stop+delete finalizer). The scaffold, fake CLI, profile TOML, and -assertions are reused verbatim from ``test_stories_e2e`` so this proves the exact -same full stack — arg parsing, prompt render, hook-signal completion, the stories -read-back, git commit, sprint advance — resolves over herdr as it does over tmux. - -Deliberately just the two-story happy path: one full-stack pass proves the -CLI-through-herdr wiring end to end; the exhaustive stories/sprint/sweep matrix -stays on tmux in ``test_stories_e2e.py``. The module is skipped when herdr is not -installed, and on win32, where the POSIX ``exec`` launch does not apply (PR-6). -""" - -from __future__ import annotations - -import os -import shutil -import subprocess -import sys -import uuid - -import pytest - -# Lift the deterministic fake-claude recipe wholesale (scaffold + assertions). -# Prepend import mode makes this cross-module import work (no tests/__init__.py); -# see the Phase-2/3 notes on the same pattern for test_herdr_poller. -from test_herdr_integration import _teardown_session -from test_stories_e2e import CLI, _commit_count, _entry, _scaffold, _status - -HAVE_HERDR = sys.platform != "win32" and shutil.which("herdr") is not None -pytestmark = pytest.mark.skipif(not HAVE_HERDR, reason="stories E2E needs herdr") - - -@pytest.fixture -def herdr_env(tmp_path, monkeypatch): - """Force the herdr backend and isolate its server/socket + sidecar for the real - ``bmad-loop`` subprocess. The child inherits these vars from ``os.environ`` - (``_run`` spawns without an explicit ``env``), so a fresh process picks herdr - with no cache to clear. Unique session name per test => xdist-safe; the - finalizer tears the private server down even on failure.""" - name = f"bmad-test-{uuid.uuid4().hex[:12]}" - monkeypatch.setenv("HERDR_SESSION", name) - monkeypatch.setenv("BMAD_LOOP_MUX_BACKEND", "herdr") - monkeypatch.setenv("BMAD_LOOP_HERDR_STATE", str(tmp_path / "herdr-state.json")) - try: - yield name - finally: - _teardown_session(name) - - -def _run(root, *args, timeout=150) -> subprocess.CompletedProcess: - # Inherits os.environ (the monkeypatched herdr vars) — same shape as - # test_stories_e2e._run, kept local so the herdr env flows to the child. - return subprocess.run( - [*CLI, args[0], "--project", str(root), *args[1:]], - capture_output=True, - text=True, - timeout=timeout, - cwd=str(root), - env=os.environ.copy(), - ) - - -def test_herdr_e2e_two_story_happy_path(tmp_path, herdr_env): - """The full CLI stack (test_stories_e2e's happy path) resolving over herdr: two - stories dispatched to done through real herdr workspaces/tabs/panes, one - squashed commit per story.""" - root = tmp_path / "sbx" - _scaffold(root, [_entry("1"), _entry("2")]) - base = _commit_count(root) - - proc = _run(root, "run") - assert proc.returncode == 0, proc.stderr or proc.stdout - assert _status(root, "1") == "done" - assert _status(root, "2") == "done" - # one squashed story commit per story above the sandbox baseline - assert _commit_count(root) == base + 2 diff --git a/tests/test_herdr_integration.py b/tests/test_herdr_integration.py deleted file mode 100644 index 9407550..0000000 --- a/tests/test_herdr_integration.py +++ /dev/null @@ -1,282 +0,0 @@ -"""Integration tests for the herdr backend, gated on a live herdr install. - -These mirror ``test_generic_tmux.py``'s three live tests — fake-CLI end-to-end, -crash detection, and pipe_pane log growth — but drive them through the -:class:`~bmad_loop.adapters.herdr_backend.HerdrMultiplexer` against a REAL herdr -0.7.3 server. As with the tmux ones a tiny shell script stands in for the CLI -binary (it writes its own SessionStart/result.json/Stop, exactly what a -hook-instrumented session produces), so spawn / env-propagation / hook-signal -waiting / window-death / kill are exercised end-to-end. - -Isolation: each test runs under its own ``HERDR_SESSION=bmad-test-`` — a -private per-session herdr server + socket (``~/.config/herdr/sessions//``) -— and forces the herdr backend by name (``BMAD_LOOP_MUX_BACKEND=herdr``); the -sidecar is redirected into ``tmp_path``. A finalizer stops+deletes that session -so no server, workspace, or poller thread outlives the test. The whole module is -skipped when herdr is not installed, and on win32, where the POSIX ``exec`` -launch does not apply (the Windows launch path is a PR-6 follow-up). -""" - -from __future__ import annotations - -import shutil -import subprocess -import sys -import time -import uuid -from pathlib import Path - -import pytest - -from bmad_loop.adapters import herdr_backend, multiplexer -from bmad_loop.adapters.base import SessionSpec -from bmad_loop.adapters.generic import GenericTmuxAdapter -from bmad_loop.adapters.profile import get_profile -from bmad_loop.policy import LimitsPolicy, Policy - -HAVE_HERDR = sys.platform != "win32" and shutil.which("herdr") is not None -pytestmark = pytest.mark.skipif(not HAVE_HERDR, reason="herdr not available") - -# Same hook-instrumented fake as test_generic_tmux.py's FAKE_CLI: the last -# positional arg is the rendered prompt; the run dir + task id ride the pane env -# (herdr `--env`, where tmux used `-e`). Emits SessionStart + result.json + Stop, -# then idles like a live interactive session until its window is killed. -FAKE_CLI = """#!/bin/bash -prompt="${@: -1}" -ts=$(date +%s%N) -mkdir -p "$BMAD_LOOP_RUN_DIR/events" "$BMAD_LOOP_RUN_DIR/tasks/$BMAD_LOOP_TASK_ID" -printf '{"ts": %s, "event": "SessionStart", "task_id": "%s", "session_id": "fake-1"}' \\ - "$ts" "$BMAD_LOOP_TASK_ID" > "$BMAD_LOOP_RUN_DIR/events/$ts-$BMAD_LOOP_TASK_ID-SessionStart.json" -echo "{\\"workflow\\": \\"auto-dev\\", \\"prompt\\": \\"$prompt\\"}" \\ - > "$BMAD_LOOP_RUN_DIR/tasks/$BMAD_LOOP_TASK_ID/result.json" -ts2=$(( ts + 1 )) -printf '{"ts": %s, "event": "Stop", "task_id": "%s", "session_id": "fake-1"}' \\ - "$ts2" "$BMAD_LOOP_TASK_ID" > "$BMAD_LOOP_RUN_DIR/events/$ts2-$BMAD_LOOP_TASK_ID-Stop.json" -sleep 60 # stay alive like an idle interactive session -""" - - -def _teardown_session(name: str) -> None: - """Tear down an isolated herdr session (its server + socket + everything under - it). Best-effort: a never-started session makes both verbs harmless no-ops.""" - for verb in ("stop", "delete"): - subprocess.run(["herdr", "session", verb, name], capture_output=True, text=True) - - -@pytest.fixture -def herdr_session(tmp_path, monkeypatch): - """Isolate the herdr backend onto a private per-test server/socket and force it - selected by name, with a guaranteed teardown. - - ``HERDR_SESSION`` gives every ``herdr`` subprocess this backend spawns its own - server + socket, so tests never touch the user's default session or each other - (safe under xdist — the name is unique per test). ``BMAD_LOOP_MUX_BACKEND=herdr`` - + a cache clear on both ends makes ``get_multiplexer()`` pick herdr regardless - of host platform (and not leak the pick). The sidecar is redirected out of - ``~/.bmad-loop``. The finalizer stops+deletes the session even on failure.""" - name = f"bmad-test-{uuid.uuid4().hex[:12]}" - monkeypatch.setenv("HERDR_SESSION", name) - monkeypatch.setenv("BMAD_LOOP_MUX_BACKEND", "herdr") - monkeypatch.setenv("BMAD_LOOP_HERDR_STATE", str(tmp_path / "herdr-state.json")) - multiplexer.get_multiplexer.cache_clear() - try: - yield name - finally: - multiplexer.get_multiplexer.cache_clear() - _teardown_session(name) - - -def _make_adapter( - tmp_path, profile_name="claude", binary=None, extra_args=None, **policy_kw -) -> GenericTmuxAdapter: - # Unique run dir per adapter => unique session name (== workspace label), so - # concurrent tests on one isolated server never race a teardown vs a create. - run_dir = tmp_path / f"run-{uuid.uuid4().hex[:8]}" - policy = Policy(limits=LimitsPolicy(**policy_kw) if policy_kw else LimitsPolicy()) - return GenericTmuxAdapter( - run_dir=run_dir, - policy=policy, - profile=get_profile(profile_name), - binary=binary, - extra_args=extra_args, - ) - - -def _write_fake_cli(tmp_path: Path, body: str = FAKE_CLI) -> Path: - fake = tmp_path / "fake-cli" - fake.write_text(body) - fake.chmod(0o755) - return fake - - -@pytest.mark.parametrize("profile_name", ["claude", "codex", "gemini"]) -def test_herdr_end_to_end_with_fake_cli(tmp_path, herdr_session, profile_name): - """Spawn a real herdr workspace/tab/pane running a fake CLI that behaves like a - hook-instrumented session (SessionStart + result.json + Stop) -> completed. The - same shape as the tmux end-to-end test, proving the herdr transport carries the - full launch/env/hook/read-back path for every profile.""" - fake = _write_fake_cli(tmp_path) - # extra_args=() drops the bypass flags so the rendered prompt is the last argv - # entry for every profile (claude/codex positional, gemini behind -i). - adapter = _make_adapter(tmp_path, profile_name=profile_name, binary=str(fake), extra_args=()) - # Guard: we are genuinely exercising herdr, not a silent fall-back to tmux. - assert isinstance(adapter.mux, herdr_backend.HerdrMultiplexer) - spec = SessionSpec( - task_id="t-int-1", - role="dev", - prompt="/bmad-dev-auto 1-1-a", - cwd=tmp_path, - env={ - "BMAD_LOOP_MODE": "1", - "BMAD_LOOP_RUN_DIR": str(adapter.run_dir), - "BMAD_LOOP_TASK_ID": "t-int-1", - }, - timeout_s=30.0, - ) - result = adapter.run(spec) - - assert result.status == "completed" - assert result.result_json["workflow"] == "auto-dev" - # the fake echoes back the rendered prompt it received via the pane env - assert result.result_json["prompt"] == adapter.profile.render_prompt(spec.prompt) - assert result.session_id == "fake-1" - assert (adapter.tasks_dir / "t-int-1" / "prompt.txt").read_text().strip() == spec.prompt - - -def test_herdr_crash_detected(tmp_path, herdr_session): - """A session that dies without writing result.json -> crashed. Pins Phase-0 O1: - an exec'd process exit vanishes the pane (no linger), so pane-presence liveness - reports window death authoritatively — the guarantee the SessionEnd-less codex - path relies on.""" - fake = _write_fake_cli(tmp_path, "#!/bin/bash\nexit 1\n") - adapter = _make_adapter( - tmp_path, profile_name="codex", binary=str(fake), stop_without_result_nudges=0 - ) - assert isinstance(adapter.mux, herdr_backend.HerdrMultiplexer) - spec = SessionSpec( - task_id="t-crash", - role="dev", - prompt="x", - cwd=tmp_path, - env={"BMAD_LOOP_RUN_DIR": str(adapter.run_dir), "BMAD_LOOP_TASK_ID": "t-crash"}, - timeout_s=20.0, - ) - result = adapter.run(spec) - - assert result.status == "crashed" - assert result.result_json is None - - -def test_herdr_pipe_pane_log_grows_under_real_pane(tmp_path, herdr_session, monkeypatch): - """pipe_pane tees a live pane's output into the log by polling (herdr has no - native pipe-pane). Under a pane emitting changing text the log must GROW and - the latest snapshot must be discoverable in it — the two consumers a tmux tee - drives: generic._log_activity_key (dev-stall re-arm on log growth) and probe - (completion-marker scan). A #85-style no-op tee would leave the log flat and - mis-stall a long silent-but-working turn. kill_session then retires the tee so - no poller thread outlives the workspace it watched.""" - # Shrink the poll interval so the growth window is a couple of seconds, not - # tens. _PanePoller reads this global at construction (pipe_pane time). - monkeypatch.setattr(herdr_backend, "POLL_INTERVAL_S", 0.25) - # Prints a fresh non-blank line ~5×/s (blank repaints aren't logged), then - # idles alive so the pane stays readable while we watch the log. - script = tmp_path / "grow.sh" - script.write_text( - '#!/bin/bash\nfor i in $(seq 1 40); do echo "MARKER line $i"; sleep 0.2; done\nsleep 30\n' - ) - script.chmod(0o755) - - mux = multiplexer.get_multiplexer() - assert isinstance(mux, herdr_backend.HerdrMultiplexer) - session = "bmad-loop-grow" - mux.new_session(session, tmp_path) - try: - window_id = mux.new_window(session, "grow", tmp_path, {}, str(script)) - log_file = tmp_path / "grow.log" - mux.pipe_pane(window_id, log_file) - - # pipe_pane primes the first snapshot synchronously, so the log exists at - # once; wait for a later poll to append more (growth == the activity signal). - first_size: int | None = None - grew = False - deadline = time.monotonic() + 15 - while time.monotonic() < deadline: - if log_file.exists(): - size = log_file.stat().st_size - if first_size is None: - first_size = size - elif size > first_size: - grew = True - break - time.sleep(0.2) - - assert grew, "pipe_pane poller never grew the log under a producing pane" - assert "MARKER" in log_file.read_text(encoding="utf-8") # probe-marker discoverability - finally: - # kill_session stops the session's tees (poller registry emptied) even - # when an assertion above fails — a leaked poller would outlive the - # fixture's server teardown (transport failures never self-retire it) - # and spin subprocess reads for the rest of the pytest worker's life. - mux.kill_session(session) - assert mux._pollers == {} - - -def test_herdr_parked_window_via_start_detached(herdr_session, tmp_path): - """PR-2 surface, live: launch.start_detached parks `bmad-loop validate` in a - bmad-loop-ctl workspace tab. Pins the whole chain: tab-label discovery - (ctl_window through the list_windows join), the exit banner rendering while - the window PARKS (stays alive) after the command exits, set_return_pane by - tmux-style name target mirroring into the per-window return file, and Enter - unparking — the trailer consumes the file, the pane closes (which is what - ends a watching `terminal attach` client), and the ctl workspace survives - on its root shell tab.""" - import re - - from bmad_loop.tui import launch - - run_id = "20260713-000000-p6live" - win_id = launch.start_detached( - tmp_path, ["validate", "--project", str(tmp_path)], run_id, "run" - ) - assert win_id # the native window id (the tab's root pane) - mux = multiplexer.get_multiplexer() - assert isinstance(mux, herdr_backend.HerdrMultiplexer) # no silent tmux fall-back - - # discovery: the ctl window comes back by its tab label - assert launch.ctl_window(run_id) == f"run-{run_id}" - - # validate exits quickly; the window must PARK with the EXPANDED banner - # (the typed recipe itself contains the literal `$ec` text, so match the - # substituted exit status, not the echo's source) - banner_re = re.compile(r"\[bmad-loop exited \d+") - deadline = time.monotonic() + 30 - screen = "" - while time.monotonic() < deadline: - screen = subprocess.run( - ["herdr", "pane", "read", win_id, "--source", "recent-unwrapped"], - capture_output=True, - text=True, - ).stdout - if banner_re.search(screen): - break - time.sleep(0.5) - assert banner_re.search(screen), f"no parked banner in pane after validate: {screen[-500:]!r}" - assert mux.window_alive(launch.CTL_SESSION, win_id) is True # parked, not closed - - # attach-time return recording by tmux-style name target lands in the - # per-window return file (the sidecar key is normalized to the native id) - launch.set_return_pane(f"={launch.CTL_SESSION}:run-{run_id}", launch.RETURN_DETACH) - retfile = herdr_backend._return_file(win_id) - assert retfile.read_text(encoding="utf-8").strip() == launch.RETURN_DETACH - assert mux.show_window_option(win_id, launch.RETURN_OPTION) == launch.RETURN_DETACH - - # Enter unparks: the trailer consumes the file, sh exits, the pane closes - subprocess.run(["herdr", "pane", "send-keys", win_id, "enter"], capture_output=True) - deadline = time.monotonic() + 15 - while time.monotonic() < deadline: - if not mux.window_alive(launch.CTL_SESSION, win_id) and not retfile.exists(): - break - time.sleep(0.5) - assert mux.window_alive(launch.CTL_SESSION, win_id) is False - assert not retfile.exists() # the trailer rm -f'd it - assert mux.has_session(launch.CTL_SESSION) is True # root shell tab keeps it alive diff --git a/tests/test_herdr_poller.py b/tests/test_herdr_poller.py deleted file mode 100644 index 5017866..0000000 --- a/tests/test_herdr_poller.py +++ /dev/null @@ -1,279 +0,0 @@ -"""Tests for the herdr ``pipe_pane`` polling tee (:class:`_PanePoller`). - -herdr has no ``pipe-pane``/tee, so :meth:`HerdrMultiplexer.pipe_pane` emulates it -with a daemon that polls ``pane read`` and appends a fresh snapshot to the log -whenever the pane content changes (content-hash gated — the CLI ``revision`` is -unusable). Two consumers depend on that log: ``generic._log_activity_key`` -re-arms the dev-stall grace on log *growth*, and ``probe`` finds completion -markers in the log text. These tests reuse ``test_herdr_backend``'s in-memory -``FakeHerdr`` transport (now answering ``pane read``) and drive the poller both -directly (deterministic) and through real threads (kill / self-retire). -""" - -from __future__ import annotations - -import threading -import time -from pathlib import Path - -import pytest -from test_herdr_backend import install_fake_herdr - -from bmad_loop.adapters import herdr_backend -from bmad_loop.adapters.herdr_backend import HerdrMultiplexer - - -@pytest.fixture -def fake(monkeypatch, tmp_path): - f = install_fake_herdr(monkeypatch, tmp_path) - # Tight cadence so thread-level tests don't sleep a real second per tick, and - # a short not-found streak so self-retire happens fast. Read from the module - # globals by _PanePoller.__init__, so these apply to pipe_pane-started tees. - monkeypatch.setattr(herdr_backend, "POLL_INTERVAL_S", 0.01) - monkeypatch.setattr(herdr_backend, "POLL_NOT_FOUND_LIMIT", 2) - return f - - -# ------------------------------------------------------------------ helpers - - -def _key(path: Path) -> tuple[int, int]: - """The exact activity signature generic._log_activity_key reads.""" - st = path.stat() - return (st.st_mtime_ns, st.st_size) - - -def _wait_until(predicate, *, timeout: float = 3.0, interval: float = 0.02) -> bool: - deadline = time.monotonic() + timeout - while time.monotonic() < deadline: - if predicate(): - return True - time.sleep(interval) - return predicate() - - -def _read(path: Path) -> str: - try: - return path.read_text(encoding="utf-8") - except OSError: - return "" - - -def _stop_all(mux: HerdrMultiplexer) -> None: - """Retire every tee and join it — keep the suite free of stray threads.""" - with mux._pollers_lock: - pollers = list(mux._pollers.values()) - mux._pollers.clear() - for poller in pollers: - poller.stop() - poller.join(timeout=2.0) - - -# --------------------------------------------------------- direct (no thread) - - -def test_poller_appends_on_change_skips_unchanged(fake, tmp_path): - # The core contract: the log's (mtime_ns, size) key advances across a CHANGED - # read and stays put across an identical one — that key is what re-arms the - # dev-stall grace, so a static screen must not keep it alive. - fake.add_workspace("bmad-loop-x") - pane = fake.panes[-1]["pane_id"] - log = tmp_path / "logs" / "t.log" - fake.set_pane_reads(pane, ["screen A\n", "screen A\n", "screen B\n"]) - - poller = herdr_backend._PanePoller( - herdr_backend._HerdrClient(), pane, log, interval_s=1, not_found_limit=2 - ) - assert poller.prime() is True # read #1 "screen A" -> appended - key1 = _key(log) - - poller._record(poller._read_snapshot()) # read #2 identical -> no growth - assert _key(log) == key1 - - poller._record(poller._read_snapshot()) # read #3 changed -> key advances - assert _key(log) != key1 - assert log.stat().st_size > key1[1] - text = log.read_text(encoding="utf-8") - assert "screen A" in text and "screen B" in text - - -def test_poller_records_marker_for_discovery(fake, tmp_path): - # probe/marker scanning reads the tee'd log; the latest snapshot (markers and - # all) must land there verbatim. - fake.add_workspace("bmad-loop-x") - pane = fake.panes[-1]["pane_id"] - log = tmp_path / "logs" / "t.log" - fake.set_pane_reads(pane, ["booting...\n", "Auto Run Result: done\nStatus: completed\n"]) - - poller = herdr_backend._PanePoller( - herdr_backend._HerdrClient(), pane, log, interval_s=1, not_found_limit=2 - ) - assert poller.prime() is True - poller._record(poller._read_snapshot()) - assert "Auto Run Result: done" in log.read_text(encoding="utf-8") - - -def test_poller_blank_screen_makes_no_log(fake, tmp_path): - # A live-but-blank pane isn't activity: prime succeeds (the pane answered) but - # writes nothing, so _log_activity_key stays None and can't spuriously re-arm. - fake.add_workspace("bmad-loop-x") - pane = fake.panes[-1]["pane_id"] - log = tmp_path / "logs" / "t.log" - fake.set_pane_reads(pane, ["", " \n"]) - - poller = herdr_backend._PanePoller( - herdr_backend._HerdrClient(), pane, log, interval_s=1, not_found_limit=2 - ) - assert poller.prime() is True - poller._record(poller._read_snapshot()) - assert not log.exists() - - -def test_prime_false_when_pane_already_gone(fake, tmp_path): - # The crash-on-launch race: pane read answers pane_not_found -> no tee. - poller = herdr_backend._PanePoller( - herdr_backend._HerdrClient(), "w9:p9", tmp_path / "t.log", interval_s=1, not_found_limit=2 - ) - assert poller.prime() is False - assert not (tmp_path / "t.log").exists() - - -def test_prime_false_when_server_unreachable(fake, tmp_path): - # A transport hiccup at attach time is swallowed (no tee), mirroring tmux - # pipe-pane's TmuxError tolerance — the run's death is caught elsewhere. - fake.add_workspace("bmad-loop-x") - pane = fake.panes[-1]["pane_id"] - fake.running = False # `pane read` now returns a non-JSON `Error: Os` - poller = herdr_backend._PanePoller( - herdr_backend._HerdrClient(), pane, tmp_path / "t.log", interval_s=1, not_found_limit=2 - ) - assert poller.prime() is False - - -# ------------------------------------------------------------- threaded tees - - -def test_pipe_pane_tees_pane_growth(fake, tmp_path): - # End-to-end: pipe_pane starts a tee that streams a growing pane into the log. - fake.add_workspace("bmad-loop-x") - pane = fake.panes[-1]["pane_id"] - fake.set_pane_reads(pane, ["line 1\n", "line 1\nline 2\n", "line 1\nline 2\nline 3\n"]) - log = tmp_path / "run" / "t.log" - mux = HerdrMultiplexer() - try: - mux.pipe_pane(pane, log) - assert mux._pollers[pane].is_alive() - assert _wait_until(lambda: "line 3" in _read(log)) - finally: - _stop_all(mux) - text = log.read_text(encoding="utf-8") - assert "line 1" in text and "line 2" in text and "line 3" in text - - -def test_pipe_pane_replaces_existing_tee(fake, tmp_path): - # A re-armed window calls pipe_pane again; only one tee should survive. - fake.add_workspace("bmad-loop-x") - pane = fake.panes[-1]["pane_id"] - fake.set_pane_reads(pane, ["hi\n"]) - mux = HerdrMultiplexer() - try: - mux.pipe_pane(pane, tmp_path / "a.log") - first = mux._pollers[pane] - mux.pipe_pane(pane, tmp_path / "b.log") - second = mux._pollers[pane] - assert first is not second - assert first.join(timeout=2.0) is None and not first.is_alive() # old tee retired - assert second.is_alive() - finally: - _stop_all(mux) - - -def test_kill_window_stops_tee(fake, tmp_path): - fake.add_workspace("bmad-loop-x") - pane = fake.panes[-1]["pane_id"] - fake.set_pane_reads(pane, ["hello\n"]) - mux = HerdrMultiplexer() - mux.pipe_pane(pane, tmp_path / "t.log") - poller = mux._pollers[pane] - assert poller.is_alive() - - mux.kill_window(pane) - poller.join(timeout=2.0) - assert not poller.is_alive() - assert pane not in mux._pollers - - -def test_tee_self_retires_when_pane_vanishes(fake, tmp_path): - # No kill_* call: the pane just disappears (its process exited). The tee sees - # POLL_NOT_FOUND_LIMIT consecutive pane_not_founds and exits on its own. - fake.add_workspace("bmad-loop-x") - pane = fake.panes[-1]["pane_id"] - fake.set_pane_reads(pane, ["alive\n"]) - mux = HerdrMultiplexer() - try: - mux.pipe_pane(pane, tmp_path / "t.log") - poller = mux._pollers[pane] - fake.panes = [p for p in fake.panes if p["pane_id"] != pane] # process exited - poller.join(timeout=3.0) - assert not poller.is_alive() - finally: - _stop_all(mux) - - -def test_transport_hiccup_does_not_retire_tee(fake, tmp_path): - # A couldn't-ask read (server briefly unreachable) is neither growth nor - # death: the tee keeps running and resumes appending once reads recover. - fake.add_workspace("bmad-loop-x") - pane = fake.panes[-1]["pane_id"] - fake.set_pane_reads(pane, ["before\n"]) - mux = HerdrMultiplexer() - try: - mux.pipe_pane(pane, tmp_path / "t.log") - poller = mux._pollers[pane] - fake.running = False # reads become non-JSON transport errors - time.sleep(0.1) # several ticks of hiccups (>> POLL_NOT_FOUND_LIMIT) - assert poller.is_alive() # NOT retired — a hiccup isn't a not-found - fake.running = True - fake.set_pane_reads(pane, ["after\n"]) - assert _wait_until(lambda: "after" in _read(tmp_path / "t.log")) - finally: - _stop_all(mux) - - -def test_kill_session_stops_all_session_tees(fake, tmp_path): - # No thread leak across kill_session: every tee for the session's windows is - # retired (matched by the pane-id's workspace prefix) and the registry clears. - fake.add_workspace("bmad-loop-x") - mux = HerdrMultiplexer() - pane_a = mux.new_window("bmad-loop-x", "w-a", tmp_path, {}, "echo a") - pane_b = mux.new_window("bmad-loop-x", "w-b", tmp_path, {}, "echo b") - fake.set_pane_reads(pane_a, ["a\n"]) - fake.set_pane_reads(pane_b, ["b\n"]) - mux.pipe_pane(pane_a, tmp_path / "a.log") - mux.pipe_pane(pane_b, tmp_path / "b.log") - pollers = [mux._pollers[pane_a], mux._pollers[pane_b]] - assert all(p.is_alive() for p in pollers) - - mux.kill_session("bmad-loop-x") - for poller in pollers: - poller.join(timeout=2.0) - assert not any(p.is_alive() for p in pollers) - assert mux._pollers == {} - - -def test_no_herdr_poll_threads_leak(fake, tmp_path): - # A guard on the whole module: after a tee's lifecycle nothing named - # herdr-poll-* is left alive. - fake.add_workspace("bmad-loop-x") - pane = fake.panes[-1]["pane_id"] - fake.set_pane_reads(pane, ["x\n"]) - mux = HerdrMultiplexer() - mux.pipe_pane(pane, tmp_path / "t.log") - mux.kill_window(pane) - _wait_until(lambda: not _live_poll_threads(pane)) - assert not _live_poll_threads(pane) - - -def _live_poll_threads(pane_id: str) -> list[threading.Thread]: - name = f"herdr-poll-{pane_id}" - return [t for t in threading.enumerate() if t.name == name and t.is_alive()] diff --git a/tests/test_multiplexer.py b/tests/test_multiplexer.py index 74c81ec..bd79db6 100644 --- a/tests/test_multiplexer.py +++ b/tests/test_multiplexer.py @@ -17,7 +17,6 @@ from bmad_loop.adapters import tmux_base from bmad_loop.adapters.base import SessionSpec from bmad_loop.adapters.generic import GenericAdapter -from bmad_loop.adapters.herdr_backend import HerdrMultiplexer from bmad_loop.adapters.multiplexer import MultiplexerError, TerminalMultiplexer, parse_target from bmad_loop.adapters.profile import get_profile from bmad_loop.adapters.tmux_backend import TmuxMultiplexer @@ -528,14 +527,6 @@ def test_target_default_grammar(): assert mux.target("s", "") == "=s" -def test_herdr_inherits_the_default_encoder(): - # herdr resolves targets lazily at use time (_parse_target), so it must NOT - # override target() to eagerly emit native ids — a token formatted ahead of - # use (e.g. attach_plan's return_window) would go stale. - assert "target" not in HerdrMultiplexer.__dict__ - assert HerdrMultiplexer().target("s", "w") == "=s:w" - - @pytest.mark.parametrize( ("session", "window"), [("s", None), ("s", "w"), ("bmad-loop-ctl", "run-20260714-abc")], diff --git a/tests/test_policy.py b/tests/test_policy.py index f56e29b..0978279 100644 --- a/tests/test_policy.py +++ b/tests/test_policy.py @@ -602,11 +602,11 @@ def test_write_mux_backend_appends_table_to_legacy_file(tmp_path): p = tmp_path / "policy.toml" legacy = '# my notes\n[gates]\nmode = "none"\n' p.write_text(legacy, encoding="utf-8") - policy.write_mux_backend(p, "herdr") + policy.write_mux_backend(p, "psmux") text = p.read_text(encoding="utf-8") assert text.startswith(legacy) # untouched prefix, table appended at EOF pol = policy.load(p) - assert pol.mux.backend == "herdr" + assert pol.mux.backend == "psmux" assert pol.gates.mode == "none" diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py index 451f900..54128c4 100644 --- a/tests/test_tui_app.py +++ b/tests/test_tui_app.py @@ -1222,16 +1222,16 @@ async def test_attach_without_agent_session_notifies(project, monkeypatch): async def test_attach_multiplexer_error_notifies(project, monkeypatch): - # attach_target_argv is a server round-trip on the herdr backend, so it can - # raise after the availability/session pre-gates pass (server died or the - # workspace was torn down in between); the TUI must surface the error as a - # toast, not crash the app. + # attach_target_argv is a server round-trip on server-backed backends (e.g. + # the external herdr adapter), so it can raise after the availability/session + # pre-gates pass (server died or the workspace was torn down in between); the + # TUI must surface the error as a toast, not crash the app. monkeypatch.setattr(launch, "mux_available", lambda: True) monkeypatch.setattr(launch, "session_exists", lambda session: True) monkeypatch.setattr(launch, "ctl_window", lambda run_id: None) def boom(_target): - raise MultiplexerError("herdr server not reachable") + raise MultiplexerError("backend server not reachable") monkeypatch.setattr("bmad_loop.tui.app.runs.attach_target_argv", boom) make_run(project.project, "20260611-100000-aaaa") @@ -1241,7 +1241,7 @@ def boom(_target): await until(pilot, lambda: dashboard(app).selected_run_id is not None) await pilot.press("a") await until( - pilot, lambda: any("herdr server not reachable" in m for m in notifications(app)) + pilot, lambda: any("backend server not reachable" in m for m in notifications(app)) ) assert isinstance(app.screen, DashboardScreen) # the action failed soft @@ -1312,7 +1312,7 @@ def _patch_attach_exec(monkeypatch) -> tuple[list[list[str]], list[tuple[str, st return calls, stamps -@pytest.mark.usefixtures("force_tmux_backend") # win32: pin tmux; default mux is herdr there +@pytest.mark.usefixtures("force_tmux_backend") # pin tmux against win32-matching externals async def test_attach_targets_ctl_window_when_decision_pending(project, monkeypatch): run_dir = make_run(project.project, "20260611-100000-aaaa", run_type="sweep", alive=True) Journal(run_dir).append("decision-pending", dw_id="DW-7", question="q?") @@ -1334,7 +1334,7 @@ async def test_attach_targets_ctl_window_when_decision_pending(project, monkeypa assert stamps == [("=bmad-loop-ctl:sweep-20260611-100000-aaaa", "%9")] -@pytest.mark.usefixtures("force_tmux_backend") # win32: pin tmux; default mux is herdr there +@pytest.mark.usefixtures("force_tmux_backend") # pin tmux against win32-matching externals async def test_attach_outside_tmux_stamps_detach(project, monkeypatch): # No TMUX: a throwaway client attaches under suspend, so the ctl window is # stamped to detach it on exit (returning to the suspended TUI) rather than @@ -1357,7 +1357,7 @@ async def test_attach_outside_tmux_stamps_detach(project, monkeypatch): assert stamps == [("=bmad-loop-ctl:sweep-20260611-100000-aaaa", "detach")] -@pytest.mark.usefixtures("force_tmux_backend") # win32: pin tmux; default mux is herdr there +@pytest.mark.usefixtures("force_tmux_backend") # pin tmux against win32-matching externals async def test_attach_prefers_agent_session_without_decision(project, monkeypatch): make_run(project.project, "20260611-100000-aaaa", alive=True) monkeypatch.setattr(launch, "mux_available", lambda: True) @@ -1375,7 +1375,7 @@ async def test_attach_prefers_agent_session_without_decision(project, monkeypatc assert stamps == [] -@pytest.mark.usefixtures("force_tmux_backend") # win32: pin tmux; default mux is herdr there +@pytest.mark.usefixtures("force_tmux_backend") # pin tmux against win32-matching externals async def test_attach_falls_back_to_ctl_window(project, monkeypatch): make_run(project.project, "20260611-100000-aaaa", alive=True) selected: list[str] = [] @@ -1395,7 +1395,7 @@ async def test_attach_falls_back_to_ctl_window(project, monkeypatch): assert stamps == [("=bmad-loop-ctl:run-20260611-100000-aaaa", "%9")] -@pytest.mark.usefixtures("force_tmux_backend") # win32: pin tmux; default mux is herdr there +@pytest.mark.usefixtures("force_tmux_backend") # pin tmux against win32-matching externals async def test_resolve_escalation_launches_and_attaches(project, monkeypatch): launched: list[str] = [] selected: list[str] = [] diff --git a/tests/test_tui_launch.py b/tests/test_tui_launch.py index a091489..28737d7 100644 --- a/tests/test_tui_launch.py +++ b/tests/test_tui_launch.py @@ -20,9 +20,9 @@ from bmad_loop.tui import launch # Every test here asserts tmux-specific argv/behaviour through the multiplexer -# seam. Now that the win32-matching herdr backend is registered, get_multiplexer() -# no longer bottom-falls-back to tmux on the Windows CI leg, so pin tmux by name -# (a no-op on POSIX, where tmux is already the default). +# seam. An installed external backend can match win32 (the herdr adapter does), +# where tmux does not — get_multiplexer() would then not bottom-fall-back to +# tmux — so pin tmux by name (a no-op on a stock POSIX box). pytestmark = pytest.mark.usefixtures("force_tmux_backend")