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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,17 @@ breaking changes may land in a minor release.

### Added

- Add `[mux] honor_ambient_psmux_data_dir` (default off): on psmux, use an
absolute `PSMUX_DATA_DIR` your profile exports into every shell as the
session registry instead of the derived per-project root; `bmad-loop mux`
says which source won (#729). Such a root can be shared by several projects,
so there a same-named session is killed, attached, read as live or adopted
by a launch only when its project tag proves it this project's; a refusal is
warned about, and a launch that would adopt one fails with the reason. The
same check applies without the opt-in when no state root can be derived and
an ambient `PSMUX_DATA_DIR` stays in force; a teardown kill it refuses is
journalled (`session-kill-refused`). A running TUI refuses launches after
the switch is flipped either way, until restarted.
- Add `[environment] probes` (+ `probe_timeout_s`): operator health checks run
before `[verify]` commands, before each dev and review session launch, and
before a failed attempt is charged; a failing, hanging or unrunnable probe
Expand Down Expand Up @@ -36,6 +47,13 @@ breaking changes may land in a minor release.

### Fixed

- Kill a resumed run's stale psmux session in the registry it predates (the
displaced or pre-#537 default root, or the derived one after opting in to
your own), tag-proven only; a same-named survivor there made the resumed
session's create fail on psmux's cross-registry name mutex. A registry that
cannot be listed, or a session left standing, is journalled and warned about.
A TUI-launched resume sweeps the TUI's displaced root too (forwarded to the
child), except a share-root shape older PowerShell would corrupt.
- Escalate an environment fault at the review-budget rescue gate instead of
deferring the story as unconverged (DW-523).

Expand Down
4 changes: 2 additions & 2 deletions docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -769,7 +769,7 @@ verdict unverifiable rather than certifying a different launch configuration.

