diff --git a/CHANGELOG.md b/CHANGELOG.md index 753d7ed..3090f9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ breaking changes may land in a minor release. ### Added +- **Documented the `BMAD_LOOP_*` environment variables (#246).** The three runtime override + vars — `BMAD_LOOP_MUX_BACKEND`, `BMAD_LOOP_PROCESS_HOST`, `BMAD_LOOP_SESSION_TIMEOUT_S` — now + have a reference table in the README. Behavior is unchanged; they are read through a single + `bmad_loop.envvars` registry so the supported knobs are discoverable in one place. + - **`python -m bmad_loop` (#240).** The package is now runnable as a module (`python -m bmad_loop …`), mirroring the installed `bmad-loop` console script via a thin `__main__.py`. Subprocess smoke tests exercise the module entry, and characterization tests diff --git a/README.md b/README.md index 196b3d9..cb41e27 100644 --- a/README.md +++ b/README.md @@ -495,6 +495,18 @@ The readiness gate runs the plugin's `ready_cmd` (`unity_ready.py`), which for ` For `per_worktree`, set `editor_mode = "per_worktree"` with `[scm] isolation = "worktree"`. The bundled Unity plugin wires the worktree-Editor lifecycle against the **IvanMurzak** CLI (`open` / `setup-mcp` / `close`, which key off the project path with auto port detection — verified against v0.81.1). A fresh worktree has no `Library` (it's gitignored), and opening Unity on an empty `Library` forces a cold full reimport that crashes the import workers on a real project — so the setup hook **primes** the worktree's `Library` with a reflink/CoW copy of your warm main `Library` (`/Library`), near-instant on btrfs/xfs, making the import incremental; it falls back to a deep copy, then to a symlinked empty cache under the gitignored `.bmad-loop/cache/`, off-CoW or when no warm `Library` exists. Tune this with `BMAD_LOOP_UNITY_LIBRARY_SEED` / `…_SEED_MODE` (and `BMAD_LOOP_UNITY_LIBRARY_CACHE` for the fallback cache root — see the [Game Engine MCP guide](docs/game-engine-mcp-guide.md) for the full env reference); a [Unity Accelerator](https://docs.unity3d.com/Manual/UnityAccelerator.html) helps further, and `unity_path` pins the Editor binary. A cold worktree Editor takes time to launch and import — bump `ready_grace_sec`/`ready_timeout_sec` if your project's first import runs long. CoplayDev's single shared-server model isn't wired for a managed per-worktree launch — point `worktree_setup_cmd`/`worktree_teardown_cmd` at your own scripts under `.bmad-loop/plugins/unity/`, or use shared mode. +## Environment variables + +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`; the other two 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_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 non-positive or unparseable value is ignored. 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). + ## Run state Everything about a run lives in `.bmad-loop/runs//` (gitignored): `state.json` (resumable engine state), `journal.jsonl` (every decision), `events/` (hook signals), `tasks//` (per-session prompt + result + escalations, plus diagnostic breadcrumbs — `session-lifecycle.jsonl` records when a timeout fired, `heartbeat.json` is the wait loop's proof-of-life, `resultless-stops.jsonl` records give-up Stops), `logs/` (raw pane output, debugging only), `deferred/` (stashed specs from deferred stories), `resolve//` (escalation `context.json` + the resolve agent's `resolution.json`), `ATTENTION` (human-readable alerts), and — only while a graceful stop is pending — `stop-request.json` (the control file the engine consumes at the next item boundary). diff --git a/src/bmad_loop/adapters/multiplexer.py b/src/bmad_loop/adapters/multiplexer.py index 58773d0..9dc171b 100644 --- a/src/bmad_loop/adapters/multiplexer.py +++ b/src/bmad_loop/adapters/multiplexer.py @@ -30,13 +30,14 @@ import functools import importlib.metadata -import os import sys from abc import ABC, abstractmethod from collections.abc import Callable from dataclasses import dataclass from pathlib import Path +from .. import envvars + class MultiplexerError(Exception): """A transport-backend operation failed. Backends raise a subclass (e.g. @@ -414,7 +415,7 @@ def backend_forced() -> bool: trusted; the backend fails loudly if it can't run), so launch preflights that refuse an unusable backend must stand down for it too — via :func:`mux_usable`, which stands down loudly.""" - return bool(os.environ.get("BMAD_LOOP_MUX_BACKEND")) or _CONFIGURED is not None + return bool(envvars.mux_backend()) or _CONFIGURED is not None _FORCED_UNUSABLE_WARNED = False @@ -473,7 +474,7 @@ def _select() -> tuple[TerminalMultiplexer, str, str]: silently fall back to tmux (wrong/unsafe on a non-POSIX host).""" _load_builtin_backends() _load_external_backends() - forced = os.environ.get("BMAD_LOOP_MUX_BACKEND") + forced = envvars.mux_backend() if forced: factory = _factory_by_name(forced) if factory is None: diff --git a/src/bmad_loop/cli.py b/src/bmad_loop/cli.py index 8585715..71ce3d2 100644 --- a/src/bmad_loop/cli.py +++ b/src/bmad_loop/cli.py @@ -19,6 +19,7 @@ bmadconfig, decisions, deferredwork, + envvars, install, machine, ) @@ -444,7 +445,7 @@ def _mux_set(project: Path, args: argparse.Namespace) -> int: "`bmad-loop validate` will report it", file=sys.stderr, ) - if os.environ.get("BMAD_LOOP_MUX_BACKEND"): + if envvars.mux_backend(): print( "note: BMAD_LOOP_MUX_BACKEND is set in this shell and outranks the persisted choice", file=sys.stderr, diff --git a/src/bmad_loop/engine.py b/src/bmad_loop/engine.py index b74b0c4..705e042 100644 --- a/src/bmad_loop/engine.py +++ b/src/bmad_loop/engine.py @@ -10,7 +10,6 @@ import contextlib import functools -import os import shutil import signal import sys @@ -20,7 +19,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Callable, NoReturn -from . import deferredwork, devcontract, gates, verify +from . import deferredwork, devcontract, envvars, gates, verify from .adapters.base import CodingCLIAdapter, SessionResult, SessionSpec from .bmadconfig import ProjectPaths from .escalation import ( @@ -1879,15 +1878,8 @@ def _session_timeout_s(default_s: float) -> float: binary run can't be monkeypatched. A non-positive or unparseable override is ignored, so a fat-fingered value can never silently shorten a real run's budget.""" - raw = os.environ.get("BMAD_LOOP_SESSION_TIMEOUT_S") - if raw is not None: - try: - override = float(raw) - except ValueError: - override = 0.0 - if override > 0: - return override - return default_s + override = envvars.session_timeout_s() + return override if override is not None else default_s def _run_session( self, diff --git a/src/bmad_loop/envvars.py b/src/bmad_loop/envvars.py new file mode 100644 index 0000000..a35e277 --- /dev/null +++ b/src/bmad_loop/envvars.py @@ -0,0 +1,55 @@ +"""Central registry of the ``BMAD_LOOP_*`` runtime environment variables. + +These operator/test override knobs used to be read inline at scattered call +sites (`engine`, `adapters.multiplexer`, `cli`, `process_host`), which left them +undiscoverable and undocumented. This module is the one place each is named, +typed, and given a reader; the operator-facing table lives in the README under +"Environment variables". + +Each reader preserves its call site's exact semantics — same parse, same +fallback — so routing a site through here changes nothing observable. Only the +three core vars belong here; the `BMAD_LOOP_UNITY_*` / `BMAD_LOOP_ENGINE_*` +family read by the bundled Unity plugin's stand-alone helper scripts is that +plugin's own contract (documented in the game-engine guide) and stays with it. +""" + +from __future__ import annotations + +import os + +#: Overrides the per-session wall-clock budget, in seconds (test / E2E hook). +SESSION_TIMEOUT_S = "BMAD_LOOP_SESSION_TIMEOUT_S" +#: Forces the terminal-multiplexer backend by registered name. +MUX_BACKEND = "BMAD_LOOP_MUX_BACKEND" +#: Forces the process-host implementation by registered name (test / override). +PROCESS_HOST = "BMAD_LOOP_PROCESS_HOST" + + +def session_timeout_s() -> float | None: + """The per-session wall-clock override in seconds, or ``None`` when unset. + + A non-positive or unparseable value reads as ``None`` (ignored), so a + fat-fingered override can never silently shorten a real run's budget. + """ + raw = os.environ.get(SESSION_TIMEOUT_S) + if raw is None: + return None + try: + value = float(raw) + except ValueError: + return None + return value if value > 0 else None + + +def mux_backend() -> str | None: + """The forced terminal-multiplexer backend name, or ``None`` when unset. + + Returned verbatim (callers test truthiness and resolve the name), so the + forced-selection semantics match the raw env read exactly. + """ + return os.environ.get(MUX_BACKEND) + + +def process_host() -> str | None: + """The forced process-host name, or ``None`` when unset.""" + return os.environ.get(PROCESS_HOST) diff --git a/src/bmad_loop/process_host.py b/src/bmad_loop/process_host.py index 2baa386..1140315 100644 --- a/src/bmad_loop/process_host.py +++ b/src/bmad_loop/process_host.py @@ -26,6 +26,8 @@ from pathlib import Path from typing import Callable +from . import envvars + # SIGKILL is absent on Windows; fall back to SIGTERM so attribute access never # raises. The POSIX host references this rather than ``signal.SIGKILL`` directly. SIGKILL = getattr(signal, "SIGKILL", signal.SIGTERM) # portability: SIGKILL absent on Windows @@ -391,7 +393,7 @@ def get_process_host() -> ProcessHost: otherwise the first host whose ``matches(sys.platform)`` is true wins. POSIX is the default fallback, so behavior on Linux/macOS is unchanged. Cached — tests that flip the env var must call ``get_process_host.cache_clear()``.""" - forced = os.environ.get("BMAD_LOOP_PROCESS_HOST") + forced = envvars.process_host() _load_builtin_hosts() for name, matches, factory in _HOSTS: if name == forced or (not forced and matches(sys.platform)):