diff --git a/CHANGELOG.md b/CHANGELOG.md index 11b1be41..59710d0f 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,23 @@ 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` 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 + 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>` — 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 + 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/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/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..877b6802 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,184 @@ 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 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` + 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, 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}' + ``` + + > **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 --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, + 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, + 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..2fb9720d 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, 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 + (`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..4b741867 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,53 @@ 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") + + +# 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. @@ -101,6 +163,218 @@ class PsmuxMultiplexer(BaseTmuxBackend): _ENCODING = "utf-8" _ERRORS = "backslashreplace" + 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 + 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`). + + ``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 + 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. 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, + 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: + 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 " + "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. + + 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. + """ + 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 + # `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"]: + """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. 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) + 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) # A command pwsh could not even start (not recognized) still runs the rest of @@ -187,8 +461,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..e70647cb 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -146,14 +146,34 @@ 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.""" - from .adapters.multiplexer import configure_multiplexer + 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 MultiplexerError, configure_multiplexer, get_multiplexer path = _policy_path(project) try: @@ -161,6 +181,80 @@ def _configure_mux(project: Path) -> None: except (policy_mod.PolicyError, OSError): name = None configure_multiplexer(name, origin=path) + # 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. 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 + # 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: + 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: + # A backend that cannot even be selected runs no verb, so there is + # nothing to point anywhere; diagnostics keep working. + return + 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: + 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: 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: @@ -171,7 +265,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 +989,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 +2557,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 +3026,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 +3225,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 +3935,46 @@ def cmd_archive(args: argparse.Namespace) -> int: return 0 +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 + 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. + + **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 + 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.""" + 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: from .adapters.multiplexer import MultiplexerError from .tui import launch # pure stdlib; no textual import @@ -3741,6 +3983,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 +4036,10 @@ def cmd_cleanup(args: argparse.Namespace) -> int: windows_survived=survived, windows_unverifiable=unverifiable, scan_error=scan_error, + # 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 @@ -3801,6 +4053,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 +4071,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..86f55629 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,205 @@ 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. + + **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 + 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 + 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 + + +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 +1175,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 +1403,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 +1443,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 +1487,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 +1550,211 @@ 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 +#: 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 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. + 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. 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. + + ``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. + + **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 + 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 — under the registry this maps them to, + which is the other half of what makes them pasteable. + """ + 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 + # "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 + here: list[str] = [] + for name in names: + 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. 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 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 — + # 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]: + """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 @@ -1400,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: @@ -1511,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: @@ -1598,17 +2341,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: - if name not in mux_sessions(): + 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 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 +2826,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..e0e69013 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,22 @@ 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. + # 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(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 # 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..d2a95626 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 @@ -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) @@ -419,6 +438,48 @@ 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). + + 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") def _project_template( tmp_path_factory: pytest.TempPathFactory, _isolate_ambient_git_ignores: None 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) 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..b1233c34 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,543 @@ 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(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 + 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_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 +): + """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( + 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 + 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( + 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 + 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_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_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 +): + """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 + 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=(): { + runs.DEFAULT_REGISTRY_LABEL: ["bmad-loop-ctl", "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 + 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=(): {runs.DEFAULT_REGISTRY_LABEL: ["bmad-loop-old-1"]}, + ) + monkeypatch.setattr(launch, "prunable_ctl_windows", lambda _p: []) + + assert cli.main(["cleanup", "--dry-run", "--project", str(project.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.project)]) == 0 + assert seen == [["old-1"]] + + seen.clear() + assert cli.main(["cleanup", "--project", str(project.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=(): {runs.DEFAULT_REGISTRY_LABEL: ["bmad-loop-old-1"]}, + ) + monkeypatch.setattr(launch, "prune_ctl_windows", lambda _p: ([], [], [])) + + 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"] + 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(launch, "prune_ctl_windows", lambda _p: ([], [], [])) + + assert cli.main(["cleanup", "--project", str(project.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..89abb39e 100644 --- a/tests/test_probe.py +++ b/tests/test_probe.py @@ -885,3 +885,62 @@ 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 `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: + 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..a9b17f9c 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,223 @@ 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() == [] + + +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 + 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..6e041005 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,1278 @@ 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 verbs the partition, the kill and the remainder use. + + `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`. + + `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) + + 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") + 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") + 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_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 + + # 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) + + root = runs.export_psmux_registry_root(tmp_path) + assert root == str(runs.mux_registry_root(tmp_path)) != theirs + assert psmux_backend._DISPLACED_ROOT == theirs + + +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 + + +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) == { + 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): + """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.DEFAULT_REGISTRY_LABEL: [runs.CTL_SESSION] + } + + +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 + 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) == { + runs.DEFAULT_REGISTRY_LABEL: ["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) == { + runs.DEFAULT_REGISTRY_LABEL: ["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) == { + runs.DEFAULT_REGISTRY_LABEL: ["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]) == { + runs.DEFAULT_REGISTRY_LABEL: ["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]) == { + 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. + + 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) + 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"]} diff --git a/tests/test_tui_app.py b/tests/test_tui_app.py index 37809066..2e22b60c 100644 --- a/tests/test_tui_app.py +++ b/tests/test_tui_app.py @@ -2764,6 +2764,101 @@ 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: { + 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) + 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")) + # 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( + r"1 session(s) left in D: heir-own-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 +3422,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 +3467,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 +3530,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)