- Single policy file written by `init`, stamped into the run at every engine start — `run`, `sweep`, `resume` — so it always describes the policy that process enforces (applies to new runs and resumes; editable live from the TUI).
- Rewrites of it are confined and permission-honoring (#593, #597). Such a write walks the components below the project no-follow and lands through the directory handle that walk produced — `O_DIRECTORY|O_NOFOLLOW` opens with `dir_fd` on POSIX, `NtCreateFile` relative to the handle above with reparse points refused on Windows (`win32_at`) — so a symlink or junction planted at `.bmad-loop/` is refused rather than followed; refusing a link at the file alone never covered its parent, and `mkdir(parents=True, exist_ok=True)` accepts a symlinked directory. A host with neither arm degrades to a documented check-then-write; `init`'s one-time seeding of a missing policy predates any session and stays a plain write. A `policy.toml` an operator marked read-only is refused with a `PermissionError` instead of being replaced and left still reading `0444`. The confined walk also covers story specs inside the checkout, park records, the decisions store, and the sweep's triage cache and bundle intent document (DW-269); the read-only refusal reaches further — story specs, `sprint-status.yaml`, park records, the decisions store, hook `settings.json` — but `sprint-status.yaml` deliberately keeps its symlink-following writer (an operator may keep the board behind a link) and the hook-settings and worktree-provisioning writers keep their own pre-existing link checks rather than the descriptor walk. The read-only refusal deliberately skips machine-minted state (run archives, stop requests, the config-digest stamp) — those are channels, not operator signals.
- Sections — all 16: `[gates]`, `[limits]`, `[verify]` (+ `env_fault_rc`), `[environment]` (operator health probes + `probe_timeout_s`), `[notify]`, `[review]`, `[stories]` (which planning pipeline drives the loop: sprint-status or a typed `stories.yaml`), `[dev]` (see below), `[adapter]` (+ per-stage `[adapter.dev|review|triage]`), `[sweep]`, `[scm]` (worktree isolation + merge-back), `[cleanup]` (run-dir retention + disk reclamation), `[plugins]` (trust allowlist + per-plugin `[plugins.<name>]` config — e.g. the opt-in game-engine layer via `[plugins.unity]`, off by default), `[tui]` (`low_frame_rate` for slow/SSH links; persisted dashboard pane sizes), `[operator]` (whether a dev session may park a story at `awaiting-operator`, and whether a review pass may demote a `done` story to one — `on_review_demotion`), `[mux]` (machine-scoped multiplexer backend choice).
- Sections — all 16: `[gates]`, `[limits]`, `[verify]` (+ `env_fault_rc`), `[environment]` (operator health probes + `probe_timeout_s`), `[notify]`, `[review]`, `[stories]` (which planning pipeline drives the loop: sprint-status or a typed `stories.yaml`), `[dev]` (see below), `[adapter]` (+ per-stage `[adapter.dev|review|triage]`), `[sweep]`, `[scm]` (worktree isolation + merge-back), `[cleanup]` (run-dir retention + disk reclamation), `[plugins]` (trust allowlist + per-plugin `[plugins.<name>]` config — e.g. the opt-in game-engine layer via `[plugins.unity]`, off by default), `[tui]` (`low_frame_rate` for slow/SSH links; persisted dashboard pane sizes), `[operator]` (whether a dev session may park a story at `awaiting-operator`, and whether a review pass may demote a `done` story to one — `on_review_demotion`), `[mux]` (machine-scoped multiplexer backend choice, plus `honor_ambient_psmux_data_dir`: on psmux, use an absolute `PSMUX_DATA_DIR` your profile exports into every shell as the session registry instead of the derived per-project root — a yes/no switch, never a path; off by default; see [multiplexer-backends.md](multiplexer-backends.md)).
- `[dev] skill` names the inner dev skill the orchestrator drives. `"bmad-dev-auto"` — the generic upstream dev primitive — is the only accepted value; the field is retained as the seam for a future alternative dev skill, and any other value is rejected at load. It is **not** the name sessions are dispatched with: upstream renamed the primitive to `bmad-build-auto`, so the invoked name is resolved from what is actually installed and a project on either era works with this field untouched. It has no entry in the core settings schema, so it is edited in the file rather than from the TUI settings editor.
- Tunable limits: `max_review_cycles`, `max_dev_attempts`, `artifact_file_max_mb`, `artifact_payload_max_mb` (binary MiB, exactly 1,048,576 raw bytes each), `max_followup_reviews`, `session_timeout_min`, `git_timeout_s`, `teardown_grace_s` (one shared budget bounding the verified window kill _and_ the follow-on reap of any straggler descendant the session detached — e.g. a `setsid` background writer — combined; whatever remains after the window dies is what the straggler reap gets, before the worktree is merged and removed), `stop_without_result_nudges`, `dev_stall_grace_s`, `dev_stall_nudges`, `dev_stall_nudges_cap`, `workflow_stall_nudges_cap`, `max_tokens_per_story`.

Expand Down Expand Up @@ -810,7 +810,7 @@ verdict unverifiable rather than certifying a different launch configuration.

- `bmad-loop init` — install skills, hooks, policy, gitignore.
- `bmad-loop validate` — preflight all prerequisites. `--render-probe` also executes the dev skill's render command in a throwaway copy (`skills.dev-render-probe`; see above). `--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); 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 `<state root>/<project>/_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 <name>` 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 `<state root>/<project>/_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)) — or, with `[mux] honor_ambient_psmux_data_dir = true` and an absolute `PSMUX_DATA_DIR` set, your own root, labelled as honoured ([#729](https://github.com/bmad-code-org/bmad-loop/issues/729)); `mux set <name>` 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.
Expand Down
41 changes: 36 additions & 5 deletions docs/multiplexer-backends.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,9 +220,9 @@ Two further consequences:
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
By default, 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
Expand All @@ -240,8 +240,39 @@ $env:PSMUX_DATA_DIR = '<root from bmad-loop mux>'
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.
One registry serving both bmad-loop and your own psmux is a preference you state rather than one
bmad-loop guesses at ([#729](https://github.com/bmad-code-org/bmad-loop/issues/729)). If your
profile exports `PSMUX_DATA_DIR` into **every** shell, turn on the switch in the project's
`policy.toml`:

```toml
[mux]
honor_ambient_psmux_data_dir = true
```

bmad-loop then uses your value as the registry, a pane child inherits and honours it, and a clean
process carrying your profile honours it too, so every process agrees. Not under `PSMUX_BARE_ENV`,
which bmad-loop does not support: a bare pane inherits no `PSMUX_DATA_DIR`, so a run started there
derives the registry instead. `bmad-loop mux` says
`your own $PSMUX_DATA_DIR, honoured` and prints the derived root it would otherwise use. Leave the
switch off for a value you typed into one shell: a process started without it would derive, and the
session would read as gone there. Rules, all fixed:

- It is a yes/no switch, never a path. `policy.toml` is writable by the sessions bmad-loop drives,
so a policy-supplied root would let one aim cleanup's kills at a registry of its choosing.
- With no `PSMUX_DATA_DIR` set, or a relative or empty one, the switch changes nothing: the derived
root is used.
- A value shaped like a bmad-loop-derived root (`<16-hex project key>\_mux`) is never treated as
your pin. That is what a pane child inherits when the outer process derived, and it re-derives
like a clean process does.
- The TUI's control session gets its own name in your registry, so turning the switch on while an
old control session still runs in the derived root does not collide with it.
- A running TUI keeps watching the registry it started with. After you flip the switch either way,
it refuses to launch a run until you restart it (`bmad-loop tui`). A run started then would land
in a registry the TUI no longer watches.
- Sessions started in the derived root before you turned the switch on are still reached by
`bmad-loop cleanup`'s tag-scoped sweep. Your registry is shared with your own sessions, so cleanup
claims a session there only by its ownership tag.

Two consequences of deriving, both benign:

Expand Down
54 changes: 50 additions & 4 deletions src/bmad_loop/adapters/generic.py
Original file line number Diff line number Diff line change
Expand Up @@ -803,14 +803,60 @@ def __init__(
# --------------------------------------------------------- multiplexer

def _ensure_session(self, cwd: Path) -> None:
if not self.mux.has_session(self.session_name):
if self.mux.has_session(self.session_name):
# Reusing it is right for this run's own session (a resume), and
# wrong for a same-named one of another project's in a shared
# registry (#729): every window would open inside it, under its tag.
refusal = runs.foreign_session_refusal(
self.session_name, self.mux, self.run_dir.parents[2]
)
if refusal is not None:
raise MultiplexerError(
f"refusing to launch into the existing session {self.session_name}: "
f"{refusal} — stop or rename that run, or turn off "
"[mux] honor_ambient_psmux_data_dir"
)
else:
self.mux.new_session(self.session_name, cwd, PANE_COLUMNS, PANE_LINES)
# Tag the session with its project so a cleanup in another project
# never prunes this run (run_dir = <project>/.bmad-loop/runs/<id>).
project = self.run_dir.parents[2]
self.mux.set_session_option(
self.session_name, runs.PROJECT_OPTION, runs.project_tag(project)
)
try:
self.mux.set_session_option(
self.session_name, runs.PROJECT_OPTION, runs.project_tag(project)
)
except Exception as tag_fault:
# Left standing, the untagged session would block this run id
# for good in a shared registry (#729): the ownership gate reads
# it as foreign, and the kill and cleanup paths refuse it. It is
# the one this call just minted, so tear it down by that exact
# name — straight through the backend, since the gate would
# refuse an untagged session by construction — and re-raise.
# The backend kill is best-effort and silent by contract, so
# whether it landed is read back rather than assumed.
listing_faults: list[str] = []
try:
self.mux.kill_session(self.session_name)
key = self.mux.session_name_key(self.session_name)
survived = any(
self.mux.session_name_key(n) == key
for n in self.mux.list_sessions_reporting(on_fault=listing_faults.append)
)
except Exception as kill_fault:
listing_faults.append(str(kill_fault))
survived = True
if survived or listing_faults:
why = (
f"could not be confirmed gone ({listing_faults[0]})"
if listing_faults
else "is still there after tearing it down"
)
raise MultiplexerError(
f"tagging the new session {self.session_name} failed ({tag_fault}), "
f"and the untagged session {why} — remove it by hand before "
"resuming this run"
) from tag_fault
raise

def interactive_argv(self, spec: SessionSpec) -> list[str]:
extra = self.extra_args
Expand Down
Loading
Loading