Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@ jobs:
# Moving this pin is a deliberate PR that also moves the floor and
# retires the workarounds; psmux-canary.yml runs the same gate against
# `latest` to say when that PR is due.
version: v3.3.8
version: v3.4.0
github-token: ${{ github.token }}

- name: Run the live psmux gate
Expand Down
22 changes: 19 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@ breaking changes may land in a minor release.
- Add the `plugins.trust` validate check: an ungranted, changed, enabled-but-ungranted,
stale, missing or unreadable project plugin, or an unreadable grant store, is a problem
(#496, #497).
- Add psmux live-gate tripwires for psmux#773 (pane `PATH` rebuilt from the registry),
psmux#775 (`show-environment NAME` ignores the name) and psmux#774 (a case-only
duplicate session is refused) (#879).
- Add psmux live-gate rows for psmux#774 (a case-only duplicate session is refused),
psmux#773 (a pane inherits the server's start `PATH`), psmux#583 (a `-`-leading option
value is stored) and psmux#775 (`show-environment NAME` answers for that one name)
(#879).
- Add `bmad-loop resolve <replay-run> --abandon` (DW-535): close a replay run that
escalated because its story was finished meanwhile. Uncommitted tracked and
untracked changes (plus the run's own spec/board/ledger copies), or commits on a
Expand Down Expand Up @@ -58,9 +59,24 @@ breaking changes may land in a minor release.
guards links, anchors, tracker refs, block length and the catalog shape.
- Name the per-command time limit in a timed-out verify command's failure reason;
`--reverify` also names the key to raise.
- **Require psmux 3.4.0 or newer (#879).** `available()` refuses 3.3.8 and below, so **on
such a host the psmux backend reports unavailable and selection falls through: upgrade
psmux**. 3.3.8 silently unsets an option given a `-`-leading value (psmux/psmux#583).
- Name the psmux control session `bmad-loop-ctl` again, one per project registry, now that
psmux 3.4.0 keys its duplicate-server guard on the registry (psmux#599). A pre-upgrade
`bmad-loop-ctl-<digest>` session is still treated as a control session and never pruned,
but new launches, the TUI's attach and stop keys and the parked-window sweep use
`bmad-loop-ctl`; end the old session once its windows have parked (#733).

### Removed

- psmux: stop refusing an `@` option value that starts with `-`; psmux 3.4.0 stores it
verbatim (#879).

### Fixed

- Fire the TUI's stale state-root note on psmux too: the psmux backend now answers which
value a new pane inherits through psmux 3.4.0's named `show-environment` (#660).
- Fix the OpenCode V2 SSE-reconnect end-to-end test failing on slow runners (Windows): the
gap's HTTP probe completed the session before the reader reconnected. The fake now
holds the turn busy until the reconnect is up.
Expand Down
2 changes: 1 addition & 1 deletion docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,7 @@ bmad-loop drives a BMAD sprint unattended: a deterministic Python loop launches
- **Description:** On native Windows, the bundled psmux backend (a ConPTY tmux re-implementation driven through PowerShell) runs sessions without WSL. It is experimental.
- **Properties:**
- On a win32 host, psmux is the default backend whenever it reports itself available.
- psmux counts as available only when `psmux` and `pwsh` are both on PATH and psmux is 3.3.8 or newer. Otherwise selection moves on to the next backend.
- psmux counts as available only when `psmux` and `pwsh` are both on PATH and psmux is 3.4.0 or newer. Otherwise selection moves on to the next backend.
- psmux refuses to open a window under PowerShell older than 7.3, or when the version cannot be read. This applies even when psmux is forced.
- psmux sessions are registered in a per-project registry under the state root. To use an ambient `PSMUX_DATA_DIR` instead, set `[mux] honor_ambient_psmux_data_dir`.
- **Config:** `[mux] backend`; `[mux] honor_ambient_psmux_data_dir`; `BMAD_LOOP_MUX_BACKEND=psmux`
Expand Down
27 changes: 16 additions & 11 deletions docs/multiplexer-backends.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,10 @@ with. A server started under one state root (`BMAD_LOOP_STATE_DIR`, `XDG_STATE_H
notes once, naming both, when they differ. A shell that must agree can
`export BMAD_LOOP_STATE_DIR=<root>`.

psmux cannot report inherited values, so there is no such note on psmux. Its registry is
keyed on the state root, so a launcher under one root never reaches a server started under
another.
psmux (3.4.0 or newer) answers the same query, so the note fires on psmux too. psmux runs one
server per session, so the inherited environment is that session's own server's. Its
registry is keyed on the state root, so a launcher normally reaches only servers started
under its own root; the note covers the cases where it does not.

## psmux (native Windows, experimental)

Expand All @@ -72,7 +73,7 @@ session and window model and the `bmad-loop-<run-id>` session names.

Requirements:

- **psmux 3.3.8 or newer** and **`pwsh`** on `PATH`. `available()` requires both binaries
- **psmux 3.4.0 or newer** and **`pwsh`** on `PATH`. `available()` requires both binaries
and reads psmux's reported version; an older or unreadable version reports unavailable
and selection falls through.
- **PowerShell 7.3 or newer** as `pwsh`. The backend resolves `pwsh` to one absolute path,
Expand All @@ -99,8 +100,8 @@ window `@3`. `psmux show-options -w` does not show these values; read one with
session-qualified (`session:@N`).

**Refused option values.** An `@` option value must read back verbatim. A value that is
empty, starts with `-`, contains a double quote or a line break, or has leading or trailing
whitespace is refused with a stderr warning, and the option reads as unset. Ordinary
empty, contains a double quote or a line break, or has leading or trailing whitespace is
refused with a stderr warning, and the option reads as unset. Ordinary
Windows paths (spaced, UNC, apostrophed, trailing separator) pass. The project ownership
tag is a hex digest of the project path, so it always passes.

Expand All @@ -127,9 +128,13 @@ psmux ls

`bmad-loop attach` needs none of this; it runs the client under the root it exported.

**The control session is per project on psmux:** `bmad-loop-ctl-<16-hex registry digest>`.
The TUI's toasts name the exact session. When no state root can be derived it is plain
`bmad-loop-ctl`, as on tmux.
**The control session is `bmad-loop-ctl`**, one per project registry on psmux. A session
named `bmad-loop-ctl-<16 hex>` is also a control session to bmad-loop, so `cleanup` never
prunes it. Nothing launches into it, though, so the TUI's attach and stop keys and the
parked-window sweep do not reach its windows. To end one, let its runs finish
(`bmad-loop stop` reaches a live engine wherever it runs) and check that every window shows
the exit banner (see `capture-pane` below). Then run
`psmux kill-session -t bmad-loop-ctl-<16 hex>`.

**If the state root cannot be derived** (for example, `BMAD_LOOP_STATE_DIR` is a relative
path), bmad-loop says so and leaves whatever `PSMUX_DATA_DIR` was in force. `cleanup` then
Expand Down Expand Up @@ -246,9 +251,9 @@ safe once `capture-pane` has shown the banner.
2. `bmad-loop cleanup`, then run `delete` or `archive` again.
3. Or pass `--force`. It stops a live engine first, then removes the run without checking for a session, and leaves any orphaned session running.

A removal can still strand a session when the session listing misses it: the multiplexer binary is not on `PATH`, or on psmux a registry entry was briefly reaped. To clear a stranded session:
A removal can still strand a session when the session listing misses it, because the multiplexer binary is not on the removing process's `PATH`. To clear a stranded session:

1. Put the multiplexer back on `PATH`. On psmux after a reap, wait a few seconds for the server to republish the entry.
1. Put the multiplexer back on `PATH`.
2. Run `bmad-loop cleanup`. It prunes a session tagged to this project even when its run directory is gone.
3. For an untagged session, inspect it by hand. `bmad-loop attach` cannot reach it, because it needs the run directory. On psmux, paste the `$env:PSMUX_DATA_DIR = …` line `bmad-loop mux` prints first ([why](#where-psmux-sessions-live-the-per-project-registry)). Then attach with `psmux attach -t bmad-loop-<run-id>` (`tmux attach -t …` on tmux).
4. Once you have confirmed from its contents that it is yours (the name does not carry the project), kill it: `psmux kill-session -t bmad-loop-<run-id>` (or `tmux kill-session -t …`).
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/run-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ A run id is `YYYYMMDD-HHMMSS-<4 hex>` in local time, for example `20261012-01152
| --------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| agent session | `bmad-loop-<run-id>`, one per run, tagged with the project key (`@bmad_project`); multiplexer-driven adapters only (opencode runs none) |
| agent window | one per agent session, named after the task id (last 40 characters); its pane is piped to `logs/<task_id>.log` |
| control session | `bmad-loop-ctl` on tmux; `bmad-loop-ctl-<16 hex>` on psmux (derived from the per-project registry) |
| control session | `bmad-loop-ctl`; on psmux one per project registry. A `bmad-loop-ctl-<16 hex>` session also counts as one, but nothing launches into it |
| control window | `<kind>-<run-id>`, where `kind` is `run`, `sweep`, `resume` or `resolve`; opened by the TUI, parks after the command exits |

Teardown:
Expand Down
2 changes: 1 addition & 1 deletion docs/setup-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ see [Installing and upgrading](../README.md#installing-and-upgrading) in the REA
- **Native Windows is experimental.** The bundled `psmux` backend (a ConPTY tmux
re-implementation) drives runs there and is selected automatically as the win32 default
when its prerequisites are present: the `psmux` and `pwsh` (PowerShell) binaries on
`PATH`, with `psmux` 3.3.8 or newer (older releases read as unavailable — see
`PATH`, with `psmux` 3.4.0 or newer (older releases read as unavailable — see
[multiplexer backends](multiplexer-backends.md#psmux-native-windows-experimental) for why).
- It is below the Linux/macOS/WSL support tier; open native-Windows work is in
[the roadmap](ROADMAP.md#native-windows-multiplexer-backend), and the port path is in
Expand Down
2 changes: 1 addition & 1 deletion docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ the live/E2E gates; in practice it holds everywhere, and the mechanism differs p
| `test_stories_e2e.py` | tmux + the real `bmad-loop run/resolve/resume` CLI | Scripted fake `claude` variants (bash string constants) wired in as a custom TOML profile; the fake writes its own SessionStart/Stop hook events | CI Linux job (the fakes use GNU coreutils + `setsid`) |
| `test_opencode_live.py` | A real `opencode serve` HTTP server | **Never sends a prompt**: only spawn/teardown and the health/OpenAPI/session/event endpoints of the detected major (1.x or 2.x); the prompt endpoint is checked against the OpenAPI schema, never called | Manual: a POSIX box with a runnable `opencode` |
| `test_opencode_live_turn.py` | A real OpenCode 2.x server running one full turn | **Prompts reach only a local fake**: the one test that prompts sends every LLM request to an in-process stdlib OpenAI-compatible server on 127.0.0.1, under a scrubbed env (scratch HOME/XDG, no credentials), a mock-only provider config and a deny on the hosted provider, and asserts the fake saw every request | Manual: a POSIX box with a runnable OpenCode 2.x `opencode` |
| `test_psmux_live.py` | A real psmux on Windows | **Parked windows only**: every window runs `pwsh -NoProfile -Command exit 0`. `test_premise_*` probes are inverted (red means a workaround became droppable); `test_adopted_*` probes are ordinary | CI Windows job + any Windows dev box with psmux 3.3.8+ and `pwsh` |
| `test_psmux_live.py` | A real psmux on Windows | **Parked windows only**: every window runs `pwsh -NoProfile -Command exit 0`. `test_premise_*` probes are inverted (red means a workaround became droppable); `test_adopted_*` probes are ordinary | CI Windows job + any Windows dev box with psmux 3.4.0+ and `pwsh` |

Never "fix" a gate to call a real CLI, and never add a completion path that trusts LLM output:
sessions complete only on hook Stop events or window death, and the fakes exercise exactly
Expand Down
3 changes: 1 addition & 2 deletions docs/tui-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,7 @@ validate viewers) are the tightest thing the TUI draws:
The TUI never runs an engine in-process.

- **Launcher.** `r`, `s`, `e`, `R` and `V` spawn detached `bmad-loop` processes
as windows of a dedicated control session, `bmad-loop-ctl` (on psmux the name
carries a per-project suffix; the launch toast names the exact session).
as windows of a dedicated control session, `bmad-loop-ctl`.
Windows are named `run-<run-id>`, `sweep-<run-id>`, `resume-<run-id>` and so
on, and run the same Python interpreter as the TUI. A window stays open after
its command exits, showing `[bmad-loop exited <code> — press enter]`, so you
Expand Down
5 changes: 2 additions & 3 deletions src/bmad_loop/adapters/multiplexer.py
Original file line number Diff line number Diff line change
Expand Up @@ -631,9 +631,8 @@ def inherited_env(
backend says nothing.

Non-abstract, defaulting to ``None`` with nothing reported, so released
out-of-tree backends keep working unchanged. psmux keeps this default:
its ``show-environment`` reports only values set through psmux itself,
never inherited ones."""
out-of-tree backends keep working unchanged. tmux and psmux answer it
through ``show-environment``."""
return None

def window_pane_pids(self, target: str) -> list[int]:
Expand Down
67 changes: 48 additions & 19 deletions src/bmad_loop/adapters/psmux_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,10 @@
rather than inventing one — and the collision it needs is an independently
created session sharing a ``bmad-loop-<run-id>`` name: an operator's, or
another run's on a colliding id (see #531). The psmux
behaviors cited in this module were read from the psmux source at tag
``v3.3.8`` — the oldest build ``available()`` admits — and the safe
observable subset is probed in ``tests/test_psmux_live.py``.
behaviors cited in this module were read from the psmux source at the tag
each citation names, most at ``v3.3.8``; ``available()`` admits 3.4.0 and
later, and the safe observable subset is probed live on the installed build in
``tests/test_psmux_live.py``.
See :mod:`.multiplexer` for the contract.
"""

Expand All @@ -79,9 +80,10 @@
import shutil
import subprocess
import sys
from collections.abc import Mapping
from collections.abc import Callable, Mapping
from pathlib import Path

from .multiplexer import Unset
from .tmux_base import PARKED_RETURN_DETACH, TMUX_TIMEOUT_S, BaseTmuxBackend, TmuxError

_ENV_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
Expand Down Expand Up @@ -562,10 +564,15 @@ def _parked_trailer(self, return_opt: str) -> str:
#
# Every psmux call is a call-operator line on the absolute path the
# backend resolves (`_psmux_path`), because a bare name resolves on the
# pane's PATH, which psmux builds from the registry rather than from the
# server's environment (psmux/psmux#773): another build, or none at all,
# with every error going to $null (#877). The path is baked in when the
# window is minted, so a window parked before an upgrade keeps its own.
# pane's PATH: another build, or none at all, with every error going to
# $null (#877). The admitted builds give a pane the PATH of the process
# that started its server (psmux/psmux#773), but that need not be this
# launcher's: a long-lived control session can be served by a server
# started earlier, from another PATH or by a pre-floor psmux still
# running after an upgrade (which rebuilds PATH from the registry), and
# a relative PATH entry stops resolving once the pane's working
# directory differs. The path is baked in when the window is minted, so
# a window parked before an upgrade keeps its own.
mux = "& " + _pwsh_quote(self._psmux_path())
probe = (
'"$(' + mux + " display-message -p -t $env:TMUX_PANE '#{window_id}' 2>$null)\".Trim()"
Expand Down Expand Up @@ -939,10 +946,8 @@ def _transportable(value: str) -> bool:
# break, and leading/trailing whitespace stay refused however cleanly
# the wire now carries them — a value that reads back different is the
# same silently-unprunable window whichever hop mangled it. An empty
# value is a silent server-side no-op (`-u` is the verb for that), and a
# `-`-leading one is still dropped as a flag server-side, or flips the
# key to unset at rc 0 (psmux/psmux#583, open upstream).
if not value or value.startswith("-"):
# value is a silent server-side no-op (`-u` is the verb for that).
if not value:
return False
if '"' in value or value != value.strip():
return False
Expand Down Expand Up @@ -1574,15 +1579,39 @@ def pipe_pane(self, window_id: str, log_file: Path) -> None:
file=sys.stderr,
)

def inherited_env(
self,
session: str,
name: str,
*,
on_fault: Callable[[str], None] | None = None,
) -> str | Unset | None:
"""The seam query (#731), with tmux's replies since psmux/psmux#775
(``src/environ.rs``, source-read at v3.4.0): the session scope first,
then the global one, through the base's ``_show_environment``. Two
psmux specifics, both measured on 3.4.0. ``-g`` is session-targeted:
one server per session makes "global" that session's server start
environment, and an untargeted ``-g`` answers from whichever server it
lands on. The echoed name is matched case-insensitively: the reply
spells the name as the environment block stores it (``PATH`` can come
back ``Path=``).

Ceiling: under ``PSMUX_BARE_ENV`` a pane gets an allowlist, not what
this answers (see ``_warn_if_bare_env``); bmad-loop does not support
that mode and already warns about it."""
target = ["-t", f"={session}"]
return self._show_environment([target, ["-g", *target]], name, on_fault, fold_case=True)

# Releases up to this version are refused. 3.3.6 and older force-kill a
# recycled PID during pane teardown and let orphaned servers accumulate —
# engine-fatal on its own. 3.3.7 is excluded for a second reason: 3.3.8 is
# the build this backend is written against, and several verbs here now
# assume its fixes rather than routing around the defects (a direct
# `pipe-pane -o` flag transport, a `select-window` id target, the `=name`
# kill-session form, and the control-line shapes `_transportable` admits).
# On 3.3.7 those would fail silently, so the floor forbids it outright.
_LAST_UNSUPPORTED = (3, 3, 7)
# engine-fatal on its own. 3.3.7 lacks the fixes several verbs here assume
# rather than route around (a direct `pipe-pane -o` flag transport, a
# `select-window` id target, the `=name` kill-session form, and the
# control-line shapes `_transportable` admits). 3.3.8 stores a `-`-leading
# option value as the unset flag at rc 0 (psmux/psmux#583), a value
# `_transportable` no longer refuses. On those builds the failures are
# silent, so the floor forbids them outright.
_LAST_UNSUPPORTED = (3, 3, 8)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
# Class-level default; instances shadow it on first probe. Never assign on
# the class outside tests — that would poison every future instance.
_version_ok: bool | None = None
Expand Down
Loading
Loading