From 6518387243b7f6fba8e013295738a239cfe43221 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Davor=20Raci=C4=87?= Date: Wed, 26 Aug 2026 11:00:51 +0200 Subject: [PATCH 1/7] feat(adapters,runs): give psmux a per-project registry root Derive the registry root from the project and the state root and export PSMUX_DATA_DIR before any psmux spawn, so a prune in one project cannot address another project's servers at all. An ambient value is overridden and reported once, because honouring it would make the registry a function of the shell a command happened to start in. Name the control session per registry. psmux keys its duplicate-server mutex on the session name in the per-login-session Local\ object namespace rather than on PSMUX_DATA_DIR (psmux/psmux#599), so one fixed name would fail every project's launch but the first. Unchanged on tmux, where the shared session is still correct. Sweep the old default registry during cleanup for tagged pre-upgrade sessions, and name whatever it still holds afterwards. Demand the project tag whenever the registry being addressed is not one bmad-loop derived. Refuse the reserved control-session shape in --run-id, which would otherwise hand a run the live control session as its own agent session. Windows minted under the old rule stay parsable, so cleanup still reaches them. Closes #537 Refs #660 --- CHANGELOG.md | 32 + README.md | 16 +- docs/FEATURES.md | 4 +- docs/adapter-authoring-guide.md | 49 +- docs/multiplexer-backends.md | 159 +++- docs/porting-to-a-new-os.md | 55 ++ docs/tui-guide.md | 6 +- src/bmad_loop/adapters/generic.py | 11 +- src/bmad_loop/adapters/multiplexer.py | 85 ++ src/bmad_loop/adapters/psmux_backend.py | 200 +++- src/bmad_loop/cli.py | 218 ++++- src/bmad_loop/documents.py | 10 + src/bmad_loop/engine.py | 11 + src/bmad_loop/envvars.py | 6 + src/bmad_loop/probe.py | 14 +- src/bmad_loop/runs.py | 751 ++++++++++++++- src/bmad_loop/tui/app.py | 90 +- src/bmad_loop/tui/launch.py | 91 +- src/bmad_loop/tui/screens/dashboard.py | 5 +- src/bmad_loop/tui/widgets.py | 2 +- tests/conftest.py | 35 +- tests/test_cleanup.py | 22 +- tests/test_cli.py | 590 +++++++++++- tests/test_engine.py | 30 + tests/test_multiplexer.py | 63 +- tests/test_probe.py | 57 ++ tests/test_psmux_backend.py | 220 ++++- tests/test_psmux_live.py | 415 ++++++++- tests/test_runs.py | 1124 ++++++++++++++++++++++- tests/test_tui_app.py | 141 ++- tests/test_tui_launch.py | 203 ++++ 31 files changed, 4538 insertions(+), 177 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11b1be41..21da8c7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,25 @@ breaking changes may land in a minor release. ### Changed +- **psmux sessions now live in a per-project registry** (#537). bmad-loop points + `PSMUX_DATA_DIR` at `//_mux`, so a prune in one project cannot address + another's servers at all. A bare `psmux ls` no longer shows them — `bmad-loop mux` prints the + root and the export that does. The `bmad-loop-ctl` control session becomes one per project — + named `bmad-loop-ctl-` on psmux, whose duplicate-server mutex is keyed on the + session name across every registry in a login session (a fixed name would fail every project's + launch but the first); + unchanged on tmux — + and `cleanup` also sweeps the old default registry for tagged pre-upgrade sessions, naming + whatever it still holds afterwards. The root is always derived: an ambient `PSMUX_DATA_DIR` is + overridden — reported once on stderr, and left alone for your own psmux sessions — because + honouring it would make the registry a function of the shell a command started in. Coding-CLI + windows are told the state root through their env; other windows inherit it as before, and + psmux's `PSMUX_BARE_ENV=1` — which breaks that inheritance — is declared unsupported and warned + about once per process. Cleanup demands the project tag + whenever the registry it addresses is not one bmad-loop derived, a shared default registry + included. The multiplexer seam signatures are unchanged; a backend built against them keeps + working. + - **`bmad-loop diagnose` routes the re-arm records by field name** (#640, #716). `spec_file` and `overwritten` are aliased, and `repo` is dropped — an absolute host path that correlates nothing. Routing is by field name across every entry rather than by kind, so no existing run's dump changes @@ -255,6 +274,19 @@ resolve` manufactures exactly that dual-key spec, inserting `baseline_revision` `$HOME` one there was wrong in both directions — an empty seed that let global ignores leak into `git add -A`, or patterns git is not applying that made session files go missing. Gated on the reported version's own `.windows.` fork string, not on the platform. +- **`--run-id` refuses the reserved control-session shape (`ctl`, `ctl-…`, any letter case)**. + Such an id mints the control session's own name as the run's agent session (`bmad-loop-ctl`, + or a per-registry `bmad-loop-ctl-` on psmux), so the adapter adopts the live control + session and the run's teardown kills it — every parked window with it; on Windows the + multiplexer resolves session names case-insensitively, so the case variants alias it too. The + two session namespaces are now disjoint at the mint — and guarded at the read paths for runs + an older release already persisted under such an id: `resume` and `resolve` refuse (at entry, + before any side effect) with the recovery steps, while `stop`, `delete`, `archive` and `clean` + work on the run without ever addressing a session that can be a control session's name (`ctl`, + `ctl-<16 hex>`). A historical run under any other `ctl-*` id keeps its genuine agent session + reachable in the registry the process addresses: `stop` kills it there by its exact name, and + `cleanup` sweeps it like any other run's — including, for a tagged pre-upgrade session left in + psmux's old default registry, through the legacy pass (`stop` does not reach that registry). - **TUI: a graceful-stop request that cannot be written is reported, not fatal.** The `S` worker caught only the helper's own refusals; an `OSError` from the write itself escaped, and Textual's default `exit_on_error` took the dashboard down with it. It now surfaces diff --git a/README.md b/README.md index 9af2d1a7..05cacb27 100644 --- a/README.md +++ b/README.md @@ -178,7 +178,7 @@ Press **`g`** to edit `.bmad-loop/policy.toml` in a form grouped by section — | `ctrl+w` | enter/leave pane resize mode (arrows resize, `Tab` picks the boundary) — or drag any divider bar | | `M` / `q` | toggle theme (light/dark mode) / quit | -**The TUI is an observer/launcher, never the engine.** Runs started with `r`/`s` are detached `bmad-loop` processes in windows of a dedicated tmux session (`bmad-loop-ctl`), so they survive a TUI exit or crash; the dashboard watches runs purely through the run-dir artifacts the engine writes atomically, so runs started from a plain shell show up identically. Launch and attach need tmux; the dashboard itself does not. Pid-based liveness is local-only — a run whose engine died shows `interrupted` (press `e`); runs on other hosts show `unknown`. +**The TUI is an observer/launcher, never the engine.** Runs started with `r`/`s` are detached `bmad-loop` processes in windows of a dedicated tmux session (`bmad-loop-ctl`; on psmux the name carries a per-project registry suffix), so they survive a TUI exit or crash; the dashboard watches runs purely through the run-dir artifacts the engine writes atomically, so runs started from a plain shell show up identically. Launch and attach need tmux; the dashboard itself does not. Pid-based liveness is local-only — a run whose engine died shows `interrupted` (press `e`); runs on other hosts show `unknown`. > 📖 See **[docs/tui-guide.md](docs/tui-guide.md)** for the full guide — layout, every key and modal, status glyphs, the settings field reference, and troubleshooting. Vector (SVG) versions of every screenshot live in [`docs/images/`](docs/images). @@ -581,13 +581,13 @@ For `per_worktree`, set `editor_mode = "per_worktree"` with `[scm] isolation = " A handful of `BMAD_LOOP_*` variables override behavior at runtime, taking precedence over the policy file. Most operators only ever touch `BMAD_LOOP_MUX_BACKEND` and, on a host with an unusual home directory, `BMAD_LOOP_STATE_DIR`; the rest are override/test hooks. -| Variable | Value | Effect | -| ----------------------------- | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `BMAD_LOOP_MUX_BACKEND` | registered backend name (e.g. `tmux`, `psmux`) | Forces the terminal-multiplexer backend, outranking the `[mux] backend` policy key and auto-selection. A name matching no registered backend is an error — it never silently falls back. Unset ⇒ auto-select. | -| `BMAD_LOOP_PROCESS_HOST` | registered host name (e.g. `posix`, `windows`) | Forces the process-lifecycle host (an override/test hook). A name matching no registered host raises rather than silently using POSIX. Unset ⇒ this platform's default. | -| `BMAD_LOOP_STATE_DIR` | absolute directory path | Overrides the user-scoped **state root** — the out-of-tree home of per-run control-plane state, keyed `///`. Used as the root itself, so nothing is appended to it. Must be **absolute**: the root is read both by the orchestrator and by the session it launches, which run from different working directories, so a relative value names two different places — bmad-loop refuses it rather than picking one. Unset ⇒ `$XDG_STATE_HOME/bmad-loop` when that names an absolute path, else `~/.local/state/bmad-loop`; on Windows `%LOCALAPPDATA%\bmad-loop\state`, else `%USERPROFILE%\AppData\Local\bmad-loop\state`. Set this when none of those is derivable or writable (a home on a network share, a locked-down service account). | -| `BMAD_LOOP_EVENTS_DIR` | directory path | **Session protocol, not an operator knob.** The orchestrator exports it into every session it drives, naming that run's hook-event directory under the state root; the hook relay writes there, falling back to the legacy in-tree `/events` when it is absent. Setting it yourself in a shell has no effect on a run (the engine overwrites it per session) and only misdirects a hand-invoked relay. | -| `BMAD_LOOP_SESSION_TIMEOUT_S` | seconds (float) | Overrides the per-session wall-clock budget (normally `limits.session_timeout_min × 60`) — mainly a test/E2E hook for sub-minute timeouts. A value that is not a finite positive number is ignored — non-positive, unparseable, or non-finite (`inf`, `1e999`), the last of which would otherwise disable the timeout outright. A large finite value is honoured. Unset ⇒ the policy value. | +| Variable | Value | Effect | +| ----------------------------- | ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `BMAD_LOOP_MUX_BACKEND` | registered backend name (e.g. `tmux`, `psmux`) | Forces the terminal-multiplexer backend, outranking the `[mux] backend` policy key and auto-selection. A name matching no registered backend is an error — it never silently falls back. Unset ⇒ auto-select. | +| `BMAD_LOOP_PROCESS_HOST` | registered host name (e.g. `posix`, `windows`) | Forces the process-lifecycle host (an override/test hook). A name matching no registered host raises rather than silently using POSIX. Unset ⇒ this platform's default. | +| `BMAD_LOOP_STATE_DIR` | absolute directory path | Overrides the user-scoped **state root** — the out-of-tree home of per-run control-plane state, keyed `///`. Used as the root itself, so nothing is appended to it. Must be **absolute**: the root is read both by the orchestrator and by the session it launches, which run from different working directories, so a relative value names two different places — bmad-loop refuses it rather than picking one. Unset ⇒ `$XDG_STATE_HOME/bmad-loop` when that names an absolute path, else `~/.local/state/bmad-loop`; on Windows `%LOCALAPPDATA%\bmad-loop\state`, else `%USERPROFILE%\AppData\Local\bmad-loop\state`. Set this when none of those is derivable or writable (a home on a network share, a locked-down service account). On Windows it moves one more thing with it: the psmux **registry** (`//_mux`, the per-project `PSMUX_DATA_DIR` bmad-loop exports) — see [multiplexer backends](docs/multiplexer-backends.md#where-psmux-sessions-live-the-per-project-registry). There is deliberately no second variable naming the registry: two knobs that can disagree would put two processes on different registries, each blind to the other's live sessions. | +| `BMAD_LOOP_EVENTS_DIR` | directory path | **Session protocol, not an operator knob.** The orchestrator exports it into every session it drives, naming that run's hook-event directory under the state root; the hook relay writes there, falling back to the legacy in-tree `/events` when it is absent. Setting it yourself in a shell has no effect on a run (the engine overwrites it per session) and only misdirects a hand-invoked relay. | +| `BMAD_LOOP_SESSION_TIMEOUT_S` | seconds (float) | Overrides the per-session wall-clock budget (normally `limits.session_timeout_min × 60`) — mainly a test/E2E hook for sub-minute timeouts. A value that is not a finite positive number is ignored — non-positive, unparseable, or non-finite (`inf`, `1e999`), the last of which would otherwise disable the timeout outright. A large finite value is honoured. Unset ⇒ the policy value. | Game-engine (Unity) runs read a wider `BMAD_LOOP_UNITY_*` / `BMAD_LOOP_ENGINE_*` set documented in the [Game Engine MCP guide](docs/game-engine-mcp-guide.md). diff --git a/docs/FEATURES.md b/docs/FEATURES.md index f46c7f33..5deeca2e 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -226,7 +226,7 @@ Result` section. Every other spec keeps warn-and-continue, and the record says w - Read-only observer + launcher (`bmad-loop tui`): runs table, expandable sprint tree (epics → stories/retro), severity-colored deferred-work ledger, per-story phase table (phase · agent · dev attempts · review cycles · tokens · commit/defer), a run header naming the live-or-configured active agent, tabs tailing journal / pane log / `ATTENTION`. - Launch & manage from keys: start run/sweep (`r`/`s`), resume (`e`), resolve escalation (`R`), answer missed decisions (`d`), attach (`a`), cleanup (`c`), validate (`v`), settings editor (`g`), theme/mode toggle (`M`), quit (`q`). - Resizable panes: every boundary is drag-adjustable by mouse (the divider bars double as the Sprint / Deferred Work section headings) or a `ctrl+w` keyboard resize mode; sizes persist per-project to `[tui]` in `policy.toml` and re-apply on the next launch. -- Survives TUI exit/crash: runs launched from the TUI are detached `bmad-loop` processes in a dedicated `bmad-loop-ctl` tmux session; the dashboard watches purely via run-dir artifacts, so shell-started runs appear identically. +- Survives TUI exit/crash: runs launched from the TUI are detached `bmad-loop` processes in a dedicated `bmad-loop-ctl` tmux session (per-project name suffix on psmux); the dashboard watches purely via run-dir artifacts, so shell-started runs appear identically. - Comment-preserving policy editor (`g`): grouped form, sections collapsed by default with one-line descriptions (`ctrl+e` toggles all), validated with the engine's own parser, unset keys show defaults as placeholders. ### tmux session management @@ -255,7 +255,7 @@ Result` section. Every other spec keeps warn-and-continue, and the record says w - `bmad-loop init` — install skills, hooks, policy, gitignore. - `bmad-loop validate` — preflight all prerequisites. `--json` instead emits a stable machine-readable document (schema-versioned; the `ok` verdict, the queue `mode`/`spec_folder`, per-severity `counts`, and every check as a flat emission-ordered finding with a stable `check` id, `severity`, human `message` and structured `detail`) per the [contract below](#machine-readable-output---json); a failing check still emits the whole document, at exit 1 — the nonzero code is the verdict, not a failure to produce one. -- `bmad-loop mux` — list registered terminal-multiplexer backends (platform · availability · version · which is selected and why; a backend whose binary is present but crashed the version probe gets a `warning:` on stderr carrying the probe's own failure, since the `-` in the VERSION column cannot tell that apart from a binary that reports no version); `mux set ` persists a machine-scoped choice into policy.toml (`--clear` reverts to auto, `--force` allows a name only registered on the target machine). Bundled backend: `tmux`; external backends (e.g. the herdr adapter) register via the `bmad_loop.mux_backends` entry-point group — see [Terminal multiplexer backends](multiplexer-backends.md). +- `bmad-loop mux` — list registered terminal-multiplexer backends (platform · availability · version · which is selected and why; a backend whose binary is present but crashed the version probe gets a `warning:` on stderr carrying the probe's own failure, since the `-` in the VERSION column cannot tell that apart from a binary that reports no version); on a backend with a registry namespace it also prints the **registry root** this project's sessions live in plus the export that reaches them from a bare client — on psmux that is `//_mux`, so a plain `psmux ls` shows none of them and says so rather than erroring ([#537](https://github.com/bmad-code-org/bmad-loop/issues/537)); `mux set ` persists a machine-scoped choice into policy.toml (`--clear` reverts to auto, `--force` allows a name only registered on the target machine). Bundled backend: `tmux`; external backends (e.g. the herdr adapter) register via the `bmad_loop.mux_backends` entry-point group — see [Terminal multiplexer backends](multiplexer-backends.md). - `bmad-loop adapters` — list registered coding-CLI adapter **kinds** (name · builtin/external · whether the family drives a multiplexer · which profiles select it), the CLI axis's counterpart to `mux`. Unlike `mux` there is no global choice to persist: a kind is selected per profile by its `adapter` field. A profile referencing an unregistered kind, and any out-of-tree adapter/profile package that failed to load, get a `warning:` on stderr; `validate` reports the same as `adapter.kind` / `adapter.external` / `adapter.external-profile`. - `bmad-loop run` — drive the dev → review → verify → commit loop. - `bmad-loop sweep` — triage + execute open deferred-work entries. diff --git a/docs/adapter-authoring-guide.md b/docs/adapter-authoring-guide.md index 86637843..883f585f 100644 --- a/docs/adapter-authoring-guide.md +++ b/docs/adapter-authoring-guide.md @@ -80,15 +80,30 @@ fragments) — or implement seams of a full OS port are in [Porting bmad-loop to a new OS](porting-to-a-new-os.md). The contract groups into: -- **Sessions** — `has_session`, `new_session` (geometry is optional: agent - sessions pin a fixed pane size because they are observed while detached; the - control session omits it), `kill_session`, `list_sessions`, `session_options` - (read a user option across all sessions), `set_session_option`. +- **Sessions** — `has_session`, `new_session` (geometry is + optional: agent sessions pin a fixed pane size because they are observed + while detached; the control session omits it), + `kill_session`, `list_sessions`, `session_options` (read a user option + across all sessions), `set_session_option`. + + One session method carries a default the seam cannot verify for your + transport: `session_name_key(name)`, the canonical comparison key — two + names denote the same live session exactly when their keys are equal. The + default is identity (exact comparison — correct for tmux, whose session + names are case-sensitive). **If your transport resolves session names + case-insensitively — or folds them any other way — you MUST override** + (psmux does: its NTFS port-file store opens names case-insensitively). + With the inherited identity key, a case-variant of the control session's + name is not discounted by the removal guard, so the documented recovery + `bmad-loop delete ctl` wedges behind a false live-session refusal on a + persisted `CTL` run. + - **Windows** — `new_window` (run a command in a fresh window), `new_parked_window` - (run a command, then _park_ on a keypress so the exit status stays inspectable, - then return any attached client to its origin — the POSIX `sh -c` recipe is - composed from the base's overridable shell-dialect hooks, so a non-POSIX - backend swaps the dialect fragments, not the method body), `list_window_ids` + (run a command, then _park_ on a keypress so the exit + status stays inspectable, then return any attached client to its origin — the + POSIX `sh -c` recipe is composed from the base's overridable shell-dialect + hooks, so a non-POSIX backend swaps the dialect fragments, not the method + body), `list_window_ids` (which MUST emit the same id form your `new_window` returns — `window_alive` is a membership test over it, so qualifying one side and not the other reads every live window as dead), `list_windows` (selected fields per window), @@ -106,6 +121,24 @@ seams of a full OS port are in `available` (is this backend usable on the current host), `version` (the binary's version string or `None` — **one bounded line**, folded with `fold_version()`; see [the porting guide](porting-to-a-new-os.md)). +- **Registry namespace** (optional; all three default to "no namespace", so a + transport without one writes nothing) — `has_registry_namespace` (a property + of the transport: does it namespace sessions by registry at all? Answer + `True` on every instance if it does, even when no root is currently in + force: cleanup uses it to tell "no namespace exists" from "running on the + transport's shared default registry", and only the first lets an untagged + session be claimed on run-directory evidence — a namespaced backend that + leaves this at the inherited `False` keeps the pre-namespace historical + reach, which on a shared registry can kill another project's session), + `registry_root` (the root your verbs + currently resolve targets through, for `bmad-loop mux` to disclose; `None` + from a namespaced backend means "no root in force" — the shared default — + not "no namespace") and + `legacy_registries` (instances bound to roots your sessions may predate, for + the cleanup sweep). Only for a transport that addresses sessions through a + directory of per-session files, as psmux does through `PSMUX_DATA_DIR`; the + rules are in [the porting + guide](porting-to-a-new-os.md#registry-namespaces-only-if-your-transport-has-one). **Both client verbs report effect, not dispatch.** They answer what the parked-window return path trusts — a bool from `detach_client`, a tri-state diff --git a/docs/multiplexer-backends.md b/docs/multiplexer-backends.md index 751ebeb6..c47ece71 100644 --- a/docs/multiplexer-backends.md +++ b/docs/multiplexer-backends.md @@ -62,8 +62,10 @@ The two floors are independent and neither implies the other. On a native-Windows host the bundled **psmux** backend is the platform default. psmux is a ConPTY tmux re-implementation that speaks the tmux CLI through its own `psmux` binary, so it -reuses tmux's session/window model — the `bmad-loop-` and `bmad-loop-ctl` session -names carry over. It is selected automatically when available; `available()` requires the +reuses tmux's session/window model — the `bmad-loop-` session names carry over, and +the control session's does with a per-registry suffix +([below](#where-psmux-sessions-live-the-per-project-registry)). It is selected automatically +when available; `available()` requires the `psmux` and `pwsh` (PowerShell) binaries on `PATH` and a psmux **3.3.8 or newer** (older releases report unavailable and selection falls through: 3.3.6 and below can force-kill a recycled PID during teardown, and the backend's verbs are written against fixes that landed @@ -100,6 +102,159 @@ longer meets this gate: it is stored as a hex digest of the project path, transp construction ([#419](https://github.com/bmad-code-org/bmad-loop/issues/419)), so sessions stay tagged whatever the path and the run-dir fallback remains only for genuinely untagged state. +### Where psmux sessions live: the per-project registry + +psmux resolves every target through a **registry** — the directory named by `PSMUX_DATA_DIR`, +holding one `.port`/`.key`/`.sid`/`.pid` set per session — and defaults it to +`%USERPROFILE%\.psmux`. bmad-loop points it at a per-project root instead: +`//_mux`, where `` is the location +[`BMAD_LOOP_STATE_DIR`](../README.md#environment-variables) resolves to and `` is +the same digest of the project's resolved path that tags session ownership. The export happens +once per bmad-loop process, before anything spawns psmux, so `run`, `cleanup`, `stop`, `attach` +and the TUI all agree on one registry without any of them persisting it. + +**The consequence to know: a bare `psmux ls` does not show these sessions.** It reads psmux's +default registry, finds nothing there, and answers "no sessions" — not an error. The same goes +for `psmux attach -t bmad-loop-` typed by hand. To reach them, export the same root +first: + +```powershell +$env:PSMUX_DATA_DIR = 'C:\Users\you\AppData\Local\bmad-loop\state\\_mux' +psmux ls +``` + +`bmad-loop mux` prints that root and a paste-ready export line for the current project, which is +the reliable way to get it — do not compose the path by hand. `bmad-loop attach` needs none of +this: it runs the client itself, under the root it just exported. + +Two further consequences: + +- **The control session is now per project, and its name says so.** `bmad-loop-ctl` was one + session shared by every project on the machine; separate registries mean one per project — and + the name must change with the scope, because psmux's duplicate-server guard is a mutex keyed on + the session name alone, across every registry in your login session (`Local\psmux-session-` + — a per-login-session object namespace, not a machine-global one), so a fixed name would let only + one registry there hold a control session and every other project's launch would fail as a + duplicate. On psmux the session is therefore `bmad-loop-ctl-<16-hex registry digest>`; the + TUI's toasts name the exact session. On tmux (no registries) the shared `bmad-loop-ctl` is + unchanged. +- **Sessions created before this change are in the default registry**, and `bmad-loop cleanup` + sweeps that registry too — but only for sessions that carry this project's ownership tag and + are not still running. Whatever it leaves standing is **named on stderr** (and in + `cleanup --json`, at `sessions.legacy_leftovers`) so a removal count never quietly stands for a + partial migration. Three kinds stay behind by design: + + - An **untagged** `bmad-loop-` session. In a shared registry a matching run directory + here is not proof of ownership — run ids are only unique within one project, and `--run-id` + is caller-supplied — so claiming one could kill a live session belonging to another project. + - **A live session of your own.** Cleanup never kills a run whose engine is still going, + wherever it lives. + - The pre-upgrade **`bmad-loop-ctl` control session**. The ctl-window sweep runs against the + current registry only. + + The list is taken by _presence_ — what the registry still holds once the sweep has run — so it + covers a kill that silently did not land as well as one that was never attempted. (`cleanup`'s + own `removed` count is still the pre-kill plan, the ceiling `cleanup --json`'s + `sessions.removed` has always documented; the leftovers line is what catches the difference.) + + These are one-time artifacts of the upgrade. To see them, open a shell with **no** + `PSMUX_DATA_DIR` set — that is psmux's default registry: + + ```powershell + psmux ls + psmux list-windows -t bmad-loop-ctl -F '#{window_index}: #{window_name}' + ``` + + > **Do not reach for `psmux kill-session -t bmad-loop-ctl`.** That session is machine-wide, and + > `kill-session` kills every child process in every one of its windows — across every project + > that used it. bmad-loop's own windows run the engine first and park only once it exits, so a + > window there is a **live run** until it isn't. If `bmad-loop cleanup` reported a leftover + > control session while any pre-upgrade run was still going, that run is what you would be + > killing. + + **`list-windows` cannot tell you which is which.** A live window and a parked one render + identically — `1: run-… (1 panes) [120x30]` — and so do `#{pane_pid}` and `#{pane_dead}`: the + parked window's shell is still alive, holding the prompt. The `[bmad-loop exited …]` banner is + pane _text_, so the one command that answers it is `capture-pane`: + + ```powershell + psmux capture-pane -p -t bmad-loop-ctl:1 # per window index from the listing above + ``` + + A window whose last line reads `[bmad-loop exited — press enter]` has finished. One that + does not is still running — take its `run-` / `sweep-` name to + `bmad-loop list --project ` and stop it through bmad-loop (`bmad-loop stop +`, or the TUI) rather than through psmux. + + Finished windows can simply be left parked — a parked window costs one idle shell. To close one, + `psmux kill-window -t bmad-loop-ctl:` kills only that window's own children, so it is + safe once `capture-pane` has shown you the banner. Closing the last window ends the session too, + but only while psmux's `exit-empty` is on; it is on by default, and `psmux show-options -g +exit-empty` says which you have. With it off, an empty session stays. + +Setting `PSMUX_DATA_DIR` yourself does **not** move bmad-loop's registry. bmad-loop derives the +root from the project and the state root and exports it over whatever it finds, saying so once on +stderr when it replaced something. Your value is left alone for your own psmux sessions — it is +psmux's variable, not bmad-loop's, so bmad-loop overrides it rather than refusing to run. + +That is deliberate, and the reason is worth having: an honoured export would make the registry a +function of the shell a command happened to start in. A TUI launched from the Start menu carries no +profile environment and would derive; a run started from a dev shell whose profile exports a root +would honour it — two registries on one machine, and a live session reading as gone in one of them. +Nor can bmad-loop tell the two apart: a value typed once in one shell and a value a profile exports +into every shell arrive identically, and they want opposite answers. + +To reach these sessions from a bare psmux, point your shell at bmad-loop's root rather than the +other way round. `bmad-loop mux` prints it ready to paste: + +```powershell +$env:PSMUX_DATA_DIR = '' +psmux ls +``` + +One registry serving both bmad-loop and your own psmux is a reasonable thing to want and is not +available today; it needs a preference you state rather than one bmad-loop guesses at. + +Two consequences of deriving, both benign: + +- A pane child agrees with a clean process by construction — `bmad-loop --project ` run from + a pane of this project's session gets the other project's root, not this one's. +- The registry follows `BMAD_LOOP_STATE_DIR`, so a process under a different state root uses a + different registry. It moves with the out-of-tree state the state root already holds — each run's + control-plane directory and its hook-event channel. The run _directory_ is not among them: that + stays in-tree at `/.bmad-loop/runs` and moves with the project. + +How the state root reaches the windows bmad-loop opens: coding-CLI windows (the engine's sessions +and the probe launcher's window) are **told** it through their env dict, which travels inside the +command the window runs; everything else — a session's initial shell window, the engine windows +the TUI parks — inherits it from the multiplexer server, as it always has. The told entry is +always this process's own answer: a `BMAD_LOOP_STATE_DIR` declared in a profile's `[env]` table is +overwritten with the resolved root, and when no root can be derived the entry is removed rather +than forwarded — the window then inherits and fails exactly as its parent does, instead of being +aimed at a state root (and so a registry) its own orchestrator cannot see. + +One psmux mode breaks that inheritance and is **not supported**: `PSMUX_BARE_ENV=1` (psmux's +escape hatch for a Windows environment block near the 32 KB `CreateProcessW` limit) empties a pane +child's environment and rebuilds it from a 14-name allowlist that drops both +`BMAD_LOOP_STATE_DIR` and the `LOCALAPPDATA` its default falls back to — so an inherited value is +no value at all. (`TMUX` is still set in the pane, but by psmux afterwards, not by the allowlist, +which does not contain it.) A `bmad-loop` run in a window-0 shell or a parked engine window under +that switch then re-derives the state root from what survived, and lands somewhere else — reading +its own live session as gone — whenever `BMAD_LOOP_STATE_DIR` was in force or `LOCALAPPDATA` points +outside `%USERPROFILE%\AppData\Local`; `USERPROFILE` survives the clear, so a default profile +re-derives the same root. Coding-CLI windows keep working — their env rides the in-command +transport, which psmux applies after the bare-env clear (source-read at v3.3.8). bmad-loop warns +once per process when the switch is on in its environment (the server, not this process, is what +reads the switch at pane spawn, so a server already running with it on under a clean client is not +detected); the +remedy is to unset `PSMUX_BARE_ENV` for bmad-loop's sessions. + +If the state root cannot be derived at all — `BMAD_LOOP_STATE_DIR` set to a relative path, say — +bmad-loop has no registry of its own to point at, says so, and leaves whatever `PSMUX_DATA_DIR` you +had in force. `cleanup` then refuses to claim an untagged session on run-directory evidence, because +that evidence only holds in a registry bmad-loop derived: a run id is unique within a project, not +across the registry you are sharing with it. + ## External backends Every backend beyond the two bundled ones is a separate package that you co-install with bmad-loop; it diff --git a/docs/porting-to-a-new-os.md b/docs/porting-to-a-new-os.md index 2a39a0c0..7f21d5e0 100644 --- a/docs/porting-to-a-new-os.md +++ b/docs/porting-to-a-new-os.md @@ -202,6 +202,61 @@ listing) — the herdr adapter in particular **never** probes or starts its background server from `available()`, `version()`, or the constructor; server autostart is lazy, confined to the mutating operations that actually need it. +### Registry namespaces (only if your transport has one) + +Some multiplexers address sessions through a **registry**: a directory of +per-session addressing files that every verb resolves a target through. psmux is +one — `PSMUX_DATA_DIR`, a `.port`/`.key` set per session. tmux is not: a server +is a socket, and there is no root a caller could be pointed at. + +The distinction matters because a registry is a **namespace, not a filter**. A +session in registry A is not merely hidden from a verb aimed at registry B; it is +unaddressable from it, and the transport usually reports that as an ordinary "no +such session" rather than an error. Two processes that disagree about the root +therefore disagree about which sessions exist — and the verbs that carry that +disagreement (`has_session`, `list_window_ids`) are exactly the ones whose seam +contract says to degrade quietly. + +If your transport namespaces, three rules: + +- **Derive the root from the project**, never from the run, never from the + launching shell, and never from anything a driven session can write + (`policy.toml` and the project tree are both session-writable). bmad-loop's is + `runs.mux_registry_root` — `//_mux`, reusing + `runs.project_tag` so two spellings of one project cannot key two registries. + The derivation belongs in `runs`, beside the state root, not in the backend. +- **Bind it once, ahead of every spawn.** `cli._configure_mux` exports it before + dispatch — the last point that still knows the project and the first that + precedes every verb. A create-call-only injection is worse than doing nothing. + An ambient value needs a real question answered, not a flag. A multiplexer + hands every pane child the server's environment, so an ambient value there was + inherited rather than typed — and a process cannot tell an inherited one from a + typed one, nor a value typed in one shell from one a profile exports into every + shell. Those want opposite answers, so do not try to decide between them: derive + unconditionally, override what you find, and report that you did. A derived root + is a pure function of (project, state root), so every process agrees without + anything having to travel between them, which is the property worth protecting. +- **Carry `new_window`'s env inside the command you launch**, not in the + environment you hope the pane inherits. A multiplexer is free not to hand a + pane child the server's environment (psmux's `PSMUX_BARE_ENV=1` mode clears + it to a 14-name allowlist — a mode bmad-loop declares unsupported and warns + about, precisely because the _other_ windows ride inheritance); an env dict a + caller passed explicitly must survive regardless, and an in-command transport + is the one that does. `_window_launch` is the dialect hook that owns each + family dialect's answer. +- **Answer `has_registry_namespace()`, `registry_root()` and + `legacy_registries()`.** The first tells cleanup your transport namespaces + sessions at all, so a registry with no root in force reads as the shared + default it is rather than as proof of ownership. The second lets + `bmad-loop mux` disclose the root, so an operator's own client is not silently + looking at an empty registry. The third hands cleanup instances bound to roots + your sessions may predate; each must be an independent instance, never a global + environment swap, because the sweep runs on a TUI worker thread beside other + threads issuing ordinary verbs. + +All three default to "no namespace" on `TerminalMultiplexer`, so a transport +without one inherits the right answers and writes no code. + **Deep contract →** [adapter authoring guide: the transport contract for a backend author](adapter-authoring-guide.md#the-transport-contract-for-a-backend-author). diff --git a/docs/tui-guide.md b/docs/tui-guide.md index 864120d4..502bb5fc 100644 --- a/docs/tui-guide.md +++ b/docs/tui-guide.md @@ -72,7 +72,9 @@ screen). An explicit `TEXTUAL_FPS` in the environment still wins. The TUI never runs an engine in-process. The two halves: - **Launcher** — `r`, `s`, and `e` spawn detached `bmad-loop` processes as - windows of a dedicated tmux session, `bmad-loop-ctl`. Windows are named + windows of a dedicated tmux session, `bmad-loop-ctl` (on psmux the name + carries a per-project registry suffix — the launch toast names the exact + session; see [multiplexer backends](multiplexer-backends.md)). Windows are named `run-`, `sweep-`, or `resume-`, run the same Python interpreter as the TUI (`python -m bmad_loop.cli`, immune to PATH/venv drift inside tmux), and stay open after exit showing @@ -693,7 +695,7 @@ if the operator has marked `policy.toml` read-only (#593, #597). | `multiplexer backend unavailable — launch/attach disabled` | install the selected backend's binary (tmux by default — see `bmad-loop mux`); the dashboard still works read-only | | `git worktree is not clean — commit or stash first` | the launch guard; commit/stash and retry. `.bmad-loop/policy.toml` is exempt, so saving in the settings editor never blocks a launch | | `another run is live: ` | a second engine on the same project may conflict — confirm only if you know they won't touch the same stories | -| `launch may have failed — attach to control session bmad-loop-ctl` | no `state.json` within 10 s of launch; attach to the ctl window to read the error (the window stays open with the exit code) | +| `launch may have failed — attach to control session ` | no `state.json` within 10 s of launch; attach to the named ctl session to read the error (the window stays open with the exit code) | | `no run selected` | `e` / `a` need a selected run — the project has no runs yet | | `state for run is unreadable` | corrupt/missing `state.json`; inspect the run dir | | `run already finished` | finished runs can't be resumed | diff --git a/src/bmad_loop/adapters/generic.py b/src/bmad_loop/adapters/generic.py index 9512a163..1c798a63 100644 --- a/src/bmad_loop/adapters/generic.py +++ b/src/bmad_loop/adapters/generic.py @@ -525,7 +525,12 @@ def interactive_argv(self, spec: SessionSpec) -> list[str]: return argv def interactive_env(self, spec: SessionSpec) -> dict[str, str]: - return {**self.profile.env, **spec.env} + # The pin chokepoint (runs.pin_state_root): the profile's [env] table + # must not be able to move a session off this process's state root — + # including when no root derives, where there is no pin key for a mere + # spread ordering to protect. `start_session`'s window merge applies + # the same rule. + return runs.pin_state_root({**self.profile.env, **spec.env}) def build_command(self, spec: SessionSpec) -> str: return " ".join(shlex.quote(a) for a in self.interactive_argv(spec)) @@ -565,7 +570,9 @@ def start_session(self, spec: SessionSpec) -> SessionHandle: self.session_name, spec.task_id[-40:], spec.cwd, - {**self.profile.env, **spec.env}, + # Same merge as interactive_env, same pin chokepoint: the profile's + # [env] table must not move the window off this process's state root. + runs.pin_state_root({**self.profile.env, **spec.env}), self.build_command(spec), ) # pipe_pane tolerates the window having already died (a CLI that crashes on diff --git a/src/bmad_loop/adapters/multiplexer.py b/src/bmad_loop/adapters/multiplexer.py index b57dbc75..9aa60036 100644 --- a/src/bmad_loop/adapters/multiplexer.py +++ b/src/bmad_loop/adapters/multiplexer.py @@ -94,6 +94,25 @@ def target(self, session: str, window: str | None = None) -> str: # ----------------------------------------------------------- sessions + def session_name_key(self, name: str) -> str: + """Canonical comparison key for a session name on this transport: two + names denote the same live session exactly when their keys are equal. + + Identity by default — tmux resolves session names case-sensitively + (measured on 3.4: ``bmad-loop-ctl`` and ``bmad-loop-CTL`` coexist), so + exact comparison is the truth there. A transport that resolves names + through a case-folding store overrides (psmux: the registry is a + directory of per-session files opened by name, and NTFS opens names + case-insensitively). Non-abstract so released out-of-tree backends + keep their exact-comparison behavior unchanged. + + This is where "are these the same session name?" gets its answer: + core must never decide it with a constant, because the same fold that + is required on one transport destroys data on the other — a + case-variant agent session discounted as "the control session" on + tmux is a genuinely live session whose run dir then gets deleted.""" + return name + @abstractmethod def has_session(self, name: str) -> bool: """True iff a session named exactly ``name`` exists. @@ -401,6 +420,72 @@ def version_error(self) -> str | None: in-tree reader and builds its own instance per row.""" return None + def registry_root(self) -> str | None: + """The registry this backend's verbs currently resolve targets through, + or ``None`` when the backend has no registry namespace at all. + + ``None`` is the default and the tmux answer: tmux addresses a server by + socket, and there is no root an operator could be pointed at. Backends + that DO namespace (see :meth:`legacy_registries` for the concept) answer + the root in force, so a frontend can disclose it — an operator whose own + client reads a different root sees none of these sessions, and is told + "no sessions" rather than an error. + + A diagnostic, like :meth:`version_error`: must not raise, and a value it + cannot use (one the transport would reject) still comes back verbatim + rather than as ``None`` — "the root is unusable" and "there is no root" + are different facts and the caller acts on the difference. + + ``None`` from a backend that DOES namespace means "no root in force": + its verbs then address the transport's own *default* registry, which is + shared with every project and with the operator. That is a different + fact from tmux's ``None`` (no namespace exists), and + :meth:`has_registry_namespace` is how a caller tells them apart.""" + return None + + def has_registry_namespace(self) -> bool: + """Whether this transport namespaces sessions by registry at all — a + property of the backend, independent of whether a root is currently in + force (see :meth:`registry_root` / :meth:`legacy_registries` for the + concept). + + ``False`` is the default and the tmux answer: one server for the + machine, and ``registry_root() is None`` means exactly that. A backend + answering ``True`` here with ``registry_root()`` ``None`` is running on + its own default registry — shared, not this project's — which is what + ``runs._registry_proves_ownership`` needs to know before it lets an + untagged session be claimed on run-directory evidence.""" + return False + + def legacy_registries(self) -> list[TerminalMultiplexer]: + """Backends addressing *other* registries this one's own sessions may + still be living in, for the cleanup sweep. ``[]`` by default — a backend + with a single registry, tmux included, has nothing to sweep. + + **The registry-namespace seam concept.** A *registry* is wherever a + multiplexer keeps the per-session addressing state its verbs resolve a + target through: for psmux, the ``PSMUX_DATA_DIR`` directory of + ``.port``/``.key`` files, one per session. It is a namespace, not a + filter — a session in registry A is not merely hidden from a verb aimed + at registry B, it is unaddressable from it. bmad-loop aims psmux at a + per-project root (``runs.mux_registry_root``), which is what makes this + method necessary: sessions created before that root existed are in + psmux's default registry, addressable only by a backend pointed there. + + Each element must be an independent instance bound to its registry, and + must NOT work by mutating this process's environment: the callers include + a TUI worker thread running beside other threads issuing ordinary verbs, + and a global swap would silently aim one of *those* at the wrong + registry — the same live-session-reads-as-gone failure the per-project + root exists to prevent. + + A porting note for a new OS or multiplexer: if the transport has no such + namespace, inherit this default and nothing else changes. If it does, + the seam wants the derivation in ``runs`` (keyed on the project, never on + the run or the shell) and the sweep here — see + ``docs/porting-to-a-new-os.md``.""" + return [] + def window_pane_pids(self, target: str) -> list[int]: """Best-effort OS pids of ``target``'s pane root processes, for the kill escalation. Not abstract: backends that can't (or don't) report pids diff --git a/src/bmad_loop/adapters/psmux_backend.py b/src/bmad_loop/adapters/psmux_backend.py index dccbbec7..b0ad90e7 100644 --- a/src/bmad_loop/adapters/psmux_backend.py +++ b/src/bmad_loop/adapters/psmux_backend.py @@ -39,7 +39,15 @@ with nothing attached is a move it cannot have made, but there is nobody here to keep prompting either. ``False`` is left to the exits that do carry the whole claim, both of which dispatch nothing: no session to measure, and a -spawn fault with no fallback. ``available()`` additionally gates on the +spawn fault with no fallback. psmux resolves every target +through a *registry* — the ``PSMUX_DATA_DIR`` directory of per-session +``.port``/``.key`` files — and bmad-loop points it at a per-project root +(``runs.mux_registry_root``), so this backend owns the two rules that follow +from that: it refuses to spawn under a root psmux would panic on, and it can +mint an instance bound to psmux's own default registry so cleanup can still +address sessions created before the move (``legacy_registries``). Both live in +the ``_run`` override, the one place psmux is spawned. +``available()`` additionally gates on the reported version — see ``_LAST_UNSUPPORTED`` for what the floor buys and why it moves. ``has_session`` @@ -71,11 +79,18 @@ import shutil import subprocess import sys +from collections.abc import Mapping from pathlib import Path from .tmux_base import PARKED_RETURN_DETACH, BaseTmuxBackend, TmuxError _ENV_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +# psmux's registry-root variable, as psmux spells it. `runs.PSMUX_DATA_DIR` holds +# the same name for the export side; the two are deliberately separate constants +# rather than one import, because the dependency only runs one way — `runs` +# imports the adapter seam, never the reverse — and a transport name belongs in +# the transport leaf. +_DATA_DIR = "PSMUX_DATA_DIR" def _pwsh_quote(value: str) -> str: @@ -84,6 +99,18 @@ def _pwsh_quote(value: str) -> str: return "'" + value.replace("'", "''") + "'" +# One warning per process about `PSMUX_BARE_ENV`, not per verb — see +# `PsmuxMultiplexer._warn_if_bare_env`. +_BARE_ENV_WARNED = False + + +def _bare_env_on(value: str | None) -> bool: + """psmux's own predicate for `PSMUX_BARE_ENV` (`src/pane.rs:890-892`, + source-read at v3.3.8): on iff the value is "1" or case-insensitively + "true".""" + return value is not None and (value == "1" or value.lower() == "true") + + class PsmuxMultiplexer(BaseTmuxBackend): """psmux backend — tmux-family argv from the base, PowerShell dialect and the documented psmux divergences here. @@ -101,6 +128,169 @@ class PsmuxMultiplexer(BaseTmuxBackend): _ENCODING = "utf-8" _ERRORS = "backslashreplace" + def __init__(self, *, default_registry: bool = False) -> None: + """``default_registry=True`` binds this instance to psmux's OWN registry + root — the one it computes when ``PSMUX_DATA_DIR`` is unset — regardless + of what this process exports. That is the legacy registry every psmux + session bmad-loop created before the per-project root existed still lives + in, and the only reason to build such an instance is to sweep it (see + :meth:`legacy_registries`). + + Bound per instance rather than swapped into ``os.environ`` around a call: + the sweep runs on a TUI worker thread beside other threads issuing + ordinary verbs, and a global swap would aim one of *those* at the wrong + registry for as long as it was in place. + + Unbinding is done by REMOVING the variable, never by re-deriving psmux's + default here: that default is ``\\.psmux``, where the home is + ``USERPROFILE`` when it is set and non-empty, then the profile API + (``GetUserProfileDirectoryW``), then ``HOMEDRIVE``+``HOMEPATH``, then + ``HOME`` (``src/paths.rs`` ``home_dir``, source-read at v3.3.8) — and a + second spelling of that cascade in Python is a second thing to keep in + sync. + """ + self._default_registry = default_registry + + def _run( + self, + argv: list[str], + *, + check: bool = True, + env: dict[str, str] | None = None, + ) -> subprocess.CompletedProcess[str]: + """The base's one spawn point, plus this backend's registry-root rules. + + Two things happen here and nowhere else, because this is the only place + psmux is spawned: + + 1. **Registry binding.** A ``default_registry`` instance spawns with + ``PSMUX_DATA_DIR`` removed, so psmux computes its own root. The parent + env is *copied* first — a Windows child needs ``SystemRoot`` and + friends — and an explicit per-call ``env`` is honoured as the base, so + ``new_session``'s scrubbed env keeps its scrubbing. + 2. **The absoluteness gate.** psmux ``assert!``s ``PSMUX_DATA_DIR`` + absolute and non-empty (``src/paths.rs``, source-read at v3.3.8) and + **panics** otherwise: a Rust panic message on stderr and a nonzero + exit, which ``has_session`` and friends read as an ordinary "no" — + a live session reading as gone, for a reason nothing in the output + names. Refusing here turns that into one bmad-named error naming the + variable and its value. Checked on the *effective* env, so a per-call + ``env`` carrying a bad value is caught too, not just the inherited one. + + The unbound, well-formed case adds one dict lookup and no copy. + """ + effective = env if env is not None else os.environ + self._warn_if_bare_env(effective) + if self._default_registry: + env = {k: v for k, v in effective.items() if k != _DATA_DIR} + else: + value = effective.get(_DATA_DIR) + if value is not None and not (value and os.path.isabs(value)): + raise TmuxError( + f"{_DATA_DIR}={value!r} is not an absolute path; psmux panics on a " + "relative or empty registry root, so no verb can run under it — " + f"unset {_DATA_DIR} to use psmux's default registry, or set it to " + "an absolute directory" + ) + return super()._run(argv, check=check, env=env) + + @staticmethod + def _warn_if_bare_env(effective: Mapping[str, str]) -> None: + """Say once per process when ``PSMUX_BARE_ENV`` is on: bmad-loop does + not support that mode, and what it breaks is quiet. + + Under it psmux ``env_clear``s every pane child and repopulates from a + 14-name allowlist (``src/pane.rs:889-908``, source-read at v3.3.8; + measured in a real pane) that drops ``BMAD_LOOP_STATE_DIR`` *and* the + ``LOCALAPPDATA`` its default cascade falls back to. Coding-CLI windows + still get the state root — their env rides the in-source + ``-EncodedCommand`` prelude, which runs in the pane after the clear + (see ``_window_launch``) — but a session's window-0 shell and the TUI's + parked engine windows rely on inheritance, so a bmad-loop run in one of + those re-derives the state root from what survived. That diverges, and + the run then reads this very session as gone, in exactly two cases: + ``BMAD_LOOP_STATE_DIR`` was in force (the clear drops it, and the + default cascade answers somewhere else), or ``LOCALAPPDATA`` names + something other than ``%USERPROFILE%\\AppData\\Local`` (a redirected + or roaming profile). ``USERPROFILE`` *is* on the allowlist, so on a + default profile the fallback arm lands on the same root and nothing + diverges — the mode is unsupported because the failure is silent when + it does happen, not because it always does. Supporting it means an env + transport on the session and parked-window verbs, which is its own + seam change — tracked + as a follow-up issue, deliberately outside #537. + + Warned, not refused: the variable is psmux's (an operator may run their + own sessions under it), and most commands never open a window. Ceiling: + psmux reads the switch in the *server* process at pane spawn; this + process's effective env is a proxy for it, so a server already running + with the mode on under a clean client is not detected here. + """ + global _BARE_ENV_WARNED + if _BARE_ENV_WARNED or not _bare_env_on(effective.get("PSMUX_BARE_ENV")): + return + _BARE_ENV_WARNED = True + print( + "warning: PSMUX_BARE_ENV is on, which bmad-loop does not support — " + "session and parked-window shells lose BMAD_LOOP_STATE_DIR and derive " + "their own state root and registry, so a run can read as gone; unset " + "PSMUX_BARE_ENV for bmad-loop's sessions", + file=sys.stderr, + ) + + def registry_root(self) -> str | None: + """The registry root a verb from this instance inherits — the process + environment's, which is what ``_run``'s ``env=None`` default passes on. + + A per-call ``env`` can still name another one; nothing reads this to + decide where a verb goes, only to disclose where they normally go. + + A ``default_registry`` instance answers ``None`` — it spawns with the + variable *removed*, and the root psmux then computes for itself is + deliberately not respelled here (see :meth:`__init__`). ``None`` from + the ordinary instance likewise means psmux is on its own default + registry; :meth:`has_registry_namespace` is what separates either from + tmux's "no namespace exists", and a reader deciding ownership must use + it — the default registry is shared with every project and with the + operator, which is precisely what a ``None`` here must not be read as + disclaiming. + """ + return None if self._default_registry else os.environ.get(_DATA_DIR) + + def has_registry_namespace(self) -> bool: + # A property of the transport, not of the instance binding: even a + # `default_registry` instance addresses A registry — psmux's own. + return True + + def session_name_key(self, name: str) -> str: + """psmux resolves a session by opening ``\\.port`` by + name (``src/paths.rs:113``, source-read at v3.3.8), and NTFS opens + names case-insensitively — measured: with ``bmad-loop-ctl-x`` live, + target ``bmad-loop-CTL-x`` answers ``has-session``, is refused as a + duplicate by ``new-session``, and a kill through it takes the + lowercase session down. So on this transport two names differing only + by ASCII case denote one session, and comparisons must fold.""" + return name.lower() + + def legacy_registries(self) -> list["PsmuxMultiplexer"]: + """psmux's default registry, when this process is pointed somewhere else. + + Sessions bmad-loop created before the per-project root existed are still + there, and after the move nothing else can address them: cleanup would + report a clean sweep while their servers ran on. One extra pass over that + root is the whole migration, and it needs no state — an already-migrated + machine just finds nothing there. + + Empty when ``PSMUX_DATA_DIR`` is unset (this process IS on the default + registry — the primary pass already covers it) and when it is set to a + value psmux would panic on, where the primary pass is not running either + and a sweep would be the only thing that appeared to work. + """ + value = os.environ.get(_DATA_DIR) + if value is None or not (value and os.path.isabs(value)): + return [] + return [type(self)(default_registry=True)] + # ------------------------------------------- shell dialect (PowerShell) # A command pwsh could not even start (not recognized) still runs the rest of @@ -187,8 +377,12 @@ def _window_launch(self, env: dict[str, str], command: str) -> list[str]: # source that must run the CLAUDE_* scrub (_source_prefix) in-pane # anyway, so the prelude is the transport already present — `-e` flags # would be a second one whose values the scrub then has to be ordered - # against. `command` arrives POSIX-quoted (callers shlex-quote each - # arg), so split it here and re-quote for pwsh. + # against. The prelude also survives `PSMUX_BARE_ENV=1` (measured both + # ways on 3.3.8: an in-source assignment runs in the pane after the + # allowlist clear), which is why a coding-CLI window keeps its + # `BMAD_LOOP_STATE_DIR` pin even under that mode — see the bare-env + # warning in `_run` for what does not. `command` arrives POSIX-quoted + # (callers shlex-quote each arg), so split it here and re-quote for pwsh. for key in env: if not _ENV_NAME.fullmatch(key): raise TmuxError(f"invalid environment variable name: {key!r}") diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index 96a33a8a..fb7569d2 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -146,13 +146,33 @@ def _policy_path(project: Path) -> Path: def _configure_mux(project: Path) -> None: - """Install the policy ``[mux] backend`` choice into the multiplexer seam. + """Install the policy ``[mux] backend`` choice into the multiplexer seam, and + point this process at the project's multiplexer registry root. The single configuration point, called from ``main()`` before dispatch so every mux consumer — including probe/diagnose/attach/stop, which never load policy themselves — selects under the persisted choice. Tolerant of a broken policy file: diagnostics must keep working on a misconfigured host, and the - commands that need policy re-load it loudly themselves.""" + commands that need policy re-load it loudly themselves. + + The registry export belongs *here*, and not at backend construction, for two + reasons this is the only place that satisfies at once: it is the last point + that still knows the project (``--project`` has been parsed, no handler has + run) and it precedes every psmux spawn in the process, the ctl session the + TUI mints included. Backend construction knows neither — a factory takes no + arguments, and ``detect_multiplexers`` builds every registered backend for a + diagnostic table, which must not move a registry as a side effect. + ``runs.export_psmux_registry_root`` never raises, on the same + keep-diagnostics-working rule as the policy read above. + + It also *overrides* an ambient ``PSMUX_DATA_DIR`` rather than honouring it — + the root is derived, always, so that two processes given one project cannot + disagree about where its sessions live (the full argument is in that + function). Overriding an operator's variable silently is how someone loses an + hour to `psmux ls` showing nothing, so it is said once, here, at the only + point that runs ahead of every command. stderr, not stdout: the ``--json`` + contract is one object on stdout and nothing else, and this is the + ``unverifiable_pid`` precedent.""" from .adapters.multiplexer import configure_multiplexer path = _policy_path(project) @@ -161,6 +181,47 @@ def _configure_mux(project: Path) -> None: except (policy_mod.PolicyError, OSError): name = None configure_multiplexer(name, origin=path) + ambient = os.environ.get(runs.PSMUX_DATA_DIR) + root = runs.export_psmux_registry_root(project) + if root is not None: + if ambient is not None and ambient != root: + print( + f"note: using bmad-loop's own psmux registry {root} — your " + f"{runs.PSMUX_DATA_DIR}={ambient} is left for your own sessions " + f"(`bmad-loop mux` prints the export that reaches these)", + file=sys.stderr, + ) + return + # The degrade arms, and the ones an operator most needs told: no root could + # be derived, so every psmux verb this command runs — the kill path included + # — addresses a registry that is not bmad-loop's own. With an ambient value + # that is THEIR registry as found; with none it is psmux's shared default. + # Cleanup will not claim an untagged session in either + # (`runs._registry_proves_ownership`), but silence would still read as + # "bmad-loop is using its own registry". + if ambient is not None: + print( + f"warning: no state root could be derived, so bmad-loop has no registry of " + f"its own here and is using {runs.PSMUX_DATA_DIR}={ambient} as found — set " + f"{envvars.STATE_DIR} to an absolute path, or unset it, to get one", + file=sys.stderr, + ) + return + # No ambient value either: only worth a line on a backend that namespaces at + # all — on tmux there is no registry for the degrade to have cost. + from .adapters.multiplexer import MultiplexerError, get_multiplexer + + try: + namespaced = get_multiplexer().has_registry_namespace() + except MultiplexerError: + return + if namespaced: + print( + f"warning: no state root could be derived, so bmad-loop has no registry of " + f"its own here and is using the multiplexer's shared default registry — set " + f"{envvars.STATE_DIR} to an absolute path, or unset it, to get one", + file=sys.stderr, + ) def _reject_bad_run_id(run_id: str | None) -> int | None: @@ -171,7 +232,8 @@ def _reject_bad_run_id(run_id: str | None) -> int | None: if run_id is not None and not runs.is_valid_run_id(run_id): print( f"error: invalid --run-id {run_id!r} — expected {runs.RUN_ID_RE.pattern} " - f"(at most {MAX_SEGMENT} characters, not a reserved device name)", + f"(at most {MAX_SEGMENT} characters, not a reserved device name, and not " + f"the reserved control-session shape 'ctl'/'ctl-…' in any letter case)", file=sys.stderr, ) return 1 @@ -894,9 +956,73 @@ def cmd_mux(args: argparse.Namespace) -> int: # fallback, which is tmux by contract — not a stale hardcoding name = chosen.name if chosen else "tmux" print(f"selection: {name} ({type(backend).__name__}) — {reason}") + _print_registry(project) return 0 +def _print_registry(project: Path) -> None: + """Name the multiplexer registry this project's sessions live in, and how to + reach them from a bare psmux. + + Without this the repo lies by omission: bmad-loop points psmux at a + per-project root (``runs.mux_registry_root``), so an operator's own + ``psmux ls`` — which reads psmux's default registry — shows none of this + project's sessions and answers "no sessions" rather than erroring. Printing + the root and a paste-ready export turns that into a fact they can act on. + Silent on a backend with no registry namespace (tmux), which has nothing to + disclose, and on one that cannot be selected at all — the caller has already + reported that. + + Line per fact rather than a table row: this is a path, which is exactly the + cell an aligned table mangles (#321), and there is only one of them.""" + from .adapters.multiplexer import MultiplexerError, get_multiplexer + + try: + mux = get_multiplexer() + root = mux.registry_root() + if root is None: + # For a namespacing backend, no root in force means the shared + # default registry — the one situation `registry:` must not stay + # silent about, since silence reads as "per-project as usual". + if mux.has_registry_namespace(): + print( + "registry: the multiplexer's shared default (no state root " + f"could be derived — set {envvars.STATE_DIR} to an absolute " + "path, or unset it, to get a per-project one)" + ) + return + except MultiplexerError: + return + try: + derived = str(runs.mux_registry_root(project)) + except (runs.StateRootError, OSError, RuntimeError): + derived = None + # bmad-loop always derives, so a mismatch is not an operator's honoured + # export — that is not a thing any more — but the one case the export + # degrades on: an underivable state root, where it leaves whatever it found + # rather than inventing a root. Saying "derived" there would be a lie about + # the one situation an operator most needs told. + origin = ( + "derived from the project" + if root == derived + else f"NOT bmad-loop's — ${runs.PSMUX_DATA_DIR} as found, " + "because no state root could be derived here" + ) + print(f"registry: {root} ({origin})") + # A single-quoted PowerShell literal, whose only escape is doubling the quote: + # an unescaped `C:\Users\O'Brien\...` ends the string mid-path and the line + # will not parse. Anything printed as paste-ready has to actually paste. Same + # rule as `psmux_backend._pwsh_quote`, spelled out rather than imported — a + # backend's argv quoting is not this module's to reach into, and the + # dependency only runs the other way. + quoted = root.replace("'", "''") + print( + f" a bare `psmux ls` reads psmux's default registry, not this one — " + f"export {runs.PSMUX_DATA_DIR} to see these sessions: " + f"$env:{runs.PSMUX_DATA_DIR} = '{quoted}'" + ) + + def _mux_set(project: Path, args: argparse.Namespace) -> int: from .adapters.multiplexer import detect_multiplexers @@ -2398,6 +2524,31 @@ def _sweep_dry_run(paths: bmadconfig.ProjectPaths, pol) -> int: def _resume_paused_run(project: Path, run_dir: Path) -> int: """Resume the engine for a paused/interrupted run. Shared by `resume` and the re-arm step of `resolve`.""" + # An id that aliases a control session (`ctl` / `ctl-<16hex>` — + # runs.run_id_aliases_control_session; NOT the mint's broader reservation, + # since a historical `ctl-foo` run has a genuine agent session and resumes + # safely) can reach here only from a run dir an OLDER release persisted: + # minting refuses the shape, but validation never sees what is already on + # disk. Driving such a run is not possible — its agent session name IS the + # control session's, so the relaunch would adopt the live control session — + # and the refusal names the way out instead of just the wall: stop/delete + # work on the run dir and, via the kill_session chokepoint, never touch any + # session under this name. This is `resume`'s gate and the backstop for any + # future caller; `resolve` gates AT ENTRY (cmd_resolve), because its flow + # runs the interactive session and re-arms the escalation before reaching + # here, and a refusal after those is a refusal after the side effects. + if runs.run_id_aliases_control_session(run_dir.name): + print( + f"run {run_dir.name}: cannot resume — its agent session name " + f"({runs.session_name(run_dir.name)}) is the control session's own, so " + "driving it would take over the live control session (ids of this shape " + "are now refused at creation; this run predates that). The run directory " + "and any worktree are intact: recover the work by hand, then remove the " + f"run with `bmad-loop delete {run_dir.name}` — stop and delete do not " + "touch any session under this name", + file=sys.stderr, + ) + return 1 paths = bmadconfig.load_paths(project) state = load_state(run_dir) if state.finished: @@ -2842,6 +2993,24 @@ def cmd_resolve(args: argparse.Namespace) -> int: print(str(e), file=sys.stderr) return 1 args.run_id = run_dir.name # normalize so echoed hints show the full id + # Ahead of EVERY side effect, not delegated to _resume_paused_run's gate: + # this flow launches the interactive resolve session and re-arms the + # escalation before it reaches that helper, and refusing after either + # leaves the run re-armed-but-not-running (or a whole agent conversation + # thrown away). Same rule, same message shape as the resume gate. + if runs.run_id_aliases_control_session(run_dir.name): + print( + f"run {run_dir.name}: cannot resolve — its agent session name " + f"({runs.session_name(run_dir.name)}) is the control session's own, so " + "re-arming and resuming it would take over the live control session " + "(ids of this shape are now refused at creation; this run predates " + "that). The run directory and any worktree are intact: recover the " + f"work by hand, then remove the run with `bmad-loop delete " + f"{run_dir.name}` — stop and delete do not touch any session under " + "this name", + file=sys.stderr, + ) + return 1 state = load_state(run_dir) if state.paused_stage != PAUSE_ESCALATION: print( @@ -3023,7 +3192,7 @@ def cmd_resolve(args: argparse.Namespace) -> int: from .tui import launch # import-safe: launch.py has no textual imports if launch.in_ctl_session(): - # We are inside the TUI's bmad-loop-ctl window the user is attached to. + # We are inside the TUI's control-session window the user is attached to. # Tell them, hand the terminal back, and let the engine run on here — a # tmux pane keeps running after its client detaches. print( @@ -3733,6 +3902,38 @@ def cmd_archive(args: argparse.Namespace) -> int: return 0 +def _warn_legacy_leftovers(leftovers: list[str]) -> None: + """Name what a legacy multiplexer registry still holds after the sweep. + + Silent on the normal path — the list is empty on every platform without a + registry namespace and on every machine that never ran the pre-registry + build. When it is not empty, saying nothing would be the failure: cleanup + prints a removal count, and a count that quietly excludes sessions it chose + not to migrate reads as "everything is clean". stderr rather than stdout, the + `unverifiable_pid` precedent, so `cleanup > log` keeps the receipt; in + `--json` mode this lives in the document instead and stderr stays empty. + + Points at the docs rather than printing a command. One of the things named + here is the machine-wide control session, and `psmux kill-session` on it kills + every child process in every one of its windows — including, on this + backend, a window still running an engine (the parked wrapper runs the + command first and parks only after it exits). A remedy this prints has to be + safe to run at the moment it is printed; that one is not, so the care lives + where there is room to state it. + + Deliberately unconditional on dry-run: a preview that omits the remainder + would disagree with the run it is previewing.""" + if not leftovers: + return + print( + "left in the multiplexer's default registry (not migrated): " + + ", ".join(leftovers) + + " — still running, ownership unprovable there, or the shared control " + "session; see docs/multiplexer-backends.md before removing any of them", + file=sys.stderr, + ) + + def cmd_cleanup(args: argparse.Namespace) -> int: from .adapters.multiplexer import MultiplexerError from .tui import launch # pure stdlib; no textual import @@ -3741,6 +3942,12 @@ def cmd_cleanup(args: argparse.Namespace) -> int: # one partition sample drives the prune and every message below, so the # warnings and live count always match what was actually killed/skipped killed, live, unknown = runs.prune_sessions(project, dry_run=args.dry_run) + # Read AFTER the prune, and by presence: what is still standing in the legacy + # registry now that the sweep has run. On a dry run nothing was killed, so the + # ids just announced as would-kills are handed over to be excluded — the plan + # this command printed, never a second sample of it. Never raises (observation + # degrades to []). + leftovers = runs.legacy_registry_leftovers(project, announced=killed if args.dry_run else ()) if not args.json: for run_id in sorted(unknown): # warn-only: unknown never blocks cleanup (same wording as delete/archive). @@ -3788,6 +3995,7 @@ def cmd_cleanup(args: argparse.Namespace) -> int: windows_survived=survived, windows_unverifiable=unverifiable, scan_error=scan_error, + legacy_leftovers=leftovers, ) ) return 0 @@ -3801,6 +4009,7 @@ def cmd_cleanup(args: argparse.Namespace) -> int: print(f"would close ctl window {name}") if live: print(f"leaving {len(live)} live session(s) untouched") + _warn_legacy_leftovers(leftovers) return 0 # The count now excludes non-removals, so on stdout alone a smaller number is # indistinguishable from a quieter sweep — and `cleanup > log` keeps only @@ -3818,6 +4027,7 @@ def cmd_cleanup(args: argparse.Namespace) -> int: # Same wording as the TUI toast: one claim, one phrase, so an operator # moving between the two surfaces is reading the same thing. print(f"ctl window(s) still open after the kill: {', '.join(survived)}", file=sys.stderr) + _warn_legacy_leftovers(leftovers) if unverifiable: # Not "killed but unverifiable": kill_window is a silent no-op on a # transport failure, so whether the kill even reached the server is part diff --git a/src/bmad_loop/documents.py b/src/bmad_loop/documents.py index c5991b99..e6eaf66e 100644 --- a/src/bmad_loop/documents.py +++ b/src/bmad_loop/documents.py @@ -386,6 +386,7 @@ def cleanup_document( windows_survived: list[str], windows_unverifiable: list[str], scan_error: str | None = None, + legacy_leftovers: list[str] | None = None, ) -> dict[str, object]: """The `cleanup --json` document: the multiplexer artifacts this invocation removed, or — under ``--dry-run`` — would remove. @@ -425,6 +426,14 @@ def cleanup_document( ceiling as prune_ctl_windows' post-kill probe; narrowing it is seam work, not a document field. + `sessions.legacy_leftovers` is the migration's remainder: session NAMES (not + run ids — the control session has no run id) that a legacy multiplexer + registry still holds and that cleanup deliberately did not remove. Additive, + so no schema bump: a consumer that does not know the key reads exactly what it + read before. Empty on every platform and every already-migrated machine. See + `runs.legacy_registry_leftovers` for what qualifies and why; the text mode + prints the same list on stderr, the `unverifiable_pid` precedent. + `sessions.removed` did NOT get the same treatment and is still the pre-kill prunable partition — an *attempted* kill, since `kill_session` is best-effort and silent in exactly the way `kill_window` is. #435 narrowed the windows @@ -437,6 +446,7 @@ def cleanup_document( "removed": list(killed), "live": list(live), "unverifiable_pid": sorted(unknown), + "legacy_leftovers": list(legacy_leftovers or []), }, "ctl_windows": { "removed": list(windows), diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index 0dee3579..a3b6c2a3 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -73,6 +73,7 @@ graceful_stop_requested, kill_session, owner_run_dir, + pinned_state_env, read_stop_request_mode, reset_owner_run_dir, set_owner_run_dir, @@ -5279,6 +5280,16 @@ def _run_session( adapter = self.adapters[role] cfg = self.policy.adapter.resolved(role) env = { + # The state root this process settled on, handed over rather than left + # to inheritance — same rule, and same reason, as the events dir below. + # A multiplexer may give a pane child none of our environment (psmux's + # PSMUX_BARE_ENV=1 keeps a 14-name allowlist that drops both + # BMAD_LOOP_STATE_DIR and the LOCALAPPDATA its default falls back to), + # and a bmad-loop invoked inside the session would then answer with a + # different state root: a different registry, so attach/stop/liveness + # read this very session as gone. `{}` when none is derivable, which is + # the one case there is nothing to say (see runs.pinned_state_env). + **pinned_state_env(), "BMAD_LOOP_MODE": "1", "BMAD_LOOP_RUN_DIR": str(self.run_dir), # Where this session's hook relay writes its events (#494). The one diff --git a/src/bmad_loop/envvars.py b/src/bmad_loop/envvars.py index c3a20e12..606435a4 100644 --- a/src/bmad_loop/envvars.py +++ b/src/bmad_loop/envvars.py @@ -16,6 +16,12 @@ (`BMAD_LOOP_RUN_DIR`, `BMAD_LOOP_TASK_ID`, …): those have a producing side inside the orchestrator, and the stdlib-only relays that read them back cannot import this module at all. + +Note what the carve-out is and is not: "has a producing side" does not qualify a +name, "is read only by a stdlib-only relay that cannot import this module" does. +Anything core orchestration reads back belongs here, and +`test_portability_guard.test_bmad_loop_env_reads_only_in_the_registry` is the +enforced form of that rule. """ from __future__ import annotations diff --git a/src/bmad_loop/probe.py b/src/bmad_loop/probe.py index bc1ed169..23ae8c85 100644 --- a/src/bmad_loop/probe.py +++ b/src/bmad_loop/probe.py @@ -55,7 +55,7 @@ from importlib import resources from pathlib import Path -from . import sanitize +from . import runs, sanitize from .adapters.multiplexer import MultiplexerError, get_multiplexer from .adapters.profile import CLIProfile from .install import merge_hooks, relay_registered @@ -549,7 +549,17 @@ def start(self, argv: list[str], env: dict[str, str], cwd: Path, log_file: Path) try: self.mux.new_session(self.session_name, cwd, 220, 50) command = " ".join(shlex.quote(a) for a in argv) - window_id = self.mux.new_window(self.session_name, PROBE_TASK_ID, cwd, env, command) + # `env` carries the profile's own `[env]` table verbatim, and a + # profile declaring BMAD_LOOP_STATE_DIR would aim a bmad-loop + # wrapper in that window at a different state root — and so a + # different registry, where this very session reads as gone. The + # pin chokepoint forces the entry to this process's own answer in + # both arms, the underivable one included (runs.pin_state_root); + # the engine's window merge applies the same rule. + window_env = runs.pin_state_root(env) + window_id = self.mux.new_window( + self.session_name, PROBE_TASK_ID, cwd, window_env, command + ) except MultiplexerError: return None # pipe-pane may race a window that dies instantly; tolerate failure. diff --git a/src/bmad_loop/runs.py b/src/bmad_loop/runs.py index c4b7f1b8..db268934 100644 --- a/src/bmad_loop/runs.py +++ b/src/bmad_loop/runs.py @@ -15,12 +15,18 @@ import sys import tarfile import time +from collections.abc import Iterable, Mapping from dataclasses import dataclass from pathlib import Path from typing import Any, Literal from . import devcontract, envvars, verify -from .adapters.multiplexer import MultiplexerError, get_multiplexer, mux_usable +from .adapters.multiplexer import ( + MultiplexerError, + TerminalMultiplexer, + get_multiplexer, + mux_usable, +) from .frontmatter import auto_dev_baseline_of, parse_frontmatter, status_of from .journal import STATE_FILE, VERIFY_DIR, Journal, load_state, save_state from .model import PAUSE_ESCALATION, Phase, RunState, StoryTask @@ -41,6 +47,18 @@ ) from .process_host import ProcessHostError, get_process_host +# The multiplexer registry's directory name inside a project's state subtree (see +# `mux_registry_root`). It sits beside the run entries and must never BE one: the +# leading underscore is what makes that structural, since `RUN_ID_RE` requires an +# alphanumeric first character, so no `--run-id` can key its state dir onto the +# registry. That is also what lets the orphan-state sweep tell the two apart by +# name alone (see `reconcile_orphan_state_dirs`). +MUX_REGISTRY_DIR = "_mux" +# psmux's own registry-root variable. Named here, in transport-agnostic code, for +# the same reason `PROJECT_OPTION` is: the export has to happen ahead of backend +# selection, which probes a subprocess, so it cannot be routed through a backend +# instance. See `export_psmux_registry_root`. +PSMUX_DATA_DIR = "PSMUX_DATA_DIR" RUNS_DIR = Path(".bmad-loop") / "runs" ARCHIVE_DIR = Path(".bmad-loop") / "archive" PID_FILE = "engine.pid" @@ -130,7 +148,25 @@ def is_valid_run_id(value: str) -> bool: The length cap is ``platform_util.MAX_SEGMENT``: a run id is a directory name. The ``safe_segment`` identity check adds the one rule ``RUN_ID_RE`` cannot express — the reserved Windows device basenames (``CON``, ``NUL``, ``COM1``…), - which are legal-looking ids that no filesystem will accept as a directory.""" + which are legal-looking ids that no filesystem will accept as a directory. + + The control-session shape (``ctl``, ``ctl-…``, any letter case) is reserved + on the same principle, against the multiplexer namespace instead of the + filesystem's — see :func:`is_reserved_run_id` for the shape and why case is + folded. Refusing the id here is what makes the two session namespaces + disjoint: every agent session is ``bmad-loop-``, so none can + reach the control session's name.""" + return _wellformed_run_id(value) and not is_reserved_run_id(value) + + +def _wellformed_run_id(value: str) -> bool: + """The shape half of :func:`is_valid_run_id`: charset, length, and the + reserved-device-basename identity check — everything except the + control-session reservation. Split out because the *parse* side + (:func:`_agent_run_id`) must accept ids the *mint* refuses: a run + persisted by an older release under e.g. ``ctl-foo`` owns a genuine + ``bmad-loop-ctl-foo`` agent session that the sweep has to be able to + reach.""" return ( bool(RUN_ID_RE.fullmatch(value)) and len(value) <= MAX_SEGMENT @@ -138,6 +174,80 @@ def is_valid_run_id(value: str) -> bool: ) +def is_reserved_run_id(value: str) -> bool: + """The MINT-side reservation: any id of the control-session shape (``ctl`` + or ``ctl-…``, any letter case) is refused at :func:`is_valid_run_id`. + Deliberately broader than :func:`run_id_aliases_control_session` — a new id + anywhere near the control namespace buys nothing but confusion, so none is + admitted — while the read paths, which must handle ids an older release + already persisted, use the narrow test. ``RUN_ID_RE`` is ASCII-only, so + ``str.lower`` is the exact fold (see the narrow test for why case folds at + all).""" + v = value.lower() + return v == "ctl" or v.startswith("ctl-") + + +def run_id_aliases_control_session(value: str) -> bool: + """True when ``session_name(value)`` names a session that can BE a live + control session: the fixed name (id ``ctl``) or a per-registry digest name + (id ``ctl-<16 hex>`` — the only suffix :func:`ctl_session_for` can mint). + The adapter's ensure-session would *adopt* that live session as the run's + own, and the run's teardown would kill the whole control session, every + parked window of every run in it — so the project-free READ paths key on + this: :func:`kill_session` skips such an id, :func:`_agent_run_id` + refuses to read such a session as a run, and ``cli``/the TUI refuse to + resume/re-arm/replan such a run. This is the SHAPE question — "could + this name be a control session's on some registry" — and it must stay + out of any site asking the *instance* question ("is it the control + session this process addresses"): :func:`live_session_may_be_ours` + compares against the actual names (the fixed one plus this project's + :func:`ctl_session_for`), because discounting the whole shape there + destroyed run dirs under live `ctl-` agents on tmux. + + Compared **case-insensitively**: psmux resolves a session by opening + ``\\.port`` by name (``src/paths.rs:113``, source-read at + v3.3.8), and NTFS opens names case-insensitively — measured: with + ``bmad-loop-ctl-x`` live, target ``bmad-loop-CTL-x`` answers + ``has-session``, is refused as a duplicate by ``new-session``, and a kill + through it takes the lowercase session down. + + Deliberately narrower than :func:`is_reserved_run_id`: a historical + ``ctl-foo`` run's session is a GENUINE agent session, distinct from every + control session and addressable exactly and safely (tmux: measured, the + exact full target removes only it; our seam sends ``=``-exact targets — + ``tmux_base.py:141,166``, source-read. psmux: exact port files, case + aside). Skipping those too made such runs unreachable by ``stop`` and + ``cleanup`` both. Ceiling, named: an id of exactly the digest shape whose + hex is NOT the current registry's digest is also skipped — undecidable + without the project in hand, and the leak direction (one stale session + left standing) is the safe one.""" + return is_ctl_session_name(session_name(value).lower()) + + +def is_parsable_run_id(value: str) -> bool: + """The PARSE-side counterpart of :func:`is_valid_run_id`: may an id + recovered from an existing multiplexer name be acted on as a run? + + The two questions are different and must never share a predicate. + :func:`is_valid_run_id` answers "may a NEW id be this", so it carries the + mint's broad ctl reservation (:func:`is_reserved_run_id`) — and a reader + that borrows it stops recognising every id an older release already + persisted. A ``ctl-foo`` run minted before that reservation owns a real + run dir and a real ``run-ctl-foo`` control-session window; asking the + mint's question about them leaks both, unreachable by the sweep forever. + + So: the shape half (:func:`_wellformed_run_id` — charset, length, and the + reserved-device-basename check, because the id still steers a run-dir + path) minus only the narrow alias test + (:func:`run_id_aliases_control_session`), which the read paths key on + because reading one of THOSE as a run points a kill path at the control + plane. Exactly :func:`_agent_run_id`'s guard, public so the other parse + sites ask it instead of re-deriving it — the ctl-window sweep in + ``tui.launch`` did borrow the mint's, and parked pre-upgrade windows + leaked from ``cleanup`` because of it.""" + return _wellformed_run_id(value) and not run_id_aliases_control_session(value) + + def list_run_dirs(project: Path) -> list[Path]: """All run dirs containing a state.json, oldest first (run ids sort chronologically).""" @@ -366,6 +476,183 @@ def project_state_root(project: Path) -> Path: return state_root() / project_tag(project) +def mux_registry_root(project: Path) -> Path: + """This project's terminal-multiplexer registry root: + ``//_mux`` (see :data:`MUX_REGISTRY_DIR`). + + A *registry* is the directory a multiplexer keeps its per-session addressing + state in — psmux writes one ``.port``/``.key``/``.sid``/``.pid`` quartet per + session under ``PSMUX_DATA_DIR`` (default ``%USERPROFILE%\\.psmux``), and + every verb resolves a session by reading that quartet back. Two processes + that disagree about the root therefore disagree about which sessions exist, + which is why the root is *derived* — from the project, through the same + :func:`project_tag` every ownership tag already uses — rather than minted per + run, read from a file, or taken from whatever the launching shell exported. + See :func:`export_psmux_registry_root` for the export and its rules. + + Keyed on the project rather than on bmad-loop as a whole so a prune bug in + one project cannot address another project's servers at all: the partition + becomes structural instead of a filter (the ``@bmad_project`` tag stays, as + the tmux-side answer and the belt). The price is that one ``psmux ls`` no + longer shows every bmad-loop session on the machine — stated for the operator + in ``docs/multiplexer-backends.md`` and printed by ``bmad-loop mux``. + + Under :func:`state_root` and not in the project tree, deliberately: a branch + switch or a rollback that deleted a ``.port`` file would leave the server + alive, unreachable, and invisible to ``psmux ls`` in *any* registry — a + manufactured orphan. Same doctrine :func:`state_root` itself exists for. + """ + return project_state_root(project) / MUX_REGISTRY_DIR + + +def export_psmux_registry_root(project: Path) -> str | None: + """Point this process — and everything it spawns — at ``project``'s registry + by exporting ``PSMUX_DATA_DIR``. Returns the value in force afterwards, or + ``None`` when no root could be derived. + + **The process environment, not a per-call argument.** The seam spawns every + psmux verb through ``BaseTmuxBackend._run``, whose ``env=None`` default means + *inherit this process's environment*, and a create-call-only injection is + worse than none: the session's server would come up under a root every later + ``has_session`` / ``list_window_ids`` cannot see, and those verbs report an + unreadable registry as ``False`` / ``[]`` — a live run reading itself as gone. + One export ahead of dispatch covers every verb in-process. + + **The root is always derived, and an ambient value never changes it.** That + is the whole rule, and the absence of an exception is the point: + :func:`mux_registry_root` is a pure function of (project, state root), so any + two bmad-loop processes given the same project and the same state root agree + — which is the entire property #537 exists to establish. A value already in + the environment is *overridden*, and the caller says so + (:func:`cli._configure_mux` reports it once on stderr; ``bmad-loop mux`` + discloses it). + + **Why an operator's own ``PSMUX_DATA_DIR`` is not honoured**, since honouring + it is the obvious kindness and it was tried: + + - It would make the registry a function of the launch *shell*. A TUI started + from the Start menu carries no profile environment and derives; a run + started from a dev shell whose profile exports a root honours that root. + Two registries on one machine, and a live session reading as gone in one of + them — which is the failure this module exists to prevent, not a corner of + it. + - Whether honouring is even the right answer is unknowable from here. A + process that finds a root in its environment cannot tell one the operator + typed once in *this* shell — where a clean sibling process would derive — + from one their profile exports into *every* shell, where a clean sibling + honours it. The two produce byte-identical environments and want opposite + answers, so no comparison settles it: the missing fact is the operator's + intent, and it is not in the environment. + - It contradicts the promise made beside it. ``BMAD_LOOP_STATE_DIR``'s + documentation says there is deliberately no second variable naming the + registry, because "two knobs that can disagree would put two processes on + different registries, each blind to the other's live sessions". An ambient + ``PSMUX_DATA_DIR`` is exactly that second knob. + + Overridden rather than *refused*, deliberately: ``PSMUX_DATA_DIR`` is psmux's + variable, and an operator may have it set for their own sessions with no + thought of bmad-loop at all. Erroring out of every command on such a machine + would be bmad-loop claiming a name it does not own. The remedy runs the other + way and ``bmad-loop mux`` prints it ready to paste: point *your* shell at + bmad-loop's root, which is a function of the project rather than of whichever + shell happened to launch something. + + Wanting one registry to serve both is a real request and is deliberately not + answered here. It needs a stated operator preference rather than a guess at + one — and it must be a policy *whether*, never a *where*: ``policy.toml`` is + written by the sessions this orchestrator drives, so a policy-sourced root + would let a driven session choose which registry the cleanup path kills in. + + **No ``BMAD_LOOP_*`` knob for the root either.** It is derived state, not + configuration; ``BMAD_LOOP_STATE_DIR`` already relocates it transitively — + one knob, one cascade, instead of two that can disagree. And ``envvars.py`` + gains no entry for ``PSMUX_DATA_DIR`` itself: that module is scoped to + ``BMAD_LOOP_*`` names and this is psmux's own, unregistered on the same + precedent as ``PSMUX_ALLOW_NESTING``. + + **No root travels between processes.** Because every bmad-loop process + derives its own root, nothing about a registry has to be transported at + all. What does have to travel is the *state root*: coding-CLI windows are + told it explicitly through their env dict (:func:`pinned_state_env`), and + everything else — a session's window-0 shell, the TUI's parked engine + windows — inherits it, as it always has. psmux's ``PSMUX_BARE_ENV=1`` mode + breaks that inheritance and is **not supported**: the psmux backend warns + once per process when it is on (see ``PsmuxMultiplexer._warn_if_bare_env``). + + Never raises. This runs ahead of *every* command, ``diagnose`` and + ``validate`` included, and an underivable state root must not take the + diagnostics down with it. ``None`` means "no root established": psmux keeps + whatever it had, which is also the root cleanup sweeps as the legacy one. + """ + try: + root = str(mux_registry_root(project)) + except (StateRootError, OSError, RuntimeError): + # OSError/RuntimeError: project_tag resolves the project, which raises on + # a path the OS cannot canonicalize and, below 3.13, on a symlink loop. + # The ambient value is left exactly as found — there is nothing better to + # put there, and PsmuxMultiplexer._run still refuses to spawn under a + # value psmux would panic on. + return None + os.environ[PSMUX_DATA_DIR] = root + return root + + +def pinned_state_env() -> dict[str, str]: + """``{BMAD_LOOP_STATE_DIR: }``, for a child that + must land on the same one — or ``{}`` when no root can be derived. + + A convenience spelling of :func:`pin_state_root` over an empty dict, for + composing env dicts (the engine's session env spreads it in). The final + merge before a window launch goes through :func:`pin_state_root` itself — + a spread of this dict is only an ordering guarantee, and ordering + guarantees nothing when the dict is ``{}``. + + **Resolved, never forwarded.** Passing this only when the operator set it + would leave exactly the default case broken, which is the common one. What + travels is the answer this process reached, however it reached it. + + What follows the state root, and what does not, since the two are easy to + swap: the run's control plane (:func:`state_dir_for`), its event channel + (:func:`events_dir_for`) and the multiplexer registry + (:func:`mux_registry_root`) all live under it, so a child computing a + different root writes and reads where nothing else looks. The run *directory* + does not — :func:`run_dir_for` is in-tree at ``/.bmad-loop/runs`` + and moves with the project, not with this. + + ``{}`` rather than a raise: a child told nothing derives its own answer and + fails on the same broken environment with its own message, which is better + than a launcher that cannot report anything at all. + """ + return pin_state_root({}) + + +def pin_state_root(env: Mapping[str, str]) -> dict[str, str]: + """``env`` with its ``BMAD_LOOP_STATE_DIR`` entry forced to this process's + own answer: **set** to the resolved state root when one derives, **removed** + when none does. Other keys pass through untouched. + + The chokepoint for every merge where a caller-supplied env (a profile's + ``[env]`` table rides those dicts) meets the state-root pin — the engine's + coding-CLI window, the probe window, and the attached resolve session. A + "pin spreads last" ordering rule is not enough, because with an underivable + state root there is no pin key to order: :func:`pinned_state_env` is ``{}`` + and a profile-declared absolute root would sail through, aiming the window + at a state root — and so a per-project registry — its own parent cannot + see. Removing the key instead makes the child inherit the parent's own + (broken) value and fail exactly as the parent fails: whatever a child + concludes is what a clean process under the same conditions concludes, in + the error arm too. The strip governs only what bmad-loop *adds* to a + child; a value already in the environment a child inherits is not + scrubbed here. + """ + pinned = dict(env) + try: + pinned[envvars.STATE_DIR] = str(state_root()) + except StateRootError: + pinned.pop(envvars.STATE_DIR, None) + return pinned + + def state_dir_for(project: Path, run_id: str) -> Path: """This run's control-plane directory: ``//``. @@ -866,15 +1153,134 @@ def discover_runs(project: Path) -> list[RunInfo]: # ----------------------------------------------------------- stop / delete / archive -def kill_session(run_id: str) -> None: +def kill_session(run_id: str, mux: TerminalMultiplexer | None = None) -> None: """Kill a run's agent session (bmad-loop-); a no-op when it is already - gone or the multiplexer is unavailable.""" - get_multiplexer().kill_session(session_name(run_id)) + gone or the multiplexer is unavailable. + + Also a no-op for an id that **aliases a control session** + (:func:`run_id_aliases_control_session` — ``ctl`` or ``ctl-<16 hex>``, + case-folded): the only session such a name can address is the control + plane, every parked window of every run in it. Unreachable through + minting (validation refuses the shape) but reachable through what an + **older release persisted**: a run dir named ``ctl`` that `stop`, + `delete` or a resume's stale-session sweep replays as a kill target. + This chokepoint keeps those read paths safe — and usable as the + operator's way out of such a run — without each caller re-deriving the + rule. + + The narrow test, not the mint's broad reservation, deliberately: a + historical ``ctl-foo`` run DOES own an agent session of its own + (``bmad-loop-ctl-foo``, distinct from every control session and killed + exactly — the seam sends ``=``-exact tmux targets, and psmux resolves + exact port files), and skipping its kill stranded it: the prune already + could not reach it, so nothing could. Scope, stated: the kill addresses + the registry THIS process addresses — a pre-upgrade session left in + psmux's old default registry is not reachable from here (measured), and + deliberately so: a by-name kill in a shared registry without tag proof + could take another project's same-named session (run ids are unique per + project only). The legacy sweep in :func:`prune_sessions`, which does + demand the tag, is the path that reaches it.""" + if run_id_aliases_control_session(run_id): + return + (mux or get_multiplexer()).kill_session(session_name(run_id)) CTL_SESSION = "bmad-loop-ctl" _SESSION_PREFIX = "bmad-loop-" + +def ctl_session_for(project: Path, mux: TerminalMultiplexer | None = None) -> str: + """The control-session name this project's launches and lookups share. + + On a transport with no registry namespace (tmux) it is the fixed + :data:`CTL_SESSION`, machine-shared as it has always been. On a namespacing + transport (psmux) the name carries the registry's identity — a 16-hex + digest of the derived registry root — because the two scopes genuinely + differ: the session lives *per registry*, but psmux's duplicate-server + guard is a mutex keyed on the session name alone, across every registry + in the **login session** (``Local\\psmux-session-{name}`` over + ``port_file_base()`` — the ``Local\\`` kernel-object namespace is + per-login-session, not machine-global; ``server/mod.rs:853`` / + ``platform.rs:346`` / ``types.rs:1345``, source-read at v3.3.8 — + ``PSMUX_DATA_DIR`` never enters it). A fixed name therefore admits ONE + control session across every registry a desktop session can reach, + and the second project's create is rejected as a duplicate server — its + TUI launch fails instead of minting its own session (measured: a second + registry answers ``new-session`` rc 1 for the fixed name while the first + registry's server lives, and rc 0 for a per-registry name). + + The digest is over ``mux_registry_root(project)`` **resolved**: the name + must be unique per *physical* registry, and the resolved path is that + registry's identity — (project, state root), both axes; ``project_tag`` + alone would recreate the collision for one project under two state roots. + Resolved rather than as spelled because two spellings of one state root + (``C:\\work\\state`` vs ``C:\\work\\alias\\..\\state``) reach **one** + registry — Windows resolves both to the same files, and psmux keeps the + spelling only while constructing those paths (``src/paths.rs:79``, + source-read at v3.3.8; convergence measured) — so an as-spelled digest + minted two control sessions inside one registry, each blind to the other's + parked windows: the split-control-plane failure again, one level up. Same + rule ``project_tag`` already states: resolve *before* digesting. + + …and then ``os.path.normcase``, because ``resolve()`` can only return the + filesystem's stored case for a path that **exists**, and the registry + root usually does not yet exist at the moment the name is needed (psmux + ``create_dir_all``\\s it at first spawn). Two case spellings of a + not-yet-created state root resolve to two strings, digest to two names — + and then land in ONE physical registry, because NTFS folds case when + psmux opens the ``.port`` files (measured). ``normcase`` folds exactly + where the filesystem does: it lowercases on Windows and is the identity + on POSIX, where case is significant and two case spellings ARE two + registries — folding there would merge genuinely distinct roots. + Ceiling, named: ``normcase`` lowercases with ``str.lower``, which can + disagree with NTFS's own fold table for a few non-ASCII case pairs; a + state root spelled in two such casings of the same non-ASCII name stays + split, as it is for every other digest of an operator-supplied path. + + The degrade arm (namespaced transport, underivable state root) answers + the fixed name: that arm runs on the transport's shared default registry, + where a shared session scoped by per-window project tags is the correct, + tmux-shaped semantic — and where a pre-#537 legacy ctl session under the + fixed name may exist to be reused rather than collided with. + """ + mux = mux or get_multiplexer() + if not mux.has_registry_namespace(): + return CTL_SESSION + try: + scope = os.path.normcase(str(mux_registry_root(project).resolve())) + except (StateRootError, OSError, RuntimeError): + return CTL_SESSION + return f"{CTL_SESSION}-{hashlib.sha256(os.fsencode(scope)).hexdigest()[:16]}" + + +def is_ctl_session_name(name: str) -> bool: + """Whether ``name`` is a control session's name — the fixed + :data:`CTL_SESSION`, or ``bmad-loop-ctl-<16 hex>``, the ONE suffix shape + :func:`ctl_session_for` can mint. + + The shape predicate exists because several readers ask "is this A control + session" without a project in hand: the agent-session parser must exclude + ctl sessions (``bmad-loop-ctl-<16hex>`` would otherwise parse as run id + ``ctl-<16hex>``, which ``RUN_ID_RE`` admits), the legacy-leftovers reader + names a surviving ctl session in a registry this process did not derive, + and ``in_ctl_session`` classifies whatever session this process woke up + inside. + + Exactly the mintable shapes, no wider: an arbitrary suffix + (``bmad-loop-ctl-foo``) is NOT a control session — it is the agent + session of a run an older release accepted as ``--run-id ctl-foo``, and + reading it as a control session made it unreachable by ``stop`` and the + prune both. No agent session of OURS can match this predicate: + :func:`is_valid_run_id` refuses every ctl-shaped id at the mint (broad — + :func:`is_reserved_run_id`), so a matching name is either genuinely a + control session or hand-made to look like one — and the hand-made + 16-hex-suffixed case stays unprunable, the leak direction.""" + if name == CTL_SESSION: + return True + suffix = name.removeprefix(CTL_SESSION + "-") + return suffix != name and len(suffix) == 16 and all(c in "0123456789abcdef" for c in suffix) + + # tmux user option stamping a session/window with the project it belongs to, so # a prune in one project never touches another project's live runs. See # prunable_sessions and tui.launch. @@ -975,13 +1381,33 @@ def session_project_tags() -> dict[str, str]: return get_multiplexer().session_options(PROJECT_OPTION) -def prunable_sessions(project: Path) -> tuple[list[str], list[str], set[str]]: +def _agent_run_id(session: str) -> str | None: + """The run id behind a ``bmad-loop-`` agent session name, or ``None`` when + the name is not one: the control session, a foreign session, or a mangled name + whose id could not be replayed as a path segment — never let one steer a + run-dir path. Shared so the prune partition and + :func:`legacy_registry_leftovers` cannot drift on what counts as ours. + + The id question is :func:`is_parsable_run_id`, deliberately NOT + :func:`is_valid_run_id` — the parse side must accept ids the mint refuses. + That predicate owns the reasoning, and the ctl-window sweep in + ``tui.launch`` asks the same one.""" + if not session.startswith(_SESSION_PREFIX): + return None + run_id = session[len(_SESSION_PREFIX) :] + return run_id if is_parsable_run_id(run_id) else None + + +def prunable_sessions( + project: Path, mux: TerminalMultiplexer | None = None, *, require_tag: bool = False +) -> tuple[list[str], list[str], set[str]]: """Partition the bmad-loop- agent sessions into (prunable, live) run ids, plus the subset of prunable ids whose engine liveness read 'unknown' (unverifiable pid). Unknown never blocks cleanup — those sessions stay prunable — but frontends surface a warning for them. - The control session (bmad-loop-ctl) is never a candidate. Pruning is scoped + A control session (:func:`is_ctl_session_name` — the fixed name or a + per-registry one) is never a candidate. Pruning is scoped to `project` via the PROJECT_OPTION tag set at session creation: - tag proves this project (see accepted_tags): ours — prunable unless a @@ -995,25 +1421,40 @@ def prunable_sessions(project: Path) -> tuple[list[str], list[str], set[str]]: the option read degrades (session_options reads unset as "no answer", never as proof nothing was written), or on a session predating a working tag write — e.g. psmux path tags refused before the digest. + + ``require_tag`` drops that last arm: an untagged session is skipped outright + rather than falling back to the run dir. Set for a **shared** registry — the + legacy psmux root every project's pre-upgrade sessions sit in together (see + :func:`prune_sessions`). The fallback proves ownership from + ``run_dir_for(project, run_id)``, and a run id is only unique *within* one + project (``--run-id`` is caller-supplied), so in a shared registry a dead run + dir here is not evidence about a session over there: project A holding a dead + `shared-id` would claim project B's live, untagged `bmad-loop-shared-id` and + kill it. In a per-project registry the same fallback is sound because the + registry itself proves ownership, which is why the flag is off by default and + the primary pass keeps the reach it always had. What the flag leaves behind is + reported by :func:`legacy_registry_leftovers`. """ - tags = session_project_tags() + # `mux` bypasses the module-level readers rather than widening them: those + # two are the seam every other caller (and every test) reaches the process-wide + # backend through, and a bound instance is this function's business alone. + tags = mux.session_options(PROJECT_OPTION) if mux is not None else session_project_tags() mine = accepted_tags(project) prunable: list[str] = [] live: list[str] = [] unknown: set[str] = set() - for name in mux_sessions(): - if name == CTL_SESSION or not name.startswith(_SESSION_PREFIX): + names = mux.list_sessions() if mux is not None else mux_sessions() + for name in names: + run_id = _agent_run_id(name) + if run_id is None: continue - run_id = name[len(_SESSION_PREFIX) :] - if not is_valid_run_id(run_id): - continue # a foreign/mangled session name must not steer a run-dir path run_dir = run_dir_for(project, run_id) tag = tags.get(name, "") if tag: if tag not in mine: continue # another project's session - elif not is_run(run_dir): - continue # untagged and no run dir here — ownership unprovable + elif require_tag or not is_run(run_dir): + continue # ownership unprovable: no tag, and no run dir here to stand in liveness = engine_liveness(run_dir) if liveness == "alive": live.append(run_id) @@ -1024,6 +1465,62 @@ def prunable_sessions(project: Path) -> tuple[list[str], list[str], set[str]]: return prunable, live, unknown +def _registry_proves_ownership(project: Path) -> bool: + """True when the registry the *primary* prune pass addresses is one bmad-loop + derived for this project — which is what makes + :func:`prunable_sessions`' untagged run-dir fallback evidence rather than a + guess. + + That fallback claims an untagged ``bmad-loop-`` session when this project + holds a dead run dir of the same id. Run ids are only unique *within* a + project (``--run-id`` is caller-supplied), so the claim is sound exactly when + the registry itself already restricts what can be listed to this project's + sessions. In a registry shared with other projects — or with the operator — + it is not, and the same reasoning that put ``require_tag=True`` on the legacy + pass applies here. + + The primary registry is not always ours. :func:`export_psmux_registry_root` + degrades to ``None`` on an underivable state root and leaves whatever ambient + ``PSMUX_DATA_DIR`` it found in force, and psmux honours any absolute value + (``src/paths.rs``, source-read at v3.3.8) — so on that arm every verb, + including the kill, addresses the operator's own registry while this project's + run dirs go on looking like ownership. + + ``registry_root()`` answering ``None`` covers two cases, and they get + **opposite** answers — conflating them was a defect, not caution. A backend + with no registry namespace at all (tmux: one server for the machine, + ``has_registry_namespace()`` False) keeps the reach it had before + per-project registries existed: the listing there is exactly what it always + was, and narrowing it would be a regression dressed as caution. A backend + that DOES namespace and has no root in force (psmux with ``PSMUX_DATA_DIR`` + unset — the export degraded on an underivable state root and there was no + ambient value either) is running on its transport's own **default** + registry — shared with every other project and with the operator + (``\\.psmux``, the home being ``USERPROFILE`` when set, else the + profile API, else ``HOMEDRIVE``+``HOMEPATH``, else ``HOME`` — + ``src/paths.rs`` ``home_dir``, source-read at v3.3.8) — which proves nothing about + ownership, exactly as an absolute ambient value naming a foreign registry + proves nothing. Both shared cases make the tag mandatory. + + A backend that cannot be asked answers ``False``: the safe direction is to + demand the tag, which leaves a session standing rather than killing one on + evidence that may not hold. + """ + try: + mux = get_multiplexer() + root = mux.registry_root() + if root is None: + # No namespace (tmux): historical reach. A namespace with no root + # in force is the transport's shared default registry: demand the tag. + return not mux.has_registry_namespace() + except MultiplexerError: + return False + try: + return root == str(mux_registry_root(project)) + except (StateRootError, OSError, RuntimeError): + return False + + def prune_sessions( project: Path, *, dry_run: bool = False ) -> tuple[list[str], list[str], set[str]]: @@ -1031,14 +1528,156 @@ def prune_sessions( returns (killed, live, unknown): the run ids that were (or, with dry_run, would be) killed, the live ids skipped, and the killed subset whose engine liveness read 'unknown'. All three come from the same partition sample, so - frontend messaging built from them always describes the performed actions.""" - prunable, live, unknown = prunable_sessions(project) + frontend messaging built from them always describes the performed actions. + + Runs once per registry: the one this process is pointed at, then each legacy + registry the backend still admits (:func:`_legacy_registries`). Sessions + bmad-loop created before it took a per-project psmux root are addressable + only from the second pass, and without it cleanup would report a clean sweep + while their servers ran on. The passes are unioned rather than concatenated — + a run id can only be in one registry, but a backend answering the same + registry twice must not make one kill look like two. + + Ownership is judged per pass by the same :func:`prunable_sessions` partition, + so a legacy registry buys no extra reach: another project's sessions and the + operator's own psmux sessions are skipped there exactly as they are here. + + The legacy pass always runs with ``require_tag=True``, and the primary pass + runs with it whenever the registry it addresses is not one bmad-loop derived + for this project (:func:`_registry_proves_ownership`). Both are the same rule: + :func:`prunable_sessions`' untagged run-dir fallback is evidence only where + the registry has already restricted the listing to this project. A legacy + registry is shared by every project by definition; the primary one is shared + whenever the derivation failed — an ambient ``PSMUX_DATA_DIR`` left in + force, or nothing in force at all, where a namespacing backend runs on its + own shared default registry. What that strictness leaves standing in a + legacy registry is reported + by :func:`legacy_registry_leftovers`, which the cleanup frontends print: a + sweep that silently declines to migrate something is the same silence this + whole change exists to remove.""" + prunable, live, unknown = prunable_sessions( + project, require_tag=not _registry_proves_ownership(project) + ) if not dry_run: for run_id in prunable: kill_session(run_id) + for legacy in _legacy_registries(): + extra, extra_live, extra_unknown = prunable_sessions(project, legacy, require_tag=True) + if not dry_run: + for run_id in extra: + kill_session(run_id, legacy) + prunable += [i for i in extra if i not in prunable] + live += [i for i in extra_live if i not in live] + unknown |= extra_unknown return prunable, live, unknown +def legacy_registry_leftovers(project: Path, *, announced: Iterable[str] = ()) -> list[str]: + """Session names a legacy registry **still holds** after :func:`prune_sessions` + ran — the migration's honest remainder, for the cleanup frontends to print. + ``[]`` when there is no legacy registry, when it holds nothing, or when the + listing fails. + + **Presence, not a second opinion.** Called after the sweep, this lists what is + actually there; a session the sweep killed is simply gone from the listing. + That is the whole judgement for anything tagged as ours, and it is deliberately + *not* a re-run of the partition: re-judging liveness would open a race the + reader cannot see the far side of. A run alive during the prune (correctly + left, and reported ``live``) can exit before the reader looks; a resampled + partition would then call it ``prunable``, and it would fall out of both the + live arm and the untagged fallback — stranded and unreported, with no kill ever + attempted. Presence has no such gap: the session is standing, so it is named. + + What that covers, in one rule: + + - **Untagged** ``bmad-loop-`` sessions. The legacy pass runs with + ``require_tag=True`` (:func:`prunable_sessions`), so an untagged session there + is skipped rather than claimed by a run dir that proves nothing in a shared + registry. + - **Ours, still standing.** Tagged this project's, and the sweep did not remove + it — because it was live, because it exited mid-sweep, or because + ``kill_session`` (best-effort and silent by contract) did not land. All three + leave the same fact behind: a session of ours in a registry ordinary attach + and cleanup no longer address. Naming it needs no cause, which is why this + also closes the failed-kill case ``cleanup --json``'s ``sessions.removed`` + documents as an *attempted* kill. + - **A surviving control session.** The prune never touches a ctl-named + session (:func:`is_ctl_session_name`), and its parked windows are not swept + in a legacy registry either — the ctl-window scan runs against the primary + backend only. + + Another project's tagged sessions never appear: the sweep skipping them is the + correct outcome, not a remainder. + + ``announced`` is the one thing presence alone cannot judge: on a **dry run** + nothing was killed, so every session the preview just announced as a would-kill + is still standing and would be named here as if the sweep had declined it. The + caller passes the run ids it printed — :func:`prune_sessions`' own return — and + they are excluded. + + Passed in rather than re-derived, and that is the whole point of the parameter. + An earlier revision re-ran the partition here to rediscover the plan, which is + a *second sample*: a tagged legacy run seen alive by the first (so printed as + live, never announced) can exit before this call, land in the second sample's + prunable arm, and be excluded from a listing it should have headed — a session + dropped from the preview outright, not merely mentioned twice. Consuming what + the preview actually printed cannot disagree with it. + + On a real cleanup the caller passes nothing: there, a killed session is gone + from the listing by presence, and one whose kill did not land must be named. + + Deliberately its own listing rather than a fourth arm on + :func:`prune_sessions`. That tuple is read by two frontends and projected into + the schema-versioned ``cleanup --json`` document; widening it is a contract + change and ~30 call sites, against one extra pair of psmux calls against a + registry that answers "no server" instantly on any machine that never ran the + pre-registry build. + + Names, not run ids: the ctl session has no run id, and the operator is going to + paste these into a ``psmux`` target. + """ + leftovers: list[str] = [] + mine = accepted_tags(project) + # Run ids, so names. `prune_sessions` unions its passes, so an id it reports + # names at most one session anywhere — the same collapse that makes its own + # "killed" count one per id. + excluded = {session_name(run_id) for run_id in announced} + for legacy in _legacy_registries(): + try: + names = legacy.list_sessions() + tags = legacy.session_options(PROJECT_OPTION) if names else {} + except MultiplexerError: + continue # observation degrades; the sweep's own report still stands + for name in names: + if name in excluded: + continue + if is_ctl_session_name(name): + # A legacy registry holds the pre-#537 fixed name; the shape + # predicate also names any per-registry-named stray. + leftovers.append(name) + continue + if _agent_run_id(name) is None: + continue # not a bmad-loop agent session at all + tag = tags.get(name, "") + if not tag or tag in mine: + leftovers.append(name) + return sorted(set(leftovers)) + + +def _legacy_registries() -> list[TerminalMultiplexer]: + """Backends bound to registries this project's sessions may predate, or [] + (see :meth:`~.multiplexer.TerminalMultiplexer.legacy_registries`, which owns + the concept and every backend's answer). + + Degrades to [] rather than raising: a backend that cannot even be selected + has no legacy registry to offer, and a cleanup that already swept the primary + registry must report that work rather than die on the migration pass.""" + try: + return list(get_multiplexer().legacy_registries()) + except MultiplexerError: + return [] + + # The run dir of the OUTERMOST engine in this call stack (#319). A nested auto-sweep # runs synchronously in its parent's thread but mints its own run id and dir, so its # adapters would poll a control file no operator ever writes to: `bmad-loop stop @@ -1598,17 +2237,73 @@ def live_session_may_be_ours(project: Path, run_id: str) -> bool: `pipe_pane` and `kill_session` are contractually best-effort, so an out-of-tree backend raises :class:`MultiplexerError` here where the bundled one returns empty (docs/adapter-authoring-guide.md). The listing is checked - first, so the tag query only runs on a name collision.""" + first, so the tag query only runs on a name collision. + + A stronger shape was built and withdrawn: a proof discipline (block unless + the transport *proves* the session absent) fell to four consecutive reviews, + each refuting its newest proof source — the transports genuinely offer none. + psmux's registry is advisory and self-healing (its server re-creates a + reaped port file on a 5 s tick, source-read at v3.3.8), a binary's PATH + presence is per-process while the server is not, and the listing is + load-sensitive; so a "proof of absence" either wedges every removal behind + `--force` or quietly accepts a refutable proof. The degrade above is the + guard's owner's documented trade, kept deliberately; the measured cost of + the unobservable-multiplexer window is filed for that owner to revisit + rather than overturned here. + + Two registry-root-era additions on that unchanged contract: + + **The control-alias discount.** An id whose session name is one of THE + control session's own names — the fixed :data:`CTL_SESSION`, or this + project's :func:`ctl_session_for` — answers False before any transport + read: that session is the control plane's, never claimed through a run + dir, so its liveness is not evidence about the run, and blocking removal + on it wedged exactly the recovery (`bmad-loop delete ctl`) the resume + refusal points an operator at, for as long as the machine had a control + session at all. This is the *instance* question, deliberately not + :func:`run_id_aliases_control_session`'s shape question: on tmux a + `main`-created run `ctl-<16 hex>` owns a genuine agent session distinct + from the fixed name (measured: killing it exactly leaves `bmad-loop-ctl` + alive), and the shape discount destroyed its run dir without ever querying + the mux. A namespace probe that cannot answer degrades to the fixed name + alone — the *smaller* discount, which blocks more, the safe direction. A + discount, not a proof source: it removes non-evidence, and never clears a + removal on transport testimony. + + **Transport-owned name comparison.** Every comparison goes through + :meth:`session_name_key`, never a constant fold: psmux resolves names + through a case-folding store, tmux is case-sensitive (both measured), and + a constant ``.lower()`` discounted a persisted `CTL` run's genuinely live + uppercase agent on tmux as "the control session" and deleted its run dir. + On tmux the key is identity, so the listing and tag reads keep their + historical exact comparison. Selecting that backend is itself part of the + listing read — :func:`mux_sessions` selects inside the caught call — so it + degrades the listing's way: a transport that cannot even be chosen (a + persisted `[mux] backend` naming a backend no longer registered) reports + no live session rather than aborting every removal path.""" + try: + mux = get_multiplexer() + except MultiplexerError: + return False + key = mux.session_name_key name = session_name(run_id) + control = {CTL_SESSION} + try: + control.add(ctl_session_for(project, mux)) + except MultiplexerError: + pass # namespace unanswerable: only the fixed name is knowable + if key(name) in {key(c) for c in control}: + return False try: - if name not in mux_sessions(): + if key(name) not in {key(s) for s in mux_sessions()}: return False except MultiplexerError: return False try: - tag = session_project_tags().get(name, "") + tags = session_project_tags() except MultiplexerError: - tag = "" # unread is not proof of foreign + tags = {} # unread is not proof of foreign + tag = next((v for s, v in tags.items() if key(s) == key(name)), "") return not tag or tag in accepted_tags(project) @@ -2027,6 +2722,20 @@ def reconcile_orphan_state_dirs(project: Path, *, dry_run: bool = False) -> list for entry in entries: if entry.name in live or entry.is_symlink() or not entry.is_dir(): continue + if entry.name == MUX_REGISTRY_DIR: + # Not a run entry at all (`mux_registry_root`), and the one entry here + # whose deletion costs more than the disk it reclaims: it holds the + # `.port`/`.key` files every psmux verb resolves a session through, so + # sweeping it while a server is up leaves that server alive, + # unreachable, and invisible to `psmux ls` in any registry — the + # manufactured orphan the root was moved out of the project tree to + # avoid. Never reaped rather than reaped-when-empty: proving it empty + # means asking every server in it whether it is alive, and this sweep + # has no seam to the multiplexer (nor may it acquire one — it must + # degrade to a no-op, and a transport probe cannot promise that). + # psmux removes its own quartet on session shutdown, so what is left + # behind is a directory of small files, not growth. + continue try: entry.resolve().relative_to(root_res) except (OSError, RuntimeError, ValueError): diff --git a/src/bmad_loop/tui/app.py b/src/bmad_loop/tui/app.py index 4dd933cc..1e7f5486 100644 --- a/src/bmad_loop/tui/app.py +++ b/src/bmad_loop/tui/app.py @@ -1,8 +1,9 @@ """`bmad-loop tui` application shell. Observer/launcher only: the TUI never runs engines in-process. Run control -(r/s/e) launches detached bmad-loop processes in the bmad-loop-ctl tmux -session via tui.launch. Dry runs are captured into a text modal; validate +(r/s/e) launches detached bmad-loop processes in the control session via +tui.launch (bmad-loop-ctl on tmux; a per-registry name on psmux, which the +launch toasts print). Dry runs are captured into a text modal; validate renders its `--json` document into a findings modal (falling back to the text one), so the verdict is the document's `ok` rather than an exit code. The g binding opens the policy.toml settings editor. @@ -313,7 +314,9 @@ def go() -> None: except launch.LaunchError as e: self.notify(str(e), severity="error") return - self.notify(f"run {run_id} launched (control session {launch.CTL_SESSION})") + self.notify( + f"run {run_id} launched (control session {launch.ctl_session(self.project)})" + ) self._dashboard.expect_run(run_id) self._guarded(go) @@ -346,7 +349,9 @@ def go() -> None: except launch.LaunchError as e: self.notify(str(e), severity="error") return - self.notify(f"sweep {run_id} launched (control session {launch.CTL_SESSION})") + self.notify( + f"sweep {run_id} launched (control session {launch.ctl_session(self.project)})" + ) self._dashboard.expect_run(run_id) self._guarded(go) @@ -454,14 +459,14 @@ def action_attach(self) -> None: # live agent session, falling back to the ctl window between sessions. if win_id is not None and (self._dashboard.decision_pending is not None or not agent_live): launch.select_ctl_window_id(win_id) - self._attach_to_target(launch.ctl_target(), return_window=win_id) + self._attach_to_target(launch.ctl_target(self.project), return_window=win_id) return elif agent_live: target = runs.session_target(run_id) else: self.notify( f"nothing to attach: no live agent session ({session}) and no " - f"{launch.CTL_SESSION} window for this run (runs started outside " + f"{launch.ctl_session(self.project)} window for this run (runs started outside " "the TUI have none)", severity="warning", timeout=10, @@ -563,7 +568,7 @@ def _launch_resolve(self, run_id: str) -> None: severity="warning", ) launch.select_ctl_window_id(win_id) - self._attach_to_target(launch.ctl_target(), return_window=win_id) + self._attach_to_target(launch.ctl_target(self.project), return_window=win_id) # -------------------------------------------------------- HITL pause review @@ -759,7 +764,10 @@ def _restore_recorded(run_dir: Path, story_key: str) -> bool: def _do_resume(self, run_id: str) -> None: """Resume a paused run — the `bmad-loop resume` / `e` path, minus the confirm modal (the viewer was the confirmation). Guards tmux + a - possibly-live engine so an approve/continue can't double-drive.""" + possibly-live engine so an approve/continue can't double-drive. No + control-alias gate here: this path mutates nothing before the launch, + and the launcher itself refuses at the mutation's chokepoint + (`launch.start_detached`) — the LaunchError lands in the except below.""" if self._mux_missing(): return run_dir = self.project / RUNS_DIR / run_id @@ -783,7 +791,9 @@ def _do_resume(self, run_id: str) -> None: "attach/stop may target an older window for this run", severity="warning", ) - self.notify(f"resume of {run_id} launched (control session {launch.CTL_SESSION})") + self.notify( + f"resume of {run_id} launched (control session {launch.ctl_session(self.project)})" + ) def _do_replan(self, run_id: str, spec_path: Path) -> None: """Request-replan: reset the planned spec to draft + strip its Auto Run @@ -793,6 +803,11 @@ def _do_replan(self, run_id: str, spec_path: Path) -> None: # strip under a still-running session would race its writes (the rearm path # already checks liveness first; match it so replan can't corrupt a live # drive, and only then does _do_resume re-check before relaunching). + # The control-alias gate sits equally early: the child `bmad-loop resume` + # would refuse such a run anyway, and a spec rewritten ahead of that + # refusal is the mutate-then-refuse shape the CLI entry gates closed. + if self._blocked_by_control_alias(run_id): + return run_dir = self.project / RUNS_DIR / run_id if self._resolve_blocked_by_liveness(run_id, run_dir): return @@ -857,6 +872,10 @@ def _do_rearm( ) -> None: """Re-arm a resolved escalation + resume — the `resolve --no-interactive` path (rearm_escalation handles sentinel auto-delete-with-preservation).""" + # Ahead of rearm_escalation for the same reason cmd_resolve gates at + # entry: a run left re-armed-but-not-running by the child's refusal. + if self._blocked_by_control_alias(run_id): + return if self._resolve_blocked_by_liveness(run_id, run_dir): return # Same seam as `cli.cmd_resolve`, for the same reason and at the same moment: @@ -942,6 +961,26 @@ def _resolve_blocked_by_liveness(self, run_id: str, run_dir: Path) -> bool: return True return False + def _blocked_by_control_alias(self, run_id: str) -> bool: + """Refuse to mutate persisted state for a run whose id aliases a + control session (`ctl`, `ctl-<16 hex>` — the CLI's resume/resolve + gates, mirrored): the launch it would end in is refused at the + mutation chokepoint (`launch.start_detached`), so a spec reset or an + escalation re-arm performed FIRST would strand the run in the mutated + state. Only the paths that mutate before launching need this — + `_do_replan` (spec draft-reset/strip) and `_do_rearm` + (rearm_escalation); plain resume/resolve mutate nothing early and are + covered by the launcher's own gate.""" + if runs.run_id_aliases_control_session(run_id): + self.notify( + f"run {run_id}: its agent session name is the control session's own — " + "cannot be driven. Recover its work by hand, then `bmad-loop delete " + f"{run_id}`", + severity="error", + ) + return True + return False + # ---------------------------------------------------- pause-context readers def _paused_spec(self, state: RunState) -> tuple[Path | None, str]: @@ -1238,8 +1277,23 @@ def done(ok: bool | None) -> None: @work(thread=True, group="lifecycle") def _cleanup_sessions_worker(self) -> None: # killed and unknown come from prune_sessions' single partition sample, - # so the warning below only ever names sessions that were actually pruned - killed, _live, unknown = runs.prune_sessions(self.project) + # so the warning below only ever names sessions that were actually pruned. + # + # Guarded for the same reason as the ctl-window arm below, with the + # opposite conclusion. This half is raiser-side too — the psmux backend + # refuses a registry root that would fail its pre-spawn absoluteness gate, + # and that raise is thrown before the tolerant listing wrapper can degrade + # it — and an escape from a worker thread takes the whole dashboard down + # (Textual's exit_on_error). Every CLI surface turns that same raise into + # one named error through main()'s backstop; a worker thread has none. + # But nothing has been killed yet, so there is no completed work to + # protect: toast and stop, rather than carry on reporting a sweep that + # never ran. + try: + killed, _live, unknown = runs.prune_sessions(self.project) + except (MultiplexerError, UnicodeError) as e: + self.call_from_thread(self.notify, f"session prune failed: {e}", severity="error") + return # prune_ctl_windows probes has_session on the shared ctl session, a # raiser-side call; on a worker thread the toast must be marshalled, and # notify() must not be called directly (see _mux_guarded — foreground only). @@ -1263,6 +1317,20 @@ def _cleanup_sessions_worker(self) -> None: f"(may still be live): {', '.join(sorted(unknown))}", severity="warning", ) + # The cli cleanup arm's stderr line, as a toast: the removal count below + # excludes sessions the migration pass declined to claim in a legacy + # registry, and a count that quietly excludes them reads as "all clean". + # Read after the prune, so it describes what is left standing. Silent on + # every platform without a registry namespace. + leftovers = runs.legacy_registry_leftovers(self.project) + if leftovers: + self.call_from_thread( + self.notify, + f"{len(leftovers)} session(s) left in the multiplexer's default " + f"registry (not migrated): {', '.join(leftovers)} — see " + "docs/multiplexer-backends.md before removing any of them", + severity="warning", + ) # A kill that did not verifiably land gets its own toast rather than a # silent subtraction from the count below (#435) — the count now reports # only verified removals, so without this the windows would just vanish diff --git a/src/bmad_loop/tui/launch.py b/src/bmad_loop/tui/launch.py index b23dda62..b9ae3efc 100644 --- a/src/bmad_loop/tui/launch.py +++ b/src/bmad_loop/tui/launch.py @@ -1,9 +1,10 @@ """Detached launching of bmad-loop commands for the TUI. The TUI never runs engines in-process: run/sweep/resume are launched in new -windows of a dedicated tmux control session (bmad-loop-ctl) so they survive -TUI exit, and the dashboard observes them through run-dir artifacts exactly -like runs started from a plain shell. Fast read-only commands (validate, +windows of a dedicated control session (bmad-loop-ctl on tmux, a per-registry +name on psmux — see the CTL_SESSION comment below) so they survive TUI exit, +and the dashboard observes them through run-dir artifacts exactly like runs +started from a plain shell. Fast read-only commands (validate, --dry-run) are captured instead, for display in a modal. No textual imports here — everything drives the multiplexer seam (or a plain @@ -21,7 +22,11 @@ from pathlib import Path from .. import runs -from ..adapters.multiplexer import MultiplexerError, get_multiplexer, mux_usable +from ..adapters.multiplexer import ( + MultiplexerError, + get_multiplexer, + mux_usable, +) from ..journal import Journal from ..platform_util import ( DIR_FD_ANCHORED_WRITES, @@ -30,7 +35,17 @@ open_dir_confined, ) -CTL_SESSION = "bmad-loop-ctl" +CTL_SESSION = runs.CTL_SESSION +# The control-session NAME is the transport's business, resolved per call +# through `runs.ctl_session_for(project)`: the fixed name on tmux, where one +# server serves the machine and the session really is machine-wide (scoped by +# the per-window PROJECT_OPTION tag below), and a per-registry name on psmux, +# whose duplicate-server mutex is keyed on the session name alone, across +# every registry in the login session (`Local\` is a per-login-session object +# namespace) — so a fixed name would let only ONE registry there hold a control +# session and every other project's launch would fail as a duplicate. +# The constant survives as the fixed base name (display fallbacks, tmux argv +# pins); anything that addresses a live session resolves the name instead. # control-session windows are named - (see start_detached) _CTL_WINDOW_RE = re.compile(r"^(?:run|sweep|resume|resolve)-(.+)$") @@ -384,7 +399,7 @@ def ctl_window_id(project: Path, run_id: str) -> str | None: tagged: list[str] = [] untagged: list[str] = [] rows = get_multiplexer().list_windows( - CTL_SESSION, ["window_id", "window_name", runs.PROJECT_OPTION] + ctl_session(project), ["window_id", "window_name", runs.PROJECT_OPTION] ) for win_id, name, tag in rows: # win_id can be "": psmux's qualifier passes a falsy id through. An @@ -458,11 +473,11 @@ def ctl_window_recorded(project: Path, run_id: str, win_id: str) -> bool: return False -def ctl_target() -> str: - """Seam-canonical target token for the control session; see +def ctl_target(project: Path) -> str: + """Seam-canonical target token for this project's control session; see :meth:`TerminalMultiplexer.target`. Windows are targeted by stable id (ctl_window_id), never by name through this token.""" - return get_multiplexer().target(CTL_SESSION) + return get_multiplexer().target(ctl_session(project)) def select_ctl_window_id(window_id: str) -> None: @@ -517,8 +532,12 @@ 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. 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 + multiplexer, so no direct TMUX/HERDR_* env sniffing happens here. The + shape predicate rather than one project's resolved name: the question its + callers ask is "am I in A control session", and on a namespacing transport + the name carries a registry suffix (runs.ctl_session_for).""" + session = current_session() + return session is not None and runs.is_ctl_session_name(session) def detach_client() -> bool: @@ -641,7 +660,7 @@ def attach_plan(project: Path, run_id: str) -> tuple[list[str], str | None] | No decision_pending(runs.run_dir_for(project, run_id)) or not agent_live ): select_ctl_window_id(win_id) - return runs.attach_target_argv(ctl_target()), win_id + return runs.attach_target_argv(ctl_target(project)), win_id if agent_live: return runs.attach_target_argv(runs.session_target(run_id)), None return None @@ -670,10 +689,11 @@ def _ctl_window_candidates(project: Path) -> list[tuple[str, str]]: directory under this project (mirrors runs.prunable_sessions). """ mux = get_multiplexer() - if not mux_usable(mux) or not session_exists(CTL_SESSION): + ctl = runs.ctl_session_for(project, mux) + if not mux_usable(mux) or not session_exists(ctl): return [] current = mux.current_window_id() - rows = mux.list_windows(CTL_SESSION, ["window_id", "window_name", runs.PROJECT_OPTION]) + rows = mux.list_windows(ctl, ["window_id", "window_name", runs.PROJECT_OPTION]) mine = runs.accepted_tags(project) candidates: list[tuple[str, str]] = [] for win_id, name, tag in rows: @@ -682,8 +702,12 @@ def _ctl_window_candidates(project: Path) -> list[tuple[str, str]]: m = _CTL_WINDOW_RE.match(name) if m is None: continue # not a run window (e.g. the session's initial shell) - if not runs.is_valid_run_id(m.group(1)): - continue # a foreign/mangled window name must not steer a run-dir path + if not runs.is_parsable_run_id(m.group(1)): + # A foreign/mangled window name must not steer a run-dir path. The + # PARSE-side predicate: this window already exists, so the mint's + # broad ctl reservation would leak every pre-upgrade `run-ctl-*` + # window out of the sweep instead of closing it. + continue run_dir = runs.run_dir_for(project, m.group(1)) if tag: if tag not in mine: @@ -756,7 +780,7 @@ def prune_ctl_windows(project: Path) -> tuple[list[str], list[str], list[str]]: except UnicodeError: pass try: - live = set(mux.list_window_ids(CTL_SESSION)) + live = set(mux.list_window_ids(runs.ctl_session_for(project, mux))) except MultiplexerError: # The kills may well have landed; nothing here can say so. Claiming the # optimistic half is exactly the bug — the next cleanup pass retries. @@ -766,18 +790,26 @@ def prune_ctl_windows(project: Path) -> tuple[list[str], list[str], list[str]]: return removed, survived, [] -def _ensure_ctl_session(project: Path) -> None: +def ctl_session(project: Path) -> str: + """The control-session name for this project on the selected transport + (see `runs.ctl_session_for`). The TUI-facing spelling, so `tui/app.py` + and the screens name the session an operator would actually attach to.""" + return runs.ctl_session_for(project, get_multiplexer()) + + +def _ensure_ctl_session(project: Path) -> str: mux = get_multiplexer() + name = runs.ctl_session_for(project, mux) # has_session is raiser-side (a server-backed backend can fail the probe after # the availability pre-gate). Keep it inside the try so a transport failure # converts to LaunchError, which the TUI launch/resume/resolve handlers already # catch — otherwise the raw MultiplexerError slips past them and crashes the app. try: - if mux.has_session(CTL_SESSION): - return - mux.new_session(CTL_SESSION, project) + if not mux.has_session(name): + mux.new_session(name, project) except MultiplexerError as e: raise LaunchError(f"multiplexer ctl-session setup failed: {e}") from e + return name def cli_argv(*tail: str) -> list[str]: @@ -799,18 +831,31 @@ def start_detached(project: Path, argv_tail: list[str], run_id: str, kind: str) names collide when several kinds share a run_id). The same id is recorded in the run dir so ctl_window_id answers this window rather than an older one under the same run id — see _record_ctl_window. + + Refuses a run id that aliases a control session, FIRST — this is the one + place every drive path converges on the mutation (the window mint and the + ctl-window record overwrite): run/sweep launches with freshly validated + ids, and resume/resolve replaying ids an older release persisted. Gating + each button separately kept finding the path nobody gated (resolve was + the fourth); gating the mutation cannot. Ahead of the mux probes so the + refusal needs no transport to be phrased. """ + if runs.run_id_aliases_control_session(run_id): + raise LaunchError( + f"run {run_id}: its agent session name is the control session's own — " + f"cannot be driven. Recover its work by hand, then `bmad-loop delete {run_id}`" + ) mux = get_multiplexer() if not mux_usable(mux): raise LaunchError( "multiplexer backend unavailable (binary missing, version unsupported, " "or a required helper absent)" ) - _ensure_ctl_session(project) + ctl = _ensure_ctl_session(project) try: win_id = ( mux.new_parked_window( - CTL_SESSION, + ctl, f"{kind}-{run_id}", project, cli_argv(*argv_tail), diff --git a/src/bmad_loop/tui/screens/dashboard.py b/src/bmad_loop/tui/screens/dashboard.py index 07581dc6..47359178 100644 --- a/src/bmad_loop/tui/screens/dashboard.py +++ b/src/bmad_loop/tui/screens/dashboard.py @@ -39,7 +39,7 @@ from ... import sprintstatus, stories from ...model import RunState, StoryTask from ...runs import RUNS_DIR -from .. import data +from .. import data, launch from ..widgets import ( DeferredEntryOption, JournalEntryOption, @@ -730,7 +730,8 @@ def _tick(self, force_rescan: bool | None = None) -> None: if self._pending_run is not None and time.monotonic() > self._pending_deadline: self._pending_run = None self.notify( - "launch may have failed — attach to control session bmad-loop-ctl", + "launch may have failed — attach to control session " + f"{launch.ctl_session(self.project)}", severity="error", timeout=15, ) diff --git a/src/bmad_loop/tui/widgets.py b/src/bmad_loop/tui/widgets.py index 2d82e354..a90228a1 100644 --- a/src/bmad_loop/tui/widgets.py +++ b/src/bmad_loop/tui/widgets.py @@ -130,7 +130,7 @@ def show_starting(self, run_id: str) -> None: text.append(" ⧗ starting…", style="yellow") text.append( "\nwaiting for the engine to write state.json" - " — if nothing appears, attach to control session bmad-loop-ctl", + " — if nothing appears, attach (a) to its control window", style="dim", ) self.update(text) diff --git a/tests/conftest.py b/tests/conftest.py index ad968004..fa3147e4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,7 +14,7 @@ import pytest import yaml -from bmad_loop import cli, documents, envvars, platform_util +from bmad_loop import cli, documents, envvars, platform_util, runs from bmad_loop.adapters.base import SessionResult, SessionSpec from bmad_loop.bmadconfig import ProjectPaths from bmad_loop.checks import ValidationReport @@ -419,6 +419,39 @@ def _isolate_state_root(tmp_path_factory: pytest.TempPathFactory, monkeypatch): monkeypatch.setenv(envvars.STATE_DIR, str(tmp_path_factory.mktemp("state-root"))) +@pytest.fixture(autouse=True) +def _isolate_mux_registry(monkeypatch): + """Keep the psmux registry root, and the inside-a-pane marker, out of the + cross-test environment. + + `runs.export_psmux_registry_root` writes `PSMUX_DATA_DIR` into `os.environ` + directly — that IS its contract, since every psmux verb inherits the process + environment — so any test that runs `cli.main` leaves the root of ITS temp + state dir behind for every later test in the worker. Two of them care: the + live psmux gate reads the default registry to prove a session is NOT visible + there, and a stale root would make that read answer about a temp directory + instead. `delenv` rather than `setenv`: monkeypatch then restores the + operator's own value (or its absence) at teardown regardless of what the code + under test put there, and unset is the state the tests that assert an override + are written against. + + ``TMUX``/``TMUX_PANE`` go with it, for the developer half of the same problem: + a multiplexer sets them on every pane child, so running the suite from inside + tmux or psmux would otherwise put every test in the run inside a pane. The + registry export no longer cares (it derives either way, and a test asserts + exactly that), but `PsmuxMultiplexer._display_message` does branch on ``TMUX``, + and the live module builds envs that assume it is absent. + + ``PSMUX_BARE_ENV`` too: bmad-loop does not support that mode and the psmux + backend warns once per process about it, so a developer whose profile sets + it would otherwise start every worker with the warning already spent (and + an unexpected stderr line in whichever test spawned psmux first).""" + monkeypatch.delenv(runs.PSMUX_DATA_DIR, raising=False) + monkeypatch.delenv("TMUX", raising=False) + monkeypatch.delenv("TMUX_PANE", raising=False) + monkeypatch.delenv("PSMUX_BARE_ENV", raising=False) + + @pytest.fixture(scope="session") def _project_template( tmp_path_factory: pytest.TempPathFactory, _isolate_ambient_git_ignores: None diff --git a/tests/test_cleanup.py b/tests/test_cleanup.py index 748c7401..553d8c1b 100644 --- a/tests/test_cleanup.py +++ b/tests/test_cleanup.py @@ -372,7 +372,11 @@ def test_cmd_clean_protects_a_run_whose_agent_session_is_still_live(project, mon wt.parent.mkdir(parents=True) verify.worktree_add(repo, wt, "fb", "main") save_state(run_dir, RunState(run_id="r", project=str(repo), started_at="x", finished=True)) - monkeypatch.setattr(runs, "mux_sessions", lambda: ["bmad-loop-20260101-000000-aaaa"]) + from test_runs import _LivenessMux + + monkeypatch.setattr( + runs, "get_multiplexer", lambda: _LivenessMux(["bmad-loop-20260101-000000-aaaa"]) + ) assert cli.cmd_clean(_clean_args(repo, retain=0)) == 0 @@ -463,11 +467,15 @@ def test_cmd_clean_reclaims_past_a_session_proven_to_be_another_project_s( repo = project.project run_dir = repo / ".bmad-loop" / "runs" / "20260101-000000-aaaa" save_state(run_dir, RunState(run_id="r", project=str(repo), started_at="x", finished=True)) - monkeypatch.setattr(runs, "mux_sessions", lambda: ["bmad-loop-20260101-000000-aaaa"]) + from test_runs import _LivenessMux + monkeypatch.setattr( runs, - "session_project_tags", - lambda: {"bmad-loop-20260101-000000-aaaa": runs.project_tag(repo / "someone-else")}, + "get_multiplexer", + lambda: _LivenessMux( + ["bmad-loop-20260101-000000-aaaa"], + tags={"bmad-loop-20260101-000000-aaaa": runs.project_tag(repo / "someone-else")}, + ), ) assert cli.cmd_clean(_clean_args(repo, retain=0)) == 0 @@ -649,7 +657,11 @@ def test_cmd_clean_json_reports_a_live_session_run_as_protected(project, monkeyp repo = project.project run_dir = repo / ".bmad-loop" / "runs" / "20260101-000000-aaaa" save_state(run_dir, RunState(run_id="r", project=str(repo), started_at="x", finished=True)) - monkeypatch.setattr(runs, "mux_sessions", lambda: ["bmad-loop-20260101-000000-aaaa"]) + from test_runs import _LivenessMux + + monkeypatch.setattr( + runs, "get_multiplexer", lambda: _LivenessMux(["bmad-loop-20260101-000000-aaaa"]) + ) doc = _clean_json(repo, capsys, "--retain", "0") diff --git a/tests/test_cli.py b/tests/test_cli.py index bcc03ec1..232d344a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -34,10 +34,10 @@ write_sprint, ) -from bmad_loop import cli, platform_util +from bmad_loop import cli, envvars, platform_util from bmad_loop import policy as policy_mod from bmad_loop import probe as probe_mod -from bmad_loop import runsetup, verify +from bmad_loop import runs, runsetup, verify from bmad_loop.adapters import multiplexer as mux_mod STORIES_SPEC_FOLDER = "_bmad-output/epic-1" @@ -1906,6 +1906,9 @@ def test_run_honors_preassigned_run_id_and_writes_pid(project, monkeypatch): "a b", # whitespace "", # empty "CON", # reserved windows device basename + "ctl", # session_name("ctl") IS the control session + "ctl-0123456789abcdef", # can equal a per-registry control-session name exactly + "CTL-0123456789ABCDEF", # Windows resolves session names case-insensitively ] @@ -2341,9 +2344,11 @@ def test_delete_refuses_an_orphaned_session_without_force(tmp_path, monkeypatch, """Engine dead, agent session still live — the one state the pid-keyed guard passes, and the one where the run dir is the only ownership proof an untagged session has left (#419).""" + from test_runs import _LivenessMux + from bmad_loop import runs - monkeypatch.setattr(runs, "mux_sessions", lambda: ["bmad-loop-r1"]) + monkeypatch.setattr(runs, "get_multiplexer", lambda: _LivenessMux(["bmad-loop-r1"])) run_dir = _make_run_with_state(tmp_path, "r1") # no pid -> engine reads dead assert cli.main(["delete", "--project", str(tmp_path), "r1"]) == 1 err = capsys.readouterr().err @@ -2369,7 +2374,9 @@ def test_delete_force_overrides_the_session_guard_without_killing_it(tmp_path, m from bmad_loop import runs killed = [] - monkeypatch.setattr(runs, "mux_sessions", lambda: ["bmad-loop-r1"]) + from test_runs import _LivenessMux + + monkeypatch.setattr(runs, "get_multiplexer", lambda: _LivenessMux(["bmad-loop-r1"])) monkeypatch.setattr(runs, "kill_session", lambda rid: killed.append(rid)) run_dir = _make_run_with_state(tmp_path, "r1") assert cli.main(["delete", "--project", str(tmp_path), "r1", "--force"]) == 0 @@ -2386,7 +2393,9 @@ def test_archive_force_overrides_the_session_guard_without_killing_it( from bmad_loop import runs killed = [] - monkeypatch.setattr(runs, "mux_sessions", lambda: ["bmad-loop-r1"]) + from test_runs import _LivenessMux + + monkeypatch.setattr(runs, "get_multiplexer", lambda: _LivenessMux(["bmad-loop-r1"])) monkeypatch.setattr(runs, "kill_session", lambda rid: killed.append(rid)) run_dir = _make_run_with_state(tmp_path, "r1") assert cli.main(["archive", "--project", str(tmp_path), "r1", "--force"]) == 0 @@ -2396,9 +2405,11 @@ def test_archive_force_overrides_the_session_guard_without_killing_it( def test_archive_refuses_an_orphaned_session_without_force(tmp_path, monkeypatch, capsys): + from test_runs import _LivenessMux + from bmad_loop import runs - monkeypatch.setattr(runs, "mux_sessions", lambda: ["bmad-loop-r1"]) + monkeypatch.setattr(runs, "get_multiplexer", lambda: _LivenessMux(["bmad-loop-r1"])) run_dir = _make_run_with_state(tmp_path, "r1") assert cli.main(["archive", "--project", str(tmp_path), "r1"]) == 1 err = capsys.readouterr().err @@ -3767,7 +3778,12 @@ def test_cleanup_json_dry_run_plans_without_pruning(tmp_path, monkeypatch, capsy assert doc["schema_version"] == cli.CLEANUP_SCHEMA_VERSION assert doc["dry_run"] is True - assert doc["sessions"] == {"removed": ["fin-1"], "live": ["live-1"], "unverifiable_pid": []} + assert doc["sessions"] == { + "removed": ["fin-1"], + "live": ["live-1"], + "unverifiable_pid": [], + "legacy_leftovers": [], + } assert doc["ctl_windows"] == { "removed": ["sweep-fin-1"], "survived": [], @@ -3818,7 +3834,12 @@ def test_cleanup_json_nothing_to_clean_up_is_a_valid_empty_document(tmp_path, mo doc = machine_json(["cleanup", "--project", str(tmp_path), "--json"], capsys) assert doc["schema_version"] == cli.CLEANUP_SCHEMA_VERSION - assert doc["sessions"] == {"removed": [], "live": [], "unverifiable_pid": []} + assert doc["sessions"] == { + "removed": [], + "live": [], + "unverifiable_pid": [], + "legacy_leftovers": [], + } assert doc["ctl_windows"] == { "removed": [], "survived": [], @@ -4001,6 +4022,196 @@ def test_cleanup_text_counts_only_verified_removals_and_names_the_rest( assert "kill attempted, outcome unverifiable: dunno-1" in captured.err +def test_resolve_refuses_a_persisted_ctl_run_before_any_side_effect(project, monkeypatch, capsys): + """Through the ENTRY POINT, deliberately — the round-15 test called the + shared helper directly and could not see that resolve's flow launches the + interactive session and re-arms the escalation before reaching it. The + gate is at cmd_resolve entry: no adapters are built (the interactive + session is downstream of that), nothing is re-armed. + + Ablate the entry gate and this fails two ways: the stderr message becomes + the not-at-an-escalation one, and the adapter build runs.""" + from bmad_loop import runs + + install_bmad_config(project) + run_dir = _make_run_with_state( + project.project, + "ctl", + paused_reason="spec approval", + paused_stage="spec-approval", + ) + built = [] + monkeypatch.setattr(cli, "_make_adapters", lambda *a, **k: built.append(1) or {}) + killed: list[str] = [] + monkeypatch.setattr(runs, "kill_session", lambda rid: killed.append(rid)) + + assert cli.main(["resolve", "ctl", "--project", str(project.project)]) == 1 + err = capsys.readouterr().err + assert "cannot resolve" in err and "bmad-loop-ctl" in err and "delete ctl" in err + assert built == [] and killed == [] + # the run is untouched: still paused exactly as persisted, nothing re-armed + from bmad_loop.journal import load_state + + assert load_state(run_dir).paused_reason == "spec approval" + + +def test_resume_entrypoint_refuses_a_persisted_ctl_run(project, monkeypatch, capsys): + """The same gate through `bmad-loop resume ctl` itself — the helper-level + test cannot see the command wiring.""" + from bmad_loop import runs + + install_bmad_config(project) + _make_run_with_state( + project.project, + "ctl", + paused_reason="spec approval", + paused_stage="spec-approval", + ) + killed: list[str] = [] + monkeypatch.setattr(runs, "kill_session", lambda rid: killed.append(rid)) + + assert cli.main(["resume", "ctl", "--project", str(project.project)]) == 1 + err = capsys.readouterr().err + assert "cannot resume" in err and "delete ctl" in err + assert killed == [] + + +def test_delete_removes_a_persisted_ctl_run_despite_the_live_control_session( + project, monkeypatch, capsys +): + """The recovery the refusal advertises has to WORK: with the normal live + `bmad-loop-ctl` control session up, `bmad-loop delete ctl` used to reach + the live-session backstop (that session name IS `session_name("ctl")`), + raise, and leave the run directory intact — a refusal naming a recovery + that fails. The control session is never claimed through a run dir, so + its liveness is not evidence about the run and the delete proceeds. + + Ablate the alias early-return in `live_session_may_be_ours` and this + fails with the LiveSessionError message.""" + from bmad_loop import runs + + install_bmad_config(project) + run_dir = _make_run_with_state( + project.project, + "ctl", + paused_reason="spec approval", + paused_stage="spec-approval", + ) + # the machine's normal, live control session — untagged, as ctl sessions are + from test_runs import _LivenessMux + + monkeypatch.setattr(runs, "get_multiplexer", lambda: _LivenessMux(["bmad-loop-ctl"])) + + assert cli.main(["delete", "ctl", "--project", str(project.project)]) == 0 + assert "deleted" in capsys.readouterr().out + assert not run_dir.exists() + + +def test_delete_refuses_a_historical_digest_run_with_a_live_agent(project, monkeypatch, capsys): + """The data-loss inverse of the recovery test below, through the ENTRY + POINT: on tmux a `main`-created run `ctl-<16 hex>` owns a genuine agent + session (there are no digest-named control sessions there), and the + round-16 shape discount let `delete` destroy its run dir while that + agent was live — removing the untagged session's only ownership proof. + The instance test queries the mux and the live-session backstop refuses. + + Ablate the discount back to the shape predicate and this fails: rc 0 + and the run dir gone.""" + from bmad_loop import runs + + install_bmad_config(project) + run_dir = _make_run_with_state( + project.project, + "ctl-0123456789abcdef", + paused_reason="spec approval", + paused_stage="spec-approval", + ) + # tmux shape: the fixed name is the only control session; the digest name + # is this run's own live agent session + from test_runs import _LivenessMux + + monkeypatch.setattr(runs, "ctl_session_for", lambda p, mux=None: runs.CTL_SESSION) + monkeypatch.setattr( + runs, + "get_multiplexer", + lambda: _LivenessMux(["bmad-loop-ctl", "bmad-loop-ctl-0123456789abcdef"]), + ) + + assert cli.main(["delete", "ctl-0123456789abcdef", "--project", str(project.project)]) == 1 + assert "still live" in capsys.readouterr().err + assert run_dir.exists() # the run dir survived its live agent + + +def test_delete_refuses_a_case_variant_run_where_the_transport_is_case_sensitive( + project, monkeypatch, capsys +): + """Finding-1's reproduction, through the ENTRY POINT: on tmux + `bmad-loop-CTL` coexists with `bmad-loop-ctl` (measured on 3.4), so a + persisted `CTL` run's uppercase agent is a genuinely live session — the + round-17 constant fold discounted it as "the control session" and delete + removed the run dir under it. The comparison now belongs to the + transport (`session_name_key`, identity on tmux) and the refusal stands. + + Ablate the seam key back to a constant `.lower()` and this fails: rc 0 + and the run dir gone.""" + from test_runs import _LivenessMux + + from bmad_loop import runs + + install_bmad_config(project) + run_dir = _make_run_with_state( + project.project, + "CTL", + paused_reason="spec approval", + paused_stage="spec-approval", + ) + monkeypatch.setattr(runs, "ctl_session_for", lambda p, mux=None: runs.CTL_SESSION) + monkeypatch.setattr( + runs, + "get_multiplexer", + lambda: _LivenessMux(["bmad-loop-ctl", "bmad-loop-CTL"], fold=False), + ) + + assert cli.main(["delete", "CTL", "--project", str(project.project)]) == 1 + assert "still live" in capsys.readouterr().err + assert run_dir.exists() + + +def test_resume_refuses_a_persisted_ctl_shaped_run(project, monkeypatch, capsys): + """Mint-time validation never sees what an older release already wrote to + disk: a run dir named `ctl`, resolved through resume, would relaunch under + the control session's own name (and its stale-session sweep would kill + that session first). Refused in the shared helper — so resolve's re-arm + gets the same gate — before any side effect, and the message names the + way out (`bmad-loop delete`, which no longer touches any session under + the name). + + Ablate the gate and this proceeds to the stub engine: rc 0, and the + stale-session sweep fires with the reserved name.""" + from conftest import install_base_skills + + from bmad_loop import runs + + install_bmad_config(project) + install_base_skills(project) + write_sprint(project, {"1-1-a": "ready-for-dev"}) + run_dir = _make_run_with_state( + project.project, + "ctl", + paused_reason="spec approval", + paused_stage="spec-approval", + ) + killed: list[str] = [] + monkeypatch.setattr(runs, "kill_session", lambda rid: killed.append(rid)) + monkeypatch.setattr(cli, "Engine", _StubEngine) + monkeypatch.setattr(cli, "_make_adapters", lambda *a, **k: {r: None for r in cli.ROLES}) + + assert cli._resume_paused_run(project.project, run_dir) == 1 + err = capsys.readouterr().err + assert "cannot resume" in err and "bmad-loop-ctl" in err and "delete ctl" in err + assert killed == [] # the stale-session sweep never ran with the reserved name + + def test_resume_kills_stale_session_before_running(project, monkeypatch): from conftest import install_base_skills @@ -6833,11 +7044,24 @@ def test_dry_run_stories_unresolvable_absolute_folder_refused(project, monkeypat class _MuxStub: - """Selection-surface double (available/version only — `mux` needs no more).""" + """Selection-surface double (available/version/registry — all `mux` reads).""" - def __init__(self, avail=True, version=None, version_error=None): + def __init__( + self, avail=True, version=None, version_error=None, registry=None, namespaced=None + ): self._avail, self._version = avail, version self._version_error = version_error + self._registry = registry + # Same coupling as the bundled backends unless the test says otherwise: + # a root in force implies a namespace, no root implies none (tmux). + self._namespaced = (registry is not None) if namespaced is None else namespaced + + def registry_root(self): + # Default None = "no registry namespace", the seam's tmux-shaped answer. + return self._registry + + def has_registry_namespace(self): + return self._namespaced def available(self): return self._avail @@ -10137,3 +10361,349 @@ def counting(lock_path, **kwargs): assert rc == 1 assert "may still be live" in capsys.readouterr().err assert acquired == [] # refused BEFORE any acquisition, not merely before the write + + +# ------------------------------------------------- registry disclosure (#537) + + +def test_main_exports_the_registry_root_before_dispatch(tmp_path, monkeypatch): + """The one chokepoint that both knows the project and precedes every psmux + spawn. Ablate the `runs.export_psmux_registry_root` call in `_configure_mux` + and the handler runs against whatever registry the launching shell had — the + cross-process failure the derivation exists to kill.""" + monkeypatch.delenv(runs.PSMUX_DATA_DIR, raising=False) + seen = {} + + def handler(args): + seen["root"] = os.environ.get(runs.PSMUX_DATA_DIR) + return 0 + + monkeypatch.setattr(cli, "cmd_list", handler) + assert cli.main(["list", "--project", str(tmp_path)]) == 0 + assert seen["root"] == str(runs.mux_registry_root(tmp_path)) + + +def test_main_says_once_when_it_overrode_an_operators_registry(tmp_path, capsys, monkeypatch): + """Overriding a variable the operator set, silently, is how someone spends an + hour on a `psmux ls` that shows nothing. bmad-loop derives its registry root + unconditionally — an ambient value cannot be told apart from an inherited one, + nor a shell-local pin from a profile-wide one — so the override is real and + has to be said. + + stderr, not stdout: the `--json` contract is one object on stdout and nothing + else (the `unverifiable_pid` precedent). Ablate the report in `_configure_mux` + and this fails while the override goes on happening.""" + theirs = str(tmp_path / "their-own-registry") + monkeypatch.setenv(runs.PSMUX_DATA_DIR, theirs) + monkeypatch.setattr(cli, "cmd_list", lambda _args: 0) + + assert cli.main(["list", "--project", str(tmp_path)]) == 0 + captured = capsys.readouterr() + assert captured.out == "" + assert str(runs.mux_registry_root(tmp_path)) in captured.err + assert theirs in captured.err + + +@pytest.mark.parametrize("ambient", ["derived", None]) +def test_main_stays_quiet_when_it_overrode_nothing(tmp_path, capsys, monkeypatch, ambient): + """The other half, and the one that keeps the note from becoming noise every + operator learns to ignore: nothing was displaced, so nothing is said. The + `derived` case is a pane child of this project's own session, which is the + ordinary way the variable is already set.""" + if ambient is None: + monkeypatch.delenv(runs.PSMUX_DATA_DIR, raising=False) + else: + monkeypatch.setenv(runs.PSMUX_DATA_DIR, str(runs.mux_registry_root(tmp_path))) + monkeypatch.setattr(cli, "cmd_list", lambda _args: 0) + + assert cli.main(["list", "--project", str(tmp_path)]) == 0 + assert capsys.readouterr().err == "" + + +def test_main_warns_when_it_has_no_registry_of_its_own(tmp_path, capsys, monkeypatch): + """The arm an operator most needs told about, and the one the first version of + this report missed: no state root could be derived, so the export left their + `PSMUX_DATA_DIR` in force and every psmux verb this command runs addresses + THEIR registry. Silence there reads as "bmad-loop is using its own". + + Ablate the `root is None` arm of the report in `_configure_mux` and this + fails.""" + theirs = str(tmp_path / "their-own-registry") + monkeypatch.setenv(envvars.STATE_DIR, "relative-state-root") # derivation fails + monkeypatch.setenv(runs.PSMUX_DATA_DIR, theirs) + monkeypatch.setattr(cli, "cmd_list", lambda _args: 0) + + assert cli.main(["list", "--project", str(tmp_path)]) == 0 + err = capsys.readouterr().err + assert theirs in err + assert envvars.STATE_DIR in err + + +def test_main_warns_when_the_backend_is_left_on_its_default_registry(tmp_path, capsys, monkeypatch): + """The other degrade arm, which the first version of this report ALSO + missed: no state root, no ambient value — so nothing was exported and a + namespacing backend (psmux) runs on its own shared default registry. Silence + there reads as "bmad-loop is using its own registry" while every verb, + the kill path included, addresses the shared one. + + Ablate the `namespaced` warning arm in `_configure_mux` and this fails.""" + from bmad_loop.adapters import multiplexer as multiplexer_mod + + class _Namespaced: + def has_registry_namespace(self): + return True + + monkeypatch.setenv(envvars.STATE_DIR, "relative-state-root") # derivation fails + monkeypatch.delenv(runs.PSMUX_DATA_DIR, raising=False) + monkeypatch.setattr(multiplexer_mod, "get_multiplexer", lambda: _Namespaced()) + monkeypatch.setattr(cli, "cmd_list", lambda _args: 0) + + assert cli.main(["list", "--project", str(tmp_path)]) == 0 + err = capsys.readouterr().err + assert "default registry" in err + assert envvars.STATE_DIR in err + + +def test_main_stays_quiet_on_a_namespace_less_backend_with_no_root(tmp_path, capsys, monkeypatch): + """The half that keeps the new warning from firing on every tmux host with a + broken state root: with no registry namespace there is no registry for the + degrade to have cost, and the failure surfaces where it actually bites.""" + from bmad_loop.adapters import multiplexer as multiplexer_mod + + class _NamespaceLess: + def has_registry_namespace(self): + return False + + monkeypatch.setenv(envvars.STATE_DIR, "relative-state-root") # derivation fails + monkeypatch.delenv(runs.PSMUX_DATA_DIR, raising=False) + monkeypatch.setattr(multiplexer_mod, "get_multiplexer", lambda: _NamespaceLess()) + monkeypatch.setattr(cli, "cmd_list", lambda _args: 0) + + assert cli.main(["list", "--project", str(tmp_path)]) == 0 + assert capsys.readouterr().err == "" + + +def test_relay_is_not_given_a_registry_root(tmp_path, monkeypatch): + """`relay` dispatches ahead of `_configure_mux` by contract — it touches + neither mux nor policy, and a hook that exits non-zero is surfaced as a + failed tool call inside the session it is reporting for.""" + monkeypatch.delenv(runs.PSMUX_DATA_DIR, raising=False) + monkeypatch.setattr(cli, "cmd_relay", lambda _args: 0) + assert cli.main(["relay", "Stop"]) == 0 + assert runs.PSMUX_DATA_DIR not in os.environ + + +def test_mux_discloses_the_registry_root(mux_registry, tmp_path, capsys, monkeypatch): + """An operator's bare `psmux ls` reads psmux's default registry and answers + "no sessions" for this project — not an error, just a lie by omission. The + root and a paste-ready export are what make that actionable.""" + import sys as _sys + + root = str(tmp_path / "reg") + mux_registry.register_multiplexer( + "alpha", lambda p: p == _sys.platform, lambda: _MuxStub(registry=root) + ) + monkeypatch.setenv(runs.PSMUX_DATA_DIR, root) + + assert cli.main(["mux", "--project", str(tmp_path)]) == 0 + out = capsys.readouterr().out + assert f"registry: {root}" in out + assert f"$env:{runs.PSMUX_DATA_DIR} = '{root}'" in out + + +def test_mux_says_when_the_registry_is_not_the_derived_one( + mux_registry, tmp_path, capsys, monkeypatch +): + """bmad-loop always derives, so a root that is NOT the derived one means the + export degraded — an underivable state root, where it leaves what it found + rather than inventing one. Reporting that as "derived" would mislead about + the single case an operator most needs told.""" + import sys as _sys + + theirs = str(tmp_path / "theirs") + mux_registry.register_multiplexer( + "alpha", lambda p: p == _sys.platform, lambda: _MuxStub(registry=theirs) + ) + monkeypatch.setenv(runs.PSMUX_DATA_DIR, theirs) + + assert cli.main(["mux", "--project", str(tmp_path)]) == 0 + out = capsys.readouterr().out + assert f"registry: {theirs} (NOT bmad-loop's" in out + assert "no state root could be derived" in out + + +def test_mux_says_when_the_registry_was_derived(mux_registry, tmp_path, capsys, monkeypatch): + """The other half: a root matching the derivation is reported as derived, so + an operator can tell "bmad-loop put it here" from "your shell did".""" + import sys as _sys + + derived = str(runs.mux_registry_root(tmp_path)) + mux_registry.register_multiplexer( + "alpha", lambda p: p == _sys.platform, lambda: _MuxStub(registry=derived) + ) + monkeypatch.setenv(runs.PSMUX_DATA_DIR, derived) + + assert cli.main(["mux", "--project", str(tmp_path)]) == 0 + assert f"registry: {derived} (derived from the project)" in capsys.readouterr().out + + +def test_mux_says_nothing_about_a_registry_a_backend_does_not_have(mux_registry, tmp_path, capsys): + """tmux has no registry namespace; disclosing one would be an invention.""" + import sys as _sys + + mux_registry.register_multiplexer("alpha", lambda p: p == _sys.platform, _MuxStub) + assert cli.main(["mux", "--project", str(tmp_path)]) == 0 + assert "registry:" not in capsys.readouterr().out + + +def test_mux_names_the_shared_default_registry_when_no_root_is_in_force( + mux_registry, tmp_path, capsys, monkeypatch +): + """A namespacing backend with no root in force is on its transport's shared + default registry — the one case `registry:` staying silent about would read + as "per-project as usual". Reachable when the export degrades on an + underivable state root with no ambient value set. + + Ablate the `has_registry_namespace()` arm in `_print_registry` and this + fails.""" + import sys as _sys + + mux_registry.register_multiplexer( + "alpha", lambda p: p == _sys.platform, lambda: _MuxStub(namespaced=True) + ) + monkeypatch.setenv(envvars.STATE_DIR, "relative-state-root") # derivation fails + monkeypatch.delenv(runs.PSMUX_DATA_DIR, raising=False) + + assert cli.main(["mux", "--project", str(tmp_path)]) == 0 + out = capsys.readouterr().out + assert "registry: the multiplexer's shared default" in out + assert envvars.STATE_DIR in out + + +# --------------------------- registry disclosure, round 2 (#537) + + +def test_mux_export_line_escapes_a_single_quote(mux_registry, tmp_path, capsys, monkeypatch): + """The line is printed as paste-ready, so it has to paste: a root under + `C:\\Users\\O'Brien\\...` ends its own single-quoted PowerShell literal unless + the quote is doubled. Ablate the `.replace` and this fails.""" + import sys as _sys + + root = str(tmp_path / "O'Brien" / "reg") + mux_registry.register_multiplexer( + "alpha", lambda p: p == _sys.platform, lambda: _MuxStub(registry=root) + ) + monkeypatch.setenv(runs.PSMUX_DATA_DIR, root) + + assert cli.main(["mux", "--project", str(tmp_path)]) == 0 + out = capsys.readouterr().out + export = next(line for line in out.splitlines() if "$env:" in line) + literal = export.split(" = ", 1)[1] + assert literal.startswith("'") and literal.endswith("'") + # The body of a single-quoted PowerShell literal holds only DOUBLED quotes; + # a lone one would terminate the string early. + assert "''" in literal and "'" not in literal[1:-1].replace("''", "") + # And it still round-trips to the real path. + assert literal[1:-1].replace("''", "'") == root + + +def test_cleanup_names_what_the_migration_left_behind(project, capsys, monkeypatch): + """cleanup prints a removal count; a count that quietly excludes sessions the + migration pass declined to claim reads as "everything is clean". Ablate the + `_warn_legacy_leftovers` call and this fails.""" + from bmad_loop.tui import launch + + monkeypatch.setattr(runs, "prune_sessions", lambda _p, dry_run=False: ([], [], set())) + monkeypatch.setattr( + runs, + "legacy_registry_leftovers", + lambda _p, announced=(): ["bmad-loop-ctl", "bmad-loop-old-1"], + ) + monkeypatch.setattr(launch, "prune_ctl_windows", lambda _p: ([], [], [])) + + assert cli.main(["cleanup", "--project", str(project)]) == 0 + err = capsys.readouterr().err + assert "not migrated" in err + assert "bmad-loop-ctl" in err and "bmad-loop-old-1" in err + + +def test_cleanup_dry_run_previews_what_the_migration_would_leave_behind( + project, capsys, monkeypatch +): + """A preview that omits the remainder would disagree with the run it previews.""" + from bmad_loop.tui import launch + + monkeypatch.setattr(runs, "prune_sessions", lambda _p, dry_run=False: ([], [], set())) + monkeypatch.setattr( + runs, "legacy_registry_leftovers", lambda _p, announced=(): ["bmad-loop-old-1"] + ) + monkeypatch.setattr(launch, "prunable_ctl_windows", lambda _p: []) + + assert cli.main(["cleanup", "--dry-run", "--project", str(project)]) == 0 + assert "bmad-loop-old-1" in capsys.readouterr().err + + +def test_cleanup_dry_run_hands_the_remainder_the_plan_it_printed(project, capsys, monkeypatch): + """The wiring, graded on its own, because the doubles above cannot grade it: a + parameter with a default absorbs a call site that stopped passing it, and both + the ablated and the wired build then return the same list. + + `legacy_registry_leftovers` judges by presence, so on a dry run — where nothing + was killed — it has to be told which standing sessions this very command + already announced as would-kills, or the preview names them twice and reads as + "the migration declined these". It must be told by being *handed* the plan, not + by re-deriving it. Ablate `announced=killed if args.dry_run else ()` at the + call site and this fails. + + A real cleanup hands over nothing: there, a killed session is gone from the + listing by presence, and one whose kill did not land must still be named.""" + from bmad_loop.tui import launch + + seen: list[object] = [] + monkeypatch.setattr(runs, "prune_sessions", lambda _p, dry_run=False: (["old-1"], [], set())) + + def _leftovers(_p, announced=()): + seen.append(sorted(announced)) + return [] + + monkeypatch.setattr(runs, "legacy_registry_leftovers", _leftovers) + monkeypatch.setattr(launch, "prunable_ctl_windows", lambda _p: []) + monkeypatch.setattr(launch, "prune_ctl_windows", lambda _p: ([], [], [])) + + assert cli.main(["cleanup", "--dry-run", "--project", str(project)]) == 0 + assert seen == [["old-1"]] + + seen.clear() + assert cli.main(["cleanup", "--project", str(project)]) == 0 + assert seen == [[]] + + +def test_cleanup_json_carries_the_remainder_and_leaves_stderr_empty(project, capsys, monkeypatch): + """The `unverifiable_pid` precedent: in --json mode the degradation travels in + the document, and stderr stays empty for a consumer reading both streams.""" + from bmad_loop.tui import launch + + monkeypatch.setattr(runs, "prune_sessions", lambda _p, dry_run=False: ([], [], set())) + monkeypatch.setattr( + runs, "legacy_registry_leftovers", lambda _p, announced=(): ["bmad-loop-old-1"] + ) + monkeypatch.setattr(launch, "prune_ctl_windows", lambda _p: ([], [], [])) + + assert cli.main(["cleanup", "--json", "--project", str(project)]) == 0 + captured = capsys.readouterr() + doc = json.loads(captured.out) + assert doc["sessions"]["legacy_leftovers"] == ["bmad-loop-old-1"] + assert captured.err == "" + + +def test_cleanup_says_nothing_about_a_registry_with_no_remainder(project, capsys, monkeypatch): + """Silence on the normal path — every platform without a registry namespace, + and every already-migrated machine.""" + from bmad_loop.tui import launch + + monkeypatch.setattr(runs, "prune_sessions", lambda _p, dry_run=False: ([], [], set())) + monkeypatch.setattr(runs, "legacy_registry_leftovers", lambda _p, announced=(): []) + monkeypatch.setattr(launch, "prune_ctl_windows", lambda _p: ([], [], [])) + + assert cli.main(["cleanup", "--project", str(project)]) == 0 + assert "not migrated" not in capsys.readouterr().err diff --git a/tests/test_engine.py b/tests/test_engine.py index 88743b9c..6a4163e1 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -3555,6 +3555,36 @@ def test_session_env_names_the_out_of_tree_events_dir(project): assert not Path(expected).is_relative_to(project.project) +def test_every_session_is_told_this_runs_state_root(project): + """A coding-session window is *told* the state root, exactly as it is told the + events dir above, and for the same reason: inheritance is not a transport a + multiplexer has to provide. psmux's `PSMUX_BARE_ENV=1` clears a pane child's + environment and rebuilds it from a 14-name allowlist that keeps `TMUX` and + drops both `BMAD_LOOP_STATE_DIR` and the `LOCALAPPDATA` its default falls back + to — so a `bmad-loop` run inside such a session would answer with a different + state root, hence a different psmux registry, and read its own live session as + gone. + + The value is `runs.state_root()` resolved, not the override forwarded: the + allowlist takes the default's sources too, so passing only what the operator + set would leave the common case broken. + + Ablation guard: delete the `pinned_state_env()` spread from the engine's + session env and this fails.""" + from bmad_loop import envvars, runs + + write_sprint(project, {"epic-1": "backlog", "1-1-a": "ready-for-dev"}) + engine, adapter = make_engine( + project, + [dev_effect(project, "1-1-a"), review_effect(project, "1-1-a", clean=True)], + ) + engine.run() + + expected = str(runs.state_root()) + assert [s.role for s in adapter.sessions] == ["dev", "review"] + assert {s.env[envvars.STATE_DIR] for s in adapter.sessions} == {expected} + + def test_post_kill_rescued_result_flows_and_journals(project): """A result rescued by the adapter's post-kill reconcile (#61) reaches the engine as an ordinary completed result — it must flow the completed path diff --git a/tests/test_multiplexer.py b/tests/test_multiplexer.py index 1bb50595..500cc71b 100644 --- a/tests/test_multiplexer.py +++ b/tests/test_multiplexer.py @@ -32,6 +32,7 @@ class StubMux(TerminalMultiplexer): def __init__(self): self.calls: list[str] = [] + self.window_env: dict[str, str] = {} self._sessions: set[str] = set() self._windows: dict[str, list[str]] = {} self._next = 0 @@ -41,7 +42,7 @@ def has_session(self, name): self.calls.append("has_session") return name in self._sessions - def new_session(self, name, cwd, cols, lines): + def new_session(self, name, cwd, cols=None, lines=None): self.calls.append("new_session") self._sessions.add(name) self._windows[name] = [] @@ -51,6 +52,7 @@ def set_session_option(self, name, option, value): def new_window(self, session, name, cwd, env, command): self.calls.append("new_window") + self.window_env = env self._next += 1 win = f"@stub{self._next}" self._windows.setdefault(session, []).append(win) @@ -195,6 +197,56 @@ def test_generic_adapter_drives_only_the_mux(tmp_path, no_tmux): assert "kill_window" in stub.calls +def test_generic_adapter_window_env_pins_the_state_root_over_profile( + tmp_path, no_tmux, monkeypatch +): + """The engine's window merge (`{**profile.env, **spec.env}`) rides through + the `runs.pin_state_root` chokepoint: a profile `[env]` table declaring + `BMAD_LOOP_STATE_DIR` is forced to this process's resolved root when one + derives, and STRIPPED when none does — with an underivable root there is no + pin key in `spec.env` for mere merge order to protect, and the profile's + absolute value would otherwise aim the coding window at a registry its own + orchestrator cannot see. `interactive_env` (the attached resolve path) + applies the same rule. + + Ablate the `runs.pin_state_root` wrap at either merge and the matching + assertion fails.""" + import dataclasses + + from bmad_loop import envvars, runs + + def make_adapter(): + stub = StubMux() + adapter = GenericAdapter( + run_dir=tmp_path / "run", + policy=Policy(limits=LimitsPolicy()), + profile=dataclasses.replace( + get_profile("claude"), env={envvars.STATE_DIR: str(tmp_path / "S2")} + ), + mux=stub, + events_dir=tmp_path / "state" / "events", + ) + return stub, adapter + + spec = _spec(tmp_path) + + # derivable: the profile's S2 is overwritten with the resolved root + monkeypatch.setenv(envvars.STATE_DIR, str(tmp_path / "S1")) + stub, adapter = make_adapter() + adapter.start_session(spec) + assert stub.window_env[envvars.STATE_DIR] == str(runs.state_root()) + assert adapter.interactive_env(spec)[envvars.STATE_DIR] == str(runs.state_root()) + + # underivable: no pin exists, so the profile's entry is stripped outright + monkeypatch.setenv(envvars.STATE_DIR, "relative-root") + stub, adapter = make_adapter() + adapter.start_session(spec) + assert envvars.STATE_DIR not in stub.window_env + assert envvars.STATE_DIR not in adapter.interactive_env(spec) + # ...and the rest of the profile/spec env is untouched + assert stub.window_env["BMAD_LOOP_TASK_ID"] == spec.task_id + + # --------------------------------------------------------------- seam honesty # # Phase 1: no tmux contract method may leak a raw subprocess.TimeoutExpired / @@ -793,6 +845,15 @@ def test_new_parked_window_posix_argv_byte_identical(monkeypatch, tmp_path): ] +def test_new_session_argv_byte_identical(monkeypatch, tmp_path): + rec = _RecordRun() + monkeypatch.setattr(tmux_base.subprocess, "run", rec) + + TmuxMultiplexer().new_session("s", tmp_path) + + assert rec.argv == ["tmux", "new-session", "-d", "-s", "s", "-c", str(tmp_path)] + + def test_new_window_posix_argv_byte_identical(monkeypatch, tmp_path): rec = _RecordRun() monkeypatch.setattr(tmux_base.subprocess, "run", rec) diff --git a/tests/test_probe.py b/tests/test_probe.py index dcb6ebba..68d9cdae 100644 --- a/tests/test_probe.py +++ b/tests/test_probe.py @@ -885,3 +885,60 @@ def fake_run(argv, **kwargs): # `errors="replace"` onto `_run_capture` (#383) never happens here and cannot # raise the UnicodeDecodeError the guard above does not name. assert kwargs.get("text") is None + + +def test_probe_launcher_pins_the_window_to_this_state_root(tmp_path, monkeypatch): + """The probe window's env carries this process's resolved state root, and + the pin WINS over the caller's env: `start`'s env is the profile's own + `[env]` table plus the probe protocol keys, and a profile declaring + `BMAD_LOOP_STATE_DIR` would otherwise aim a bmad-loop wrapper in that + window at a different state root — a different registry, where this very + session reads as gone. Same precedence as the engine path, where the + session env dict (pin included) wins over `profile.env`. + + Ablate the `runs.pinned_state_env()` spread — or move it back ahead of + `env` — and one of the two assertions fails.""" + from bmad_loop import envvars, runs + + class _Mux: + def __init__(self): + self.window_env = None + + def new_session(self, name, cwd, cols=None, lines=None): + pass + + def new_window(self, session, name, cwd, env, command): + self.window_env = env + return "@1" + + def pipe_pane(self, window_id, log_file): + pass + + monkeypatch.setenv(envvars.STATE_DIR, str(tmp_path / "S")) + mux = _Mux() + monkeypatch.setattr(probe, "get_multiplexer", lambda: mux) + launcher = probe._ProbeLauncher(session_name="bmad-loop-probe-x") + + win = launcher.start( + ["prog"], + {"CALLER": "1", envvars.STATE_DIR: str(tmp_path / "elsewhere")}, + tmp_path, + tmp_path / "log.txt", + ) + + assert win == "@1" + assert mux.window_env == {"CALLER": "1", envvars.STATE_DIR: str(runs.state_root())} + + # The underivable arm — the round-11 gap: with no pin key to spread, an + # ordering rule protects nothing, so the key is STRIPPED instead and the + # window inherits the parent's own (broken) value, failing as the parent + # fails. Ablate `runs.pin_state_root` back to the round-11 spread merge + # and the profile's C:\elsewhere lands in the window. + monkeypatch.setenv(envvars.STATE_DIR, "relative-root") + launcher.start( + ["prog"], + {"CALLER": "1", envvars.STATE_DIR: str(tmp_path / "elsewhere")}, + tmp_path, + tmp_path / "log.txt", + ) + assert mux.window_env == {"CALLER": "1"} diff --git a/tests/test_psmux_backend.py b/tests/test_psmux_backend.py index 6619f9a3..f2b9ca0c 100644 --- a/tests/test_psmux_backend.py +++ b/tests/test_psmux_backend.py @@ -124,7 +124,7 @@ def test_new_window_rejects_malformed_command(rec, tmp_path): assert rec.calls == [] -def test_new_parked_window_rejects_empty_argv(rec, tmp_path): +def test_parked_window_rejects_empty_argv(rec, tmp_path): with pytest.raises(MultiplexerError): PsmuxMultiplexer().new_parked_window("s", "n", tmp_path, [], "") assert rec.calls == [] @@ -318,6 +318,62 @@ def timeout(*_a, **_k): PsmuxMultiplexer().new_session("s", tmp_path) +# ------------------------------------------------------ PSMUX_BARE_ENV (unsupported) + + +@pytest.fixture +def _bare_env_unwarned(monkeypatch): + """Reset the once-per-process guard so each test grades its own firing.""" + monkeypatch.setattr(psmux_backend, "_BARE_ENV_WARNED", False) + + +@pytest.mark.parametrize("value", ["1", "true", "TRUE"]) +def test_bare_env_mode_warns_once_per_process( + rec, tmp_path, monkeypatch, capsys, _bare_env_unwarned, value +): + """bmad-loop does not support `PSMUX_BARE_ENV` (psmux is on iff the value is + "1" or case-insensitive "true" — `src/pane.rs:890-892`, source-read at + v3.3.8): under it a session's window-0 shell and the TUI's parked engine + windows lose `BMAD_LOOP_STATE_DIR` by inheritance and derive their own + registry, so a run can read as gone. Said once per process, not per verb. + + Ablate the warning out of `_warn_if_bare_env` and this fails; ablate the + `_BARE_ENV_WARNED` guard and the count reads two.""" + monkeypatch.setenv("PSMUX_BARE_ENV", value) + mux = PsmuxMultiplexer() + mux._run(["list-sessions"], check=False) + mux._run(["list-sessions"], check=False) + err = capsys.readouterr().err + assert err.count("warning: PSMUX_BARE_ENV") == 1 + assert "does not support" in err + + +@pytest.mark.parametrize("value", [None, "0", "", "yes"]) +def test_bare_env_mode_off_stays_quiet( + rec, tmp_path, monkeypatch, capsys, _bare_env_unwarned, value +): + """The predicate is psmux's own: any value psmux reads as off must not warn, + or the line becomes noise an operator learns to ignore. Ablate the + `_bare_env_on` condition (warn unconditionally) and this fails.""" + if value is None: + monkeypatch.delenv("PSMUX_BARE_ENV", raising=False) + else: + monkeypatch.setenv("PSMUX_BARE_ENV", value) + PsmuxMultiplexer()._run(["list-sessions"], check=False) + assert "PSMUX_BARE_ENV" not in capsys.readouterr().err + + +def test_bare_env_mode_detected_on_a_per_call_env( + rec, tmp_path, monkeypatch, capsys, _bare_env_unwarned +): + """`_run` judges the EFFECTIVE env — a per-call `env=` carrying the switch is + what the spawned server would inherit, so it is what gets warned about.""" + monkeypatch.delenv("PSMUX_BARE_ENV", raising=False) + env = {**os.environ, "PSMUX_BARE_ENV": "1"} + PsmuxMultiplexer()._run(["list-sessions"], check=False, env=env) + assert "PSMUX_BARE_ENV" in capsys.readouterr().err + + # --------------------------------------------------------------- kill_session @@ -402,7 +458,7 @@ def test_return_target_bare_pane_on_unqualifiable_session_name(monkeypatch): # ------------------------------------------------------------- parked window -def test_new_parked_window_composes_pwsh_source(rec, tmp_path): +def test_parked_window_composes_pwsh_source(rec, tmp_path): PsmuxMultiplexer().new_parked_window("s", "n", tmp_path, ["claude", "--resume"], "%3") # calls[0]: the mint; the orphan-key sweep spawns after it (own test below) @@ -617,7 +673,7 @@ def test_registry_selects_psmux_when_forced(monkeypatch): # The launcher/prune surfaces hand ids around from a process that is usually # OUTSIDE any pane, where a bare `@N` resolves through the most-recent-session # fallback instead of the session that minted it. kill-window on such an id is -# destructive against the wrong server, so new_parked_window, the `window_id` +# destructive against the wrong server, so parked_window, the `window_id` # columns of list_windows, and current_window_id all carry `session:@N`, and # every `-t` consumer replays that form verbatim. @@ -632,13 +688,13 @@ def fake(argv, **kwargs): monkeypatch.setattr(tmux_base.subprocess, "run", fake) -def test_new_parked_window_returns_session_qualified_id(monkeypatch, tmp_path): +def test_parked_window_returns_session_qualified_id(monkeypatch, tmp_path): _window_fake(monkeypatch) win = PsmuxMultiplexer().new_parked_window("ctl", "run-x", tmp_path, ["prog"], "@ret") assert win == "ctl:@2" -def test_new_parked_window_degrades_on_colon_session(monkeypatch, tmp_path): +def test_parked_window_degrades_on_colon_session(monkeypatch, tmp_path): # Same #221 rule the engine-side mint follows: `a:b:@2` would split at the # wrong colon, so the id stays bare rather than becoming a wrong target. _window_fake(monkeypatch) @@ -653,7 +709,7 @@ def test_qualified_window_id_degrades_on_empty_session(): assert PsmuxMultiplexer()._qualified_window_id("", "@2") == "@2" -def test_new_parked_window_falsy_id_passes_through(monkeypatch, tmp_path): +def test_parked_window_falsy_id_passes_through(monkeypatch, tmp_path): # An empty id is start_detached's "window id not captured" sentinel; forging # "ctl:" out of it would turn a detected failure into a plausible target. _window_fake(monkeypatch, new_window_id="") @@ -1166,8 +1222,8 @@ def fake(argv, **kwargs): def test_stranded_keys_after_cleanup_crash_are_reclaimed_by_the_sweep(monkeypatch): # The two halves of the strand-then-reclaim contract: a landed kill whose # key-free step dies strands the keys, and a later sweep claims them. The - # sweep is driven directly here; new_parked_window's wiring to it is pinned - # by test_new_parked_window_sweeps_orphan_keys. + # sweep is driven directly here; parked_window's wiring to it is pinned + # by test_parked_window_sweeps_orphan_keys. key = "@bmad_project__blw@3" state = {"healed": False} freed = [] @@ -1438,7 +1494,7 @@ def test_list_windows_option_fill_declines_on_unroutable_session(monkeypatch): assert len(rec_.calls) == 1 # the listing only — no show-options spawned -def test_new_parked_window_sweeps_orphan_keys(monkeypatch, tmp_path): +def test_parked_window_sweeps_orphan_keys(monkeypatch, tmp_path): # Enter-dismissing a parked window closes it without kill_window, so its # keys outlive it; launch reconciles. `__blw@7` has no window → freed. # `__blw@2` is live → kept. Foreign options are untouched — `@color_3`, @@ -1637,7 +1693,11 @@ def fake(argv, **kwargs): monkeypatch.setattr(runs, "engine_alive", lambda _dir: False) candidates = launch._ctl_window_candidates(tmp_path) - assert candidates == [("bmad-loop-ctl:@2", "run-20260726-1")] + # The scan resolves the per-registry ctl name (runs.ctl_session_for) on a + # namespacing backend, and the qualified candidate ids carry it. + ctl = runs.ctl_session_for(tmp_path, mux) + assert ctl.startswith("bmad-loop-ctl-") + assert candidates == [(f"{ctl}:@2", "run-20260726-1")] # ---------------------------------------- client verbs: observed effect (#317) @@ -2150,3 +2210,143 @@ def test_attached_count_refuses_a_name_differing_only_by_whitespace(monkeypatch) before any compare.""" _count_fake(monkeypatch, stdout="1| ctl\n") assert PsmuxMultiplexer()._attached_clients("ctl") is None + + +# ------------------------------------------------- registry namespace (#537) + + +@pytest.mark.parametrize("bad", ["", "relative\\root", "."]) +def test_run_refuses_a_registry_root_psmux_would_panic_on(monkeypatch, bad): + """psmux asserts PSMUX_DATA_DIR absolute and non-empty and panics otherwise — + a Rust panic and a nonzero exit, which `has_session` and friends read as an + ordinary "no". A live session would read as gone for a reason nothing in the + output names. Ablate the gate in `_run` and this passes the value straight + through to the spawn, which is the whole defect.""" + run = _RecordRun() + monkeypatch.setattr(tmux_base.subprocess, "run", run) + monkeypatch.setenv("PSMUX_DATA_DIR", bad) + + with pytest.raises(TmuxError) as exc: + PsmuxMultiplexer()._run(["has-session", "-t", "s"], check=False) + assert "PSMUX_DATA_DIR" in str(exc.value) + assert run.calls == [] # refused before the spawn, not after + + +def test_run_refuses_a_bad_root_carried_by_an_explicit_per_call_env(monkeypatch): + """`new_session` builds its own env, so the gate reads the EFFECTIVE + environment rather than only the inherited one.""" + run = _RecordRun() + monkeypatch.setattr(tmux_base.subprocess, "run", run) + monkeypatch.delenv("PSMUX_DATA_DIR", raising=False) + + with pytest.raises(TmuxError): + PsmuxMultiplexer()._run(["has-session"], check=False, env={"PSMUX_DATA_DIR": "rel"}) + assert run.calls == [] + + +def test_run_passes_an_absolute_root_through_untouched(monkeypatch, tmp_path): + run = _RecordRun() + monkeypatch.setattr(tmux_base.subprocess, "run", run) + monkeypatch.setenv("PSMUX_DATA_DIR", str(tmp_path)) + + PsmuxMultiplexer()._run(["has-session", "-t", "s"], check=False) + # env=None: the child inherits this process's environment, which is how the + # one export reaches every verb. + assert run.kwargs["env"] is None + + +def test_run_never_shadows_an_explicit_per_call_env(monkeypatch, tmp_path): + """The seam's process-wide export must not overwrite what a caller passed — + the live suite's isolated fixture root is exactly such a caller.""" + run = _RecordRun() + monkeypatch.setattr(tmux_base.subprocess, "run", run) + monkeypatch.setenv("PSMUX_DATA_DIR", str(tmp_path / "process")) + theirs = {"PSMUX_DATA_DIR": str(tmp_path / "theirs")} + + PsmuxMultiplexer()._run(["has-session"], check=False, env=theirs) + assert run.kwargs["env"] == theirs + + +def test_default_registry_instance_spawns_with_the_variable_removed(monkeypatch, tmp_path): + """Unbinding is done by REMOVING the variable, so psmux computes its own + default root — never by respelling `%USERPROFILE%\\.psmux` in Python.""" + run = _RecordRun() + monkeypatch.setattr(tmux_base.subprocess, "run", run) + monkeypatch.setenv("PSMUX_DATA_DIR", str(tmp_path)) + + PsmuxMultiplexer(default_registry=True)._run(["list-sessions"], check=False) + env = run.kwargs["env"] + assert "PSMUX_DATA_DIR" not in env + # A copy of the parent env, not a fresh one: a Windows child needs SystemRoot. + assert set(env) == set(os.environ) - {"PSMUX_DATA_DIR"} + + +def test_default_registry_instance_keeps_an_explicit_envs_other_scrubbing(monkeypatch, tmp_path): + """`new_session`'s claude-var scrub is the env this composes onto; unbinding + the registry must not undo it.""" + run = _RecordRun() + monkeypatch.setattr(tmux_base.subprocess, "run", run) + scrubbed = {"PSMUX_ALLOW_NESTING": "1", "PSMUX_DATA_DIR": str(tmp_path)} + + PsmuxMultiplexer(default_registry=True)._run(["kill-session"], check=False, env=scrubbed) + assert run.kwargs["env"] == {"PSMUX_ALLOW_NESTING": "1"} + + +def test_session_name_key_is_the_transports_answer(): + """ "Are these the same session name?" belongs to the transport, never to + a constant in core: tmux compares exactly (measured on 3.4 — + `bmad-loop-ctl` and `bmad-loop-CTL` coexist), while psmux resolves names + through NTFS port files, which fold case (measured on 3.3.8 — the + uppercase target addresses, duplicates against, and kills the lowercase + session). A constant fold destroyed a tmux run dir under a live + case-variant agent; a constant exact-compare would blind psmux's + control-session discount. + + Ablate the psmux override and its half fails; fold the base default and + the tmux half fails.""" + assert TmuxMultiplexer().session_name_key("bmad-loop-CTL") == "bmad-loop-CTL" + assert PsmuxMultiplexer().session_name_key("bmad-loop-CTL") == "bmad-loop-ctl" + + +def test_registry_root_reports_the_root_in_force(monkeypatch, tmp_path): + monkeypatch.setenv("PSMUX_DATA_DIR", str(tmp_path)) + assert PsmuxMultiplexer().registry_root() == str(tmp_path) + + +def test_registry_root_is_none_on_the_default_registry(monkeypatch, tmp_path): + """Nothing an operator would have to export to reach it — the same None a + namespace-less backend answers, and correctly so for the one caller.""" + monkeypatch.setenv("PSMUX_DATA_DIR", str(tmp_path)) + assert PsmuxMultiplexer(default_registry=True).registry_root() is None + monkeypatch.delenv("PSMUX_DATA_DIR") + assert PsmuxMultiplexer().registry_root() is None + + +def test_tmux_has_no_registry_namespace(monkeypatch, tmp_path): + """tmux addresses a server by socket; there is no root to disclose, and the + variable being set for a psmux next door must not make one appear.""" + monkeypatch.setenv("PSMUX_DATA_DIR", str(tmp_path)) + assert TmuxMultiplexer().registry_root() is None + assert TmuxMultiplexer().legacy_registries() == [] + + +def test_legacy_registries_offers_psmuxs_default_root(monkeypatch, tmp_path): + monkeypatch.setenv("PSMUX_DATA_DIR", str(tmp_path)) + legacy = PsmuxMultiplexer().legacy_registries() + assert len(legacy) == 1 + assert legacy[0]._default_registry is True + + +def test_legacy_registries_is_empty_when_this_process_is_already_on_the_default(monkeypatch): + """The primary pass already covers that registry; a second one would double + every kill's report.""" + monkeypatch.delenv("PSMUX_DATA_DIR", raising=False) + assert PsmuxMultiplexer().legacy_registries() == [] + + +@pytest.mark.parametrize("bad", ["", "relative\\root"]) +def test_legacy_registries_is_empty_under_a_root_psmux_would_panic_on(monkeypatch, bad): + """The primary pass is not running either, and a sweep that appeared to work + while the real registry was unreachable would read as "nothing to clean".""" + monkeypatch.setenv("PSMUX_DATA_DIR", bad) + assert PsmuxMultiplexer().legacy_registries() == [] diff --git a/tests/test_psmux_live.py b/tests/test_psmux_live.py index 383800db..3cb68176 100644 --- a/tests/test_psmux_live.py +++ b/tests/test_psmux_live.py @@ -40,6 +40,7 @@ import pytest from bmad_loop import runs +from bmad_loop.adapters import tmux_base from bmad_loop.adapters.psmux_backend import PsmuxMultiplexer from bmad_loop.adapters.tmux_base import TmuxError from bmad_loop.tui import launch @@ -88,7 +89,10 @@ def test_prune_kills_only_the_owning_projects_window(tmp_path: Path, monkeypatch proc = mux._run(["set-option", "-t", session, foreign, "user"], check=False) assert proc.returncode == 0, proc.stderr - monkeypatch.setattr(launch, "CTL_SESSION", session) + # The ctl-session NAME is resolved per (project, transport) since the + # per-registry rename (runs.ctl_session_for); pin the resolver to this + # test's own session so the prune sweeps the windows minted above. + monkeypatch.setattr(runs, "ctl_session_for", lambda _p, _m=None: session) monkeypatch.setattr(launch, "get_multiplexer", lambda: mux) # Pin "outside any pane": when pytest itself runs inside psmux the # target-less probe can resolve the test's own window and exclude it. @@ -122,22 +126,26 @@ def test_prune_kills_only_the_owning_projects_window(tmp_path: Path, monkeypatch assert options.get(foreign) == "user" # foreign key survives every sweep body_ok = True finally: - # kill_session is a best-effort backstop; verify it worked so a real - # server never leaks silently off a green (or already-failing) run. - mux.kill_session(session) + # The registry-scoped teardown, not a name-scoped kill_session: every + # non-warm psmux server spawns a replacement `__warm__` server AT + # STARTUP (`server/mod.rs:1196` / `spawn_warm_server`, source-read at + # v3.3.8), and that warm server inherits this test's PSMUX_DATA_DIR and + # registers its own `.port` under the same private root — so a + # `kill-session -t ` leaves it running forever (measured: this + # test alone, from zero psmux processes, left one `psmux.exe server -s + # __warm__` behind). `_teardown_probe_session`'s `kill-server` read_dirs + # the root and reaps both. `_new_session_env()` carries the + # monkeypatched PSMUX_DATA_DIR, so it addresses this test's registry. try: - leaked = mux.has_session(session) - except TmuxError: - leaked = True - if leaked: - note = f"live-gate session {session} survived teardown; kill it manually" - # Only raise when the body passed. pytest.fail() here on an + _teardown_probe_session(mux, session, _new_session_env()) + except AssertionError as exc: + # Only raise when the body passed. Raising here on an # already-failing run replaces the real diagnostic — the leak takes # over the summary line and the assertion drops to a chained # "during handling" frame — so that run keeps the warning instead. if body_ok: - pytest.fail(note) - print(f"warning: {note}", file=sys.stderr) + raise + print(f"warning: {exc}", file=sys.stderr) # --------------------------------------------------------------- premise probes @@ -210,6 +218,197 @@ def _active_window(mux: PsmuxMultiplexer, session: str) -> str: return f"{session}:{active[0]}" +# psmux's own client-side readiness deadline: `src/main.rs`, source-read at +# v3.3.8 — `ready_deadline = Instant::now() + Duration::from_secs(15)`, after which +# the client prints `psmux: failed to create session` and exits 1 WITHOUT killing +# the server it spawned. So it is also the longest a server may take to register +# while psmux still considers that a normal start. +_PSMUX_READY_DEADLINE_S = 15.0 + + +def _seen_anywhere(mux: PsmuxMultiplexer, session: str, env: dict[str, str]) -> bool: + """True if the session answers in the isolated registry or in the default one.""" + return _plain_has_session(mux, session, env=env) or _plain_has_session(mux, session) + + +_CMDLINE_TOKEN = re.compile(r'"([^"]*)"|(\S+)') + + +def _server_session_of(cmdline: str) -> str | None: + """The session a psmux server process was started for, or None. + + `psmux` builds its server argv as `["server", "-s", , ...]` + (`src/main.rs:1421-1423`, source-read at v3.3.8), so the name is the whole + token after the FIRST `-s` — never a substring of the command line, which is + the distinction that matters when the killer below acts on the answer. The + first is taken deliberately: a later `-c ""` can hold anything, + including another `-s`, and it arrives as one quoted token here. + """ + tokens = [quoted if quoted else bare for quoted, bare in _CMDLINE_TOKEN.findall(cmdline)] + for index, token in enumerate(tokens[:-1]): + if token == "-s": + return tokens[index + 1] + return None + + +def _kill_unregistered_servers(session: str) -> list[str]: + """Last resort: kill psmux server processes for ``session`` that no psmux verb + can reach, and return the pids killed. + + Sustained absence is not death. A server whose registry root cannot be + written **binds its port and runs anyway** — `ensure_session_registry_files` + (`server/mod.rs:104-177`, source-read at v3.3.8) swallows every failure with + `let _ = create_dir_all(...)` / `let _ = fs::write(...)` — so it publishes no + `.port` file. `has-session` resolves through that file and `kill-server` + enumerates the ones under the root (`main.rs:746`, source-read), which means + both verbs report a running server as absent and neither can address it. + Absence for any length of time is then indistinguishable from death, and the + teardown below would return clean over a live process. + + Reachable in exactly the world the self-heal probe's control mint makes red: + psmux no longer creating a missing root. That path is where the leak was + observed — twice, killed by hand — so it is the path this closes. + + The process table is the only remaining witness, and it is read in two steps + on purpose. **Selection is an exact match on the session token** + (:func:`_server_session_of`), not a substring of the command line: a + substring also selects `foreign--tail`, and the second step + `Stop-Process -Force`s what the first chose. Measured — two servers so named, + in separate private registries, were both selected by a substring predicate + and only the right one by this. A backstop that force-kills has to be exactly + scoped, or the operator's own session pays for a probe's naming. + + Filtering on `Name='psmux.exe'` keeps the pwsh doing the asking, whose own + command line quotes the session name, out of the answer. Windows-only, like + the whole module. + """ + listing = _powershell( + "Get-CimInstance Win32_Process -Filter \"Name='psmux.exe'\" | " + 'ForEach-Object { "$($_.ProcessId)`t$($_.CommandLine)" }' + ) + doomed: list[str] = [] + for line in listing.splitlines(): + pid, _, cmdline = line.partition("\t") + if pid.strip().isdigit() and _server_session_of(cmdline) == session: + doomed.append(pid.strip()) + if doomed: + _powershell( + "; ".join( + f"Stop-Process -Id {pid} -Force -ErrorAction SilentlyContinue" for pid in doomed + ) + ) + return doomed + + +def _powershell(script: str) -> str: + """Run one PowerShell command, returning stdout (empty on any failure — the + caller's own report still stands without this witness).""" + try: + proc = subprocess.run( + ["powershell", "-NoProfile", "-NonInteractive", "-Command", script], + capture_output=True, + text=True, + encoding="utf-8", + errors="backslashreplace", + timeout=tmux_base.TMUX_TIMEOUT_S, + ) + except (OSError, subprocess.TimeoutExpired): + return "" + return proc.stdout + + +def _teardown_probe_session(mux: PsmuxMultiplexer, session: str, env: dict[str, str]) -> None: + """Kill a probe session and its server, and refuse to return until it is + provably gone in BOTH the isolated and the default registry. + + RETRIED, and aimed at the REGISTRY rather than only at the name. + + `psmux: failed to create session` is the CLIENT's readiness poll timing out, + not a creation failure (`src/main.rs`, source-read at v3.3.8: the message is + printed once `ready_deadline` passes), so under load the server routinely + comes up a moment after the mint reported failure. A single-shot + `kill-session` fires while that server is still starting, misses, and both + reads then answer "not there" — a clean-looking teardown over a real leak. + Every leak makes the NEXT run's mint slower and its own timeout likelier, + which is how one instrument failure cascades across a box (observed: a full + suite going from 0 to 13 fixture errors as leaked servers accumulated). + + `kill-server` is what makes this decisive: it force-kills every server whose + port file is under `psmux_dir()` (`src/main.rs`, source-read — it `read_dir`s + that root), and every root here is a private temp directory holding nothing + but the probe session. So it does not depend on the session having registered + under its NAME yet, which is exactly what a mid-start server has not done. + Both verbs are issued each pass because they fail in opposite directions: the + name-scoped one works before the port file settles, the registry-scoped one + after. + + The default-registry read is checked too: a build ignoring `PSMUX_DATA_DIR` + would have created the session in the developer's real registry, and that is + the one leak nothing here would otherwise catch. + + HOW LONG ABSENCE HAS TO HOLD depends on whether the session was ever THERE, + and that asymmetry is the whole of the timing here. + + - Seen present: the server registered, so both verbs can address it and a + short confirmation is honest — the port file is gone and stays gone. + - Never seen: the server may simply not have registered YET, and a mid-start + server is indistinguishable from no server at all. Both `has-session` and + `kill-server` work off the port files under the root, so neither can reach + one that has not written its own. Two absent reads a beat apart mean nothing + here — measured: a delayed registration let an earlier revision return after + 0.50s with the server visible immediately afterwards. + + So the unseen case holds its vigil for `_PSMUX_READY_DEADLINE_S`, which is not + a guessed number: it is the CLIENT's own readiness deadline (`src/main.rs`, + source-read at v3.3.8 — `ready_deadline = Instant::now() + 15s`, then + `psmux: failed to create session` and `exit(1)`). A client that gave up there + does NOT take the server down with it, so 15s is exactly how long psmux itself + is prepared to wait for a registration, and the kills keep firing throughout — + the moment a port file appears, `kill-server` reaches it. + + Only the pathological path pays that. Every fixture here tears down a session + it minted successfully, so the first read sees it and teardown costs a beat. + """ + seen = _seen_anywhere(mux, session, env) + deadline = time.monotonic() + _PSMUX_READY_DEADLINE_S + 45 + quiet_since: float | None = None + while time.monotonic() < deadline: + try: + mux._run(["kill-session", "-t", session], check=False, env=env) + mux._run(["kill-server"], check=False, env=env) + present = _seen_anywhere(mux, session, env) + except (OSError, TmuxError, subprocess.TimeoutExpired): + present = True + if present: + seen = True # it registered after all; the kills can address it now + quiet_since = None + else: + needed = 1.0 if seen else _PSMUX_READY_DEADLINE_S + now = time.monotonic() + if quiet_since is None: + quiet_since = now + elif now - quiet_since >= needed: + if seen: + return # it was addressable, the kill landed, it is gone + # Never seen, and no psmux verb can see it now — which is also + # true of a server running under a root it could not write. Ask + # the process table before calling this death. + killed = _kill_unregistered_servers(session) + if not killed: + return + print( + f"warning: probe session {session} was running with an " + f"unwritable registry — no psmux verb could reach it; killed " + f"pid(s) {', '.join(killed)} directly", + file=sys.stderr, + ) + quiet_since = None # re-confirm now that something was killed + time.sleep(0.5) + raise AssertionError( + f"probe setup: probe session {session} survived teardown; kill it manually" + ) + + @pytest.fixture(scope="module") def psmux_data_root(tmp_path_factory): """Return an isolated registry root, or fail loudly if one cannot be had. @@ -225,7 +424,19 @@ def psmux_data_root(tmp_path_factory): mux = PsmuxMultiplexer() if not mux.available(): pytest.skip("psmux present but not an admitted version") - root = tmp_path_factory.mktemp("psmux-data") + # Pre-created, and that is not incidental. An earlier revision handed out an + # UNCREATED path so this fixture's own probe session would double as the proof + # that psmux create_dir_all's a missing root. Measured cost of that: psmux has + # to build the directory before the server can write its port file, and under + # a full-suite `-n logical` load that was enough to push past the CLIENT's + # readiness deadline — `psmux: failed to create session` while the server came + # up anyway (`src/main.rs`, source-read at v3.3.8). One full suite: 13 fixture + # errors with the uncreated root, 0 with this line. The self-heal claim is + # measured on its own root instead, by + # `test_adopted_a_missing_registry_root_self_heals`, so one test pays that cost + # rather than every test in the module. + root = tmp_path_factory.mktemp("psmux-data") / "registry" + root.mkdir() session = f"bmad-loop-data-probe-{uuid.uuid4().hex[:8]}" env = _new_session_env() env["PSMUX_DATA_DIR"] = str(root) @@ -238,6 +449,13 @@ def psmux_data_root(tmp_path_factory): "probe setup: could not mint the isolation probe session: " f"{created.stderr.strip()!r}" ) + # These two reads are also the live half of the registry-namespace claim + # the cleanup sweep rests on: the second passes no env, so it inherits a + # process with PSMUX_DATA_DIR unset — byte-identical to what a + # `PsmuxMultiplexer(default_registry=True)` instance spawns with (that + # strip is unit-asserted in test_psmux_backend). A session in one + # registry being invisible from the other is what makes the sweep's + # second pass address anything at all. isolated = _plain_has_session(mux, session, env=env) default = _plain_has_session(mux, session) if not isolated or default: @@ -257,14 +475,8 @@ def psmux_data_root(tmp_path_factory): # timeout even under check=False. A kill that hung is exactly when the # session is most likely still standing, so it must reach the report # below rather than escape with a bare TimeoutExpired. - try: - mux._run(["kill-session", "-t", session], check=False, env=env) - leaked = _plain_has_session(mux, session, env=env) or _plain_has_session(mux, session) - except (OSError, TmuxError, subprocess.TimeoutExpired): - leaked = True - assert ( - not leaked - ), f"probe setup: data-probe session {session} survived teardown; kill it manually" + # + _teardown_probe_session(mux, session, env) @pytest.fixture @@ -275,25 +487,31 @@ def probe(tmp_path, monkeypatch, psmux_data_root): pytest.skip("psmux present but not an admitted version") monkeypatch.setenv("PSMUX_DATA_DIR", str(psmux_data_root)) session = f"bmad-loop-test-{uuid.uuid4().hex[:8]}" + env = _new_session_env() + env["PSMUX_DATA_DIR"] = str(psmux_data_root) try: _raw_new_session(mux, session, tmp_path) windows = [_mint_probe_window(mux, session, f"probe-{n}", tmp_path) for n in (1, 2)] yield mux, session, windows finally: - mux.kill_session(session) - try: - leaked = _plain_has_session(mux, session) - except (OSError, TmuxError, subprocess.TimeoutExpired): - # TimeoutExpired too: _plain_has_session goes through raw _run, which - # propagates a timeout even under check=False, so a hung psmux would - # otherwise escape this teardown instead of reporting the leak. The - # kill_session above needs no such cover — it swallows - # SubprocessError, and TimeoutExpired is one — which is why only the - # data-root fixture's raw-_run kill had to move inside its try. - leaked = True - assert ( - not leaked - ), f"probe setup: probe session {session} survived teardown; kill it manually" + # The SAME teardown the data-root fixture uses, and it has to be: a + # single-shot kill plus one read is a clean-looking teardown over a real + # leak whenever the server is mid-start, and every leaked server slows the + # next mint and makes its own timeout likelier. This is the fixture that + # runs fifteen times, so it is the one that compounds. See + # `_teardown_probe_session` for why absence has to hold, and for how long. + # + # `kill-server` is registry-wide, and this root is shared with the module + # fixture — which is safe by construction: that fixture's own probe session + # is torn down inside its setup, so this session is the only one in the + # root while a test runs. + # + # The teardown's second read passes no env, but this fixture has + # PSMUX_DATA_DIR monkeypatched into the process for the duration, so that + # read lands in the isolated registry too rather than in the default one. + # It is a duplicate here, not a default-registry check; the module fixture + # is where that check has teeth. + _teardown_probe_session(mux, session, env) def test_premise_version_leads_with_a_tmux_triple(): @@ -658,3 +876,130 @@ def test_premise_a_port_and_key_pair_is_answered_by_whichever_server_it_names( finally: for suffix in ("port", "key"): (root / f"{forged}.{suffix}").unlink(missing_ok=True) + + +def test_adopted_a_missing_registry_root_self_heals(tmp_path): + """The derived root is a path under the state root that nothing creates in + advance, so the design leans on psmux ``create_dir_all``-ing it itself + (``server/mod.rs``, source-read at v3.3.8). Measured rather than trusted: + without it every first run on a fresh project would fail at session creation. + + Its own root and its own session, deliberately, and not the module fixture's. + Folding this into that fixture is what made every test here pay for the extra + work on psmux's session-creation path — see the comment there for the measured + cost. One test paying it is the right trade; the whole module paying it is not. + """ + mux = PsmuxMultiplexer() + if not mux.available(): + pytest.skip("psmux present but not an admitted version") + root = tmp_path / "derived" / "never-created" + assert not root.exists(), "probe setup: the root under test must not exist yet" + env = _new_session_env() + env["PSMUX_DATA_DIR"] = str(root) + session = f"bmad-loop-heal-probe-{uuid.uuid4().hex[:8]}" + try: + created = mux._run( + ["new-session", "-d", "-s", session, "-c", str(tmp_path)], check=False, env=env + ) + # The DIRECTORY is the claim, not the exit code. + # + # `psmux: failed to create session` is the client's readiness poll hitting + # `ready_deadline` (`src/main.rs`, source-read at v3.3.8), and building + # this very directory is part of what it is waiting on. Under a loaded box + # that fires while the server is still coming up — or before it comes up at + # all. Neither says anything about `create_dir_all`, so neither may be read + # as a premise flip; a server that never started is an INSTRUMENT failure, + # the same class every `probe setup:` message in this module names. + for _ in range(60): + if root.is_dir(): + break + time.sleep(0.25) + finally: + _teardown_probe_session(mux, session, env) + + if root.is_dir(): + return # the claim holds + + # It does not, and the two reasons mean opposite things. An earlier revision + # separated them by asking whether the SUBJECT's server was observable — which + # it can never be while the root is absent: psmux creates the directory before + # writing the registry files it publishes a server through (`server/mod.rs`, + # source-read at v3.3.8), so no root means no port file means `has-session` + # says no. The regression arm was unreachable and the load arm swallowed + # everything, including the regression. A probe that cannot fail when its + # premise flips is not a probe. + # + # What does separate them is a CONTROL: the identical mint against a + # PRE-CREATED root, on this box, at this moment, under this load. If psmux can + # start a server when the directory is already there and cannot when it is + # not, the missing directory is the difference — that is the regression, and + # every first run on a fresh project would fail at session creation. If it + # cannot start one either way, the box is the difference and nothing was + # measured. + # + # Minted only on this path, so the passing run still costs exactly one server. + control_root = tmp_path / "control" / "pre-created" + control_root.mkdir(parents=True) + control_env = _new_session_env() + control_env["PSMUX_DATA_DIR"] = str(control_root) + control = f"bmad-loop-heal-control-{uuid.uuid4().hex[:8]}" + try: + control_made = mux._run( + ["new-session", "-d", "-s", control, "-c", str(tmp_path)], check=False, env=control_env + ) + control_up = False + for _ in range(60): + if _plain_has_session(mux, control, env=control_env): + control_up = True + break + time.sleep(0.25) + finally: + _teardown_probe_session(mux, control, control_env) + + if control_up: + pytest.fail( + "psmux no longer creates a missing PSMUX_DATA_DIR: it started a server " + f"under a pre-created root but left {str(root)!r} absent " + f"(mint rc={created.returncode}, stderr={created.stderr.strip()!r}). The " + "derived root must be mkdir'd before the first spawn, or every first run " + "on a fresh project fails at session creation." + ) + pytest.skip( + "probe setup: psmux could not start a server on this box under either root " + f"(missing-root rc={created.returncode}, stderr={created.stderr.strip()!r}; " + f"pre-created-root rc={control_made.returncode}, " + f"stderr={control_made.stderr.strip()!r}) — nothing was measured about " + "create_dir_all" + ) + + +def test_adopted_a_relative_registry_root_is_refused_before_the_spawn(monkeypatch, tmp_path): + """psmux ``assert!``s the root absolute and non-empty and panics otherwise + (``src/paths.rs``, source-read at v3.3.8). The backend refuses first, so the + operator gets one bmad-named error instead of a Rust panic whose nonzero exit + ``has_session`` would report as an ordinary "no session". + + A red here means psmux started tolerating a relative root, and the gate in + ``PsmuxMultiplexer._run`` became a refusal psmux itself no longer needs. + """ + mux = PsmuxMultiplexer() + if not mux.available(): + pytest.skip("psmux present but not an admitted version") + monkeypatch.chdir(tmp_path) + env = _new_session_env() + env["PSMUX_DATA_DIR"] = "relative-root" + # Bypass the backend's own gate deliberately: this probe is about psmux. + raw = subprocess.run( + ["psmux", "has-session", "-t", "no-such-session"], + capture_output=True, + text=True, + encoding="utf-8", + errors="backslashreplace", + env=env, + timeout=tmux_base.TMUX_TIMEOUT_S, + ) + assert raw.returncode != 0 and "PSMUX_DATA_DIR" in (raw.stderr or ""), ( + "psmux no longer refuses a relative PSMUX_DATA_DIR — the absoluteness gate " + "in PsmuxMultiplexer._run is no longer standing in for a panic: " + f"rc={raw.returncode} stderr={raw.stderr.strip()!r}" + ) diff --git a/tests/test_runs.py b/tests/test_runs.py index 3b1135fa..ade320a7 100644 --- a/tests/test_runs.py +++ b/tests/test_runs.py @@ -2468,7 +2468,7 @@ def test_delete_run_refuses_while_the_agent_session_is_live(tmp_path, monkeypatc dir is the only ownership proof a later prune can read, so the dir must outlive the session, not the other way round.""" run_dir = _make_state_run(tmp_path, "r1") - monkeypatch.setattr(runs, "mux_sessions", lambda: ["bmad-loop-r1"]) + monkeypatch.setattr(runs, "get_multiplexer", lambda: _LivenessMux(["bmad-loop-r1"])) with pytest.raises(runs.LiveSessionError, match="still live") as exc: runs.delete_run(tmp_path, run_dir) assert run_dir.exists() @@ -2490,11 +2490,13 @@ def test_delete_run_ignores_a_session_proven_to_be_another_project_s(tmp_path, m has no override. Untagged still refuses: unread is not proof (see the degradation test above).""" run_dir = _make_state_run(tmp_path, "r1") - monkeypatch.setattr(runs, "mux_sessions", lambda: ["bmad-loop-r1"]) monkeypatch.setattr( runs, - "session_project_tags", - lambda: {"bmad-loop-r1": runs.project_tag(tmp_path / "someone-else")}, + "get_multiplexer", + lambda: _LivenessMux( + ["bmad-loop-r1"], + tags={"bmad-loop-r1": runs.project_tag(tmp_path / "someone-else")}, + ), ) runs.delete_run(tmp_path, run_dir) assert not run_dir.exists() @@ -2505,9 +2507,10 @@ def test_delete_run_refuses_a_session_tagged_as_ours(tmp_path, monkeypatch): tag clears the guard". Our own tag proves nothing about whether the removal is safe — it only fails to prove the session foreign — so the refusal stands.""" run_dir = _make_state_run(tmp_path, "r1") - monkeypatch.setattr(runs, "mux_sessions", lambda: ["bmad-loop-r1"]) monkeypatch.setattr( - runs, "session_project_tags", lambda: {"bmad-loop-r1": runs.project_tag(tmp_path)} + runs, + "get_multiplexer", + lambda: _LivenessMux(["bmad-loop-r1"], tags={"bmad-loop-r1": runs.project_tag(tmp_path)}), ) with pytest.raises(runs.LiveSessionError): runs.delete_run(tmp_path, run_dir) @@ -2520,30 +2523,28 @@ def test_delete_run_proceeds_when_the_session_listing_raises(tmp_path, monkeypat bundled one answers `[]`. Both must reach the same place, or the guard would turn a transient transport error into a failed `delete`/`archive`/`clean` — and `clean` has no override. Degrading to "no session" matches what tmux - already does for a dead server.""" + already does for a dead server. (A stronger contract — refuse on the raise — + was built on this branch and withdrawn: the guard's degrade is main's + documented decision, and the measured cost is filed for its owner.)""" run_dir = _make_state_run(tmp_path, "r1") - - def boom(): - raise MultiplexerError("transport down") - - monkeypatch.setattr(runs, "mux_sessions", boom) + monkeypatch.setattr(runs, "get_multiplexer", lambda: _LivenessMux([], unanswerable=True)) runs.delete_run(tmp_path, run_dir) assert not run_dir.exists() def test_delete_run_refuses_when_the_tag_read_raises(tmp_path, monkeypatch): - """The other read degrades the other way. By the time the tag is queried the - listing has already proven a session live, and a tag that could not be read is - not proof it is another project's — so it reads as untagged and the refusal - stands. Asserted separately from the listing case: one `except` returning the - wrong constant would otherwise hide behind the other.""" + """The tag read degrades the other way. By the time the tag is queried the + probe has already proven a session live, and a tag that could not be read + is not proof it is another project's — so it reads as untagged and the + refusal stands. Asserted separately from the probe case: one `except` + landing the wrong constant would otherwise hide behind the other.""" run_dir = _make_state_run(tmp_path, "r1") - def boom(*_args): - raise MultiplexerError("option read failed") + class _TagsBroken(_LivenessMux): + def session_options(self, option): + raise MultiplexerError("option read failed") - monkeypatch.setattr(runs, "mux_sessions", lambda: ["bmad-loop-r1"]) - monkeypatch.setattr(runs, "session_project_tags", boom) + monkeypatch.setattr(runs, "get_multiplexer", lambda: _TagsBroken(["bmad-loop-r1"])) with pytest.raises(runs.LiveSessionError): runs.delete_run(tmp_path, run_dir) assert run_dir.exists() @@ -2554,7 +2555,11 @@ def test_delete_run_matches_the_session_by_exact_run_id(tmp_path, monkeypatch): including one whose id merely extends ours — must not block this removal, or one live run would wedge cleanup for every id it prefixes.""" run_dir = _make_state_run(tmp_path, "r1") - monkeypatch.setattr(runs, "mux_sessions", lambda: ["bmad-loop-r1-2", "bmad-loop-ctl", "r1"]) + monkeypatch.setattr( + runs, + "get_multiplexer", + lambda: _LivenessMux(["bmad-loop-r1-2", "bmad-loop-ctl", "r1"]), + ) runs.delete_run(tmp_path, run_dir) assert not run_dir.exists() @@ -2683,6 +2688,26 @@ def test_delete_run_refuses_a_redirected_run_dir(tmp_path, level): assert canary.is_file() +def test_delete_run_never_consults_availability(tmp_path, monkeypatch): + """A regression this branch once shipped and withdrew: an arm that read + `mux_usable(False)` as session absence. Usability folds in helper binaries + and version gates — psmux with `pwsh` off PATH probes unavailable while its + server hosts this very session — so the guard must key on the listing + alone: a listable live session refuses even when `available()` is False. + + Ablate by re-adding a `mux_usable` short-circuit ahead of the listing and + this fails with the run dir gone under the live session.""" + run_dir = _make_state_run(tmp_path, "r1") + monkeypatch.setattr( + runs, + "get_multiplexer", + lambda: _LivenessMux(["bmad-loop-r1"], unavailable=True), + ) + with pytest.raises(runs.LiveSessionError, match="still live"): + runs.delete_run(tmp_path, run_dir) + assert run_dir.exists() + + def _escalated_run(tmp_path, spec_text, *, restore_patch_stale=None, git_project=False): """conftest's builder with this module's shape: the spec is written first (so `git_project=True` commits it), and only `(run_dir, spec)` comes back.""" @@ -3177,7 +3202,9 @@ def test_archive_run_refuses_while_the_agent_session_is_live(tmp_path, monkeypat """Same backstop as delete (#419), and it runs before the tarball is written — a refusal must not leave a half-archived run behind for the operator to find.""" run_dir = _make_state_run(tmp_path, "20260611-100000-aaaa") - monkeypatch.setattr(runs, "mux_sessions", lambda: ["bmad-loop-20260611-100000-aaaa"]) + monkeypatch.setattr( + runs, "get_multiplexer", lambda: _LivenessMux(["bmad-loop-20260611-100000-aaaa"]) + ) with pytest.raises(runs.LiveSessionError, match="still live"): runs.archive_run(tmp_path, run_dir) assert run_dir.exists() @@ -3833,3 +3860,1054 @@ def test_project_of_a_real_run_dir_is_the_project_root(tmp_path): run_dir = _make_state_run(tmp_path, "r1") assert runs._project_of_run_dir(run_dir) == tmp_path + + +# ------------------------------------------------- psmux registry root (#537) + + +def test_mux_registry_root_lives_under_the_projects_state_subtree(tmp_path): + root = runs.mux_registry_root(tmp_path) + assert root == runs.project_state_root(tmp_path) / runs.MUX_REGISTRY_DIR + assert root.parent.name == runs.project_tag(tmp_path) + assert root.is_absolute() + + +def test_mux_registry_root_can_never_collide_with_a_run(tmp_path): + """`--run-id` is caller-supplied, so a run whose id spelled the registry's + directory name would key its state dir ONTO the registry — the run's control + plane and every live server's addressing files in one directory, each side + deleting the other's entries. The leading underscore is what makes that + unreachable: RUN_ID_RE requires an alphanumeric first character. Ablate it + (name the directory `mux`) and this fails.""" + assert not runs.is_valid_run_id(runs.MUX_REGISTRY_DIR) + assert runs.mux_registry_root(tmp_path) != runs.state_dir_for(tmp_path, "mux") + + +def test_mux_registry_root_agrees_across_two_spellings_of_one_project(tmp_path): + """The whole cross-process contract: two processes reaching one project by + different paths must land on the SAME registry, or each reads the other's + live sessions as gone. Guaranteed by project_tag resolving first, which is + why the root reuses it rather than deriving a second identity.""" + nested = tmp_path / "a" / "b" + nested.mkdir(parents=True) + detoured = tmp_path / "a" / ".." / "a" / "b" + assert runs.mux_registry_root(nested) == runs.mux_registry_root(detoured) + + +def test_mux_registry_root_separates_two_projects(tmp_path): + one, two = tmp_path / "one", tmp_path / "two" + one.mkdir() + two.mkdir() + assert runs.mux_registry_root(one) != runs.mux_registry_root(two) + + +def test_export_psmux_registry_root_sets_the_derived_root(tmp_path, monkeypatch): + monkeypatch.delenv(runs.PSMUX_DATA_DIR, raising=False) + expected = str(runs.mux_registry_root(tmp_path)) + assert runs.export_psmux_registry_root(tmp_path) == expected + assert os.environ[runs.PSMUX_DATA_DIR] == expected + + +def test_export_psmux_registry_root_overrides_an_operators_own(tmp_path, monkeypatch): + """The rule, and the absence of an exception to it is the point: the root is + derived from (project, state root), full stop, so two bmad-loop processes + given one project cannot land in different registries. + + Honouring an ambient value was tried and is the thing that was cut. It makes + the registry a function of the launch *shell* — a TUI from the Start menu + derives while a run from a dev shell whose profile exports a root honours it, + two registries on one machine — and no rule can be right for both operators, + because the process that finds a root in its environment cannot tell one + typed in this shell alone from one the profile exports into every shell. + + Ablate the unconditional export (restore an if-unset guard) and this fails.""" + theirs = str(tmp_path / "theirs") + derived = str(runs.mux_registry_root(tmp_path)) + monkeypatch.setenv(runs.PSMUX_DATA_DIR, theirs) + + assert runs.export_psmux_registry_root(tmp_path) == derived + assert os.environ[runs.PSMUX_DATA_DIR] == derived + + +@pytest.mark.parametrize("ambient", ["", "relative/root", ".", "/an/absolute/one"]) +def test_export_psmux_registry_root_overrides_any_ambient_spelling(tmp_path, monkeypatch, ambient): + """Including the ones psmux would panic on. An earlier rule left a relative or + empty value untouched so as not to countermand something the operator typed — + which, now that nothing ambient is honoured, only preserved a value that makes + every verb fail. Replacing it is strictly better: the derived root works.""" + derived = str(runs.mux_registry_root(tmp_path)) + monkeypatch.setenv(runs.PSMUX_DATA_DIR, ambient) + + assert runs.export_psmux_registry_root(tmp_path) == derived + assert os.environ[runs.PSMUX_DATA_DIR] == derived + + +def test_export_psmux_registry_root_is_indifferent_to_being_inside_a_pane(tmp_path, monkeypatch): + """A pane child derives exactly what a clean process derives — the convergence + four rounds of inherited-token designs were trying to buy, and which having no + token buys outright. + + psmux hands a pane child the server's whole environment (measured on 3.3.8), + so `bmad-loop --project B` from a pane of project A's session arrives carrying + A's root; it must still get B's. And it must get the same answer whether or + not it is in a pane at all, since pane-ness says nothing about which registry + a project's sessions belong in.""" + a_root = str(tmp_path / "registry-A") + project_b = tmp_path / "B" + project_b.mkdir() + derived = str(runs.mux_registry_root(project_b)) + + monkeypatch.setenv(runs.PSMUX_DATA_DIR, a_root) + monkeypatch.setenv("TMUX", "/tmp/psmux-1000/default,123,0") # inside a pane + assert runs.export_psmux_registry_root(project_b) == derived + + monkeypatch.delenv("TMUX", raising=False) # and outside one + monkeypatch.setenv(runs.PSMUX_DATA_DIR, a_root) + assert runs.export_psmux_registry_root(project_b) == derived + + +def test_export_psmux_registry_root_converges_a_pane_child_that_moves_the_state_root( + tmp_path, monkeypatch +): + """The scenario every round of review found a way to break, in its final form: + whatever a pane child concludes is what a clean process under the same + conditions concludes — for a pinned root and a derived one alike, because + there is no longer a difference between them. + + The registry lives under the state root, so a child running under a different + one must re-derive; keeping the parent's would put it where nothing else + looks.""" + pinned = str(tmp_path / "pinned") + monkeypatch.setenv(envvars.STATE_DIR, str(tmp_path / "S1")) + monkeypatch.setenv(runs.PSMUX_DATA_DIR, pinned) + under_s1 = runs.export_psmux_registry_root(tmp_path) + + # the pane child, carrying S1's settled root, now under S2 + monkeypatch.setenv("TMUX", "/tmp/psmux-1000/default,123,0") + monkeypatch.setenv(envvars.STATE_DIR, str(tmp_path / "S2")) + child = runs.export_psmux_registry_root(tmp_path) + + # a clean process under S2: no pane, no inherited root + monkeypatch.delenv("TMUX", raising=False) + monkeypatch.delenv(runs.PSMUX_DATA_DIR, raising=False) + clean = runs.export_psmux_registry_root(tmp_path) + + # ...and one under S2 whose PROFILE exports the pin into every shell + monkeypatch.setenv(runs.PSMUX_DATA_DIR, pinned) + clean_pinned = runs.export_psmux_registry_root(tmp_path) + + assert child != under_s1 and child != pinned + assert child == clean == clean_pinned == str(runs.mux_registry_root(tmp_path)) + + +def test_pinned_state_env_resolves_rather_than_forwards(tmp_path, monkeypatch): + """What travels is the answer this process reached, not the override it was + handed. Forwarding only when the operator set something leaves the common case + — no override at all — with nothing to pass, and that is exactly the case + `PSMUX_BARE_ENV=1` also breaks: its allowlist drops `LOCALAPPDATA` and + `XDG_STATE_HOME` too, so a child there cannot recompute the default either. + + Ablate the resolve (return the raw environment value, or `{}` when unset) and + the second half fails.""" + monkeypatch.setenv(envvars.STATE_DIR, str(tmp_path / "S")) + assert runs.pinned_state_env() == {envvars.STATE_DIR: str(runs.state_root())} + + monkeypatch.delenv(envvars.STATE_DIR, raising=False) + assert runs.pinned_state_env() == {envvars.STATE_DIR: str(runs.state_root())} + + +def test_pinned_state_env_degrades_on_an_underivable_state_root(monkeypatch): + """`{}` rather than a raise: a child told nothing derives its own answer and + fails on the same broken environment with its own message, which beats a + launcher that cannot report anything at all. + + Ablate the `except StateRootError` and this raises.""" + + def boom(): + raise runs.StateRootError("no state root") + + monkeypatch.setattr(runs, "state_root", boom) + assert runs.pinned_state_env() == {} + + +class _NamespaceStub: + """Duck-typed mux answering only the namespace question — all + ctl_session_for consults.""" + + def __init__(self, namespaced): + self._namespaced = namespaced + + def has_registry_namespace(self): + return self._namespaced + + +def test_ctl_session_for_is_fixed_without_a_registry_namespace(tmp_path): + """tmux keeps the machine-shared `bmad-loop-ctl` byte-identically — the + shared session is correct there and every pinned tmux argv depends on it. + + Ablate the `has_registry_namespace()` arm (suffix always) and this fails.""" + assert runs.ctl_session_for(tmp_path, _NamespaceStub(False)) == runs.CTL_SESSION + + +def test_ctl_session_for_carries_the_registry_identity(tmp_path, monkeypatch): + """On a namespacing transport the name is per REGISTRY, because psmux's + duplicate-server mutex is keyed on the session name alone, machine-wide + (`Local\\psmux-session-{name}`, source-read at v3.3.8): a fixed name lets + only one registry on the machine hold a control session, and the second + project's create is rejected as a duplicate server (measured: rc 1). Both + axes of the registry key must move the name — a project-only tag would + recreate the collision for one project under two state roots. + + Ablate the suffix (return the fixed name always) and every assertion but + the stability one fails; key the suffix on `project_tag` alone and the + state-root case fails.""" + mux = _NamespaceStub(True) + a, b = tmp_path / "proj-a", tmp_path / "proj-b" + a.mkdir() + b.mkdir() + + name_a = runs.ctl_session_for(a, mux) + name_b = runs.ctl_session_for(b, mux) + assert name_a.startswith(runs.CTL_SESSION + "-") and name_b.startswith(runs.CTL_SESSION + "-") + assert name_a != name_b # two projects, two registries, two names + assert runs.ctl_session_for(a, mux) == name_a # stable per registry + + # ...and the OTHER axis of the registry key: same project, moved state root + monkeypatch.setenv(envvars.STATE_DIR, str(tmp_path / "S2")) + assert runs.ctl_session_for(a, mux) != name_a + + +def test_ctl_session_for_converges_across_spellings_of_one_root(tmp_path, monkeypatch): + """Two spellings of one state root reach ONE physical registry (the OS + resolves both to the same files; psmux keeps the spelling only while + constructing those paths — src/paths.rs:79, source-read at v3.3.8), so + they must mint ONE control-session name: an as-spelled digest gave the + same registry two ctl sessions, each blind to the other's parked windows. + + Ablate the `.resolve()` in ctl_session_for and this fails.""" + mux = _NamespaceStub(True) + project = tmp_path / "proj" + project.mkdir() + (tmp_path / "state").mkdir() + (tmp_path / "alias").mkdir() + plain = str(tmp_path / "state") + detour = str(tmp_path / "alias" / ".." / "state") + assert plain != detour # the premise: two spellings, not one + + monkeypatch.setenv(envvars.STATE_DIR, plain) + name_plain = runs.ctl_session_for(project, mux) + monkeypatch.setenv(envvars.STATE_DIR, detour) + name_detour = runs.ctl_session_for(project, mux) + + assert name_plain.startswith(runs.CTL_SESSION + "-") + assert name_plain == name_detour + + +def test_ctl_session_for_degrades_to_the_fixed_name(tmp_path, monkeypatch): + """The underivable arm runs on the transport's shared default registry — + the one place psmux is in the tmux-shaped world where a shared session + scoped by window tags is correct, and where a pre-#537 legacy ctl session + under the fixed name may exist to be reused rather than collided with.""" + monkeypatch.setenv(envvars.STATE_DIR, "relative-root") + assert runs.ctl_session_for(tmp_path, _NamespaceStub(True)) == runs.CTL_SESSION + + +def test_is_ctl_session_name_shapes(): + """Exactly the shapes ctl_session_for can mint — the fixed name and a + 16-hex suffix. An arbitrary suffix is NOT a control session: it is the + agent session of a run an older release accepted (`--run-id ctl-foo`), + and reading it as a control session made it unreachable by stop and the + prune both. Ablate the 16-hex narrowing (back to any `-` suffix) and the + arbitrary-suffix refusals fail.""" + assert runs.is_ctl_session_name(runs.CTL_SESSION) + assert runs.is_ctl_session_name(runs.CTL_SESSION + "-0123456789abcdef") + assert not runs.is_ctl_session_name("bmad-loop-ctl2") # no `-` boundary + assert not runs.is_ctl_session_name("bmad-loop-20260825-000000-run1") + assert not runs.is_ctl_session_name("") + # historical agent-session shapes, not control sessions: + assert not runs.is_ctl_session_name("bmad-loop-ctl-foo") + assert not runs.is_ctl_session_name("bmad-loop-ctl-0123456789abcde") # 15 hex + assert not runs.is_ctl_session_name("bmad-loop-ctl-0123456789abcdeff") # 17 hex + assert not runs.is_ctl_session_name( + "bmad-loop-ctl-0123456789ABCDEF" + ) # case: exact-name predicate + + +def test_agent_run_id_never_reads_a_ctl_session_as_a_run(): + """`bmad-loop-ctl-<16hex>` strips to `ctl-<16hex>`, which RUN_ID_RE admits — + so without the control-alias exclusion the prune partition would treat + this project's own control session as an untagged agent session, making + the prune a kill path into the control plane. Ablate the + `run_id_aliases_control_session` check in `_agent_run_id` and this + fails.""" + assert runs._agent_run_id(runs.CTL_SESSION) is None + assert runs._agent_run_id(runs.CTL_SESSION + "-0123456789abcdef") is None + assert runs._agent_run_id("bmad-loop-20260825-000000-run1") == "20260825-000000-run1" + + +def test_agent_run_id_reads_a_historical_ctl_prefixed_run(): + """The inverse boundary: `bmad-loop-ctl-foo` is NOT a control session — it + is the agent session of a run an older release accepted as `--run-id + ctl-foo` — and the parse must return its id or the sweep can never reach + the session (the mint refuses the shape, so nothing new can collide). + Ablate the parse back to `is_valid_run_id` (the mint's broad reservation) + and this fails.""" + assert runs._agent_run_id("bmad-loop-ctl-foo") == "ctl-foo" + assert not runs.is_valid_run_id("ctl-foo") # ...while the mint still refuses it + + +@pytest.mark.parametrize( + "bad", + [ + "ctl", + "ctl-0123456789abcdef", + "ctl-x", + "ctl-run-1", + # case variants: psmux resolves session names through a case-folding + # filesystem, so `bmad-loop-CTL-` addresses — and kills — the + # lowercase control session (measured on 3.3.8) + "CTL", + "Ctl-0123456789abcdef", + "cTl-x", + ], +) +def test_run_id_of_the_ctl_shape_is_refused(bad): + """The control-session namespace is reserved: `session_name("ctl")` IS the + fixed control session, and `session_name("ctl-<16hex>")` can equal a + per-registry one exactly — the adapter would adopt the live control + session as the run's agent session and the run's teardown would kill it. + Case-insensitively, because the adoption and the kill both go through the + multiplexer's case-folding name resolution on Windows. + + Ablate the `is_reserved_run_id(value)` clause and every case fails; + ablate only the `.lower()` inside `is_reserved_run_id` and the case + variants fail.""" + assert not runs.is_valid_run_id(bad) + # every refusal here is exactly the reservation — the overlap with the + # control-session namespace under the platform's worst-case name folding + assert runs.is_reserved_run_id(bad) + + +@pytest.mark.parametrize("near_miss", ["ctl2", "CTL2", "ctlfoo", "ctl_x", "controller-1"]) +def test_run_id_reservation_stops_at_the_ctl_shape(near_miss): + """The inverse sweep: ids that merely start with `ctl` stay valid — the + reservation is the predicate's own boundary (`ctl`, `ctl-…`), not a + prefix ban, and the case fold widens no further than the shape.""" + assert runs.is_valid_run_id(near_miss) + + +def test_ctl_session_for_folds_case_only_where_the_filesystem_does(tmp_path, monkeypatch): + """Two case spellings of a NOT-YET-created state root: `resolve()` can + return stored case only for a path that exists, and the registry root + usually does not exist at name time — so the digest folds case itself, + via `os.path.normcase`. On Windows both spellings land in ONE physical + registry (the `.port` files open case-insensitively), so they must mint + one name; on POSIX case is significant — two case spellings ARE two + registries and must keep two names. + + Ablate the normcase and the win32 arm fails; replace it with an + unconditional `str.lower` and the POSIX arm fails.""" + mux = _NamespaceStub(True) + project = tmp_path / "proj" + project.mkdir() + monkeypatch.setenv(envvars.STATE_DIR, str(tmp_path / "MiXeD-State")) # never created + name_mixed = runs.ctl_session_for(project, mux) + monkeypatch.setenv(envvars.STATE_DIR, str(tmp_path / "mixed-state")) # never created + name_lower = runs.ctl_session_for(project, mux) + if sys.platform == "win32": + assert name_mixed == name_lower + else: + assert name_mixed != name_lower + + +def test_kill_session_never_addresses_a_control_session_alias(): + """An id that aliases a control session (`ctl`, `ctl-<16 hex>`, folded) + can only address the control plane, so its kill is skipped — reachable + only through run dirs an older release persisted, replayed by `stop`, + `delete` and resume's stale-session sweep. A historical `ctl-foo` id is + NOT an alias: its `bmad-loop-ctl-foo` session is a genuine agent session, + distinct and exactly addressable, and skipping it stranded the session + (the prune already refuses the id, so nothing else could reach it). + + Ablate the guard and the alias cases fail; widen the alias test back to + every ctl-* shape and the `ctl-foo` case fails.""" + killed = [] + + class _Recorder: + def kill_session(self, name): + killed.append(name) + + runs.kill_session("ctl", _Recorder()) + runs.kill_session("ctl-0123456789abcdef", _Recorder()) + runs.kill_session("CTL-0123456789ABCDEF", _Recorder()) + assert killed == [] + runs.kill_session("ctl-foo", _Recorder()) # historical agent session: killable + runs.kill_session("20260826-000000-run1", _Recorder()) + assert killed == ["bmad-loop-ctl-foo", "bmad-loop-20260826-000000-run1"] + + +class _LivenessMux: + """Duck-typed mux for live_session_may_be_ours: transport-controlled name + key (`fold`), a listing that raises when `unanswerable` (the out-of-tree + seam shape the guard's degrade arm exists for), foldable tags. + `unavailable` drives `available()` only — the guard must never consult + availability, which is exactly what the degraded-backend test grades.""" + + def __init__(self, sessions, tags=None, fold=False, unanswerable=False, unavailable=False): + self._sessions = sessions + self._tags = tags or {} + self._fold = fold + self._unanswerable = unanswerable + self._unavailable = unavailable + + def available(self): + return not self._unavailable + + def session_name_key(self, name): + return name.lower() if self._fold else name + + def has_registry_namespace(self): + return False # tmux-shaped: ctl_session_for answers the fixed name + + def list_sessions(self): + if self._unanswerable: + raise MultiplexerError("simulated transport failure") + return list(self._sessions) + + def session_options(self, option): + if self._unanswerable: + raise MultiplexerError("simulated transport failure") + return dict(self._tags) + + +def test_live_session_may_be_ours_compares_names_the_transports_way(tmp_path, monkeypatch): + """Two layers of the same rule. The discount answers the INSTANCE question + — "is this name the control session this process addresses" — never the + shape question (round-17: the shape discount destroyed a tmux run dir + under a live `ctl-<16 hex>` agent). And every name comparison goes + through the transport's `session_name_key`, never a constant fold: tmux + is case-sensitive (measured on 3.4 — `bmad-loop-ctl` and `bmad-loop-CTL` + coexist), so the unconditional `.lower()` discounted a persisted `CTL` + run's genuinely live uppercase agent as "the control session" and its + run dir was deleted. + + Ablate the discount entirely and the `ctl` case fails; restore the + round-16 shape predicate and the tmux digest case fails; restore the + round-17 constant fold (base `session_name_key` returning `lower()`) + and the tmux `CTL` case fails.""" + sessions = [ + "bmad-loop-ctl", + "bmad-loop-CTL", + "bmad-loop-ctl-foo", + "bmad-loop-ctl-0123456789abcdef", + "bmad-loop-ctl-aaaabbbbccccdddd", + ] + + # tmux shape: case-sensitive, the only control session is the fixed name + monkeypatch.setattr(runs, "get_multiplexer", lambda: _LivenessMux(sessions, fold=False)) + monkeypatch.setattr(runs, "ctl_session_for", lambda project, mux=None: runs.CTL_SESSION) + assert not runs.live_session_may_be_ours(tmp_path, "ctl") + # a case-variant is a DIFFERENT, coexisting session on tmux — live evidence + assert runs.live_session_may_be_ours(tmp_path, "CTL") + # ...and a digest-shaped id is a genuine agent session there + assert runs.live_session_may_be_ours(tmp_path, "ctl-0123456789abcdef") + assert runs.live_session_may_be_ours(tmp_path, "ctl-foo") + + # psmux shape: the transport folds, so the case-variant IS the control session + monkeypatch.setattr(runs, "get_multiplexer", lambda: _LivenessMux(sessions, fold=True)) + assert not runs.live_session_may_be_ours(tmp_path, "CTL") + # this project's derived name is also the control session's + monkeypatch.setattr( + runs, "ctl_session_for", lambda project, mux=None: "bmad-loop-ctl-aaaabbbbccccdddd" + ) + assert not runs.live_session_may_be_ours(tmp_path, "ctl-aaaabbbbccccdddd") + # ...while an OTHER digest in this registry is still not the control session + assert runs.live_session_may_be_ours(tmp_path, "ctl-0123456789abcdef") + + +def test_live_session_may_be_ours_degrades_an_unanswerable_listing_to_absent(tmp_path, monkeypatch): + """Observation degrades — the guard's documented contract, restored over + this branch's withdrawn raise-propagation: a listing that cannot answer + reads as "no session", the same answer the bundled backend gives for a + missing multiplexer or a dead server. The control-name discount still + answers before any probe at all, so the recovery `bmad-loop delete ctl` + needs no transport.""" + monkeypatch.setattr( + runs, "get_multiplexer", lambda: _LivenessMux([], fold=False, unanswerable=True) + ) + monkeypatch.setattr(runs, "ctl_session_for", lambda project, mux=None: runs.CTL_SESSION) + assert not runs.live_session_may_be_ours(tmp_path, "20260826-000000-run1") + assert not runs.live_session_may_be_ours(tmp_path, "ctl") + + +def test_live_session_may_be_ours_degrades_an_unselectable_backend_to_absent(tmp_path, monkeypatch): + """Selection is part of the listing read, so it degrades the listing's way. + + `mux_sessions()` selects the backend *inside* the call the guard catches, so + a persisted `[mux] backend` naming a backend that is no longer registered has + always read as "no live session" — a misconfigured host still gets a working + `delete`/`archive`/`clean`. Naming the backend outside the handler turns that + degrade into an abort on every removal path, including `clean`, which has no + `--force`. + + Ablation: hoist the selection back above the `try` and this fails with the + `MultiplexerError` the misconfiguration raises.""" + + def unselectable(): + raise MultiplexerError("[mux] backend = 'ghost' matches no registered backend") + + monkeypatch.setattr(runs, "get_multiplexer", unselectable) + assert not runs.live_session_may_be_ours(tmp_path, "20260826-000000-run1") + # ...and the control-name discount needs the transport too, so it degrades alike + assert not runs.live_session_may_be_ours(tmp_path, "ctl") + + +def test_prune_sessions_claims_a_historical_ctl_prefixed_session(tmp_path, monkeypatch): + """End to end through the sweep: a `bmad-loop-ctl-foo` session minted by an + older release (`--run-id ctl-foo`), untagged, with this project's dead run + dir as ownership proof, is prunable in the project's own registry — the + round-16 leak was this exact session being unreachable by both `stop` and + the prune. Ablate `_agent_run_id`'s wellformed-not-valid split and this + fails (the id never enters the partition).""" + (_make_state_run(tmp_path, "ctl-foo") / "engine.pid").write_text(str(_dead_pid())) + monkeypatch.setenv(runs.PSMUX_DATA_DIR, str(runs.mux_registry_root(tmp_path))) + monkeypatch.setattr(runs, "mux_sessions", lambda: ["bmad-loop-ctl-foo"]) + monkeypatch.setattr(runs, "session_project_tags", lambda: {}) + monkeypatch.setattr(runs, "_legacy_registries", lambda: []) + + assert runs.prune_sessions(tmp_path, dry_run=True) == (["ctl-foo"], [], set()) + + +def test_no_valid_run_id_can_mint_the_control_session_name(tmp_path): + """The reviewer's reproduction, pinned end to end: read the live per-registry + control-session name, replay its suffix as a --run-id, and the id is + refused before `session_name` can alias the two session types. Covers the + fixed name too — `--run-id ctl` minted `bmad-loop-ctl` itself, on every + transport.""" + project = tmp_path / "proj" + project.mkdir() + ctl = runs.ctl_session_for(project, _NamespaceStub(True)) + colliding_id = ctl[len("bmad-loop-") :] + assert runs.session_name(colliding_id) == ctl # the alias, were the id valid + assert not runs.is_valid_run_id(colliding_id) + # ...nor its case variants: a Windows multiplexer resolves the uppercase + # target onto the lowercase control session (measured on psmux 3.3.8) + assert not runs.is_valid_run_id(colliding_id.upper()) + assert runs.session_name("ctl") == runs.CTL_SESSION + assert not runs.is_valid_run_id("ctl") + + +def test_pin_state_root_overwrites_a_colliding_entry(tmp_path, monkeypatch): + """The chokepoint's derivable arm: a caller-supplied (profile `[env]`) + `BMAD_LOOP_STATE_DIR` is forced to this process's resolved root; other + keys pass through untouched. + + Ablate the assignment (return `dict(env)` unchanged) and this fails.""" + monkeypatch.setenv(envvars.STATE_DIR, str(tmp_path / "S1")) + pinned = runs.pin_state_root({"CALLER": "1", envvars.STATE_DIR: str(tmp_path / "S2")}) + assert pinned == {"CALLER": "1", envvars.STATE_DIR: str(runs.state_root())} + + +def test_pin_state_root_strips_the_entry_when_no_root_derives(monkeypatch): + """The chokepoint's underivable arm — the round-11 gap: with no pin key to + spread, an ordering rule protects nothing, so the key is REMOVED instead. + The child then inherits the parent's own broken value and fails as the + parent fails, rather than being aimed at a state root — and so a + per-project registry — its parent cannot see. + + Ablate the `pop` (leave the caller's entry standing) and this fails.""" + monkeypatch.setenv(envvars.STATE_DIR, "relative-root") # underivable + pinned = runs.pin_state_root({"CALLER": "1", envvars.STATE_DIR: r"C:\S2"}) + assert pinned == {"CALLER": "1"} + + +def test_export_psmux_registry_root_degrades_on_an_underivable_state_root(tmp_path, monkeypatch): + """Runs ahead of every command, `diagnose` and `validate` included, so a + broken environment must not take the diagnostics down with it.""" + + def boom(_project): + raise runs.StateRootError("no state root") + + monkeypatch.delenv(runs.PSMUX_DATA_DIR, raising=False) + monkeypatch.setattr(runs, "project_state_root", boom) + assert runs.export_psmux_registry_root(tmp_path) is None + assert runs.PSMUX_DATA_DIR not in os.environ + + # And an ambient value is left exactly as found here — the one case there is + # nothing better to put in its place. `bmad-loop mux` reports that the root in + # force is not bmad-loop's, rather than calling it derived. + monkeypatch.setenv(runs.PSMUX_DATA_DIR, "/whatever/they/had") + assert runs.export_psmux_registry_root(tmp_path) is None + assert os.environ[runs.PSMUX_DATA_DIR] == "/whatever/they/had" + + +def test_orphan_state_sweep_never_reaps_the_registry(tmp_path): + """The registry holds the .port/.key files every psmux verb resolves a + session through; sweeping it while a server is up leaves that server alive, + unreachable, and invisible to `psmux ls` in any registry. Ablate the guard + (drop the MUX_REGISTRY_DIR arm) and this fails, since `mux` is never a live + run dir name — the sibling below is the other half, proving the guard did not + simply stop the sweep.""" + registry = runs.mux_registry_root(tmp_path) + registry.mkdir(parents=True) + port = registry / "bmad-loop-r1.port" + port.write_text("54321\n") + + assert runs.reconcile_orphan_state_dirs(tmp_path) == [] + assert port.exists() + + +def test_orphan_state_sweep_still_reaps_a_real_orphan_beside_the_registry(tmp_path): + """The ablation's other half: sparing `mux` must not spare the orphan run + dirs the sweep exists for.""" + runs.mux_registry_root(tmp_path).mkdir(parents=True) + orphan = runs.state_dir_for(tmp_path, "20260101-000000-dead") + orphan.mkdir(parents=True) + + assert runs.reconcile_orphan_state_dirs(tmp_path) == [orphan] + assert not orphan.exists() + assert runs.mux_registry_root(tmp_path).exists() + + +class _RegistryMux: + """A backend bound to one registry, standing in for the cleanup sweep's + second pass. Only the three verbs the partition and the kill use.""" + + def __init__(self, sessions, tags): + self._sessions, self._tags = sessions, tags + self.killed: list[str] = [] + + def list_sessions(self): + return list(self._sessions) + + def session_options(self, _option): + return dict(self._tags) + + def kill_session(self, name): + self.killed.append(name) + + +def test_prune_sessions_sweeps_a_legacy_registry(tmp_path, monkeypatch): + """Sessions created before the per-project root existed are addressable only + from a backend bound to the old registry; without the second pass cleanup + reports a clean sweep while their servers run on.""" + monkeypatch.setattr(runs, "mux_sessions", lambda: []) + monkeypatch.setattr(runs, "session_project_tags", lambda: {}) + monkeypatch.setattr(runs, "engine_liveness", lambda _d: ("dead", None)) + legacy = _RegistryMux(["bmad-loop-old-1"], {"bmad-loop-old-1": runs.project_tag(tmp_path)}) + monkeypatch.setattr(runs, "_legacy_registries", lambda: [legacy]) + + assert runs.prune_sessions(tmp_path) == (["old-1"], [], set()) + assert legacy.killed == ["bmad-loop-old-1"] + + +def test_prune_sessions_leaves_another_projects_session_in_the_legacy_registry( + tmp_path, monkeypatch +): + """The second pass buys no extra reach: ownership is judged by the same + partition, so a neighbouring project's sessions — and the operator's own + psmux sessions — are skipped there exactly as they are in the primary pass.""" + monkeypatch.setattr(runs, "mux_sessions", lambda: []) + monkeypatch.setattr(runs, "session_project_tags", lambda: {}) + legacy = _RegistryMux(["bmad-loop-old-1"], {"bmad-loop-old-1": "0123456789abcdef"}) + monkeypatch.setattr(runs, "_legacy_registries", lambda: [legacy]) + + assert runs.prune_sessions(tmp_path) == ([], [], set()) + assert legacy.killed == [] + + +def test_prune_sessions_dry_run_kills_nothing_in_a_legacy_registry(tmp_path, monkeypatch): + monkeypatch.setattr(runs, "mux_sessions", lambda: []) + monkeypatch.setattr(runs, "session_project_tags", lambda: {}) + monkeypatch.setattr(runs, "engine_liveness", lambda _d: ("dead", None)) + legacy = _RegistryMux(["bmad-loop-old-1"], {"bmad-loop-old-1": runs.project_tag(tmp_path)}) + monkeypatch.setattr(runs, "_legacy_registries", lambda: [legacy]) + + assert runs.prune_sessions(tmp_path, dry_run=True) == (["old-1"], [], set()) + assert legacy.killed == [] + + +def test_legacy_registries_degrades_when_no_backend_can_be_selected(monkeypatch): + """A cleanup that already swept the primary registry must report that work + rather than die on the migration pass.""" + + def boom(): + raise MultiplexerError("no backend") + + monkeypatch.setattr(runs, "get_multiplexer", boom) + assert runs._legacy_registries() == [] + + +# --------------------------------- legacy registry: ownership and remainder + + +def test_prune_sessions_refuses_an_untagged_legacy_session_claimed_only_by_a_run_dir( + tmp_path, monkeypatch +): + """The legacy registry is shared by every project, so a matching run dir here + is not evidence about a session over there: run ids are unique only within one + project and `--run-id` is caller-supplied. This project holding a dead + `shared-id` must not let it kill another project's live, untagged + `bmad-loop-shared-id`. + + Ablate the `require_tag` term in prunable_sessions (or stop passing it from + the legacy pass) and this fails with the session killed — the cross-project + reap the per-project registry removed from the primary pass, reintroduced in + the one registry where every project's sessions sit together.""" + ours = tmp_path / "ours" + ours.mkdir() + (_make_state_run(ours, "shared-id") / "engine.pid").write_text(str(_dead_pid())) + monkeypatch.setattr(runs, "mux_sessions", lambda: []) + monkeypatch.setattr(runs, "session_project_tags", lambda: {}) + legacy = _RegistryMux(["bmad-loop-shared-id"], {}) # untagged: someone else's + monkeypatch.setattr(runs, "_legacy_registries", lambda: [legacy]) + + assert runs.prune_sessions(ours) == ([], [], set()) + assert legacy.killed == [] + + +def test_prune_sessions_still_claims_an_untagged_session_in_the_primary_registry( + tmp_path, monkeypatch +): + """The other half of the ablation: `require_tag` must not leak into the + primary pass. There the registry itself proves ownership, so the run-dir + fallback keeps the reach it always had — a session whose tag write failed is + still cleanable by its own project. The export is put in force first, as + `cli._configure_mux` does ahead of every command: with nothing exported a + namespacing backend is on its shared default registry, where the fallback is + correctly refused (the round-8 gate) — the reach this test pins is + conditional on the registry being ours, not unconditional.""" + (_make_state_run(tmp_path, "fin-1") / "engine.pid").write_text(str(_dead_pid())) + monkeypatch.setenv(runs.PSMUX_DATA_DIR, str(runs.mux_registry_root(tmp_path))) + monkeypatch.setattr(runs, "mux_sessions", lambda: ["bmad-loop-fin-1"]) + monkeypatch.setattr(runs, "session_project_tags", lambda: {}) + monkeypatch.setattr(runs, "_legacy_registries", lambda: []) + + assert runs.prune_sessions(tmp_path, dry_run=True) == (["fin-1"], [], set()) + + +class _RootedMux(_RegistryMux): + """A registry mux that also answers which registry root it addresses, and + whether the transport namespaces at all. The default couples the two the + way the bundled backends do — a root in force implies a namespace, no root + implies none (tmux) — and `namespaced=True` with `root=None` is the psmux + default-registry shape.""" + + def __init__(self, sessions, tags, root, namespaced=None): + super().__init__(sessions, tags) + self._root = root + self._namespaced = (root is not None) if namespaced is None else namespaced + + def registry_root(self): + return self._root + + def has_registry_namespace(self): + return self._namespaced + + +def test_prune_refuses_an_untagged_session_in_a_registry_it_does_not_own(tmp_path, monkeypatch): + """The untagged run-dir fallback is evidence only where the registry has + already restricted the listing to this project. When the derivation fails, + `export_psmux_registry_root` leaves whatever ambient `PSMUX_DATA_DIR` it found + in force and psmux honours any absolute value — so the primary pass addresses + the OPERATOR'S registry while this project's run dirs go on looking like + ownership, and a run id is unique within a project, not across a registry + shared with someone else. + + This is round-1 finding 2 reopened by the derivation-failure arm: the cut made + an ambient value a no-op on the success path and left it live here. + + Ablate the `require_tag=not _registry_proves_ownership(project)` term and the + kill lands in their registry.""" + (_make_state_run(tmp_path, "shared-1") / "engine.pid").write_text(str(_dead_pid())) + theirs = _RootedMux(["bmad-loop-shared-1"], {}, str(tmp_path / "their-registry")) + monkeypatch.setattr(runs, "get_multiplexer", lambda: theirs) + monkeypatch.setattr(runs, "mux_sessions", theirs.list_sessions) + monkeypatch.setattr(runs, "session_project_tags", lambda: {}) + monkeypatch.setattr(runs, "_legacy_registries", lambda: []) + killed: list[str] = [] + monkeypatch.setattr(runs, "kill_session", lambda rid, mux=None: killed.append(rid)) + + assert runs.prune_sessions(tmp_path) == ([], [], set()) + assert killed == [] + + +def test_prune_still_claims_an_untagged_session_in_the_registry_it_derived(tmp_path, monkeypatch): + """The other half, and the one that proves the gate did not simply stop the + sweep: in bmad-loop's own per-project registry the fallback is sound, because + the registry itself is what restricts the listing to this project.""" + (_make_state_run(tmp_path, "mine-1") / "engine.pid").write_text(str(_dead_pid())) + ours = _RootedMux(["bmad-loop-mine-1"], {}, str(runs.mux_registry_root(tmp_path))) + monkeypatch.setattr(runs, "get_multiplexer", lambda: ours) + monkeypatch.setattr(runs, "mux_sessions", ours.list_sessions) + monkeypatch.setattr(runs, "session_project_tags", lambda: {}) + monkeypatch.setattr(runs, "_legacy_registries", lambda: []) + killed: list[str] = [] + monkeypatch.setattr(runs, "kill_session", lambda rid, mux=None: killed.append(rid)) + + assert runs.prune_sessions(tmp_path) == (["mine-1"], [], set()) + assert killed == ["mine-1"] + + +def test_prune_keeps_its_historical_reach_with_no_registry_namespace(tmp_path, monkeypatch): + """A backend with NO registry namespace (tmux: one server for the machine) + keeps the reach it always had — its `registry_root()` None means exactly + "there is nothing to compare", and narrowing it would be a regression + dressed as caution. An earlier revision of this test read every None this + way, which pinned the unsafe kill its sibling below now refuses.""" + (_make_state_run(tmp_path, "tmux-1") / "engine.pid").write_text(str(_dead_pid())) + plain = _RootedMux(["bmad-loop-tmux-1"], {}, None) + monkeypatch.setattr(runs, "get_multiplexer", lambda: plain) + monkeypatch.setattr(runs, "mux_sessions", plain.list_sessions) + monkeypatch.setattr(runs, "session_project_tags", lambda: {}) + monkeypatch.setattr(runs, "_legacy_registries", lambda: []) + killed: list[str] = [] + monkeypatch.setattr(runs, "kill_session", lambda rid, mux=None: killed.append(rid)) + + assert runs.prune_sessions(tmp_path) == (["tmux-1"], [], set()) + + +def test_prune_refuses_an_untagged_session_on_a_backends_default_registry(tmp_path, monkeypatch): + """`registry_root()` None from a backend that DOES namespace is not tmux's + None: psmux with no root in force runs on its own user-wide default registry + — shared with every project and with the operator — and there a dead run dir + here proves nothing about an untagged session over there. Reachable when the + export degrades on an underivable state root and no ambient value is set. + + Ablate the `has_registry_namespace()` term in `_registry_proves_ownership` + (read every None as "nothing to own") and the kill lands in the shared + default registry.""" + (_make_state_run(tmp_path, "shared-2") / "engine.pid").write_text(str(_dead_pid())) + default = _RootedMux(["bmad-loop-shared-2"], {}, None, namespaced=True) + monkeypatch.setattr(runs, "get_multiplexer", lambda: default) + monkeypatch.setattr(runs, "mux_sessions", default.list_sessions) + monkeypatch.setattr(runs, "session_project_tags", lambda: {}) + monkeypatch.setattr(runs, "_legacy_registries", lambda: []) + killed: list[str] = [] + monkeypatch.setattr(runs, "kill_session", lambda rid, mux=None: killed.append(rid)) + + assert runs.prune_sessions(tmp_path) == ([], [], set()) + assert killed == [] + + +def test_registry_ownership_demands_a_tag_when_it_cannot_be_asked(tmp_path, monkeypatch): + """A backend that cannot be selected answers False, so the pass requires the + tag. The safe direction is to leave a session standing rather than kill one on + evidence that may not hold.""" + + def boom(): + raise MultiplexerError("no backend") + + monkeypatch.setattr(runs, "get_multiplexer", boom) + assert runs._registry_proves_ownership(tmp_path) is False + + +def test_legacy_registry_leftovers_names_an_untagged_session(tmp_path, monkeypatch): + """A sweep that silently declines to migrate something is the same silence + this change exists to remove: cleanup prints a removal count, and a count that + excludes what it chose not to claim reads as "everything is clean".""" + legacy = _RegistryMux(["bmad-loop-old-1"], {}) + monkeypatch.setattr(runs, "_legacy_registries", lambda: [legacy]) + assert runs.legacy_registry_leftovers(tmp_path) == ["bmad-loop-old-1"] + + +def test_legacy_registry_leftovers_names_a_surviving_control_session(tmp_path, monkeypatch): + """The prune partition never touches CTL_SESSION and the ctl-window sweep runs + against the current registry only, so a pre-upgrade control session survives + the migration. Naming it is the whole remedy.""" + legacy = _RegistryMux([runs.CTL_SESSION], {}) + monkeypatch.setattr(runs, "_legacy_registries", lambda: [legacy]) + assert runs.legacy_registry_leftovers(tmp_path) == [runs.CTL_SESSION] + + +def test_legacy_registry_leftovers_degrades_on_a_transport_fault(tmp_path, monkeypatch): + """Observation degrades: the sweep's own report still stands, and a migration + remainder nobody could read is not a reason to fail a cleanup that already + killed sessions.""" + + class _Broken(_RegistryMux): + def list_sessions(self): + raise MultiplexerError("no server") + + monkeypatch.setattr(runs, "_legacy_registries", lambda: [_Broken([], {})]) + assert runs.legacy_registry_leftovers(tmp_path) == [] + + +def test_legacy_registry_leftovers_is_empty_with_no_legacy_registry(tmp_path, monkeypatch): + monkeypatch.setattr(runs, "_legacy_registries", lambda: []) + assert runs.legacy_registry_leftovers(tmp_path) == [] + + +# ------------------ legacy remainder: our own stranded sessions (#537) + + +def test_legacy_registry_leftovers_names_our_own_live_session(tmp_path, monkeypatch): + """Tagged is not the same as dealt with. The legacy partition correctly + declines to kill a live run of ours, and that session then sits in a registry + ordinary attach and cleanup no longer address — the stranding worth naming. + Ablate the `live` arm and this fails while the sweep still reports nothing.""" + runs.write_pid(_make_state_run(tmp_path, "live-1")) + legacy = _RegistryMux(["bmad-loop-live-1"], {"bmad-loop-live-1": runs.project_tag(tmp_path)}) + monkeypatch.setattr(runs, "_legacy_registries", lambda: [legacy]) + + # the sweep itself is right: not prunable, and not killed + assert runs.prune_sessions(tmp_path) == ([], ["live-1"], set()) + assert legacy.killed == [] + # ...and the remainder says so + assert runs.legacy_registry_leftovers(tmp_path) == ["bmad-loop-live-1"] + + +def test_legacy_registry_leftovers_stays_quiet_about_a_dead_session_the_sweep_takes( + tmp_path, monkeypatch +): + """The other half of the ablation: a tagged, dead session of ours is the + sweep's to remove, and reporting it as a leftover would contradict the + "removed" line printed beside it — in --dry-run too, where it is announced as + a would-kill.""" + (_make_state_run(tmp_path, "fin-1") / "engine.pid").write_text(str(_dead_pid())) + legacy = _RegistryMux(["bmad-loop-fin-1"], {"bmad-loop-fin-1": runs.project_tag(tmp_path)}) + monkeypatch.setattr(runs, "_legacy_registries", lambda: [legacy]) + + plan = runs.prune_sessions(tmp_path, dry_run=True) + assert plan == (["fin-1"], [], set()) + assert runs.legacy_registry_leftovers(tmp_path, announced=plan[0]) == [] + + +def test_legacy_registry_leftovers_still_stays_quiet_about_another_projects_session( + tmp_path, monkeypatch +): + """Unchanged and load-bearing: another project's tagged session is not this + operator's business, and the sweep skipping it is the correct outcome rather + than a remainder.""" + legacy = _RegistryMux( + ["bmad-loop-theirs-1", "not-a-bmad-session"], + {"bmad-loop-theirs-1": "0123456789abcdef"}, + ) + monkeypatch.setattr(runs, "_legacy_registries", lambda: [legacy]) + assert runs.legacy_registry_leftovers(tmp_path) == [] + + +# ---------------- legacy remainder: presence, not a resampled partition (#537) + + +class _VanishingMux(_RegistryMux): + """A registry whose engine exits between the sweep and the read. + + The sweep sees the run alive and correctly leaves it; by the time the reader + looks, the pid is gone. A reader that re-ran the partition would call the + session `prunable` and drop it from every arm it checks — reported by nobody, + with no kill ever attempted. Presence has no such window. + """ + + def __init__(self, sessions, tags, run_dir): + super().__init__(sessions, tags) + self._run_dir = run_dir + self.reads = 0 + + def session_options(self, option): + self.reads += 1 + if self.reads > 1: # the reader's look, after the sweep's + (self._run_dir / "engine.pid").write_text(str(_dead_pid())) + return super().session_options(option) + + +def test_legacy_leftovers_names_a_session_whose_engine_exited_mid_sweep(tmp_path, monkeypatch): + """The race the presence rule exists for. Ablate it back to consuming a + re-run partition's `live` arm and this fails with `[]` — a session standing in + a registry nothing addresses, and no kill attempted to explain it.""" + run_dir = _make_state_run(tmp_path, "race-live") + runs.write_pid(run_dir) + legacy = _VanishingMux( + ["bmad-loop-race-live"], {"bmad-loop-race-live": runs.project_tag(tmp_path)}, run_dir + ) + monkeypatch.setattr(runs, "_legacy_registries", lambda: [legacy]) + + # the sweep: alive, so correctly left standing and never killed + assert runs.prune_sessions(tmp_path) == ([], ["race-live"], set()) + assert legacy.killed == [] + # ...and the reader names it even though it now looks prunable + assert runs.legacy_registry_leftovers(tmp_path) == ["bmad-loop-race-live"] + + +def test_legacy_leftovers_names_a_session_whose_kill_did_not_land(tmp_path, monkeypatch): + """`kill_session` is best-effort and silent by contract, so `sessions.removed` + has always been an *attempted* kill. Presence closes that for the legacy + registry at no extra cost: the session is still listed, so it is still named.""" + + class _DeafMux(_RegistryMux): + def kill_session(self, name): + self.killed.append(name) # recorded, but the session survives + + (_make_state_run(tmp_path, "fin-1") / "engine.pid").write_text(str(_dead_pid())) + legacy = _DeafMux(["bmad-loop-fin-1"], {"bmad-loop-fin-1": runs.project_tag(tmp_path)}) + monkeypatch.setattr(runs, "_legacy_registries", lambda: [legacy]) + + assert runs.prune_sessions(tmp_path) == (["fin-1"], [], set()) + assert legacy.killed == ["bmad-loop-fin-1"] + assert runs.legacy_registry_leftovers(tmp_path) == ["bmad-loop-fin-1"] + + +def test_legacy_leftovers_is_quiet_once_the_sweep_actually_removed_the_session( + tmp_path, monkeypatch +): + """The other half of the presence ablation: a session the sweep really did + remove is gone from the listing, so it must not be named — otherwise every + successful migration would report itself as unfinished.""" + + class _RealMux(_RegistryMux): + def kill_session(self, name): + self.killed.append(name) + self._sessions = [n for n in self._sessions if n != name] + + (_make_state_run(tmp_path, "fin-1") / "engine.pid").write_text(str(_dead_pid())) + legacy = _RealMux(["bmad-loop-fin-1"], {"bmad-loop-fin-1": runs.project_tag(tmp_path)}) + monkeypatch.setattr(runs, "_legacy_registries", lambda: [legacy]) + + assert runs.prune_sessions(tmp_path) == (["fin-1"], [], set()) + assert runs.legacy_registry_leftovers(tmp_path) == [] + + +def test_legacy_leftovers_dry_run_excludes_what_the_preview_announced(tmp_path, monkeypatch): + """A dry run kills nothing, so presence alone would name every would-kill + session the preview just listed — the preview would contradict itself. Those + ids are excluded from the same partition the preview used.""" + (_make_state_run(tmp_path, "fin-1") / "engine.pid").write_text(str(_dead_pid())) + runs.write_pid(_make_state_run(tmp_path, "live-1")) + tag = runs.project_tag(tmp_path) + legacy = _RegistryMux( + ["bmad-loop-fin-1", "bmad-loop-live-1"], + {"bmad-loop-fin-1": tag, "bmad-loop-live-1": tag}, + ) + monkeypatch.setattr(runs, "_legacy_registries", lambda: [legacy]) + + plan = runs.prune_sessions(tmp_path, dry_run=True) + assert plan == (["fin-1"], ["live-1"], set()) + assert runs.legacy_registry_leftovers(tmp_path, announced=plan[0]) == ["bmad-loop-live-1"] + + +def test_legacy_leftovers_dry_run_never_drops_what_the_preview_did_not_announce( + tmp_path, monkeypatch +): + """The dry-run half of the same race the presence rule closed for real cleanup, + and the reason the plan is passed in rather than rediscovered here. + + The preview sees this run alive, so it prints it as live and announces no + would-kill. The engine then exits. A reader that re-ran the partition to + rediscover the plan would find the session prunable *now*, treat it as + announced, and drop it from the preview entirely — a standing session named by + nobody. Consuming the plan the preview actually printed cannot disagree with + it. + + Ablate by re-deriving `announced` inside the reader (a second + `prunable_sessions(..., require_tag=True)` call) and this fails with `[]`.""" + run_dir = _make_state_run(tmp_path, "race-live") + runs.write_pid(run_dir) + legacy = _VanishingMux( + ["bmad-loop-race-live"], {"bmad-loop-race-live": runs.project_tag(tmp_path)}, run_dir + ) + monkeypatch.setattr(runs, "_legacy_registries", lambda: [legacy]) + + plan = runs.prune_sessions(tmp_path, dry_run=True) + assert plan == ([], ["race-live"], set()) # nothing announced as a would-kill + assert legacy.killed == [] + assert runs.legacy_registry_leftovers(tmp_path, announced=plan[0]) == ["bmad-loop-race-live"] diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py index 37809066..a9e71d9e 100644 --- a/tests/test_tui_app.py +++ b/tests/test_tui_app.py @@ -2764,6 +2764,85 @@ def boom(_p): assert isinstance(app.screen, DashboardScreen) # worker failed soft, no crash +@pytest.mark.parametrize( + "fault, toast", + [ + (MultiplexerError("PSMUX_DATA_DIR='' is not an absolute path"), "not an absolute path"), + (UnicodeDecodeError("utf-8", b"\xff", 0, 1, "invalid start byte"), "invalid start byte"), + ], +) +async def test_cleanup_sessions_session_prune_error_notifies(project, monkeypatch, fault, toast): + """The session half is raiser-side too, and the worker must fail as soft. + + The psmux backend refuses a registry root that would fail its pre-spawn + absoluteness gate, and that raise happens before the tolerant listing + wrapper can degrade it — so `prune_sessions` can raise where every other + caller has a backstop that names the error. A worker thread has none, and + an escape takes the whole dashboard down (Textual's `exit_on_error`). + + The opposite conclusion to its ctl-window twin above, on purpose: nothing + has been killed yet, so there is no completed work to keep reporting and + the worker stops. A summary toast here would claim a sweep that never ran. + + Ablate the guard (call `prune_sessions` outside the try) and the app is no + longer on the dashboard — the worker's exception took it down.""" + from bmad_loop import runs + + monkeypatch.setattr(launch, "mux_available", lambda: True) + + def boom(_p): + raise fault + + monkeypatch.setattr(runs, "prune_sessions", boom) + monkeypatch.setattr(launch, "prune_ctl_windows", lambda _p: ([], [], [])) + 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 pilot.press("c") + await until(pilot, lambda: isinstance(app.screen, ConfirmModal)) + await pilot.click(await ready(pilot, "#ok")) + await until(pilot, lambda: any(toast in m for m in notifications(app))) + assert isinstance(app.screen, DashboardScreen) # worker failed soft, no crash + # nothing ran, so nothing is summarised as having run + assert not any("removed" in m and "session(s)" in m for m in notifications(app)) + + +async def test_cleanup_warns_about_sessions_left_in_the_legacy_registry(project, monkeypatch): + """The cli cleanup arm's stderr line, as a toast. + + The summary below it counts only what this registry's sweep removed, so a + tagged pre-upgrade session the migration pass declined to claim is silently + absent from it — and a count that quietly excludes them reads as "all + clean". Read after the prune, so it names what is left standing. + + Ablate the toast and this fails; the twin CLI assertion lives in + `test_cli.py`, and the reader itself is unit-tested in `test_runs.py`.""" + from bmad_loop import runs + + monkeypatch.setattr(launch, "mux_available", lambda: True) + monkeypatch.setattr(runs, "prune_sessions", lambda _p: ([], [], set())) + monkeypatch.setattr(launch, "prune_ctl_windows", lambda _p: ([], [], [])) + monkeypatch.setattr( + runs, "legacy_registry_leftovers", lambda _p: ["bmad-loop-ctl", "bmad-loop-old-1"] + ) + 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 pilot.press("c") + await until(pilot, lambda: isinstance(app.screen, ConfirmModal)) + await pilot.click(await ready(pilot, "#ok")) + await until( + pilot, + lambda: any( + "2 session(s) left in the multiplexer's default registry" in m + and "bmad-loop-old-1" in m + for m in notifications(app) + ), + ) + + async def test_cleanup_warns_about_ctl_windows_that_survived_the_kill(project, monkeypatch): # The summary counts only verified removals now (#435), so a window that # outlived its kill would otherwise just be missing from the toast with @@ -3327,6 +3406,7 @@ def _stories_paused_run( root: Path, *, stage: str, + run_id: str = "20260611-100000-aaaa", story_key: str = "1", spec_status: str = "ready-for-dev", spec_checkpoint: bool = True, @@ -3371,7 +3451,7 @@ def _stories_paused_run( task.commit_sha = commit_sha run_dir = make_run( root, - "20260611-100000-aaaa", + run_id, source="stories", spec_folder="epic-1", paused_stage=stage, @@ -3434,6 +3514,65 @@ async def test_plan_checkpoint_replan_resets_and_resumes(project, monkeypatch): assert strips == [(spec, project.project)] +async def test_plan_checkpoint_replan_refuses_a_control_alias_run_before_mutating( + project, monkeypatch +): + """Through the ENTRY POINT (the modal's Replan button): a run persisted by + an older release under `ctl` must not have its spec reset to draft ahead + of the child `bmad-loop resume`'s refusal — the TUI is a second frontend + onto the same state, and it kept the mutate-then-refuse shape after the + CLI entry gates closed it. + + Ablate `_blocked_by_control_alias` in `_do_replan` and this fails: the + spec is reset and the resume child is launched.""" + from bmad_loop import devcontract + + calls: list[str] = [] + resets: list[tuple] = [] + monkeypatch.setattr(launch, "mux_available", lambda: True) + monkeypatch.setattr(launch, "resume_detached", lambda proj, rid: calls.append(rid)) + monkeypatch.setattr(data, "liveness", lambda run_dir: "dead") + monkeypatch.setattr( + devcontract, "reset_spec_status", lambda p, s, **kw: resets.append((p, s)) or True + ) + monkeypatch.setattr(devcontract, "strip_auto_run_result", lambda p, **kw: True) + _stories_paused_run(project.project, stage="plan-checkpoint", run_id="ctl") + app = BmadLoopApp(project.project) + async with app.run_test() as pilot: + await _open_review(app, pilot, SpecReviewModal) + await pilot.click(await ready(pilot, "#act-replan")) + await until(pilot, lambda: not isinstance(app.screen, SpecReviewModal)) + await pilot.pause() + assert resets == [] # the spec was NOT rewritten ahead of the refusal + assert calls == [] # and no resume child was launched to bounce off the CLI gate + + +async def test_tui_rearm_refuses_a_control_alias_run_before_mutating(project, monkeypatch): + """The re-arm path (`_do_rearm`, resolve-modal Re-arm & resume) gates + ahead of `rearm_escalation` — the pre-launch mutation the launcher's own + chokepoint gate cannot protect. Direct method drive inside a running app + — the modal wiring is pinned by the existing checkpoint tests, and the + launch paths themselves (resume, resolve, and any future button) are + gated at their convergence, `launch.start_detached`, graded in + test_tui_launch.py. + + Ablate `_blocked_by_control_alias` in `_do_rearm` and the rearm recorder + fills.""" + from bmad_loop import runs + + rearms: list[tuple] = [] + monkeypatch.setattr(launch, "mux_available", lambda: True) + monkeypatch.setattr(data, "liveness", lambda run_dir: "dead") + monkeypatch.setattr(runs, "rearm_escalation", lambda rd, sk, **kw: rearms.append((rd, sk))) + run_dir, _spec = _stories_paused_run(project.project, stage="plan-checkpoint", run_id="ctl") + app = BmadLoopApp(project.project) + async with app.run_test() as pilot: + await until(pilot, lambda: isinstance(app.screen, DashboardScreen)) + app._do_rearm("ctl", run_dir, "1") + await pilot.pause() + assert rearms == [] + + async def test_story_checkpoint_continue_resumes(project, monkeypatch): calls: list[str] = [] monkeypatch.setattr(launch, "mux_available", lambda: True) diff --git a/tests/test_tui_launch.py b/tests/test_tui_launch.py index b48681f1..920a4adc 100644 --- a/tests/test_tui_launch.py +++ b/tests/test_tui_launch.py @@ -109,6 +109,9 @@ def test_start_run_detached_argv(fake_run, tmp_path: Path): "bmad-loop-ctl", "-c", str(tmp_path), + # no `-e` pairs: session env is not part of the released verb, and on + # tmux this ONE ctl session is shared by every project on the machine, + # so no single project's value could be right for its window 0 anyway. ] nw = fake_run.by_verb("new-window")[0] @@ -652,6 +655,71 @@ def test_current_return_target_none_on_empty_pane(monkeypatch): assert launch.current_return_target() is None +def test_start_detached_uses_the_per_registry_ctl_name(tmp_path: Path, monkeypatch): + """On a namespacing transport the launcher creates and parks into the + per-registry control session (runs.ctl_session_for), never the fixed name: + psmux's duplicate-server mutex is keyed on the session name machine-wide, + so a second project minting the fixed `bmad-loop-ctl` is rejected as a + duplicate and its launch fails. The tmux half — the fixed name, + byte-identical argv — is pinned by test_start_run_detached_argv. + + Ablate `runs.ctl_session_for` at `_ensure_ctl_session` / the parked-window + call (hardcode CTL_SESSION) and this fails.""" + + class _NamespacedStub: + def __init__(self): + self.created = [] + self.parked = [] + + def has_registry_namespace(self): + return True + + def has_session(self, name): + return False + + def new_session(self, name, cwd, cols=None, lines=None): + self.created.append(name) + + def new_parked_window(self, session, name, cwd, argv, return_opt): + self.parked.append((session, name)) + return "@7" + + def set_window_option(self, window, option, value): + pass + + stub = _NamespacedStub() + monkeypatch.setattr(launch, "get_multiplexer", lambda: stub) + monkeypatch.setattr(launch, "mux_usable", lambda _m: True) + + assert launch.start_detached(tmp_path, ["run"], "RID", "run") == "@7" + expected = runs.ctl_session_for(tmp_path, stub) + assert expected.startswith(runs.CTL_SESSION + "-") + assert stub.created == [expected] + assert stub.parked == [(expected, "run-RID")] + + +@pytest.mark.parametrize( + "drive", + [ + lambda p: launch.resume_detached(p, "ctl"), + lambda p: launch.start_resolve_detached(p, "ctl-0123456789abcdef"), + lambda p: launch.start_detached(p, ["resume"], "CTL", "resume"), + ], +) +def test_start_detached_refuses_a_control_alias_run(tmp_path: Path, drive): + """The convergence gate: every drive path — resume, resolve, and any + future button — mints its window and overwrites the ctl-window record + through `start_detached`, so the control-alias refusal lives there, not + per button (gating buttons kept finding the ungated fourth: resolve). + First, ahead of every mux probe, so no window is minted, no record + overwritten, and no child is launched only to bounce off the CLI gate. + + Ablate the gate in `start_detached` and all three fail (with no mux + stubbed, the next probe raises a different LaunchError text).""" + with pytest.raises(launch.LaunchError, match="control session's own"): + drive(tmp_path) + + def test_start_detached_returns_window_id(fake_run, tmp_path: Path): assert launch.start_resolve_detached(tmp_path, "RID") == "@7" @@ -1524,6 +1592,137 @@ def fake(argv, **kwargs): assert killed == [["tmux", "kill-window", "-t", "@2"]] +def test_prune_ctl_windows_reads_a_pre_upgrade_ctl_shaped_run_id(monkeypatch, tmp_path: Path): + """The sweep asks the PARSE question about a window that already exists, + never the mint's. + + `--run-id ctl-foo` was accepted before the control-session shape was + reserved, so `run-ctl-foo` windows are parked in real control sessions + right now. `is_valid_run_id` — the mint-side predicate — refuses that id, + so borrowing it here leaked every such window out of `cleanup` and its + `--dry-run` forever: never listed, never closed, and no error anywhere. + `runs.is_parsable_run_id` asks what the name IS instead. + + What stays excluded is the narrow alias shape (`ctl`, `ctl-<16 hex>`): + those ids are the ones a control session's own name can be, and the read + paths keep them out of run-shaped handling everywhere. + + Ablate to `runs.is_valid_run_id` and the `run-ctl-foo` assertions fail; + drop the alias half of `is_parsable_run_id` and the `run-ctl` / + digest-shaped rows are pruned, failing the killed-argv assertion.""" + from bmad_loop import runs + + mine = runs.project_tag(tmp_path) + windows = ( + f"@2\trun-ctl-foo\t{mine}\n" # pre-upgrade run: a genuine parked window + f"@3\trun-ctl\t{mine}\n" # aliases the fixed control session — skipped + f"@4\tsweep-ctl-0123456789abcdef\t{mine}\n" # aliases a per-registry name + f"@5\trun-ctl-0123456789abcde\t{mine}\n" # 15 hex: not a mintable ctl name + ) + killed: list[list[str]] = [] + + def fake(argv, **kwargs): + verb = argv[1] + if verb == "has-session": + return subprocess.CompletedProcess(argv, 0, stdout="", stderr="") + if verb == "display-message": # current window is none of the rows + return subprocess.CompletedProcess(argv, 0, stdout="@1\n", stderr="") + if verb == "list-windows": + if argv[-1] == "#{window_id}": # post-kill liveness: the kills landed + gone = {a[-1] for a in killed} + ids = [line.split("\t")[0] for line in windows.splitlines()] + return subprocess.CompletedProcess( + argv, 0, stdout="\n".join(i for i in ids if i not in gone), stderr="" + ) + return subprocess.CompletedProcess(argv, 0, stdout=windows, stderr="") + if verb == "kill-window": + 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}") + + expected = ["run-ctl-foo", "run-ctl-0123456789abcde"] + assert launch.prunable_ctl_windows(tmp_path) == expected + assert launch.prune_ctl_windows(tmp_path) == (expected, [], []) + assert killed == [ + ["tmux", "kill-window", "-t", "@2"], + ["tmux", "kill-window", "-t", "@5"], + ] + + +class _NamespacedMux: + """Duck-typed namespacing backend recording every session name the launch + layer addresses. Only what the three ctl-name sites and their pre-gates + consult — a psmux-shaped transport with no psmux.""" + + def __init__(self, rows): + self._rows = rows + self.sessions: list[str] = [] + self.killed: list[str] = [] + + def available(self): + return True + + def has_registry_namespace(self): + return True + + def has_session(self, session): + self.sessions.append(session) + return True + + def target(self, session): + self.sessions.append(session) + return f"={session}" + + def current_window_id(self): + return "@1" + + def list_windows(self, session, fields): + self.sessions.append(session) + return list(self._rows) + + def list_window_ids(self, session): + self.sessions.append(session) + return [w for w, _n, _t in self._rows if w not in self.killed] + + def kill_window(self, win_id): + self.killed.append(win_id) + + +def test_launch_addresses_the_per_registry_control_session(monkeypatch, tmp_path: Path): + """Every launch-layer read of the control session resolves its name through + `runs.ctl_session_for`, never the `CTL_SESSION` constant. + + On a namespacing transport the name carries the registry digest, so a site + still spelling the constant addresses a session that does not exist there: + `list_windows` answers empty and attach reports "nothing to attach", while + the post-kill listing reads every candidate as removed — cleanup claims + windows it never closed. All three fail silently, which is why the constant + survived at these sites at all. + + Ablate any one of `ctl_window_id`'s listing, `ctl_target`'s token, or + `prune_ctl_windows`' post-kill listing back to `runs.CTL_SESSION` and the + final assertion fails naming that call.""" + from bmad_loop import runs + + mine = runs.project_tag(tmp_path) + mux = _NamespacedMux([("@2", "run-20260101-000000-dead", mine)]) + monkeypatch.setattr(launch, "get_multiplexer", lambda: mux) + + expected = runs.ctl_session_for(tmp_path, mux) + # premise: the digest name is what this project's control session is called, + # and it is NOT the constant — without this the assertion below is vacuous + assert expected.startswith(runs.CTL_SESSION + "-") and expected != runs.CTL_SESSION + + assert launch.ctl_window_id(tmp_path, "20260101-000000-dead") == "@2" + assert launch.ctl_target(tmp_path) == f"={expected}" + assert launch.prune_ctl_windows(tmp_path) == (["run-20260101-000000-dead"], [], []) + + assert mux.sessions and set(mux.sessions) == {expected} + + def test_prune_ctl_windows_no_session(monkeypatch, tmp_path: Path): def fake(argv, **kwargs): # has-session reports the ctl session is gone return subprocess.CompletedProcess(argv, 1, stdout="", stderr="") @@ -1545,6 +1744,10 @@ def test_in_ctl_session(monkeypatch): # see test_in_ctl_session_outside_tmux). monkeypatch.setattr(launch, "current_session", lambda: "bmad-loop-ctl") assert launch.in_ctl_session() is True + # ...and a per-registry name (runs.ctl_session_for on a namespacing + # transport): the question is "am I in A control session". + monkeypatch.setattr(launch, "current_session", lambda: "bmad-loop-ctl-0123456789abcdef") + 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) From 5b8a1a37685f51f72468313a5ac48f86fa8e9f1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Davor=20Raci=C4=87?= Date: Wed, 26 Aug 2026 13:35:29 +0200 Subject: [PATCH 2/7] fix(adapters,cli,runs): sweep the displaced registry, and keep PSMUX_DATA_DIR off non-psmux hosts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the PR review on #728. The migration sweep assumed every pre-upgrade machine used psmux's default registry. One that never set PSMUX_DATA_DIR did — but the old backend simply inherited the variable, so a machine whose operator exported an absolute root kept its sessions there instead, and this branch is the change that stops honouring that value. `legacy_registries()` offered only the default root, so on such a machine stop, attach and cleanup each saw nothing while the coding processes ran on, with cleanup reporting a clean sweep. The displaced value is the only record of that registry and it stops existing the moment the export lands, so it is captured exactly there and handed to the backend, which now also binds an instance to a named root. Both passes were already tag-scoped and stay that way: prune_sessions runs every legacy registry with require_tag=True, so nothing is claimed on run-directory evidence in a registry we do not own. The export also fired on transports that never read it. On a tmux host it replaced the operator's PSMUX_DATA_DIR and announced a psmux registry, and because the value lives in the process environment so children inherit it, a tmux server cold-started here passed the replacement to every coding-CLI pane — `psmux ls` typed in one of those windows would look at bmad-loop's registry instead of the operator's own sessions. It is now gated on the seam's has_registry_namespace(), with the residual named in the code: an out-of-tree backend namespacing through some other variable would still see it. That gate exposed four registry-disclosure tests that never pinned a backend and so read the host's default — exercising the export on win32 and nothing at all on Linux. A force_psmux_backend fixture pins them, mirroring the tmux one. Test-hygiene fixes from the same review: two prune stubs returned a tuple where engine_liveness returns a str, which made every comparison false and left the unknown-pid arm of the legacy pass unfalsifiable — corrected, and that arm plus its live sibling now have graders of their own. Six cleanup invocations passed a ProjectPaths repr to --project. A probe ablation note named a mutation that does not exist (pinned_state_env rather than pin_state_root). The porting guide said three rules above four bullets. The changelog's reserved-shape line was ambiguous but the suggested wording would have been wrong: the mint refuses `ctl` and every `ctl-` (is_reserved_run_id), which is deliberately broader than the `ctl-<16 hex>` alias shape the read paths use. Both lines now say which question they answer. --- CHANGELOG.md | 6 +- docs/porting-to-a-new-os.md | 2 +- src/bmad_loop/adapters/psmux_backend.py | 116 ++++++++++++++++++++---- src/bmad_loop/cli.py | 46 ++++++---- src/bmad_loop/runs.py | 22 +++++ tests/conftest.py | 30 +++++- tests/test_cli.py | 84 +++++++++++++++-- tests/test_probe.py | 6 +- tests/test_psmux_backend.py | 80 ++++++++++++++++ tests/test_runs.py | 72 ++++++++++++++- 10 files changed, 414 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21da8c7e..8de97b34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -274,7 +274,8 @@ resolve` manufactures exactly that dual-key spec, inserting `baseline_revision` `$HOME` one there was wrong in both directions — an empty seed that let global ignores leak into `git add -A`, or patterns git is not applying that made session files go missing. Gated on the reported version's own `.windows.` fork string, not on the platform. -- **`--run-id` refuses the reserved control-session shape (`ctl`, `ctl-…`, any letter case)**. +- **`--run-id` refuses the reserved control-session shape — `ctl` and every `ctl-`, in + any letter case**. Such an id mints the control session's own name as the run's agent session (`bmad-loop-ctl`, or a per-registry `bmad-loop-ctl-` on psmux), so the adapter adopts the live control session and the run's teardown kills it — every parked window with it; on Windows the @@ -283,7 +284,8 @@ resolve` manufactures exactly that dual-key spec, inserting `baseline_revision` an older release already persisted under such an id: `resume` and `resolve` refuse (at entry, before any side effect) with the recovery steps, while `stop`, `delete`, `archive` and `clean` work on the run without ever addressing a session that can be a control session's name (`ctl`, - `ctl-<16 hex>`). A historical run under any other `ctl-*` id keeps its genuine agent session + `ctl-<16 hex>` — the narrower shape a control session's name can actually take). A historical + run under any other `ctl-*` id keeps its genuine agent session reachable in the registry the process addresses: `stop` kills it there by its exact name, and `cleanup` sweeps it like any other run's — including, for a tagged pre-upgrade session left in psmux's old default registry, through the legacy pass (`stop` does not reach that registry). diff --git a/docs/porting-to-a-new-os.md b/docs/porting-to-a-new-os.md index 7f21d5e0..2fb9720d 100644 --- a/docs/porting-to-a-new-os.md +++ b/docs/porting-to-a-new-os.md @@ -217,7 +217,7 @@ therefore disagree about which sessions exist — and the verbs that carry that disagreement (`has_session`, `list_window_ids`) are exactly the ones whose seam contract says to degrade quietly. -If your transport namespaces, three rules: +If your transport namespaces, four rules: - **Derive the root from the project**, never from the run, never from the launching shell, and never from anything a driven session can write diff --git a/src/bmad_loop/adapters/psmux_backend.py b/src/bmad_loop/adapters/psmux_backend.py index b0ad90e7..4b741867 100644 --- a/src/bmad_loop/adapters/psmux_backend.py +++ b/src/bmad_loop/adapters/psmux_backend.py @@ -111,6 +111,41 @@ def _bare_env_on(value: str | None) -> bool: return value is not None and (value == "1" or value.lower() == "true") +# The `PSMUX_DATA_DIR` that was in force before this process derived its own and +# overwrote it (`runs.export_psmux_registry_root`), or None when nothing was +# displaced. Process-local, and the *only* record of it: the variable itself is +# gone by the time anything asks. See `note_displaced_registry`. +_DISPLACED_ROOT: str | None = None + + +def note_displaced_registry(value: str | None) -> None: + """Record the registry root this process took over from, so the migration + sweep can still reach the sessions living in it. + + Before #537 the backend simply inherited whatever ``PSMUX_DATA_DIR`` the + environment carried, so an operator who exported an absolute root of their + own has *their* pre-upgrade bmad-loop sessions in THAT registry — not in + psmux's default. This process now overrides the variable, which makes those + sessions unaddressable by every later verb: `stop`, `attach` and `cleanup` + would each report nothing while the coding processes ran on. The default + registry cannot stand in for it; the two are different directories, and + which one a machine used is a fact only the displaced value carries. + + First non-empty value wins, and a later call is ignored. The caller runs + once per process, ahead of dispatch (``cli._configure_mux``), so the first + value is the one the operator's environment actually had; a second call + would be handing back a root *this* process exported. Ceiling, named: a + single process that configured two different projects in turn would record + the first project's root as the second's displaced one. ``main()`` does not + do that, and the consequence if something ever did is a no-op sweep rather + than a hazard — the legacy pass demands this project's tag, which the other + project's sessions do not carry. + """ + global _DISPLACED_ROOT + if _DISPLACED_ROOT is None and value: + _DISPLACED_ROOT = value + + class PsmuxMultiplexer(BaseTmuxBackend): """psmux backend — tmux-family argv from the base, PowerShell dialect and the documented psmux divergences here. @@ -128,7 +163,7 @@ class PsmuxMultiplexer(BaseTmuxBackend): _ENCODING = "utf-8" _ERRORS = "backslashreplace" - def __init__(self, *, default_registry: bool = False) -> None: + def __init__(self, *, default_registry: bool = False, registry_root: str | None = None) -> None: """``default_registry=True`` binds this instance to psmux's OWN registry root — the one it computes when ``PSMUX_DATA_DIR`` is unset — regardless of what this process exports. That is the legacy registry every psmux @@ -136,6 +171,16 @@ def __init__(self, *, default_registry: bool = False) -> None: in, and the only reason to build such an instance is to sweep it (see :meth:`legacy_registries`). + ``registry_root`` binds the instance to one NAMED root instead, for the + other pre-upgrade world: a machine whose operator exported an absolute + ``PSMUX_DATA_DIR`` before the upgrade kept its bmad-loop sessions there, + not in psmux's default (see :func:`note_displaced_registry`). Same + purpose, same single caller, and the same rule below about not touching + the process environment to do it. + + The two are mutually exclusive and ``default_registry`` wins if both are + passed — a programming error either way, since a registry is one place. + Bound per instance rather than swapped into ``os.environ`` around a call: the sweep runs on a TUI worker thread beside other threads issuing ordinary verbs, and a global swap would aim one of *those* at the wrong @@ -147,9 +192,11 @@ def __init__(self, *, default_registry: bool = False) -> None: (``GetUserProfileDirectoryW``), then ``HOMEDRIVE``+``HOMEPATH``, then ``HOME`` (``src/paths.rs`` ``home_dir``, source-read at v3.3.8) — and a second spelling of that cascade in Python is a second thing to keep in - sync. + sync. A named ``registry_root`` is the opposite case and needs no + cascade: the value is the root, verbatim as the operator spelled it. """ self._default_registry = default_registry + self._registry_root = None if default_registry else registry_root def _run( self, @@ -184,7 +231,11 @@ def _run( if self._default_registry: env = {k: v for k, v in effective.items() if k != _DATA_DIR} else: - value = effective.get(_DATA_DIR) + if self._registry_root is not None: + # Bound instance: the root is set in the child's env, never in + # this process's — same threading rule as `default_registry`. + env = {**effective, _DATA_DIR: self._registry_root} + value = env.get(_DATA_DIR) if env is not None else effective.get(_DATA_DIR) if value is not None and not (value and os.path.isabs(value)): raise TmuxError( f"{_DATA_DIR}={value!r} is not an absolute path; psmux panics on a " @@ -254,8 +305,13 @@ def registry_root(self) -> str | None: it — the default registry is shared with every project and with the operator, which is precisely what a ``None`` here must not be read as disclaiming. + + An instance bound to a named ``registry_root`` answers that root: it is + where its verbs actually go, and the environment's value is not. """ - return None if self._default_registry else os.environ.get(_DATA_DIR) + if self._default_registry: + return None + return self._registry_root or os.environ.get(_DATA_DIR) def has_registry_namespace(self) -> bool: # A property of the transport, not of the instance binding: even a @@ -273,23 +329,51 @@ def session_name_key(self, name: str) -> str: return name.lower() def legacy_registries(self) -> list["PsmuxMultiplexer"]: - """psmux's default registry, when this process is pointed somewhere else. + """The registries a pre-upgrade session of ours may still be living in: + psmux's default, and the root this process displaced. Sessions bmad-loop created before the per-project root existed are still there, and after the move nothing else can address them: cleanup would - report a clean sweep while their servers ran on. One extra pass over that - root is the whole migration, and it needs no state — an already-migrated - machine just finds nothing there. - - Empty when ``PSMUX_DATA_DIR`` is unset (this process IS on the default - registry — the primary pass already covers it) and when it is set to a - value psmux would panic on, where the primary pass is not running either - and a sweep would be the only thing that appeared to work. + report a clean sweep while their servers ran on. An extra pass over each + is the whole migration, and an already-migrated machine just finds + nothing in either. + + **Two of them, because there were two pre-upgrade worlds.** The old + backend inherited whatever ``PSMUX_DATA_DIR`` the process carried, so a + machine that never set it kept its sessions in psmux's *default* root, + while one whose operator exported an absolute root of their own kept + them THERE. Returning only the default assumed the first machine and + left the second's sessions — and their live coding processes — + unreachable by every verb, with cleanup reporting success. The displaced + root is remembered by :func:`note_displaced_registry`, because the + variable no longer holds it. + + Neither pass gets extra reach from being here: ``prune_sessions`` runs + every legacy registry with ``require_tag=True``, so a session is claimed + only on its own ownership tag and never on run-directory evidence, which + proves nothing in a registry shared with other projects and with the + operator's own psmux sessions. That is what keeps this off a by-name + kill in somebody else's registry. + + The default-registry pass is skipped when ``PSMUX_DATA_DIR`` is unset + (this process IS on the default registry — the primary pass already + covers it) and when it is set to a value psmux would panic on, where the + primary pass is not running either and a sweep would be the only thing + that appeared to work. The displaced pass is skipped when nothing was + displaced, when the displaced value is one psmux would panic on, and + when it is the root in force (nothing moved). A displaced value that + happens to spell psmux's own default is swept twice, harmlessly: + ``prune_sessions`` unions its passes by run id, so one session cannot + become two kills. """ value = os.environ.get(_DATA_DIR) - if value is None or not (value and os.path.isabs(value)): - return [] - return [type(self)(default_registry=True)] + registries: list[PsmuxMultiplexer] = [] + if value and os.path.isabs(value): + registries.append(type(self)(default_registry=True)) + displaced = _DISPLACED_ROOT + if displaced and os.path.isabs(displaced) and displaced != value: + registries.append(type(self)(registry_root=displaced)) + return registries # ------------------------------------------- shell dialect (PowerShell) diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index fb7569d2..9c12bfcd 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -173,7 +173,7 @@ def _configure_mux(project: Path) -> None: point that runs ahead of every command. stderr, not stdout: the ``--json`` contract is one object on stdout and nothing else, and this is the ``unverifiable_pid`` precedent.""" - from .adapters.multiplexer import configure_multiplexer + from .adapters.multiplexer import MultiplexerError, configure_multiplexer, get_multiplexer path = _policy_path(project) try: @@ -181,6 +181,26 @@ def _configure_mux(project: Path) -> None: except (policy_mod.PolicyError, OSError): name = None configure_multiplexer(name, origin=path) + # Selection first, because the export is a *psmux* fact and not every host + # runs psmux. On tmux there is no registry for a root to point at, and + # `PSMUX_DATA_DIR` is not bmad-loop's variable to spend: replacing it there + # would announce a registry the transport never consults, and the operator's + # own psmux sessions would then be unreachable from every window this + # process spawns — a tmux server cold-started here passes the replacement + # down to each of its coding-CLI panes. Ceiling, named: the gate is the + # seam's own "does this transport namespace by registry at all", so an + # out-of-tree backend that namespaces through some *other* variable still + # sees the export. The seam has no finer question, and adding one for a + # backend that does not exist yet is the wrong trade — `bmad-loop mux` + # discloses the root either way. + try: + if not get_multiplexer().has_registry_namespace(): + return + except MultiplexerError: + # A backend that cannot even be selected runs no verb, so there is + # nothing to point anywhere; the commands that need it fail loudly on + # their own and diagnostics keep working. + return ambient = os.environ.get(runs.PSMUX_DATA_DIR) root = runs.export_psmux_registry_root(project) if root is not None: @@ -207,21 +227,15 @@ def _configure_mux(project: Path) -> None: file=sys.stderr, ) return - # No ambient value either: only worth a line on a backend that namespaces at - # all — on tmux there is no registry for the degrade to have cost. - from .adapters.multiplexer import MultiplexerError, get_multiplexer - - try: - namespaced = get_multiplexer().has_registry_namespace() - except MultiplexerError: - return - if namespaced: - print( - f"warning: no state root could be derived, so bmad-loop has no registry of " - f"its own here and is using the multiplexer's shared default registry — set " - f"{envvars.STATE_DIR} to an absolute path, or unset it, to get one", - file=sys.stderr, - ) + # No ambient value either: psmux's shared default is what every verb here + # will address. Unconditional now — the namespacing gate above already + # returned on a transport with no registry for the degrade to have cost. + print( + f"warning: no state root could be derived, so bmad-loop has no registry of " + f"its own here and is using the multiplexer's shared default registry — set " + f"{envvars.STATE_DIR} to an absolute path, or unset it, to get one", + file=sys.stderr, + ) def _reject_bad_run_id(run_id: str | None) -> int | None: diff --git a/src/bmad_loop/runs.py b/src/bmad_loop/runs.py index db268934..9e23dc29 100644 --- a/src/bmad_loop/runs.py +++ b/src/bmad_loop/runs.py @@ -557,6 +557,16 @@ def export_psmux_registry_root(project: Path) -> str | None: bmad-loop's root, which is a function of the project rather than of whichever shell happened to launch something. + **Overridden, but not abandoned.** A machine that had an absolute value + exported before the upgrade kept its bmad-loop sessions in THAT registry, + because the old backend simply inherited it — so the displaced root is + handed to :func:`~.adapters.psmux_backend.note_displaced_registry` here, + the last moment anything can still read it, and the migration sweep runs a + tag-scoped pass over it alongside psmux's default + (:meth:`~.adapters.psmux_backend.PsmuxMultiplexer.legacy_registries`). + Without that the override would strand exactly the sessions it displaced, + with cleanup reporting a clean machine. + Wanting one registry to serve both is a real request and is deliberately not answered here. It needs a stated operator preference rather than a guess at one — and it must be a policy *whether*, never a *where*: ``policy.toml`` is @@ -593,7 +603,19 @@ def export_psmux_registry_root(project: Path) -> str | None: # put there, and PsmuxMultiplexer._run still refuses to spawn under a # value psmux would panic on. return None + displaced = os.environ.get(PSMUX_DATA_DIR) os.environ[PSMUX_DATA_DIR] = root + if displaced is not None and displaced != root: + # The variable is now gone, and it was the only record of where a + # pre-upgrade machine's sessions live: before #537 the backend simply + # inherited it. Hand it to the backend that has to sweep there, at the + # one moment it is still knowable. Imported here rather than at module + # scope because this is the psmux leaf, and this module talks to the + # seam — the coupling is confined to the function already named for + # psmux's own variable. + from .adapters.psmux_backend import note_displaced_registry + + note_displaced_registry(displaced) return root diff --git a/tests/conftest.py b/tests/conftest.py index fa3147e4..d2a95626 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -133,6 +133,25 @@ def force_tmux_backend(monkeypatch): multiplexer.get_multiplexer.cache_clear() +@pytest.fixture +def force_psmux_backend(monkeypatch): + """Pin the psmux transport backend by name, regardless of host platform. + + The mirror of :func:`force_tmux_backend`, for the tests that assert what + happens on a transport that namespaces sessions by registry. The registry + root is exported only when the selected backend has such a namespace, so + without this pin those tests read the *host's* default backend — passing on + win32 and vacuously "passing" on Linux, where tmux is selected and nothing + is exported at all. A forced name bypasses the platform predicate and + ``available()``, and nothing here spawns psmux, so no binary is needed.""" + from bmad_loop.adapters import multiplexer + + monkeypatch.setenv("BMAD_LOOP_MUX_BACKEND", "psmux") + 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) @@ -445,11 +464,20 @@ def _isolate_mux_registry(monkeypatch): ``PSMUX_BARE_ENV`` too: bmad-loop does not support that mode and the psmux backend warns once per process about it, so a developer whose profile sets it would otherwise start every worker with the warning already spent (and - an unexpected stderr line in whichever test spawned psmux first).""" + an unexpected stderr line in whichever test spawned psmux first). + + The backend's record of the root it *displaced* is reset on the same rule + and for a sharper reason: it is written by the same export, it survives in + module state rather than in the environment (which is the whole point of + it), and a leftover value makes `legacy_registries()` hand every later test + an extra registry to sweep.""" + from bmad_loop.adapters import psmux_backend + monkeypatch.delenv(runs.PSMUX_DATA_DIR, raising=False) monkeypatch.delenv("TMUX", raising=False) monkeypatch.delenv("TMUX_PANE", raising=False) monkeypatch.delenv("PSMUX_BARE_ENV", raising=False) + monkeypatch.setattr(psmux_backend, "_DISPLACED_ROOT", None) @pytest.fixture(scope="session") diff --git a/tests/test_cli.py b/tests/test_cli.py index 232d344a..179f51a3 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -10366,7 +10366,7 @@ def counting(lock_path, **kwargs): # ------------------------------------------------- registry disclosure (#537) -def test_main_exports_the_registry_root_before_dispatch(tmp_path, monkeypatch): +def test_main_exports_the_registry_root_before_dispatch(force_psmux_backend, tmp_path, monkeypatch): """The one chokepoint that both knows the project and precedes every psmux spawn. Ablate the `runs.export_psmux_registry_root` call in `_configure_mux` and the handler runs against whatever registry the launching shell had — the @@ -10383,7 +10383,9 @@ def handler(args): assert seen["root"] == str(runs.mux_registry_root(tmp_path)) -def test_main_says_once_when_it_overrode_an_operators_registry(tmp_path, capsys, monkeypatch): +def test_main_says_once_when_it_overrode_an_operators_registry( + force_psmux_backend, tmp_path, capsys, monkeypatch +): """Overriding a variable the operator set, silently, is how someone spends an hour on a `psmux ls` that shows nothing. bmad-loop derives its registry root unconditionally — an ambient value cannot be told apart from an inherited one, @@ -10405,7 +10407,9 @@ def test_main_says_once_when_it_overrode_an_operators_registry(tmp_path, capsys, @pytest.mark.parametrize("ambient", ["derived", None]) -def test_main_stays_quiet_when_it_overrode_nothing(tmp_path, capsys, monkeypatch, ambient): +def test_main_stays_quiet_when_it_overrode_nothing( + force_psmux_backend, tmp_path, capsys, monkeypatch, ambient +): """The other half, and the one that keeps the note from becoming noise every operator learns to ignore: nothing was displaced, so nothing is said. The `derived` case is a pane child of this project's own session, which is the @@ -10420,7 +10424,9 @@ def test_main_stays_quiet_when_it_overrode_nothing(tmp_path, capsys, monkeypatch assert capsys.readouterr().err == "" -def test_main_warns_when_it_has_no_registry_of_its_own(tmp_path, capsys, monkeypatch): +def test_main_warns_when_it_has_no_registry_of_its_own( + force_psmux_backend, tmp_path, capsys, monkeypatch +): """The arm an operator most needs told about, and the one the first version of this report missed: no state root could be derived, so the export left their `PSMUX_DATA_DIR` in force and every psmux verb this command runs addresses @@ -10483,6 +10489,64 @@ def has_registry_namespace(self): assert capsys.readouterr().err == "" +def test_main_leaves_psmux_data_dir_alone_on_a_namespace_less_transport( + force_tmux_backend, tmp_path, capsys, monkeypatch +): + """`PSMUX_DATA_DIR` is psmux's variable, and on tmux psmux is not the + transport — so bmad-loop neither spends it nor announces a registry the + selected backend never consults. + + Replacing it there is not merely untidy: a tmux server cold-started by this + process hands its environment to every pane child, so `psmux ls` typed in a + coding-CLI window would look at bmad-loop's registry instead of the + operator's own live sessions, on a host where bmad-loop is not using psmux + at all. + + Ablate the `has_registry_namespace()` gate in `_configure_mux` and both + assertions fail: the value is replaced and the note is printed.""" + theirs = str(tmp_path / "their-own-registry") + monkeypatch.setenv(runs.PSMUX_DATA_DIR, theirs) + seen = {} + + def handler(_args): + seen["root"] = os.environ.get(runs.PSMUX_DATA_DIR) + return 0 + + monkeypatch.setattr(cli, "cmd_list", handler) + + assert cli.main(["list", "--project", str(tmp_path)]) == 0 + assert seen["root"] == theirs # untouched, all the way into the handler + assert capsys.readouterr().err == "" + + +def test_main_leaves_psmux_data_dir_alone_when_no_backend_can_be_selected( + tmp_path, capsys, monkeypatch +): + """The same gate's unanswerable arm. A persisted `[mux] backend` naming a + backend that is no longer registered cannot say whether the transport + namespaces, and a backend that cannot be selected runs no verb — so there is + nothing to point anywhere. Diagnostics keep working and the operator's + variable survives. + + Ablate the `except MultiplexerError` arm and `bmad-loop list` dies in + `_configure_mux` on a misconfigured host instead of in the command that + needs the transport.""" + from bmad_loop.adapters import multiplexer as multiplexer_mod + + theirs = str(tmp_path / "their-own-registry") + monkeypatch.setenv(runs.PSMUX_DATA_DIR, theirs) + + def boom(): + raise multiplexer_mod.MultiplexerError("[mux] backend = 'ghost' matches nothing") + + monkeypatch.setattr(multiplexer_mod, "get_multiplexer", boom) + monkeypatch.setattr(cli, "cmd_list", lambda _args: 0) + + assert cli.main(["list", "--project", str(tmp_path)]) == 0 + assert os.environ.get(runs.PSMUX_DATA_DIR) == theirs + assert capsys.readouterr().err == "" + + def test_relay_is_not_given_a_registry_root(tmp_path, monkeypatch): """`relay` dispatches ahead of `_configure_mux` by contract — it touches neither mux nor policy, and a hook that exits non-zero is surfaced as a @@ -10621,7 +10685,7 @@ def test_cleanup_names_what_the_migration_left_behind(project, capsys, monkeypat ) monkeypatch.setattr(launch, "prune_ctl_windows", lambda _p: ([], [], [])) - assert cli.main(["cleanup", "--project", str(project)]) == 0 + assert cli.main(["cleanup", "--project", str(project.project)]) == 0 err = capsys.readouterr().err assert "not migrated" in err assert "bmad-loop-ctl" in err and "bmad-loop-old-1" in err @@ -10639,7 +10703,7 @@ def test_cleanup_dry_run_previews_what_the_migration_would_leave_behind( ) monkeypatch.setattr(launch, "prunable_ctl_windows", lambda _p: []) - assert cli.main(["cleanup", "--dry-run", "--project", str(project)]) == 0 + assert cli.main(["cleanup", "--dry-run", "--project", str(project.project)]) == 0 assert "bmad-loop-old-1" in capsys.readouterr().err @@ -10670,11 +10734,11 @@ def _leftovers(_p, announced=()): monkeypatch.setattr(launch, "prunable_ctl_windows", lambda _p: []) monkeypatch.setattr(launch, "prune_ctl_windows", lambda _p: ([], [], [])) - assert cli.main(["cleanup", "--dry-run", "--project", str(project)]) == 0 + assert cli.main(["cleanup", "--dry-run", "--project", str(project.project)]) == 0 assert seen == [["old-1"]] seen.clear() - assert cli.main(["cleanup", "--project", str(project)]) == 0 + assert cli.main(["cleanup", "--project", str(project.project)]) == 0 assert seen == [[]] @@ -10689,7 +10753,7 @@ def test_cleanup_json_carries_the_remainder_and_leaves_stderr_empty(project, cap ) monkeypatch.setattr(launch, "prune_ctl_windows", lambda _p: ([], [], [])) - assert cli.main(["cleanup", "--json", "--project", str(project)]) == 0 + assert cli.main(["cleanup", "--json", "--project", str(project.project)]) == 0 captured = capsys.readouterr() doc = json.loads(captured.out) assert doc["sessions"]["legacy_leftovers"] == ["bmad-loop-old-1"] @@ -10705,5 +10769,5 @@ def test_cleanup_says_nothing_about_a_registry_with_no_remainder(project, capsys monkeypatch.setattr(runs, "legacy_registry_leftovers", lambda _p, announced=(): []) monkeypatch.setattr(launch, "prune_ctl_windows", lambda _p: ([], [], [])) - assert cli.main(["cleanup", "--project", str(project)]) == 0 + assert cli.main(["cleanup", "--project", str(project.project)]) == 0 assert "not migrated" not in capsys.readouterr().err diff --git a/tests/test_probe.py b/tests/test_probe.py index 68d9cdae..89abb39e 100644 --- a/tests/test_probe.py +++ b/tests/test_probe.py @@ -896,8 +896,10 @@ def test_probe_launcher_pins_the_window_to_this_state_root(tmp_path, monkeypatch session reads as gone. Same precedence as the engine path, where the session env dict (pin included) wins over `profile.env`. - Ablate the `runs.pinned_state_env()` spread — or move it back ahead of - `env` — and one of the two assertions fails.""" + Ablate `runs.pin_state_root` in `_ProbeLauncher.start` — return `env` + unchanged, or restore the round-11 `{**runs.pinned_state_env(), **env}` + merge, where the caller's own key wins — and one of the two assertions + fails.""" from bmad_loop import envvars, runs class _Mux: diff --git a/tests/test_psmux_backend.py b/tests/test_psmux_backend.py index f2b9ca0c..a9b17f9c 100644 --- a/tests/test_psmux_backend.py +++ b/tests/test_psmux_backend.py @@ -2344,6 +2344,86 @@ def test_legacy_registries_is_empty_when_this_process_is_already_on_the_default( assert PsmuxMultiplexer().legacy_registries() == [] +def test_legacy_registries_also_sweeps_the_displaced_ambient_root(monkeypatch, tmp_path): + """A machine whose operator exported an absolute `PSMUX_DATA_DIR` before the + upgrade kept its bmad-loop sessions in THAT registry — the old backend simply + inherited the variable. This branch overrides it, so returning only psmux's + default assumes a pre-upgrade world that machine never had: `stop`, `attach` + and `cleanup` would each see nothing while the coding processes ran on, and + cleanup would report a clean sweep. + + Ablate the displaced arm of `legacy_registries` and only the default-registry + instance comes back.""" + monkeypatch.setattr(psmux_backend, "_DISPLACED_ROOT", str(tmp_path / "theirs")) + monkeypatch.setenv("PSMUX_DATA_DIR", str(tmp_path / "ours")) + + legacy = PsmuxMultiplexer().legacy_registries() + assert [x.registry_root() for x in legacy] == [None, str(tmp_path / "theirs")] + assert legacy[0]._default_registry is True + + +def test_a_bound_instance_spawns_under_its_own_root(monkeypatch, tmp_path): + """The binding is per instance and lands in the CHILD's env, never in this + process's: the sweep runs on a TUI worker thread beside other threads issuing + ordinary verbs, and a global swap would aim one of those at the wrong + registry for as long as it was in place. + + Ablate the `registry_root` arm of `_run` and the child inherits this + process's root instead — the sweep would then re-scan its own registry and + report the displaced one as empty.""" + run = _RecordRun() + monkeypatch.setattr(tmux_base.subprocess, "run", run) + monkeypatch.setenv("PSMUX_DATA_DIR", str(tmp_path / "ours")) + + PsmuxMultiplexer(registry_root=str(tmp_path / "theirs"))._run(["list-sessions"], check=False) + assert run.kwargs["env"]["PSMUX_DATA_DIR"] == str(tmp_path / "theirs") + assert os.environ["PSMUX_DATA_DIR"] == str(tmp_path / "ours") # untouched + + +def test_a_bound_instance_still_refuses_a_root_psmux_would_panic_on(monkeypatch, tmp_path): + """The absoluteness gate covers the bound arm too — a relative bound root + would panic psmux exactly as an inherited one does, and the nonzero exit + reads to every observer as an ordinary "no sessions".""" + run = _RecordRun() + monkeypatch.setattr(tmux_base.subprocess, "run", run) + monkeypatch.setenv("PSMUX_DATA_DIR", str(tmp_path)) + + with pytest.raises(TmuxError) as exc: + PsmuxMultiplexer(registry_root="relative\\root")._run(["list-sessions"], check=False) + assert "PSMUX_DATA_DIR" in str(exc.value) + assert run.calls == [] + + +def test_legacy_registries_skips_a_displaced_root_that_is_the_one_in_force(monkeypatch, tmp_path): + """Nothing moved, so there is nothing extra to sweep — the primary pass is + already addressing it. psmux's default is still offered, as always: this + process is pointed away from it either way.""" + monkeypatch.setattr(psmux_backend, "_DISPLACED_ROOT", str(tmp_path)) + monkeypatch.setenv("PSMUX_DATA_DIR", str(tmp_path)) + assert [x._default_registry for x in PsmuxMultiplexer().legacy_registries()] == [True] + + +@pytest.mark.parametrize("bad", ["", "relative\\root"]) +def test_legacy_registries_skips_a_displaced_root_psmux_would_panic_on(monkeypatch, tmp_path, bad): + """Same rule as the primary root: a sweep that appeared to work while the + registry was unreachable would read as "nothing to clean".""" + monkeypatch.setattr(psmux_backend, "_DISPLACED_ROOT", bad) + monkeypatch.setenv("PSMUX_DATA_DIR", str(tmp_path)) + assert [x._default_registry for x in PsmuxMultiplexer().legacy_registries()] == [True] + + +def test_note_displaced_registry_keeps_the_first_value(monkeypatch): + """The operator's own root is what the FIRST call carries — `_configure_mux` + runs once per process, ahead of dispatch. A later call would be handing back + a root this process itself exported, which is not a pre-upgrade world.""" + monkeypatch.setattr(psmux_backend, "_DISPLACED_ROOT", None) + psmux_backend.note_displaced_registry("") # empty is nothing displaced + assert psmux_backend._DISPLACED_ROOT is None + psmux_backend.note_displaced_registry("C:\\theirs") + psmux_backend.note_displaced_registry("C:\\ours") + assert psmux_backend._DISPLACED_ROOT == "C:\\theirs" + + @pytest.mark.parametrize("bad", ["", "relative\\root"]) def test_legacy_registries_is_empty_under_a_root_psmux_would_panic_on(monkeypatch, bad): """The primary pass is not running either, and a sweep that appeared to work diff --git a/tests/test_runs.py b/tests/test_runs.py index ade320a7..7cf65ddc 100644 --- a/tests/test_runs.py +++ b/tests/test_runs.py @@ -4493,7 +4493,7 @@ def test_prune_sessions_sweeps_a_legacy_registry(tmp_path, monkeypatch): reports a clean sweep while their servers run on.""" monkeypatch.setattr(runs, "mux_sessions", lambda: []) monkeypatch.setattr(runs, "session_project_tags", lambda: {}) - monkeypatch.setattr(runs, "engine_liveness", lambda _d: ("dead", None)) + monkeypatch.setattr(runs, "engine_liveness", lambda _d: "dead") legacy = _RegistryMux(["bmad-loop-old-1"], {"bmad-loop-old-1": runs.project_tag(tmp_path)}) monkeypatch.setattr(runs, "_legacy_registries", lambda: [legacy]) @@ -4519,7 +4519,7 @@ def test_prune_sessions_leaves_another_projects_session_in_the_legacy_registry( def test_prune_sessions_dry_run_kills_nothing_in_a_legacy_registry(tmp_path, monkeypatch): monkeypatch.setattr(runs, "mux_sessions", lambda: []) monkeypatch.setattr(runs, "session_project_tags", lambda: {}) - monkeypatch.setattr(runs, "engine_liveness", lambda _d: ("dead", None)) + monkeypatch.setattr(runs, "engine_liveness", lambda _d: "dead") legacy = _RegistryMux(["bmad-loop-old-1"], {"bmad-loop-old-1": runs.project_tag(tmp_path)}) monkeypatch.setattr(runs, "_legacy_registries", lambda: [legacy]) @@ -4527,6 +4527,74 @@ def test_prune_sessions_dry_run_kills_nothing_in_a_legacy_registry(tmp_path, mon assert legacy.killed == [] +def test_prune_sessions_carries_an_unknown_pid_out_of_the_legacy_registry(tmp_path, monkeypatch): + """The legacy pass reports an unverifiable engine pid like the primary one. + + `unknown` is the killed subset whose liveness could not be read (win32 + ERROR_ACCESS_DENIED), and every cleanup frontend turns it into the "may + still be live" warning. A session swept out of a legacy registry is exactly + as unverifiable as one swept here, and the union in `prune_sessions` is what + carries it — an arm that stayed green for years because the sibling tests + stubbed `engine_liveness` with a tuple, which compares equal to neither + "alive" nor "unknown". + + Ablate `unknown |= extra_unknown` in `prune_sessions` and this fails.""" + monkeypatch.setattr(runs, "mux_sessions", lambda: []) + monkeypatch.setattr(runs, "session_project_tags", lambda: {}) + monkeypatch.setattr(runs, "engine_liveness", lambda _d: "unknown") + legacy = _RegistryMux(["bmad-loop-old-1"], {"bmad-loop-old-1": runs.project_tag(tmp_path)}) + monkeypatch.setattr(runs, "_legacy_registries", lambda: [legacy]) + + # still killed — unknown never blocks cleanup — but named as unverifiable + assert runs.prune_sessions(tmp_path) == (["old-1"], [], {"old-1"}) + assert legacy.killed == ["bmad-loop-old-1"] + + +def test_prune_sessions_leaves_a_live_legacy_session_standing(tmp_path, monkeypatch): + """The live arm of the same union: a legacy session whose engine is provably + running is reported live and never killed. + + Ablate `live += [...]` in `prune_sessions` and the tuple goes empty; ablate + the `liveness == "alive"` continue and the session is killed.""" + monkeypatch.setattr(runs, "mux_sessions", lambda: []) + monkeypatch.setattr(runs, "session_project_tags", lambda: {}) + monkeypatch.setattr(runs, "engine_liveness", lambda _d: "alive") + legacy = _RegistryMux(["bmad-loop-old-1"], {"bmad-loop-old-1": runs.project_tag(tmp_path)}) + monkeypatch.setattr(runs, "_legacy_registries", lambda: [legacy]) + + assert runs.prune_sessions(tmp_path) == ([], ["old-1"], set()) + assert legacy.killed == [] + + +def test_export_records_the_root_it_displaced_for_the_migration_sweep(tmp_path, monkeypatch): + """The wiring, end to end: the value the export overwrites is the only record + of where a pre-upgrade machine's sessions live, and it is handed to the + backend at the one moment it is still readable. + + Before #537 the backend inherited `PSMUX_DATA_DIR` as found, so an operator + who exported an absolute root of their own has THEIR bmad-loop sessions in + THAT registry — not in psmux's default. Without this hand-off the override + strands exactly the sessions it displaced, with `cleanup` reporting a clean + machine while the coding processes run on. + + Ablate the `note_displaced_registry` call in `export_psmux_registry_root` + and the sweep is back to psmux's default alone.""" + from bmad_loop.adapters import psmux_backend + + theirs = str(tmp_path / "their-own-registry") + monkeypatch.setenv(runs.PSMUX_DATA_DIR, theirs) + + root = runs.export_psmux_registry_root(tmp_path) + assert root == str(runs.mux_registry_root(tmp_path)) != theirs + assert psmux_backend._DISPLACED_ROOT == theirs + + # ...and it is not recorded when nothing was displaced (a pane child of this + # project's own session, the ordinary way the variable is already set). + monkeypatch.setattr(psmux_backend, "_DISPLACED_ROOT", None) + runs.export_psmux_registry_root(tmp_path) + assert psmux_backend._DISPLACED_ROOT is None + + def test_legacy_registries_degrades_when_no_backend_can_be_selected(monkeypatch): """A cleanup that already swept the primary registry must report that work rather than die on the migration pass.""" From 1c49ba3ff8668334827f99971473b53abbf86dd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Davor=20Raci=C4=87?= Date: Wed, 26 Aug 2026 14:19:53 +0200 Subject: [PATCH 3/7] fix(runs,cli,tui): name the registry each migration leftover is actually in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the second review round on #728. The previous commit widened what the migration sweep addresses — psmux's default registry and any absolute PSMUX_DATA_DIR this process displaced — and left the operator-facing message describing the narrower world it replaced. An operator whose pre-upgrade sessions are in their own exported root was told they were in psmux's default, sent to look somewhere they are not, and pointed at documentation about a registry that is not theirs. Both the cleanup stderr line and its TUI toast had it. legacy_registry_leftovers now groups by registry rather than returning a flat list, and the frontends print one line each. Grouping rather than listing the swept roots alongside a flat list: the cheaper shape names registries that contributed nothing, which is the same wrong errand in miniature. Registries holding nothing are absent from the mapping for the same reason. The key is registry_root()'s answer, or a shared label where that is None — psmux's own default, whose home cascade the seam deliberately never respells. The schema-versioned cleanup --json field keeps its documented list of names; the grouping serves the text mode, which has room to say where. The multiplexer guide said pre-upgrade sessions are in the default registry and told the reader to open a shell with no PSMUX_DATA_DIR set. That covers one of the two pre-upgrade worlds; it now covers both, and points at the leftovers line as the thing that says which. Also hoists a test's reset of the backend's displaced-root global above the export that writes it, so monkeypatch records the true pre-test value, and splits the case that needed a second mid-body reset into its own test. The global did not in fact leak — the autouse registry-isolation fixture registers the same reset first and undo is LIFO, measured both ways with a sentinel — but a test whose hygiene rests on the undo ordering of a fixture in another file is one edit away from being wrong. --- docs/multiplexer-backends.md | 25 +++++--- src/bmad_loop/cli.py | 35 +++++++---- src/bmad_loop/runs.py | 45 +++++++++++--- src/bmad_loop/tui/app.py | 12 ++-- tests/test_cli.py | 81 +++++++++++++++++++++++-- tests/test_runs.py | 112 ++++++++++++++++++++++++++++++----- tests/test_tui_app.py | 22 ++++++- 7 files changed, 276 insertions(+), 56 deletions(-) diff --git a/docs/multiplexer-backends.md b/docs/multiplexer-backends.md index c47ece71..7bcc5e69 100644 --- a/docs/multiplexer-backends.md +++ b/docs/multiplexer-backends.md @@ -138,11 +138,17 @@ Two further consequences: duplicate. On psmux the session is therefore `bmad-loop-ctl-<16-hex registry digest>`; the TUI's toasts name the exact session. On tmux (no registries) the shared `bmad-loop-ctl` is unchanged. -- **Sessions created before this change are in the default registry**, and `bmad-loop cleanup` - sweeps that registry too — but only for sessions that carry this project's ownership tag and - are not still running. Whatever it leaves standing is **named on stderr** (and in - `cleanup --json`, at `sessions.legacy_leftovers`) so a removal count never quietly stands for a - partial migration. Three kinds stay behind by design: +- **Sessions created before this change are in whichever registry the old build inherited**, + and `bmad-loop cleanup` sweeps those too — but only for sessions that carry this project's + ownership tag and are not still running. There are two such registries, because the old build + simply used whatever `PSMUX_DATA_DIR` it found: psmux's **default** registry on a machine that + never set the variable, and **your own exported root** on one that did — the same root + bmad-loop now overrides (it remembers what it displaced, for exactly this sweep). The leftovers + line names the registry each session is in, so the one to open is the one it names. + + Whatever the sweep leaves standing is **named on stderr** (and in `cleanup --json`, at + `sessions.legacy_leftovers`) so a removal count never quietly stands for a partial migration. + Three kinds stay behind by design: - An **untagged** `bmad-loop-` session. In a shared registry a matching run directory here is not proof of ownership — run ids are only unique within one project, and `--run-id` @@ -157,10 +163,15 @@ Two further consequences: own `removed` count is still the pre-kill plan, the ceiling `cleanup --json`'s `sessions.removed` has always documented; the leftovers line is what catches the difference.) - These are one-time artifacts of the upgrade. To see them, open a shell with **no** - `PSMUX_DATA_DIR` set — that is psmux's default registry: + These are one-time artifacts of the upgrade. To see them, put the shell on the registry the + leftovers line named. For psmux's default registry that means a shell with **no** + `PSMUX_DATA_DIR` set; for a root of your own, export that root: ```powershell + Remove-Item Env:PSMUX_DATA_DIR # psmux's default registry + # ...or, for the root bmad-loop displaced, the one the message named: + $env:PSMUX_DATA_DIR = 'D:\your-own-registry' + psmux ls psmux list-windows -t bmad-loop-ctl -F '#{window_index}: #{window_name}' ``` diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index 9c12bfcd..753f4c0d 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -3916,8 +3916,8 @@ def cmd_archive(args: argparse.Namespace) -> int: return 0 -def _warn_legacy_leftovers(leftovers: list[str]) -> None: - """Name what a legacy multiplexer registry still holds after the sweep. +def _warn_legacy_leftovers(leftovers: dict[str, list[str]]) -> None: + """Name what each legacy multiplexer registry still holds after the sweep. Silent on the normal path — the list is empty on every platform without a registry namespace and on every machine that never ran the pre-registry @@ -3927,6 +3927,15 @@ def _warn_legacy_leftovers(leftovers: list[str]) -> None: `unverifiable_pid` precedent, so `cleanup > log` keeps the receipt; in `--json` mode this lives in the document instead and stderr stays empty. + **One line per registry, naming it.** There is more than one legacy registry + now — psmux's default, and any root this process displaced — and the + operator's next action is to open the one holding these sessions. A message + that named the default for all of them sent the reader to a registry the + sessions are not in, and at documentation about a registry that is not + theirs. `runs.legacy_registry_leftovers` maps names to registries so this + does not have to guess, and omits a registry that holds nothing so no line + here points somewhere empty. + Points at the docs rather than printing a command. One of the things named here is the machine-wide control session, and `psmux kill-session` on it kills every child process in every one of its windows — including, on this @@ -3937,15 +3946,14 @@ def _warn_legacy_leftovers(leftovers: list[str]) -> None: Deliberately unconditional on dry-run: a preview that omits the remainder would disagree with the run it is previewing.""" - if not leftovers: - return - print( - "left in the multiplexer's default registry (not migrated): " - + ", ".join(leftovers) - + " — still running, ownership unprovable there, or the shared control " - "session; see docs/multiplexer-backends.md before removing any of them", - file=sys.stderr, - ) + for registry, names in leftovers.items(): + print( + f"left in {registry} (not migrated): " + + ", ".join(names) + + " — still running, ownership unprovable there, or the shared control " + "session; see docs/multiplexer-backends.md before removing any of them", + file=sys.stderr, + ) def cmd_cleanup(args: argparse.Namespace) -> int: @@ -4009,7 +4017,10 @@ def cmd_cleanup(args: argparse.Namespace) -> int: windows_survived=survived, windows_unverifiable=unverifiable, scan_error=scan_error, - legacy_leftovers=leftovers, + # Flattened: `sessions.legacy_leftovers` is a documented + # list of names and widening it would bump the schema. The + # grouping serves the text mode, which has room to say where. + legacy_leftovers=sorted({n for names in leftovers.values() for n in names}), ) ) return 0 diff --git a/src/bmad_loop/runs.py b/src/bmad_loop/runs.py index 9e23dc29..7b2f31e7 100644 --- a/src/bmad_loop/runs.py +++ b/src/bmad_loop/runs.py @@ -1594,11 +1594,31 @@ def prune_sessions( return prunable, live, unknown -def legacy_registry_leftovers(project: Path, *, announced: Iterable[str] = ()) -> list[str]: +#: How a frontend names psmux's OWN default registry, the one root +#: :meth:`~.adapters.multiplexer.TerminalMultiplexer.registry_root` deliberately +#: answers ``None`` for (respelling its home cascade in Python is a second thing +#: to keep in sync). Lives here so both frontends say it the same way. +DEFAULT_REGISTRY_LABEL = "the multiplexer's own default registry" + + +def legacy_registry_leftovers( + project: Path, *, announced: Iterable[str] = () +) -> dict[str, list[str]]: """Session names a legacy registry **still holds** after :func:`prune_sessions` ran — the migration's honest remainder, for the cleanup frontends to print. - ``[]`` when there is no legacy registry, when it holds nothing, or when the - listing fails. + ``{}`` when there is no legacy registry, when they hold nothing, or when + every listing fails. + + **Grouped by registry, and that is load-bearing.** There is more than one + legacy registry now (:meth:`~.adapters.psmux_backend.PsmuxMultiplexer.legacy_registries` + — psmux's default, and the root this process displaced), so a flat list + cannot say where to go look: a message built from one would either name a + registry the leftovers are not in, or name every registry the sweep + addressed including the ones that contributed nothing. The operator's next + action is to open that registry, so the answer has to be per registry. Keys + are :meth:`registry_root`'s answer, or :data:`DEFAULT_REGISTRY_LABEL` where + that is ``None``; a registry holding nothing is absent rather than empty, so + a caller can print the keys without checking. **Presence, not a second opinion.** Called after the sweep, this lists what is actually there; a session the sweep killed is simply gone from the listing. @@ -1656,9 +1676,10 @@ def legacy_registry_leftovers(project: Path, *, announced: Iterable[str] = ()) - pre-registry build. Names, not run ids: the ctl session has no run id, and the operator is going to - paste these into a ``psmux`` target. + paste these into a ``psmux`` target — under the registry this maps them to, + which is the other half of what makes them pasteable. """ - leftovers: list[str] = [] + grouped: dict[str, list[str]] = {} mine = accepted_tags(project) # Run ids, so names. `prune_sessions` unions its passes, so an id it reports # names at most one session anywhere — the same collapse that makes its own @@ -1670,20 +1691,28 @@ def legacy_registry_leftovers(project: Path, *, announced: Iterable[str] = ()) - tags = legacy.session_options(PROJECT_OPTION) if names else {} except MultiplexerError: continue # observation degrades; the sweep's own report still stands + here: list[str] = [] for name in names: if name in excluded: continue if is_ctl_session_name(name): # A legacy registry holds the pre-#537 fixed name; the shape # predicate also names any per-registry-named stray. - leftovers.append(name) + here.append(name) continue if _agent_run_id(name) is None: continue # not a bmad-loop agent session at all tag = tags.get(name, "") if not tag or tag in mine: - leftovers.append(name) - return sorted(set(leftovers)) + here.append(name) + if here: + # `registry_root()` is a diagnostic and never raises (seam contract). + # Two admitted registries could in principle answer the same label — + # a displaced root that spells the default is swept twice — so the + # rows are merged rather than overwritten. + label = legacy.registry_root() or DEFAULT_REGISTRY_LABEL + grouped[label] = sorted(set(grouped.get(label, []) + here)) + return grouped def _legacy_registries() -> list[TerminalMultiplexer]: diff --git a/src/bmad_loop/tui/app.py b/src/bmad_loop/tui/app.py index 1e7f5486..e0e69013 100644 --- a/src/bmad_loop/tui/app.py +++ b/src/bmad_loop/tui/app.py @@ -1322,13 +1322,15 @@ def _cleanup_sessions_worker(self) -> None: # registry, and a count that quietly excludes them reads as "all clean". # Read after the prune, so it describes what is left standing. Silent on # every platform without a registry namespace. - leftovers = runs.legacy_registry_leftovers(self.project) - if leftovers: + # One toast per registry, naming it: there is more than one legacy + # registry (psmux's default, and any root this process displaced), and + # the operator's next action is to open the one holding these. + for registry, names in runs.legacy_registry_leftovers(self.project).items(): self.call_from_thread( self.notify, - f"{len(leftovers)} session(s) left in the multiplexer's default " - f"registry (not migrated): {', '.join(leftovers)} — see " - "docs/multiplexer-backends.md before removing any of them", + f"{len(names)} session(s) left in {registry} (not migrated): " + f"{', '.join(names)} — see docs/multiplexer-backends.md before " + "removing any of them", severity="warning", ) # A kill that did not verifiably land gets its own toast rather than a diff --git a/tests/test_cli.py b/tests/test_cli.py index 179f51a3..9ea2eff0 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -10681,7 +10681,9 @@ def test_cleanup_names_what_the_migration_left_behind(project, capsys, monkeypat monkeypatch.setattr( runs, "legacy_registry_leftovers", - lambda _p, announced=(): ["bmad-loop-ctl", "bmad-loop-old-1"], + lambda _p, announced=(): { + runs.DEFAULT_REGISTRY_LABEL: ["bmad-loop-ctl", "bmad-loop-old-1"] + }, ) monkeypatch.setattr(launch, "prune_ctl_windows", lambda _p: ([], [], [])) @@ -10699,7 +10701,9 @@ def test_cleanup_dry_run_previews_what_the_migration_would_leave_behind( monkeypatch.setattr(runs, "prune_sessions", lambda _p, dry_run=False: ([], [], set())) monkeypatch.setattr( - runs, "legacy_registry_leftovers", lambda _p, announced=(): ["bmad-loop-old-1"] + runs, + "legacy_registry_leftovers", + lambda _p, announced=(): {runs.DEFAULT_REGISTRY_LABEL: ["bmad-loop-old-1"]}, ) monkeypatch.setattr(launch, "prunable_ctl_windows", lambda _p: []) @@ -10728,7 +10732,7 @@ def test_cleanup_dry_run_hands_the_remainder_the_plan_it_printed(project, capsys def _leftovers(_p, announced=()): seen.append(sorted(announced)) - return [] + return {} monkeypatch.setattr(runs, "legacy_registry_leftovers", _leftovers) monkeypatch.setattr(launch, "prunable_ctl_windows", lambda _p: []) @@ -10749,7 +10753,9 @@ def test_cleanup_json_carries_the_remainder_and_leaves_stderr_empty(project, cap monkeypatch.setattr(runs, "prune_sessions", lambda _p, dry_run=False: ([], [], set())) monkeypatch.setattr( - runs, "legacy_registry_leftovers", lambda _p, announced=(): ["bmad-loop-old-1"] + runs, + "legacy_registry_leftovers", + lambda _p, announced=(): {runs.DEFAULT_REGISTRY_LABEL: ["bmad-loop-old-1"]}, ) monkeypatch.setattr(launch, "prune_ctl_windows", lambda _p: ([], [], [])) @@ -10760,13 +10766,78 @@ def test_cleanup_json_carries_the_remainder_and_leaves_stderr_empty(project, cap assert captured.err == "" +def test_cleanup_names_the_registry_each_leftover_is_actually_in(project, capsys, monkeypatch): + """The reader's next action is to open the registry and look, so the message + has to name the right one. + + There are two legacy registries now — psmux's default, and any absolute + `PSMUX_DATA_DIR` this process displaced — and the sweep reads both. A message + that called them all "the multiplexer's default registry" sent an operator + whose sessions are in their own exported root to a registry those sessions + are not in, and at documentation describing a registry that is not theirs. + + One line per registry, and only for registries that hold something: a line + naming an empty one is the same wrong errand in miniature. + + Ablate the per-registry loop in `_warn_legacy_leftovers` back to a single + line naming the default and the displaced root goes unnamed.""" + from bmad_loop.tui import launch + + theirs = r"D:\their-own-registry" + monkeypatch.setattr(runs, "prune_sessions", lambda _p, dry_run=False: ([], [], set())) + monkeypatch.setattr( + runs, + "legacy_registry_leftovers", + lambda _p, announced=(): { + runs.DEFAULT_REGISTRY_LABEL: ["bmad-loop-ctl"], + theirs: ["bmad-loop-old-1"], + }, + ) + monkeypatch.setattr(launch, "prune_ctl_windows", lambda _p: ([], [], [])) + + assert cli.main(["cleanup", "--project", str(project.project)]) == 0 + err = capsys.readouterr().err + lines = [ln for ln in err.splitlines() if "not migrated" in ln] + assert len(lines) == 2 + # each registry names its OWN sessions, and nobody else's + default_line = next(ln for ln in lines if runs.DEFAULT_REGISTRY_LABEL in ln) + theirs_line = next(ln for ln in lines if theirs in ln) + assert "bmad-loop-ctl" in default_line and "bmad-loop-old-1" not in default_line + assert "bmad-loop-old-1" in theirs_line and "bmad-loop-ctl" not in theirs_line + + +def test_cleanup_json_flattens_the_remainder_to_the_documented_list(project, capsys, monkeypatch): + """`sessions.legacy_leftovers` is a documented list of NAMES in a + schema-versioned document, so the grouping serves the text mode only — + widening the field would be a contract change and a schema bump. + + Ablate the flatten at the `cleanup_document` call and the field carries the + grouping instead, which no consumer of this schema version can read.""" + from bmad_loop.tui import launch + + monkeypatch.setattr(runs, "prune_sessions", lambda _p, dry_run=False: ([], [], set())) + monkeypatch.setattr( + runs, + "legacy_registry_leftovers", + lambda _p, announced=(): { + runs.DEFAULT_REGISTRY_LABEL: ["bmad-loop-ctl"], + r"D:\theirs": ["bmad-loop-old-1"], + }, + ) + monkeypatch.setattr(launch, "prune_ctl_windows", lambda _p: ([], [], [])) + + assert cli.main(["cleanup", "--json", "--project", str(project.project)]) == 0 + doc = json.loads(capsys.readouterr().out) + assert doc["sessions"]["legacy_leftovers"] == ["bmad-loop-ctl", "bmad-loop-old-1"] + + def test_cleanup_says_nothing_about_a_registry_with_no_remainder(project, capsys, monkeypatch): """Silence on the normal path — every platform without a registry namespace, and every already-migrated machine.""" from bmad_loop.tui import launch monkeypatch.setattr(runs, "prune_sessions", lambda _p, dry_run=False: ([], [], set())) - monkeypatch.setattr(runs, "legacy_registry_leftovers", lambda _p, announced=(): []) + monkeypatch.setattr(runs, "legacy_registry_leftovers", lambda _p, announced=(): {}) monkeypatch.setattr(launch, "prune_ctl_windows", lambda _p: ([], [], [])) assert cli.main(["cleanup", "--project", str(project.project)]) == 0 diff --git a/tests/test_runs.py b/tests/test_runs.py index 7cf65ddc..2d336d06 100644 --- a/tests/test_runs.py +++ b/tests/test_runs.py @@ -4471,12 +4471,20 @@ def test_orphan_state_sweep_still_reaps_a_real_orphan_beside_the_registry(tmp_pa class _RegistryMux: """A backend bound to one registry, standing in for the cleanup sweep's - second pass. Only the three verbs the partition and the kill use.""" + second pass. Only the verbs the partition, the kill and the remainder use. - def __init__(self, sessions, tags): + `root` is what `registry_root()` answers: `None` is psmux's own default + registry (the seam deliberately never respells its home cascade), which the + remainder labels `runs.DEFAULT_REGISTRY_LABEL`.""" + + def __init__(self, sessions, tags, root=None): self._sessions, self._tags = sessions, tags + self._root = root self.killed: list[str] = [] + def registry_root(self): + return self._root + def list_sessions(self): return list(self._sessions) @@ -4581,6 +4589,14 @@ def test_export_records_the_root_it_displaced_for_the_migration_sweep(tmp_path, and the sweep is back to psmux's default alone.""" from bmad_loop.adapters import psmux_backend + # Before the export, never after: `monkeypatch.setattr` records whatever it + # finds as the value to restore, so a reset placed *below* a real write would + # record that write and hand it back at teardown. The autouse + # `_isolate_mux_registry` fixture registers the same reset first and so + # restores last (undo is LIFO), which is what keeps that mistake from + # actually leaking — but a test whose own hygiene depends on the ordering of + # a fixture in another file is one edit away from being wrong. + monkeypatch.setattr(psmux_backend, "_DISPLACED_ROOT", None) theirs = str(tmp_path / "their-own-registry") monkeypatch.setenv(runs.PSMUX_DATA_DIR, theirs) @@ -4588,9 +4604,20 @@ def test_export_records_the_root_it_displaced_for_the_migration_sweep(tmp_path, assert root == str(runs.mux_registry_root(tmp_path)) != theirs assert psmux_backend._DISPLACED_ROOT == theirs - # ...and it is not recorded when nothing was displaced (a pane child of this - # project's own session, the ordinary way the variable is already set). + +def test_export_records_nothing_when_it_displaced_nothing(tmp_path, monkeypatch): + """The other half, split into its own test rather than reset mid-body: a pane + child of this project's own session already carries the derived root, which is + the ordinary way the variable is set, and recording it would hand the sweep + this project's *current* registry as a legacy one. + + Ablate the `displaced != root` guard in `export_psmux_registry_root` and this + fails.""" + from bmad_loop.adapters import psmux_backend + monkeypatch.setattr(psmux_backend, "_DISPLACED_ROOT", None) + monkeypatch.setenv(runs.PSMUX_DATA_DIR, str(runs.mux_registry_root(tmp_path))) + runs.export_psmux_registry_root(tmp_path) assert psmux_backend._DISPLACED_ROOT is None @@ -4776,7 +4803,48 @@ def test_legacy_registry_leftovers_names_an_untagged_session(tmp_path, monkeypat excludes what it chose not to claim reads as "everything is clean".""" legacy = _RegistryMux(["bmad-loop-old-1"], {}) monkeypatch.setattr(runs, "_legacy_registries", lambda: [legacy]) - assert runs.legacy_registry_leftovers(tmp_path) == ["bmad-loop-old-1"] + assert runs.legacy_registry_leftovers(tmp_path) == { + runs.DEFAULT_REGISTRY_LABEL: ["bmad-loop-old-1"] + } + + +def test_legacy_registry_leftovers_keys_each_session_to_its_own_registry(tmp_path, monkeypatch): + """The grouping is the whole point of the shape: the operator's next action is + to open the registry and look, and there are two of them now — psmux's own + default, and any absolute `PSMUX_DATA_DIR` this process displaced. + + A flat list, or a grouping that keyed everything on the default, told an + operator whose sessions are in their own exported root to go look in a + registry those sessions are not in. + + `registry_root()` answers `None` for psmux's default — the seam deliberately + never respells its home cascade — so that arm is labelled instead. + + Ablate `legacy.registry_root() or DEFAULT_REGISTRY_LABEL` down to the + constant and both keys collapse into one.""" + theirs = r"D: heir-own-registry" + default_reg = _RegistryMux(["bmad-loop-ctl"], {}) + displaced = _RegistryMux(["bmad-loop-old-1"], {}, root=theirs) + monkeypatch.setattr(runs, "_legacy_registries", lambda: [default_reg, displaced]) + + assert runs.legacy_registry_leftovers(tmp_path) == { + runs.DEFAULT_REGISTRY_LABEL: ["bmad-loop-ctl"], + theirs: ["bmad-loop-old-1"], + } + + +def test_legacy_registry_leftovers_merges_two_registries_that_name_one_root(tmp_path, monkeypatch): + """A displaced root that happens to spell psmux's own default is admitted + twice, and the rows merge rather than the second overwriting the first. + + Ablate the `grouped.get(label, [])` merge and the first registry's sessions + vanish from a message that claims to name what is standing.""" + both = _RegistryMux(["bmad-loop-a"], {}), _RegistryMux(["bmad-loop-b"], {}) + monkeypatch.setattr(runs, "_legacy_registries", lambda: list(both)) + + assert runs.legacy_registry_leftovers(tmp_path) == { + runs.DEFAULT_REGISTRY_LABEL: ["bmad-loop-a", "bmad-loop-b"] + } def test_legacy_registry_leftovers_names_a_surviving_control_session(tmp_path, monkeypatch): @@ -4785,7 +4853,9 @@ def test_legacy_registry_leftovers_names_a_surviving_control_session(tmp_path, m the migration. Naming it is the whole remedy.""" legacy = _RegistryMux([runs.CTL_SESSION], {}) monkeypatch.setattr(runs, "_legacy_registries", lambda: [legacy]) - assert runs.legacy_registry_leftovers(tmp_path) == [runs.CTL_SESSION] + assert runs.legacy_registry_leftovers(tmp_path) == { + runs.DEFAULT_REGISTRY_LABEL: [runs.CTL_SESSION] + } def test_legacy_registry_leftovers_degrades_on_a_transport_fault(tmp_path, monkeypatch): @@ -4798,12 +4868,12 @@ def list_sessions(self): raise MultiplexerError("no server") monkeypatch.setattr(runs, "_legacy_registries", lambda: [_Broken([], {})]) - assert runs.legacy_registry_leftovers(tmp_path) == [] + assert runs.legacy_registry_leftovers(tmp_path) == {} def test_legacy_registry_leftovers_is_empty_with_no_legacy_registry(tmp_path, monkeypatch): monkeypatch.setattr(runs, "_legacy_registries", lambda: []) - assert runs.legacy_registry_leftovers(tmp_path) == [] + assert runs.legacy_registry_leftovers(tmp_path) == {} # ------------------ legacy remainder: our own stranded sessions (#537) @@ -4822,7 +4892,9 @@ def test_legacy_registry_leftovers_names_our_own_live_session(tmp_path, monkeypa assert runs.prune_sessions(tmp_path) == ([], ["live-1"], set()) assert legacy.killed == [] # ...and the remainder says so - assert runs.legacy_registry_leftovers(tmp_path) == ["bmad-loop-live-1"] + assert runs.legacy_registry_leftovers(tmp_path) == { + runs.DEFAULT_REGISTRY_LABEL: ["bmad-loop-live-1"] + } def test_legacy_registry_leftovers_stays_quiet_about_a_dead_session_the_sweep_takes( @@ -4838,7 +4910,7 @@ def test_legacy_registry_leftovers_stays_quiet_about_a_dead_session_the_sweep_ta plan = runs.prune_sessions(tmp_path, dry_run=True) assert plan == (["fin-1"], [], set()) - assert runs.legacy_registry_leftovers(tmp_path, announced=plan[0]) == [] + assert runs.legacy_registry_leftovers(tmp_path, announced=plan[0]) == {} def test_legacy_registry_leftovers_still_stays_quiet_about_another_projects_session( @@ -4852,7 +4924,7 @@ def test_legacy_registry_leftovers_still_stays_quiet_about_another_projects_sess {"bmad-loop-theirs-1": "0123456789abcdef"}, ) monkeypatch.setattr(runs, "_legacy_registries", lambda: [legacy]) - assert runs.legacy_registry_leftovers(tmp_path) == [] + assert runs.legacy_registry_leftovers(tmp_path) == {} # ---------------- legacy remainder: presence, not a resampled partition (#537) @@ -4894,7 +4966,9 @@ def test_legacy_leftovers_names_a_session_whose_engine_exited_mid_sweep(tmp_path assert runs.prune_sessions(tmp_path) == ([], ["race-live"], set()) assert legacy.killed == [] # ...and the reader names it even though it now looks prunable - assert runs.legacy_registry_leftovers(tmp_path) == ["bmad-loop-race-live"] + assert runs.legacy_registry_leftovers(tmp_path) == { + runs.DEFAULT_REGISTRY_LABEL: ["bmad-loop-race-live"] + } def test_legacy_leftovers_names_a_session_whose_kill_did_not_land(tmp_path, monkeypatch): @@ -4912,7 +4986,9 @@ def kill_session(self, name): assert runs.prune_sessions(tmp_path) == (["fin-1"], [], set()) assert legacy.killed == ["bmad-loop-fin-1"] - assert runs.legacy_registry_leftovers(tmp_path) == ["bmad-loop-fin-1"] + assert runs.legacy_registry_leftovers(tmp_path) == { + runs.DEFAULT_REGISTRY_LABEL: ["bmad-loop-fin-1"] + } def test_legacy_leftovers_is_quiet_once_the_sweep_actually_removed_the_session( @@ -4932,7 +5008,7 @@ def kill_session(self, name): monkeypatch.setattr(runs, "_legacy_registries", lambda: [legacy]) assert runs.prune_sessions(tmp_path) == (["fin-1"], [], set()) - assert runs.legacy_registry_leftovers(tmp_path) == [] + assert runs.legacy_registry_leftovers(tmp_path) == {} def test_legacy_leftovers_dry_run_excludes_what_the_preview_announced(tmp_path, monkeypatch): @@ -4950,7 +5026,9 @@ def test_legacy_leftovers_dry_run_excludes_what_the_preview_announced(tmp_path, plan = runs.prune_sessions(tmp_path, dry_run=True) assert plan == (["fin-1"], ["live-1"], set()) - assert runs.legacy_registry_leftovers(tmp_path, announced=plan[0]) == ["bmad-loop-live-1"] + assert runs.legacy_registry_leftovers(tmp_path, announced=plan[0]) == { + runs.DEFAULT_REGISTRY_LABEL: ["bmad-loop-live-1"] + } def test_legacy_leftovers_dry_run_never_drops_what_the_preview_did_not_announce( @@ -4978,4 +5056,6 @@ def test_legacy_leftovers_dry_run_never_drops_what_the_preview_did_not_announce( plan = runs.prune_sessions(tmp_path, dry_run=True) assert plan == ([], ["race-live"], set()) # nothing announced as a would-kill assert legacy.killed == [] - assert runs.legacy_registry_leftovers(tmp_path, announced=plan[0]) == ["bmad-loop-race-live"] + assert runs.legacy_registry_leftovers(tmp_path, announced=plan[0]) == { + runs.DEFAULT_REGISTRY_LABEL: ["bmad-loop-race-live"] + } diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py index a9e71d9e..2e22b60c 100644 --- a/tests/test_tui_app.py +++ b/tests/test_tui_app.py @@ -2824,7 +2824,12 @@ async def test_cleanup_warns_about_sessions_left_in_the_legacy_registry(project, monkeypatch.setattr(runs, "prune_sessions", lambda _p: ([], [], set())) monkeypatch.setattr(launch, "prune_ctl_windows", lambda _p: ([], [], [])) monkeypatch.setattr( - runs, "legacy_registry_leftovers", lambda _p: ["bmad-loop-ctl", "bmad-loop-old-1"] + runs, + "legacy_registry_leftovers", + lambda _p: { + runs.DEFAULT_REGISTRY_LABEL: ["bmad-loop-ctl"], + r"D: heir-own-registry": ["bmad-loop-old-1"], + }, ) make_run(project.project, "20260611-100000-aaaa") app = BmadLoopApp(project.project) @@ -2833,11 +2838,22 @@ async def test_cleanup_warns_about_sessions_left_in_the_legacy_registry(project, await pilot.press("c") await until(pilot, lambda: isinstance(app.screen, ConfirmModal)) await pilot.click(await ready(pilot, "#ok")) + # One toast per registry, each naming its own — the CLI arm's twin. + # A single toast calling both "the default registry" sent an operator + # whose sessions are in their own displaced root to the wrong place. + await until( + pilot, + lambda: any( + f"1 session(s) left in {runs.DEFAULT_REGISTRY_LABEL}" in m + and "bmad-loop-ctl" in m + and "bmad-loop-old-1" not in m + for m in notifications(app) + ), + ) await until( pilot, lambda: any( - "2 session(s) left in the multiplexer's default registry" in m - and "bmad-loop-old-1" in m + r"1 session(s) left in D: heir-own-registry" in m and "bmad-loop-old-1" in m for m in notifications(app) ), ) From efd6f73dc2b29d634777bdd7fd15aebecc7cf164 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Davor=20Raci=C4=87?= Date: Wed, 26 Aug 2026 16:10:21 +0200 Subject: [PATCH 4/7] fix(runs,docs): ask the ctl and would-kill questions per registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The legacy-leftovers reader asked two questions with answers that are not global, and `stop`'s registry scope went unstated where the docs send an operator to it. The control-session shape test read the name as spelled, so a legacy `bmad-loop-CTL-<16 hex>` — the control session itself on a store that opens names case-insensitively — missed that branch, fell through the agent-session parser (which refuses every ctl-aliasing id, case-folded) and went unreported by both arms. A blanket fold is not the fix: on a case-sensitive transport the same name is a distinct session bmad-loop cannot have minted, and naming it would point the operator at somebody else's. Ask the bound registry through `session_name_key`, which is where that answer lives. The dry-run exclusion took the prune's flat plan, which unions every pass, so a would-kill in the primary registry silenced a same-named session in a legacy one that the legacy pass — running with `require_tag=True` — deliberately cannot claim: a preview disagreeing with the cleanup it previews. Scope it to the tagged-ours arm, the only arm that registry's own pass can announce from. No per-registry plan is threaded down: liveness comes from the run directory, one per (project, run id), so an id the primary pass judged dead the legacy pass judges dead too. `stop` is left as it is, and now says so. Its two channels address the engine process, not a session, so a pre-upgrade run stops wherever it lives; only the backstop session kill is registry-scoped, and widening that would be a by-name kill without tag proof in a registry shared with other projects — including the displaced ambient root, which is per environment, not per project. `cleanup`'s legacy pass is the path that reaches such a session. --- CHANGELOG.md | 4 +- docs/multiplexer-backends.md | 13 +++++++ src/bmad_loop/runs.py | 67 ++++++++++++++++++++++++++++---- tests/test_runs.py | 75 +++++++++++++++++++++++++++++++++++- 4 files changed, 149 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8de97b34..59710d0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -288,7 +288,9 @@ resolve` manufactures exactly that dual-key spec, inserting `baseline_revision` run under any other `ctl-*` id keeps its genuine agent session reachable in the registry the process addresses: `stop` kills it there by its exact name, and `cleanup` sweeps it like any other run's — including, for a tagged pre-upgrade session left in - psmux's old default registry, through the legacy pass (`stop` does not reach that registry). + a legacy registry, through the legacy pass (`stop` reaches no registry but the one this + process exported; the stop itself is registry-independent, only its backstop session kill is + scoped). - **TUI: a graceful-stop request that cannot be written is reported, not fatal.** The `S` worker caught only the helper's own refusals; an `OSError` from the write itself escaped, and Textual's default `exit_on_error` took the dashboard down with it. It now surfaces diff --git a/docs/multiplexer-backends.md b/docs/multiplexer-backends.md index 7bcc5e69..8fe3f03d 100644 --- a/docs/multiplexer-backends.md +++ b/docs/multiplexer-backends.md @@ -197,6 +197,19 @@ Two further consequences: `bmad-loop list --project ` and stop it through bmad-loop (`bmad-loop stop `, or the TUI) rather than through psmux. + **`stop` works there, but it does not sweep the old registry.** The stop reaches the engine + _process_, not a session: it lodges a request in the run directory and signals the recorded pid, + and a run directory belongs to a project and a run id, not to a registry. So a pre-upgrade run + stops, and a still-live engine tears its own window down under the registry it was launched with. + What `stop` does not reach is its own backstop kill — that one addresses the registry bmad-loop + exported for this project, so an agent session an already-dead engine left behind in an older + registry stays standing and the run is still marked stopped. It is `bmad-loop cleanup` that + reaches those, through the legacy pass described above: a session carrying this project's tag is + killed there, and anything the tag rule declines is named on the leftovers line with the registry + it is in. `stop` is deliberately not widened to match — a by-name kill in a registry shared with + other projects, without that tag proof, could take a neighbour's same-named session, since run + ids are unique only within one project. + Finished windows can simply be left parked — a parked window costs one idle shell. To close one, `psmux kill-window -t bmad-loop-ctl:` kills only that window's own children, so it is safe once `capture-pane` has shown you the banner. Closing the last window ends the session too, diff --git a/src/bmad_loop/runs.py b/src/bmad_loop/runs.py index 7b2f31e7..86f55629 100644 --- a/src/bmad_loop/runs.py +++ b/src/bmad_loop/runs.py @@ -1646,7 +1646,12 @@ def legacy_registry_leftovers( - **A surviving control session.** The prune never touches a ctl-named session (:func:`is_ctl_session_name`), and its parked windows are not swept in a legacy registry either — the ctl-window scan runs against the primary - backend only. + backend only. The shape question is asked through *that registry's* + :meth:`~.adapters.multiplexer.TerminalMultiplexer.session_name_key`, never a + constant fold: on a case-folding store ``bmad-loop-CTL-`` IS the + control session and goes unnamed without it, while on an exact one it is a + distinct session bmad-loop cannot have minted — naming it there would send + the operator after somebody else's. Another project's tagged sessions never appear: the sweep skipping them is the correct outcome, not a remainder. @@ -1657,6 +1662,23 @@ def legacy_registry_leftovers( caller passes the run ids it printed — :func:`prune_sessions`' own return — and they are excluded. + **Excluded only where THIS registry's own pass could have announced it**, which + is the tagged-ours arm and only it. :func:`prune_sessions` unions the ids of + every pass, the *primary* registry's included, so the flat set says no more + than "some registry would kill this id" — while a legacy pass runs with + ``require_tag=True`` and therefore cannot claim an untagged session at all. + Applied to the untagged arm the set hid exactly the remainder this listing + exists for: a dead ``bmad-loop-X`` the primary pass plans to kill, an untagged + ``bmad-loop-X`` over here that the real cleanup leaves and reports, and a + preview of that same cleanup that does not mention it. + + Inside the tagged arm the flat set is exact, so no per-registry plan has to be + threaded down here. Liveness is read from ``run_dir_for(project, run_id)`` — + one directory per (project, id), whatever registry the session sits in — so an + id the primary pass judged dead the legacy pass judges dead too: if the same + id is standing here under a tag proving ours, this pass announced it as well + and the union merely collapsed the two. + Passed in rather than re-derived, and that is the whole point of the parameter. An earlier revision re-ran the partition here to rediscover the plan, which is a *second sample*: a tagged legacy run seen alive by the first (so printed as @@ -1693,18 +1715,22 @@ def legacy_registry_leftovers( continue # observation degrades; the sweep's own report still stands here: list[str] = [] for name in names: - if name in excluded: - continue - if is_ctl_session_name(name): + if is_ctl_session_name(legacy.session_name_key(name)): # A legacy registry holds the pre-#537 fixed name; the shape - # predicate also names any per-registry-named stray. + # predicate also names any per-registry-named stray. Asked + # through THIS registry's own comparison key, never a constant + # fold: whether `bmad-loop-CTL-` denotes the control + # session is the transport's answer to give. here.append(name) continue if _agent_run_id(name) is None: continue # not a bmad-loop agent session at all tag = tags.get(name, "") - if not tag or tag in mine: - here.append(name) + if tag and tag not in mine: + continue # another project's session + if tag and name in excluded: + continue # a would-kill of this registry's own pass (dry run) + here.append(name) if here: # `registry_root()` is a diagnostic and never raises (seam contract). # Two admitted registries could in principle answer the same label — @@ -2090,6 +2116,30 @@ def stop_run(run_dir: Path) -> bool: only channel that can still stop it: the StopRunError refusal below (we decline to force-kill an unverifiable pid), and the ``engine_may_live`` paths where the signal or the kill was refused outright rather than racing us to exit. + + **Registry scope, stated because it is easy to read past.** The stop + itself is registry-independent: both channels address the engine *process* — + the request file lands in the run directory, the signal on the pid recorded + there — and a run directory is per (project, run id), not per registry. So a + pre-upgrade run living in a legacy psmux registry stops, and a still-live + engine tears down its own window under the registry it was launched with. + What is scoped is the backstop below: :func:`kill_session` addresses the + registry THIS process exported, so an agent session an already-dead engine + leaked in a legacy registry is not reached from here and the run is marked + stopped with that session standing. + + Deliberately not widened, and for the reason ``kill_session``'s own docstring + gives: a by-name kill in a registry shared with other projects, without tag + proof, could take a neighbour's same-named session — run ids are unique per + project only. Both legacy registries are shared in exactly that sense. The + displaced one is no exception: it is the *ambient* ``PSMUX_DATA_DIR`` this + process found (:func:`~.adapters.psmux_backend.note_displaced_registry`), so + a profile that exports one exports it into every project's shell and every + one of them kept its pre-upgrade sessions there. That is why the legacy pass + of :func:`prune_sessions` demands the tag in both, and it is the path that + reaches such a session — ``bmad-loop cleanup``, with + :func:`legacy_registry_leftovers` naming whatever the tag rule leaves and the + registry it is in. """ state = load_state(run_dir) if state.finished: @@ -2201,6 +2251,9 @@ def stop_run(run_dir: Path) -> bool: # in case it died before tearing it down. Ahead of everything below, because both # exits from here need it — an engine that honored the stop and died before # tearing its window down leaks the session just as surely as one we killed. + # This is the one registry-scoped step of the stop (see the docstring): it + # addresses the registry this process exported, and `cleanup`'s legacy pass is + # what reaches a session left in an older one. kill_session(run_dir.name) state = load_state(run_dir) if state.stopped: diff --git a/tests/test_runs.py b/tests/test_runs.py index 2d336d06..b5693971 100644 --- a/tests/test_runs.py +++ b/tests/test_runs.py @@ -4475,16 +4475,24 @@ class _RegistryMux: `root` is what `registry_root()` answers: `None` is psmux's own default registry (the seam deliberately never respells its home cascade), which the - remainder labels `runs.DEFAULT_REGISTRY_LABEL`.""" + remainder labels `runs.DEFAULT_REGISTRY_LABEL`. - def __init__(self, sessions, tags, root=None): + `fold` is the transport's name comparison: the seam's identity default + (tmux, exact) unless set, `name.lower()` when set (psmux, whose registry is + a directory of per-session files NTFS opens case-insensitively).""" + + def __init__(self, sessions, tags, root=None, fold=False): self._sessions, self._tags = sessions, tags self._root = root + self._fold = fold self.killed: list[str] = [] def registry_root(self): return self._root + def session_name_key(self, name): + return name.lower() if self._fold else name + def list_sessions(self): return list(self._sessions) @@ -4858,6 +4866,38 @@ def test_legacy_registry_leftovers_names_a_surviving_control_session(tmp_path, m } +def test_legacy_leftovers_names_a_case_variant_ctl_where_the_transport_folds(tmp_path, monkeypatch): + """psmux resolves a session by opening `\\.port`, and NTFS + opens names case-insensitively, so in ITS registry `bmad-loop-CTL-` is + the control session. Asking `is_ctl_session_name` about the name as spelled + misses it, and it then falls through `_agent_run_id` — which refuses every + ctl-aliasing id, case-folded — so the leftover goes unreported by both arms. + + Ablate `legacy.session_name_key(name)` back to `name` and this fails with + `{}`: the survivor is standing in a registry nothing else addresses, unnamed.""" + upper = runs.CTL_SESSION.upper() + "-0123456789ABCDEF" + legacy = _RegistryMux([upper], {}, fold=True) + monkeypatch.setattr(runs, "_legacy_registries", lambda: [legacy]) + assert runs.legacy_registry_leftovers(tmp_path) == {runs.DEFAULT_REGISTRY_LABEL: [upper]} + + +def test_legacy_leftovers_leaves_a_case_variant_alone_where_the_transport_is_exact( + tmp_path, monkeypatch +): + """The other direction, and the reason the fold cannot be a constant here. + On an exact transport (tmux: `bmad-loop-ctl` and `bmad-loop-CTL` coexist as + distinct sessions, measured on 3.4) that name is NOT the control session, and + it is not a session of ours either — the mint refuses every ctl-aliasing id + case-folded, so bmad-loop cannot have created it. Naming it would send the + operator after somebody else's session. + + Ablate to the blanket `.lower()` the review proposed and this fails.""" + upper = runs.CTL_SESSION.upper() + "-0123456789ABCDEF" + legacy = _RegistryMux([upper], {}) # identity key: the seam default + monkeypatch.setattr(runs, "_legacy_registries", lambda: [legacy]) + assert runs.legacy_registry_leftovers(tmp_path) == {} + + def test_legacy_registry_leftovers_degrades_on_a_transport_fault(tmp_path, monkeypatch): """Observation degrades: the sweep's own report still stands, and a migration remainder nobody could read is not a reason to fail a cleanup that already @@ -5059,3 +5099,34 @@ def test_legacy_leftovers_dry_run_never_drops_what_the_preview_did_not_announce( assert runs.legacy_registry_leftovers(tmp_path, announced=plan[0]) == { runs.DEFAULT_REGISTRY_LABEL: ["bmad-loop-race-live"] } + + +def test_legacy_leftovers_dry_run_keeps_what_the_legacy_pass_cannot_claim(tmp_path, monkeypatch): + """`prune_sessions` unions the ids of every pass, so the flat plan says only + "some registry would kill this id" — and applying it here as a global name set + let a would-kill in the PRIMARY registry silence a same-named session in a + legacy one that the legacy pass, running with `require_tag=True`, deliberately + cannot claim. The preview then disagreed with the cleanup it previews. + + Both halves are asserted against the same two registries: the real sweep leaves + and reports the untagged session, and the dry run must say the same thing. + + Ablate by hoisting the exclusion back above the tag arms and the dry-run half + fails with `{}` while the real half still reports it — the disagreement itself.""" + (_make_state_run(tmp_path, "dup") / "engine.pid").write_text(str(_dead_pid())) + ours = _RootedMux(["bmad-loop-dup"], {}, str(runs.mux_registry_root(tmp_path))) + monkeypatch.setattr(runs, "get_multiplexer", lambda: ours) + monkeypatch.setattr(runs, "mux_sessions", ours.list_sessions) + monkeypatch.setattr(runs, "session_project_tags", lambda: {}) + # untagged over there: the run dir proves nothing in a shared registry + legacy = _RegistryMux(["bmad-loop-dup"], {}) + monkeypatch.setattr(runs, "_legacy_registries", lambda: [legacy]) + + plan = runs.prune_sessions(tmp_path, dry_run=True) + assert plan == (["dup"], [], set()) # announced by the primary pass alone + preview = runs.legacy_registry_leftovers(tmp_path, announced=plan[0]) + + assert runs.prune_sessions(tmp_path) == (["dup"], [], set()) + assert legacy.killed == [] # the legacy pass declined it, as it must + assert preview == runs.legacy_registry_leftovers(tmp_path) + assert preview == {runs.DEFAULT_REGISTRY_LABEL: ["bmad-loop-dup"]} From c4cd514e4067fe3f8f17e5c5025177369d23f08a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Davor=20Raci=C4=87?= Date: Wed, 26 Aug 2026 16:47:20 +0200 Subject: [PATCH 5/7] fix(mux): probe after deriving psmux registry root Automatic backend selection probes psmux availability before returning its cached instance. An empty or relative ambient PSMUX_DATA_DIR therefore made the failed version probe stick for the process even after the derived root was exported; probe with the derived root first, then perform the normal export for a namespaced transport. Restore the ambient value when the selected transport has no registry namespace, so tmux and other namespace-less backends do not spend psmux's variable. The legacy refusal-gate ablation changed require_tag=True to False and the targeted test failed with legacy.killed == ['bmad-loop-dup']; the gate was restored. Document stop --project because cmd_stop resolves --project, defaulting to the current directory. The seam ceiling remains explicit: has_registry_namespace() is the only question; backends using another variable are outside this seam, and a finer question is not justified before such a backend exists. --- CONTRIBUTING.md | 4 +++ docs/multiplexer-backends.md | 5 ++-- src/bmad_loop/cli.py | 48 +++++++++++++++++++++++------------- tests/test_cli.py | 22 +++++++++++++++++ tests/test_runs.py | 7 +++++- 5 files changed, 66 insertions(+), 20 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f57fd1db..f72671e8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -96,6 +96,10 @@ trunk check # lint + format verification on changed files, as CI does trunk check --all # the whole repo — catches files your change didn't touch ``` +The checkout uses CRLF while Prettier is configured for LF. A direct local +`prettier --check` can therefore disagree with CI; CI checks LF-normalized +content, so use the trunk commands above for the authoritative result. + ### CHANGELOG **Every user-visible change needs a CHANGELOG entry.** Add it under the `## [Unreleased]` heading in [CHANGELOG.md](CHANGELOG.md), and only under one of the six [Keep a Changelog](https://keepachangelog.com) subsections — `Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, `Security`. Keep entries terse, scannable, and imperative. diff --git a/docs/multiplexer-backends.md b/docs/multiplexer-backends.md index 8fe3f03d..877b6802 100644 --- a/docs/multiplexer-backends.md +++ b/docs/multiplexer-backends.md @@ -194,8 +194,9 @@ Two further consequences: A window whose last line reads `[bmad-loop exited — press enter]` has finished. One that does not is still running — take its `run-` / `sweep-` name to - `bmad-loop list --project ` and stop it through bmad-loop (`bmad-loop stop -`, or the TUI) rather than through psmux. + `bmad-loop list --project ` and stop it through bmad-loop + (`bmad-loop stop --project `, or run it from that project) + rather than through psmux. **`stop` works there, but it does not sweep the old registry.** The stop reaches the engine _process_, not a session: it lodges a request in the run directory and signals the recorded pid, diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index 753f4c0d..e92f8e26 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -181,27 +181,41 @@ def _configure_mux(project: Path) -> None: except (policy_mod.PolicyError, OSError): name = None configure_multiplexer(name, origin=path) - # Selection first, because the export is a *psmux* fact and not every host - # runs psmux. On tmux there is no registry for a root to point at, and - # `PSMUX_DATA_DIR` is not bmad-loop's variable to spend: replacing it there - # would announce a registry the transport never consults, and the operator's - # own psmux sessions would then be unreachable from every window this - # process spawns — a tmux server cold-started here passes the replacement - # down to each of its coding-CLI panes. Ceiling, named: the gate is the - # seam's own "does this transport namespace by registry at all", so an - # out-of-tree backend that namespaces through some *other* variable still - # sees the export. The seam has no finer question, and adding one for a - # backend that does not exist yet is the wrong trade — `bmad-loop mux` - # discloses the root either way. + # Automatic selection probes availability before returning its cached + # instance. Give that probe the derived root first: psmux's version probe + # reaches `_run`, which must reject an empty/relative ambient value, and a + # failed probe stays cached for this process. Restore the ambient value + # before the real export when the selected transport has no registry. + # The gate asks only the seam's `has_registry_namespace()` question. Ceiling, + # named: an out-of-tree backend that namespaces through some other variable + # still sees this export, because the seam has no finer question. Adding one + # for a backend that does not exist yet is not worth expanding the seam; + # `bmad-loop mux` discloses the root either way. + ambient = os.environ.get(runs.PSMUX_DATA_DIR) try: - if not get_multiplexer().has_registry_namespace(): - return + probe_root = str(runs.mux_registry_root(project)) + except (runs.StateRootError, OSError, RuntimeError): + probe_root = None + if probe_root is not None: + os.environ[runs.PSMUX_DATA_DIR] = probe_root + try: + namespaced = get_multiplexer().has_registry_namespace() except MultiplexerError: + if probe_root is not None: + if ambient is None: + os.environ.pop(runs.PSMUX_DATA_DIR, None) + else: + os.environ[runs.PSMUX_DATA_DIR] = ambient # A backend that cannot even be selected runs no verb, so there is - # nothing to point anywhere; the commands that need it fail loudly on - # their own and diagnostics keep working. + # nothing to point anywhere; diagnostics keep working. + return + if not namespaced: + if probe_root is not None: + if ambient is None: + os.environ.pop(runs.PSMUX_DATA_DIR, None) + else: + os.environ[runs.PSMUX_DATA_DIR] = ambient return - ambient = os.environ.get(runs.PSMUX_DATA_DIR) root = runs.export_psmux_registry_root(project) if root is not None: if ambient is not None and ambient != root: diff --git a/tests/test_cli.py b/tests/test_cli.py index 9ea2eff0..eb09598b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -10383,6 +10383,28 @@ def handler(args): assert seen["root"] == str(runs.mux_registry_root(tmp_path)) +def test_main_derives_registry_before_automatic_availability_probe(tmp_path, monkeypatch): + """An automatic psmux availability probe must see the derived root. + + Its result is cached by ``get_multiplexer``; probing an ambient relative + root first would therefore leave later launches unavailable in this process. + """ + monkeypatch.setenv(envvars.STATE_DIR, str(tmp_path / "state")) + monkeypatch.setenv(runs.PSMUX_DATA_DIR, "relative-registry") + seen = [] + + class _Namespaced: + def has_registry_namespace(self): + seen.append(os.environ[runs.PSMUX_DATA_DIR]) + return True + + monkeypatch.setattr(mux_mod, "get_multiplexer", lambda: _Namespaced()) + monkeypatch.setattr(cli, "cmd_list", lambda _args: 0) + + assert cli.main(["list", "--project", str(tmp_path)]) == 0 + assert seen == [str(runs.mux_registry_root(tmp_path))] + + def test_main_says_once_when_it_overrode_an_operators_registry( force_psmux_backend, tmp_path, capsys, monkeypatch ): diff --git a/tests/test_runs.py b/tests/test_runs.py index b5693971..6e041005 100644 --- a/tests/test_runs.py +++ b/tests/test_runs.py @@ -5112,7 +5112,12 @@ def test_legacy_leftovers_dry_run_keeps_what_the_legacy_pass_cannot_claim(tmp_pa and reports the untagged session, and the dry run must say the same thing. Ablate by hoisting the exclusion back above the tag arms and the dry-run half - fails with `{}` while the real half still reports it — the disagreement itself.""" + fails with `{}` while the real half still reports it — the disagreement itself. + + Legacy refusal-gate ablation: temporarily changed the legacy call's + ``require_tag=True`` to ``False`` and ran this test; it failed as intended, + with ``legacy.killed == ['bmad-loop-dup']`` (the untagged session was killed). + The gate was restored.""" (_make_state_run(tmp_path, "dup") / "engine.pid").write_text(str(_dead_pid())) ours = _RootedMux(["bmad-loop-dup"], {}, str(runs.mux_registry_root(tmp_path))) monkeypatch.setattr(runs, "get_multiplexer", lambda: ours) From 1ae4dafe04ac793ece5511bfd792b63fcb344af5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Davor=20Raci=C4=87?= Date: Wed, 26 Aug 2026 18:25:46 +0200 Subject: [PATCH 6/7] test(backend-registry): pin the package attribute the retry rebinds The Windows py3.11 CI cell failed one test, test_export_records_the_root_it_displaced_for_the_migration_sweep, asserting _DISPLACED_ROOT is None after an export that demonstrably wrote it. The export path is not implicated: the test read the answer off a different module object. test_a_failed_builtin_import_leaves_the_seeding_retryable evicts bmad_loop.adapters.psmux_backend from sys.modules and lets the retry re-import it for real. That re-execution rebinds psmux_backend on the parent package object as well as in sys.modules, and monkeypatch.undo() restores only the sys.modules entry. For the rest of the worker the two spellings disagree: `from bmad_loop.adapters import psmux_backend` is a getattr on the package and answers the new module, while `from bmad_loop.adapters.psmux_backend import x` resolves through sys.modules and answers the original. The victim test, and conftest's _isolate_mux_registry fixture with it, reset and asserted the new module's global while export_psmux_registry_root wrote the original's. Fixed at the leak rather than at the victim, so every later reader of the package attribute is correct -- the fixture included, since its whole job is keeping that global out of the cross-test environment. Same hazard and same fix as the bmad_loop.tui subtree eviction in tests/test_tui_app.py. Nothing under src/ changes. Ablating the pin reproduces the CI assertion exactly when the two tests run in that order; restored, the pair passes. No permanent sentinel test: one is order-dependent under pytest-randomly, and a self-contained replay of the eviction cannot redden if the pin is deleted from the real test. --- tests/test_backend_registry.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/test_backend_registry.py b/tests/test_backend_registry.py index e1c17681..ea6a4ad7 100644 --- a/tests/test_backend_registry.py +++ b/tests/test_backend_registry.py @@ -17,6 +17,7 @@ import pytest +from bmad_loop import adapters from bmad_loop.adapters import multiplexer as m from bmad_loop.adapters.multiplexer import MultiplexerError from bmad_loop.adapters.psmux_backend import PsmuxMultiplexer @@ -587,6 +588,18 @@ def test_a_failed_builtin_import_leaves_the_seeding_retryable(fresh_registry, mo # that property. The adapter twin sets its flag at the very top only because # its builtins are lazy thunks with nothing to import first. key = "bmad_loop.adapters.psmux_backend" + # Evicting the entry alone leaks: the retry below re-imports the module for + # real, which rebinds `psmux_backend` on the *parent package object* to the + # new module, and restoring sys.modules does not undo that rebinding. Pin the + # attribute through monkeypatch so the original comes back with it. Without + # it the two import spellings disagree for the rest of the worker -- + # `from bmad_loop.adapters import psmux_backend` is a getattr on the package + # and answers the new module, while `from bmad_loop.adapters.psmux_backend + # import x` resolves through sys.modules and answers the original -- so a + # later test asserts on one module's globals while the code under test writes + # the other's. (Same hazard, same fix, as the `bmad_loop.tui` eviction in + # tests/test_tui_app.py.) + monkeypatch.setattr(adapters, "psmux_backend", sys.modules[key]) # A None value in sys.modules makes `from ... import ...` raise # ModuleNotFoundError (an ImportError subclass) without touching the disk. monkeypatch.setitem(sys.modules, key, None) From 851d8872ce4ede65de940f5edc49ca0b93daa573 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Davor=20Raci=C4=87?= Date: Sat, 29 Aug 2026 18:08:22 +0200 Subject: [PATCH 7/7] fix(cli): hand the export the displaced registry, not the probe's own root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The availability probe's `PSMUX_DATA_DIR` override was restored only on the two namespace-less arms, so on psmux it was still standing when `export_psmux_registry_root` ran. The export reads the variable to learn which registry it is displacing; it read back the derived root the probe had just written, found it equal to the value it was about to write, and skipped `note_displaced_registry`. The cost is the one that hand-off exists to prevent: a machine that had an absolute `PSMUX_DATA_DIR` exported before the upgrade keeps its pre-upgrade sessions in that registry, `legacy_registries()` can no longer name it, and cleanup reports a clean machine while those coding processes run on. The restore moves into a `finally`, which also collapses the two duplicated restore blocks into one. Both registry-level halves were already pinned in test_runs.py and both still passed — only the composition could catch this, so the new test is at the `_configure_mux` level. --- src/bmad_loop/cli.py | 21 +++++++++++++-------- tests/test_cli.py | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index e92f8e26..e70647cb 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -184,8 +184,9 @@ def _configure_mux(project: Path) -> None: # Automatic selection probes availability before returning its cached # instance. Give that probe the derived root first: psmux's version probe # reaches `_run`, which must reject an empty/relative ambient value, and a - # failed probe stays cached for this process. Restore the ambient value - # before the real export when the selected transport has no registry. + # failed probe stays cached for this process. The override is temporary in + # every arm: the ambient value is restored before the real export, which is + # the last reader of it. # The gate asks only the seam's `has_registry_namespace()` question. Ceiling, # named: an out-of-tree backend that namespaces through some other variable # still sees this export, because the seam has no finer question. Adding one @@ -201,20 +202,24 @@ def _configure_mux(project: Path) -> None: try: namespaced = get_multiplexer().has_registry_namespace() except MultiplexerError: - if probe_root is not None: - if ambient is None: - os.environ.pop(runs.PSMUX_DATA_DIR, None) - else: - os.environ[runs.PSMUX_DATA_DIR] = ambient # A backend that cannot even be selected runs no verb, so there is # nothing to point anywhere; diagnostics keep working. return - if not namespaced: + finally: + # Undone on EVERY arm, the psmux one included, because the export below + # is the last reader of the operator's own value. Restore only the + # namespace-less arms and `export_psmux_registry_root` reads the derived + # root back as the displaced one, finds it equal to what it is about to + # write, and skips `note_displaced_registry` — so a machine that had an + # absolute `PSMUX_DATA_DIR` before the upgrade keeps its pre-upgrade + # sessions in a registry `legacy_registries` can no longer name, and + # cleanup reports a clean machine while their coding processes run on. if probe_root is not None: if ambient is None: os.environ.pop(runs.PSMUX_DATA_DIR, None) else: os.environ[runs.PSMUX_DATA_DIR] = ambient + if not namespaced: return root = runs.export_psmux_registry_root(project) if root is not None: diff --git a/tests/test_cli.py b/tests/test_cli.py index eb09598b..b1233c34 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -10541,6 +10541,43 @@ def handler(_args): assert capsys.readouterr().err == "" +def test_main_hands_the_export_the_displaced_registry_not_the_probe_root(tmp_path, monkeypatch): + """The availability probe's override is temporary on the psmux arm too, + because `export_psmux_registry_root` is the last reader of the operator's own + root. + + `_configure_mux` points `PSMUX_DATA_DIR` at the derived root before selection, + since automatic selection probes `available()` and caches a failure. Leave that + override standing and the export reads the DERIVED root back as the displaced + one, finds it equal to the value it is about to write, and records nothing — so + a machine that had an absolute `PSMUX_DATA_DIR` before the upgrade keeps its + pre-upgrade sessions in a registry `legacy_registries()` can no longer name, + and `cleanup` reports a clean machine while their coding processes run on. + + The registry-level halves of this are pinned in test_runs.py; only the + composition can catch it, which is why it is asserted here. + + Ablate by moving the restore back out of the `finally` and into the two + namespace-less arms alone: `_DISPLACED_ROOT` comes back `None`.""" + from bmad_loop.adapters import multiplexer as multiplexer_mod + from bmad_loop.adapters import psmux_backend + + class _Namespaced: + def has_registry_namespace(self): + return True + + monkeypatch.setattr(psmux_backend, "_DISPLACED_ROOT", None) + theirs = str(tmp_path / "their-own-registry") + monkeypatch.setenv(runs.PSMUX_DATA_DIR, theirs) + monkeypatch.setattr(multiplexer_mod, "get_multiplexer", lambda: _Namespaced()) + monkeypatch.setattr(cli, "cmd_list", lambda _args: 0) + + assert cli.main(["list", "--project", str(tmp_path)]) == 0 + assert psmux_backend._DISPLACED_ROOT == theirs + # The restore is a hand-off, not an abandonment: the export still landed. + assert os.environ[runs.PSMUX_DATA_DIR] == str(runs.mux_registry_root(tmp_path)) + + def test_main_leaves_psmux_data_dir_alone_when_no_backend_can_be_selected( tmp_path, capsys, monkeypatch ):