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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` (`<repo>/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/<run-id>/` (gitignored): `state.json` (resumable engine state), `journal.jsonl` (every decision), `events/` (hook signals), `tasks/<id>/` (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/<story>/` (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).
Expand Down
7 changes: 4 additions & 3 deletions src/bmad_loop/adapters/multiplexer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion src/bmad_loop/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
bmadconfig,
decisions,
deferredwork,
envvars,
install,
machine,
)
Expand Down Expand Up @@ -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,
Expand Down
14 changes: 3 additions & 11 deletions src/bmad_loop/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@

import contextlib
import functools
import os
import shutil
import signal
import sys
Expand All @@ -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 (
Expand Down Expand Up @@ -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,
Expand Down
55 changes: 55 additions & 0 deletions src/bmad_loop/envvars.py
Original file line number Diff line number Diff line change
@@ -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)
4 changes: 3 additions & 1 deletion src/bmad_loop/process_host.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)):
Expand Down