Skip to content

Commit 3178b7d

Browse files
committed
fix(mux): probe after deriving psmux registry root
Automatic backend selection probes psmux availability before returning its cached instance. An empty or relative ambient PSMUX_DATA_DIR therefore made the failed version probe stick for the process even after the derived root was exported; probe with the derived root first, then perform the normal export for a namespaced transport. Restore the ambient value when the selected transport has no registry namespace, so tmux and other namespace-less backends do not spend psmux's variable. The legacy refusal-gate ablation changed require_tag=True to False and the targeted test failed with legacy.killed == ['bmad-loop-dup']; the gate was restored. Document stop --project <that project> <run-id> because cmd_stop resolves --project, defaulting to the current directory. The seam ceiling remains explicit: has_registry_namespace() is the only question; backends using another variable are outside this seam, and a finer question is not justified before such a backend exists.
1 parent 315d86b commit 3178b7d

5 files changed

Lines changed: 66 additions & 20 deletions

File tree

CONTRIBUTING.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,10 @@ trunk check # lint + format verification on changed files, as CI does
9696
trunk check --all # the whole repo — catches files your change didn't touch
9797
```
9898

99+
The checkout uses CRLF while Prettier is configured for LF. A direct local
100+
`prettier --check` can therefore disagree with CI; CI checks LF-normalized
101+
content, so use the trunk commands above for the authoritative result.
102+
99103
### CHANGELOG
100104

101105
**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.

docs/multiplexer-backends.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -194,8 +194,9 @@ Two further consequences:
194194

195195
A window whose last line reads `[bmad-loop exited <code> — press enter]` has finished. One that
196196
does not is still running — take its `run-<run-id>` / `sweep-<run-id>` name to
197-
`bmad-loop list --project <that project>` and stop it through bmad-loop (`bmad-loop stop
198-
<run-id>`, or the TUI) rather than through psmux.
197+
`bmad-loop list --project <that project>` and stop it through bmad-loop
198+
(`bmad-loop stop --project <that project> <run-id>`, or run it from that project)
199+
rather than through psmux.
199200

200201
**`stop` works there, but it does not sweep the old registry.** The stop reaches the engine
201202
_process_, not a session: it lodges a request in the run directory and signals the recorded pid,

src/bmad_loop/cli.py

Lines changed: 31 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -181,27 +181,41 @@ def _configure_mux(project: Path) -> None:
181181
except (policy_mod.PolicyError, OSError):
182182
name = None
183183
configure_multiplexer(name, origin=path)
184-
# Selection first, because the export is a *psmux* fact and not every host
185-
# runs psmux. On tmux there is no registry for a root to point at, and
186-
# `PSMUX_DATA_DIR` is not bmad-loop's variable to spend: replacing it there
187-
# would announce a registry the transport never consults, and the operator's
188-
# own psmux sessions would then be unreachable from every window this
189-
# process spawns — a tmux server cold-started here passes the replacement
190-
# down to each of its coding-CLI panes. Ceiling, named: the gate is the
191-
# seam's own "does this transport namespace by registry at all", so an
192-
# out-of-tree backend that namespaces through some *other* variable still
193-
# sees the export. The seam has no finer question, and adding one for a
194-
# backend that does not exist yet is the wrong trade — `bmad-loop mux`
195-
# discloses the root either way.
184+
# Automatic selection probes availability before returning its cached
185+
# instance. Give that probe the derived root first: psmux's version probe
186+
# reaches `_run`, which must reject an empty/relative ambient value, and a
187+
# failed probe stays cached for this process. Restore the ambient value
188+
# before the real export when the selected transport has no registry.
189+
# The gate asks only the seam's `has_registry_namespace()` question. Ceiling,
190+
# named: an out-of-tree backend that namespaces through some other variable
191+
# still sees this export, because the seam has no finer question. Adding one
192+
# for a backend that does not exist yet is not worth expanding the seam;
193+
# `bmad-loop mux` discloses the root either way.
194+
ambient = os.environ.get(runs.PSMUX_DATA_DIR)
196195
try:
197-
if not get_multiplexer().has_registry_namespace():
198-
return
196+
probe_root = str(runs.mux_registry_root(project))
197+
except (runs.StateRootError, OSError, RuntimeError):
198+
probe_root = None
199+
if probe_root is not None:
200+
os.environ[runs.PSMUX_DATA_DIR] = probe_root
201+
try:
202+
namespaced = get_multiplexer().has_registry_namespace()
199203
except MultiplexerError:
204+
if probe_root is not None:
205+
if ambient is None:
206+
os.environ.pop(runs.PSMUX_DATA_DIR, None)
207+
else:
208+
os.environ[runs.PSMUX_DATA_DIR] = ambient
200209
# A backend that cannot even be selected runs no verb, so there is
201-
# nothing to point anywhere; the commands that need it fail loudly on
202-
# their own and diagnostics keep working.
210+
# nothing to point anywhere; diagnostics keep working.
211+
return
212+
if not namespaced:
213+
if probe_root is not None:
214+
if ambient is None:
215+
os.environ.pop(runs.PSMUX_DATA_DIR, None)
216+
else:
217+
os.environ[runs.PSMUX_DATA_DIR] = ambient
203218
return
204-
ambient = os.environ.get(runs.PSMUX_DATA_DIR)
205219
root = runs.export_psmux_registry_root(project)
206220
if root is not None:
207221
if ambient is not None and ambient != root:

tests/test_cli.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9635,6 +9635,28 @@ def handler(args):
96359635
assert seen["root"] == str(runs.mux_registry_root(tmp_path))
96369636

96379637

9638+
def test_main_derives_registry_before_automatic_availability_probe(tmp_path, monkeypatch):
9639+
"""An automatic psmux availability probe must see the derived root.
9640+
9641+
Its result is cached by ``get_multiplexer``; probing an ambient relative
9642+
root first would therefore leave later launches unavailable in this process.
9643+
"""
9644+
monkeypatch.setenv(envvars.STATE_DIR, str(tmp_path / "state"))
9645+
monkeypatch.setenv(runs.PSMUX_DATA_DIR, "relative-registry")
9646+
seen = []
9647+
9648+
class _Namespaced:
9649+
def has_registry_namespace(self):
9650+
seen.append(os.environ[runs.PSMUX_DATA_DIR])
9651+
return True
9652+
9653+
monkeypatch.setattr(mux_mod, "get_multiplexer", lambda: _Namespaced())
9654+
monkeypatch.setattr(cli, "cmd_list", lambda _args: 0)
9655+
9656+
assert cli.main(["list", "--project", str(tmp_path)]) == 0
9657+
assert seen == [str(runs.mux_registry_root(tmp_path))]
9658+
9659+
96389660
def test_main_says_once_when_it_overrode_an_operators_registry(
96399661
force_psmux_backend, tmp_path, capsys, monkeypatch
96409662
):

tests/test_runs.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4985,7 +4985,12 @@ def test_legacy_leftovers_dry_run_keeps_what_the_legacy_pass_cannot_claim(tmp_pa
49854985
and reports the untagged session, and the dry run must say the same thing.
49864986
49874987
Ablate by hoisting the exclusion back above the tag arms and the dry-run half
4988-
fails with `{}` while the real half still reports it — the disagreement itself."""
4988+
fails with `{}` while the real half still reports it — the disagreement itself.
4989+
4990+
Legacy refusal-gate ablation: temporarily changed the legacy call's
4991+
``require_tag=True`` to ``False`` and ran this test; it failed as intended,
4992+
with ``legacy.killed == ['bmad-loop-dup']`` (the untagged session was killed).
4993+
The gate was restored."""
49894994
(_make_state_run(tmp_path, "dup") / "engine.pid").write_text(str(_dead_pid()))
49904995
ours = _RootedMux(["bmad-loop-dup"], {}, str(runs.mux_registry_root(tmp_path)))
49914996
monkeypatch.setattr(runs, "get_multiplexer", lambda: ours)

0 commit comments

Comments
 (0)