diff --git a/CHANGELOG.md b/CHANGELOG.md index 86f1121..6008955 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,33 @@ breaking changes may land in a minor release. forced selection. A tmux-less POSIX host still selects `TmuxMultiplexer` and reports it unavailable, exactly as before. +- **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. - **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. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 1366295..f2a42a0 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) +**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) 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`), @@ -34,19 +34,38 @@ held by a CI portability guard (`tests/test_portability_guard.py`). **WSL alread — it _is_ Linux, so it takes every fast path unchanged; this is purely about a future _native_ Windows host. -The remaining work is the native-Windows backend itself. Two candidates are in flight, and -they are **sibling tmux-family backends**, not stages of one plan: `psmux` (drives psmux's -tmux-compatible shim via pwsh) and `tmux-windows` (#85; drives the tmux-windows port) — both -subclass `BaseTmuxBackend`, both register for `win32`, and both invoke a binary literally -named `tmux`, which is exactly why selection is availability-aware with discriminating -`available()` probes (psmux → `which("psmux") and which("tmux") and which("pwsh")`; -tmux-windows → `which("tmux") and not which("psmux")`) and an explicit -`bmad-loop mux set ` tie-break. The seams are designed so each 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). 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 +The remaining work is the native-Windows backend itself. Three candidates now exist, and +they are **not stages of one plan**. Two are **sibling tmux-family backends** still in +flight: `psmux` (drives psmux's tmux-compatible shim via pwsh) and `tmux-windows` (#85; +drives the tmux-windows port) — both subclass `BaseTmuxBackend`, both register for `win32`, +and both invoke a binary literally named `tmux`, which is exactly why selection is +availability-aware with discriminating `available()` probes (psmux → +`which("psmux") and which("tmux") and which("pwsh")`; tmux-windows → +`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) 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, +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 — +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 [adapter authoring guide](adapter-authoring-guide.md#the-transport-contract-for-a-backend-author). diff --git a/docs/adapter-authoring-guide.md b/docs/adapter-authoring-guide.md index 5d25ee0..f0a332b 100644 --- a/docs/adapter-authoring-guide.md +++ b/docs/adapter-authoring-guide.md @@ -95,6 +95,26 @@ 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 +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 ` +(`pane run` = type + Enter) so process-exit == pane-close == tab-close == +tmux-identical window death. Where herdr has no analogue, the backend degrades +honestly rather than faking it: session/window **options** (which herdr lacks +entirely) live in a cross-process JSON **sidecar** (atomic swaps for readers, an OS +advisory lock around each read-modify-write so concurrent writers never lose +updates), and `pipe_pane` — herdr has no +tee — runs a per-window **poller** thread that snapshots `pane read` into the log +whenever the content changes, which is exactly enough to drive the two log consumers +a tmux tee would (`generic._log_activity_key`'s stall re-arm and `probe`'s marker +discovery). Its module docstring is a **degradation ledger** of every such +divergence (sidecar options, poller `pipe_pane`, no-op `detach_client`, the attach +argv, the advisory geometry, the protocol-version policy) — the reference for what +"implement fresh" costs when the host has no tmux-shaped CLI. + 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 `session_id` / `transcript_path` / `cwd` are present), where it writes its session diff --git a/docs/porting-to-a-new-os.md b/docs/porting-to-a-new-os.md index 93c6930..ee96640 100644 --- a/docs/porting-to-a-new-os.md +++ b/docs/porting-to-a-new-os.md @@ -88,6 +88,19 @@ version, and the current selection. (The result is cached — see - **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. `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 @@ -123,6 +136,16 @@ default, but `bmad-loop mux` and the validate preflight would list both as 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 +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. + **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/src/bmad_loop/adapters/herdr_backend.py b/src/bmad_loop/adapters/herdr_backend.py new file mode 100644 index 0000000..768d824 --- /dev/null +++ b/src/bmad_loop/adapters/herdr_backend.py @@ -0,0 +1,1292 @@ +"""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 + tmux-style ``=session[:window]`` specs ``tui/launch.py`` builds — 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 + +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: a tmux-style ``=session[:window]`` spec + (``tui/launch.py`` builds these) and our own native pane id. The ``=`` + prefix is the discriminator — 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).""" + if not target.startswith("="): + return target + session, _, window = target[1:].partition(":") + 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 cfed86f..11959fd 100644 --- a/src/bmad_loop/adapters/multiplexer.py +++ b/src/bmad_loop/adapters/multiplexer.py @@ -246,11 +246,19 @@ def _load_builtin_backends() -> None: 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. 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/src/bmad_loop/adapters/tmux_base.py b/src/bmad_loop/adapters/tmux_base.py index 6ba2985..3893781 100644 --- a/src/bmad_loop/adapters/tmux_base.py +++ b/src/bmad_loop/adapters/tmux_base.py @@ -379,7 +379,13 @@ def current_session(self) -> str | None: def _display_message(self, fmt: str) -> str | None: """Resolve a tmux format string against this process's client, or None - when not inside tmux / tmux is unavailable.""" + when not inside tmux / tmux is unavailable. The TMUX guard is what makes + "not inside" honest: against a live server, display-message would answer + for some OTHER client's session and misreport a plain shell as being + inside tmux — callers (in_ctl_session, the attach return-pane recording) + branch on exactly that distinction.""" + if not os.environ.get("TMUX"): + return None try: proc = self._run(["display-message", "-p", fmt], check=False) except (subprocess.SubprocessError, OSError): diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index 15a800b..6ca5270 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -1346,11 +1346,13 @@ def cmd_attach(args: argparse.Namespace) -> int: # Record where to send the client once the sweep finishes this cycle's # decisions (see launch.return_attached_client), so answering them hands the # terminal back instead of stranding the user in the orchestrator window. + # Backend-honest inside-the-multiplexer probe: current_pane_id() is None + # outside, so a resolvable own pane means "switch the client back here", + # anything else means a throwaway client was attached and must detach. if return_window is not None: - if os.environ.get("TMUX"): - pane = launch.current_pane_id() - if pane is not None: - launch.set_return_pane(return_window, pane) + pane = launch.current_pane_id() + if pane is not None: + launch.set_return_pane(return_window, pane) else: launch.set_return_pane(return_window, launch.RETURN_DETACH) return subprocess.call(argv) diff --git a/src/bmad_loop/platform_util.py b/src/bmad_loop/platform_util.py index cbe7a15..36ae4a5 100644 --- a/src/bmad_loop/platform_util.py +++ b/src/bmad_loop/platform_util.py @@ -25,8 +25,9 @@ import subprocess import sys import time +from contextlib import contextmanager from pathlib import Path, PurePosixPath, PureWindowsPath -from typing import Callable +from typing import Callable, Iterator from .process_host import get_process_host @@ -152,6 +153,47 @@ def retrying_unlink(path: Path) -> None: _retry_on_sharing_violation(path.unlink) +@contextmanager +def file_lock(path: Path, *, blocking: bool = True) -> Iterator[None]: + """Exclusive OS advisory lock on ``path`` (created if missing), released on + exit — and by the kernel when the holder dies, so a crashed process never + wedges the lock (no stale-lockfile scheme to clean up). ``blocking=False`` + raises ``OSError`` at once when the lock is already held, giving tests a + deterministic exclusion probe instead of a sleep-based negative assertion. + + Lock a dedicated sibling file, never data that is swapped via + :func:`atomic_replace` — the lock rides the open fd's inode, and a replace + would swap that inode out from under later acquirers. ``fcntl.flock`` on + POSIX (blocks indefinitely; holders only do brief file I/O); ``msvcrt.locking`` + on Windows, where the blocking mode's built-in ~10 s retry bounds the wait + and surfaces contention as ``OSError``. + """ + path.parent.mkdir(parents=True, exist_ok=True) + fd = os.open(path, os.O_RDWR | os.O_CREAT) + try: + if sys.platform == "win32": + import msvcrt + + # Locks 1 byte at the current position — 0 on a fresh fd. + msvcrt.locking(fd, msvcrt.LK_LOCK if blocking else msvcrt.LK_NBLCK, 1) + else: + import fcntl + + fcntl.flock(fd, fcntl.LOCK_EX | (0 if blocking else fcntl.LOCK_NB)) + try: + yield + finally: + if sys.platform == "win32": + import msvcrt + + # Unlock the same 1-byte region before close; POSIX flock is + # released by the close itself. + os.lseek(fd, 0, os.SEEK_SET) + msvcrt.locking(fd, msvcrt.LK_UNLCK, 1) + finally: + os.close(fd) + + def _is_reserved_basename(seg: str) -> bool: """True if ``seg``'s basename (before the first dot, trailing spaces trimmed — ``CON .txt`` counts) is a Windows reserved device name.""" diff --git a/src/bmad_loop/probe.py b/src/bmad_loop/probe.py index ec0c9d3..c50808f 100644 --- a/src/bmad_loop/probe.py +++ b/src/bmad_loop/probe.py @@ -491,8 +491,18 @@ def probe( ) finding.flags = run_version_help(binary) - if not shutil.which("tmux") or not shutil.which(binary): - missing = "tmux" if not shutil.which("tmux") else binary + # The live probe launches through the selected multiplexer backend (see + # _ProbeLauncher), so gate on THAT backend's availability rather than a + # hardcoded `which("tmux")` — a Windows host running herdr (or a future psmux) + # must still probe. `available()` is guarded so a backend whose host probe + # raises reads as unavailable, exactly like selection's _usable(). + mux = get_multiplexer() + try: + mux_ready = bool(mux.available()) + except Exception: # noqa: BLE001 — a raising host probe means "cannot probe", not a crash + mux_ready = False + if not mux_ready or not shutil.which(binary): + missing = f"multiplexer backend {type(mux).__name__}" if not mux_ready else binary finding.warnings.append(f"{missing} not on PATH — cannot probe; falling back to scan") scanned = scan(cli=cli, profile=profile, project=project, hints=hints) scanned.mode = "probe" diff --git a/src/bmad_loop/tui/app.py b/src/bmad_loop/tui/app.py index 7d5cefa..daab212 100644 --- a/src/bmad_loop/tui/app.py +++ b/src/bmad_loop/tui/app.py @@ -8,7 +8,6 @@ from __future__ import annotations -import os import shlex import subprocess import time @@ -22,6 +21,7 @@ from tomlkit.exceptions import ParseError from .. import bmadconfig, decisions, devcontract, policy, resolve, runs, stories, verify +from ..adapters.multiplexer import MultiplexerError from ..journal import load_state from ..model import ( PAUSE_EPIC_BOUNDARY, @@ -378,15 +378,21 @@ def action_attach(self) -> None: self._attach_to_target(target) def _attach_to_target(self, target: str, return_window: str | None = None) -> None: - argv = runs.attach_target_argv(target) - if os.environ.get("TMUX"): - # switch-client is fire-and-forget; record our own pane on the ctl - # window so its trailing shell switches the client back here when it - # exits, instead of stranding the user in the control session. + try: + argv = runs.attach_target_argv(target) + except MultiplexerError as e: + self.notify(str(e), severity="error") + return + # Backend-honest inside-the-multiplexer probe (current_pane_id() is None + # outside): inside, attach_target_argv returned the fire-and-forget + # switch/focus form, so no suspend is needed. + pane = launch.current_pane_id() + if pane is not None: + # Record our own pane on the ctl window so its trailing shell + # switches the client back here when it exits, instead of stranding + # the user in the control session. if return_window is not None: - pane = launch.current_pane_id() - if pane is not None: - launch.set_return_pane(return_window, pane) + launch.set_return_pane(return_window, pane) subprocess.call(argv) return # Outside tmux we attach a throwaway client (under suspend). The ctl diff --git a/src/bmad_loop/tui/launch.py b/src/bmad_loop/tui/launch.py index 024bf96..a79af20 100644 --- a/src/bmad_loop/tui/launch.py +++ b/src/bmad_loop/tui/launch.py @@ -12,7 +12,6 @@ from __future__ import annotations -import os import re import subprocess import sys @@ -99,8 +98,10 @@ def current_session() -> str | None: def in_ctl_session() -> bool: """True when we are running inside a control-session window (i.e. launched - detached by the TUI), as opposed to a user's own shell.""" - return bool(os.environ.get("TMUX")) and current_session() == CTL_SESSION + detached by the TUI), as opposed to a user's own shell. Backend-honest: + current_session() is None whenever this process is not inside the selected + multiplexer, so no direct TMUX/HERDR_* env sniffing happens here.""" + return current_session() == CTL_SESSION def detach_client() -> None: diff --git a/tests/conftest.py b/tests/conftest.py index 218e98a..f8d8e48 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -32,6 +32,27 @@ ) +@pytest.fixture +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.""" + from bmad_loop.adapters import multiplexer + + monkeypatch.setenv("BMAD_LOOP_MUX_BACKEND", "tmux") + multiplexer.get_multiplexer.cache_clear() + yield + multiplexer.get_multiplexer.cache_clear() + + def write_script_launcher(directory: Path, name: str, body: str) -> Path: """Write a fake CLI launcher for the host OS.""" directory = Path(directory) diff --git a/tests/test_backend_registry.py b/tests/test_backend_registry.py index ed6584a..7c0770a 100644 --- a/tests/test_backend_registry.py +++ b/tests/test_backend_registry.py @@ -11,12 +11,14 @@ the tiny :class:`_Stub` instead. """ +import shutil import sys from pathlib import Path 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 @@ -67,9 +69,12 @@ def fresh_registry(monkeypatch): m.get_multiplexer.cache_clear() -def test_default_is_tmux(fresh_registry): +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'``), not just the bottom fallback.""" + 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.""" + monkeypatch.setattr(sys, "platform", "linux") assert isinstance(fresh_registry.get_multiplexer(), TmuxMultiplexer) @@ -259,9 +264,11 @@ def test_configure_multiplexer_clears_cache_only_on_change(fresh_registry): assert fresh_registry.get_multiplexer.cache_info().currsize == 1 -def test_empty_string_configuration_means_auto(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.""" + 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).""" + monkeypatch.setattr(sys, "platform", "linux") fresh_registry.configure_multiplexer("") assert isinstance(fresh_registry.get_multiplexer(), TmuxMultiplexer) @@ -326,3 +333,91 @@ def test_detect_multiplexers_version_crash_keeps_availability(fresh_registry): assert rows["verless"].available is True assert rows["verless"].version is None assert rows["verless"].selected is True and rows["verless"].reason == "first-match" + + +# --------------------------------------------------------------------------- +# 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`. + + +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.""" + 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.""" + 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 == "" diff --git a/tests/test_cli.py b/tests/test_cli.py index 6bb271e..2b5301f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -498,6 +498,22 @@ def test_attach_nothing_to_attach(project, monkeypatch, capsys): assert "nothing to attach" in capsys.readouterr().err +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. + from bmad_loop.adapters.multiplexer import MultiplexerError + from bmad_loop.tui import launch + + def boom(_proj, _rid): + raise MultiplexerError("herdr 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 + + def test_sweep_dry_run_lists_open_entries(project, capsys): from conftest import write_ledger diff --git a/tests/test_generic_tmux.py b/tests/test_generic_tmux.py index a028682..6b4cbff 100644 --- a/tests/test_generic_tmux.py +++ b/tests/test_generic_tmux.py @@ -68,10 +68,12 @@ def make_adapter( ) -def test_ensure_session_tags_project(tmp_path, monkeypatch): +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.""" + 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()``.""" from bmad_loop import runs project = tmp_path diff --git a/tests/test_herdr_backend.py b/tests/test_herdr_backend.py new file mode 100644 index 0000000..e91370f --- /dev/null +++ b/tests/test_herdr_backend.py @@ -0,0 +1,945 @@ +"""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 new file mode 100644 index 0000000..49086f0 --- /dev/null +++ b/tests/test_herdr_e2e.py @@ -0,0 +1,81 @@ +"""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 new file mode 100644 index 0000000..9407550 --- /dev/null +++ b/tests/test_herdr_integration.py @@ -0,0 +1,282 @@ +"""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 new file mode 100644 index 0000000..5017866 --- /dev/null +++ b/tests/test_herdr_poller.py @@ -0,0 +1,279 @@ +"""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_platform_util.py b/tests/test_platform_util.py index 970bb3d..fbacc61 100644 --- a/tests/test_platform_util.py +++ b/tests/test_platform_util.py @@ -183,6 +183,39 @@ def test_retrying_unlink_propagates_missing_file(tmp_path): platform_util.retrying_unlink(tmp_path / "gone.md") +# --------------------------------------------------------------------- file_lock + + +def test_file_lock_excludes_second_acquirer(tmp_path): + """While held, a second (non-blocking) acquisition on the same path fails — + the deterministic exclusion probe, no sleep-based negative assertion. Runs + the fcntl branch on POSIX and the msvcrt branch on the Windows CI leg.""" + lock = tmp_path / "state.json.lock" + with platform_util.file_lock(lock): + with pytest.raises(OSError): + with platform_util.file_lock(lock, blocking=False): + pass # pragma: no cover — must not be reached + # Released on exit: the probe now succeeds. + with platform_util.file_lock(lock, blocking=False): + pass + + +def test_file_lock_creates_parent_and_lock_file(tmp_path): + lock = tmp_path / "deep" / "nested" / "s.lock" + with platform_util.file_lock(lock): + assert lock.exists() + + +def test_file_lock_reentry_after_exception(tmp_path): + """An exception inside the critical section still releases the lock.""" + lock = tmp_path / "s.lock" + with pytest.raises(RuntimeError): + with platform_util.file_lock(lock): + raise RuntimeError("boom") + with platform_util.file_lock(lock, blocking=False): + pass + + # ------------------------------------------------------------------ safe_segment diff --git a/tests/test_runs.py b/tests/test_runs.py index e61d1df..94eebd7 100644 --- a/tests/test_runs.py +++ b/tests/test_runs.py @@ -173,11 +173,13 @@ def test_write_pid(tmp_path): assert float(tokens[1]) > 0 +@pytest.mark.usefixtures("force_tmux_backend") # attach_argv goes through the seam def test_attach_argv_outside_tmux(monkeypatch): monkeypatch.delenv("TMUX", raising=False) assert runs.attach_argv("r1") == ["tmux", "attach", "-t", "=bmad-loop-r1"] +@pytest.mark.usefixtures("force_tmux_backend") # attach_argv goes through the seam def test_attach_argv_inside_tmux(monkeypatch): monkeypatch.setenv("TMUX", "/tmp/tmux-1000/default,123,0") assert runs.attach_argv("r1") == ["tmux", "switch-client", "-t", "=bmad-loop-r1"] diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py index aa285ce..1311cbf 100644 --- a/tests/test_tui_app.py +++ b/tests/test_tui_app.py @@ -31,6 +31,7 @@ ) from bmad_loop import policy as policy_mod +from bmad_loop.adapters.multiplexer import MultiplexerError from bmad_loop.journal import Journal, save_state from bmad_loop.model import Phase, RunState, StoryTask, TokenUsage from bmad_loop.runs import RUNS_DIR @@ -1217,6 +1218,31 @@ async def test_attach_without_agent_session_notifies(project, monkeypatch): await until(pilot, lambda: any("no live agent session" in m for m in notifications(app))) +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. + monkeypatch.setattr(launch, "tmux_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") + + monkeypatch.setattr("bmad_loop.tui.app.runs.attach_target_argv", boom) + make_run(project.project, "20260611-100000-aaaa") + app = BmadLoopApp(project.project) + async with app.run_test() as pilot: + await until(pilot, lambda: isinstance(app.screen, DashboardScreen)) + 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)) + ) + assert isinstance(app.screen, DashboardScreen) # the action failed soft + + # ------------------------------------------------------- sweep decision flow @@ -1283,6 +1309,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 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?") @@ -1304,6 +1331,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 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 @@ -1326,6 +1354,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 async def test_attach_prefers_agent_session_without_decision(project, monkeypatch): make_run(project.project, "20260611-100000-aaaa", alive=True) monkeypatch.setattr(launch, "tmux_available", lambda: True) @@ -1343,6 +1372,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 async def test_attach_falls_back_to_ctl_window(project, monkeypatch): make_run(project.project, "20260611-100000-aaaa", alive=True) selected: list[str] = [] @@ -1362,6 +1392,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 async def test_resolve_escalation_launches_and_attaches(project, monkeypatch): launched: list[str] = [] selected: list[str] = [] diff --git a/tests/test_tui_data.py b/tests/test_tui_data.py index 4aea5e4..bed3533 100644 --- a/tests/test_tui_data.py +++ b/tests/test_tui_data.py @@ -9,6 +9,7 @@ import sys from pathlib import Path +import pytest from conftest import install_bmad_config, write_sprint from bmad_loop import deferredwork @@ -184,6 +185,7 @@ def test_discover_runs_legacy_no_pid_is_unknown(tmp_path, monkeypatch): assert data.discover_runs(tmp_path)[0].status == data.UNKNOWN +@pytest.mark.usefixtures("force_tmux_backend") # asserts tmux liveness through the seam def test_legacy_run_with_live_tmux_session_is_running(tmp_path, monkeypatch): run_dir = make_run(tmp_path, "20260611-100000-aaaa") monkeypatch.setattr(tmux_base.shutil, "which", lambda _: "/usr/bin/tmux") diff --git a/tests/test_tui_launch.py b/tests/test_tui_launch.py index 177f4eb..4e862cc 100644 --- a/tests/test_tui_launch.py +++ b/tests/test_tui_launch.py @@ -19,6 +19,12 @@ from bmad_loop.adapters import tmux_base 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). +pytestmark = pytest.mark.usefixtures("force_tmux_backend") + class FakeRun: """Records argv; scripts the returncode of `tmux has-session`.""" @@ -242,14 +248,29 @@ def test_current_pane_id_reads_pane(monkeypatch): def fake(argv, **kwargs): return subprocess.CompletedProcess(argv, 0, stdout="%9\n", stderr="") + monkeypatch.setenv("TMUX", "/tmp/tmux-1000/default,123,0") # inside tmux monkeypatch.setattr(tmux_base.subprocess, "run", fake) assert launch.current_pane_id() == "%9" def test_current_pane_id_none_outside_tmux(monkeypatch): + # Outside tmux the TMUX guard answers None WITHOUT shelling out: against a + # live server, display-message would answer for some OTHER client's session + # and misreport a plain shell as being inside tmux. + def boom(*_a, **_k): + raise AssertionError("outside tmux, current_* must not shell out") + + monkeypatch.delenv("TMUX", raising=False) + monkeypatch.setattr(tmux_base.subprocess, "run", boom) + assert launch.current_pane_id() is None + assert launch.current_session() is None + + +def test_current_pane_id_none_on_transport_failure(monkeypatch): def fake(argv, **kwargs): return subprocess.CompletedProcess(argv, 1, stdout="", stderr="no server") + monkeypatch.setenv("TMUX", "/tmp/tmux-1000/default,123,0") monkeypatch.setattr(tmux_base.subprocess, "run", fake) assert launch.current_pane_id() is None @@ -290,6 +311,7 @@ def fake(argv, **kwargs): killed.append(list(argv)) return subprocess.CompletedProcess(argv, 0, stdout="", stderr="") + monkeypatch.setenv("TMUX", "/tmp/tmux-1000/default,123,0") # we sit in a pane of @4 monkeypatch.setattr(tmux_base.subprocess, "run", fake) monkeypatch.setattr(tmux_base.shutil, "which", lambda name: f"/usr/bin/{name}") @@ -336,6 +358,7 @@ def fake(argv, **kwargs): killed.append(list(argv)) return subprocess.CompletedProcess(argv, 0, stdout="", stderr="") + monkeypatch.setenv("TMUX", "/tmp/tmux-1000/default,123,0") monkeypatch.setattr(tmux_base.subprocess, "run", fake) monkeypatch.setattr(tmux_base.shutil, "which", lambda name: f"/usr/bin/{name}") @@ -359,14 +382,28 @@ def test_select_ctl_window_id_argv(fake_run): def test_in_ctl_session(monkeypatch): + # in_ctl_session is backend-honest: it trusts current_session(), which is + # None whenever this process is not inside the selected multiplexer (the + # old direct TMUX sniff lives in the tmux backend's _display_message now — + # see test_in_ctl_session_outside_tmux). monkeypatch.setattr(launch, "current_session", lambda: "bmad-loop-ctl") - monkeypatch.setenv("TMUX", "/tmp/tmux-1000/default,123,0") assert launch.in_ctl_session() is True monkeypatch.setattr(launch, "current_session", lambda: "some-other-session") assert launch.in_ctl_session() is False + monkeypatch.setattr(launch, "current_session", lambda: None) + assert launch.in_ctl_session() is False # not inside the multiplexer + + +def test_in_ctl_session_outside_tmux(monkeypatch): + # End-to-end through the real tmux backend: outside tmux (no TMUX env) the + # backend's current_session() is None without shelling out, even when a + # live server would answer display-message for some other client. + def boom(*_a, **_k): + raise AssertionError("outside tmux, in_ctl_session must not shell out") + monkeypatch.delenv("TMUX", raising=False) - monkeypatch.setattr(launch, "current_session", lambda: "bmad-loop-ctl") - assert launch.in_ctl_session() is False # not inside tmux + monkeypatch.setattr(tmux_base.subprocess, "run", boom) + assert launch.in_ctl_session() is False def test_detach_client_argv(fake_run): @@ -376,7 +413,10 @@ def test_detach_client_argv(fake_run): def _return_fake(monkeypatch, *, win="@5", option="%9", switch_rc=0): """Script tmux for return_attached_client: display-message -> window id, - show-options -> the recorded RETURN_OPTION, switch-client -> switch_rc.""" + show-options -> the recorded RETURN_OPTION, switch-client -> switch_rc. + return_attached_client runs inside a ctl window, so TMUX is set (the + backend's current_window_id answers None otherwise).""" + monkeypatch.setenv("TMUX", "/tmp/tmux-1000/default,123,0") calls: list[list[str]] = [] def fake(argv, **kwargs):