Skip to content
Open
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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,18 @@ whose seams had diverged enough that several ports needed a different fix, and t

### Fixed

- **`cleanup` no longer reports a surviving ctl window as removed (#435).** Killing a window is
best-effort and reports nothing, so the prune counted every _attempted_ kill as a removal. It now
verifies with one liveness listing and partitions into removed / survived / unverifiable.
`ctl_windows.removed` means _verifiably gone_ and gains `survived` / `unverifiable` siblings, so

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied, partly — the #435 entry was 9 lines against a median of 6 across the 113 entries under Unreleased; trimmed to 6 in 8e824c0 by cutting the prune_sessions comparison and the --dry-run clause, both of which docs/FEATURES.md and the cleanup_document docstring already carry.

Kept deliberately, because a changelog reader who does not open FEATURES.md still needs them:

  • CLEANUP_SCHEMA_VERSION is 2 — a consumer pinning the version has to see the bump here.
  • still at exit 0 — the count on stdout got smaller and the difference goes to stderr; a wrapper checking $? sees no change and would otherwise have no signal that anything moved.
  • sessions.removed is untouched and still an attempted kill — without it, "cleanup now verifies removals" reads as covering both halves of the document when it covers one.

The #428 entry is 6 lines, exactly the file's median, so it is left as is.

`CLEANUP_SCHEMA_VERSION` is **2**; text mode names the two non-removed arms on stderr, still at
exit 0. `sessions.removed` is untouched and still an attempted kill.
- **A crashed version probe is distinguishable from "reports no version" (#428).** A binary on
`PATH` that dies answering `-V` — corrupt install, AV-blocked exe, hung server — collapsed to the
same `None` a quiet binary returns, and its stderr was gone. `version()` keeps that contract, but
a new `version_error()` seam accessor carries the dropped diagnostic, and `bmad-loop mux` prints
it as a whitespace-collapsed `warning:` line on stderr below the table — the `-` in the VERSION
column cannot say which of the two happened.
- **A native-Windows install driven from a WSL shell now says so (#332).** WSL appends the Windows
`PATH` to its own, so a bash prompt can reach a Windows-installed `bmad-loop`: that interpreter
reports `win32`, takes the psmux platform default, and never sees the distro's tmux — while
Expand Down
4 changes: 2 additions & 2 deletions docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se
- Each run drives agents in a dedicated `bmad-loop-<run-id>` session; `attach` to watch live.
- Auto-teardown on finish (`cleanup_session_on_finish`, disable to inspect); a hard `stop` always kills it, a graceful `stop --graceful` tears it down under the same `cleanup_session_on_finish` gate a normal finish uses; paused/interrupted runs keep the session for `resume`.
- `bmad-loop cleanup` (or `c` in the TUI) sweeps leftover sessions/windows for finished/stopped/orphaned runs **of the current project**; live runs, and anything belonging to another project, are never touched.
- `--json` emits a stable machine-readable document per the [contract below](#machine-readable-output---json) (schema-versioned; the run ids whose sessions were removed, the live ids left alone, the ctl windows closed, and a `dry_run` flag) instead of the text. Plan and outcome share one schema — same fields, same meanings, with `dry_run` saying which one you are holding — so a script can pre-flight a sweep and compare it against what actually happened. (Values are each invocation's own sample, not a promise the two agree: a live session can die between the preview and the real run.) The unverifiable-pid warning, which text mode writes to stderr, becomes `sessions.unverifiable_pid` in the document, leaving stderr empty.
- `--json` emits a stable machine-readable document per the [contract below](#machine-readable-output---json) (schema-versioned; the run ids whose sessions were removed, the live ids left alone, the ctl windows closed, and a `dry_run` flag) instead of the text. `ctl_windows` is a three-way partition — `removed` (verified gone after the kill; under `--dry-run` it is the would-close plan), `survived` (still listed) and `unverifiable` (the liveness listing itself failed) — because killing a window is best-effort and reports nothing; a survivor is retried by the next `cleanup`, and text mode marks the stdout count and names both non-removed arms on stderr rather than counting them as removed. Exit stays 0 either way — the verdict is the text/document, not the code. `sessions.removed` keeps its older, weaker meaning: an attempted kill. Plan and outcome share one schema — same fields, same meanings, with `dry_run` saying which one you are holding — so a script can pre-flight a sweep and compare it against what actually happened. (Values are each invocation's own sample, not a promise the two agree: a live session can die between the preview and the real run.) The unverifiable-pid warning, which text mode writes to stderr, becomes `sessions.unverifiable_pid` in the document, leaving stderr empty.

### Disk reclamation (`[cleanup]`)

Expand All @@ -191,7 +191,7 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se

- `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); `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); `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 run` — drive the dev → review → verify → commit loop.
- `bmad-loop sweep` — triage + execute open deferred-work entries.
- `bmad-loop resume <run-id>` — continue a paused/interrupted run.
Expand Down
43 changes: 42 additions & 1 deletion src/bmad_loop/adapters/multiplexer.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,13 @@ def list_windows(self, session: str, fields: list[str]) -> list[tuple[str, ...]]
probe, so a sentinel is safe).

A ``window_id`` column carries the same id form :meth:`current_window_id`
returns; core compares the two directly."""
AND :meth:`list_window_ids` return; core compares all three directly. The
second pairing is load-bearing for the ctl-window prune's kill verdict,
which is a membership test of this column against that listing
(:func:`bmad_loop.tui.launch.prune_ctl_windows`): a backend that
qualifies one side and not the other reports every killed window as
verifiably gone — silently, and in the optimistic direction the verdict
exists to remove (#435)."""

@abstractmethod
def window_alive(self, session: str, window_id: str) -> bool:
Expand Down Expand Up @@ -330,6 +336,27 @@ def version(self) -> str | None:
folding backend keeps the identifying version in the first segment."""
return None

def version_error(self) -> str | None:
"""Why the most recent :meth:`version` call answered None despite the
binary being there — a crashing probe, a hung server, an AV-blocked exe.
None when that call succeeded, when there was no binary to ask, when no
probe has run yet, or when the backend keeps no such record (the default
here, so an out-of-tree backend inherits silence rather than breaking).

This is a *diagnostic*, not a second contract: `version()` keeps its None
sentinel (observation may degrade) and this only recovers the identity of
the failure it dropped, which is otherwise indistinguishable from "the
binary reports no version" (#428). Must not raise.

It describes the LAST probe, so read it directly after :meth:`version`,
**on an instance you own** — nothing recomputes it, a later successful
probe clears it, and the record is unsynchronized per-instance state. The
process-wide :func:`get_multiplexer` backend is shared across the TUI's
worker threads, so a caller reading the accessor off THAT instance can be
handed another thread's probe. :func:`detect_multiplexers` is the one
in-tree reader and builds its own instance per row."""
return None

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
Expand Down Expand Up @@ -638,6 +665,10 @@ class MuxBackendInfo:
version: str | None
selected: bool
reason: str # "" unless selected: env | policy | platform-default | first-match | fallback
# The diagnostic version() dropped, when it answered None with the binary
# present (TerminalMultiplexer.version_error). Defaulted so it is additive
# for anyone constructing this row positionally.
version_error: str | None = None


def detect_multiplexers() -> list[MuxBackendInfo]:
Expand Down Expand Up @@ -666,6 +697,7 @@ def detect_multiplexers() -> list[MuxBackendInfo]:
except Exception:
matches_platform = False
version: str | None = None
version_error: str | None = None
try:
backend = factory()
available = _usable(backend)
Expand All @@ -679,6 +711,14 @@ def detect_multiplexers() -> list[MuxBackendInfo]:
version = fold_version(backend.version())
except Exception:
version = None
if version is None:
# Read only after version(), which is what it describes, and
# only when there is a None to explain. Guarded like every other
# probe here — this function never raises.
try:
version_error = backend.version_error()
except Exception:
version_error = None
selected = name == selected_name
rows.append(
MuxBackendInfo(
Expand All @@ -688,6 +728,7 @@ def detect_multiplexers() -> list[MuxBackendInfo]:
version=version,
selected=selected,
reason=reason if selected else "",
version_error=version_error,
)
)
return rows
32 changes: 31 additions & 1 deletion src/bmad_loop/adapters/tmux_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,15 @@ class BaseTmuxBackend(TerminalMultiplexer):
#: the default strict handler; a Windows leaf sets ``"backslashreplace"`` so
#: a stray non-UTF-8 byte degrades visibly instead of raising mid-capture.
_ERRORS: str | None = None
#: Diagnostic from the last :meth:`version` probe (see
#: :meth:`TerminalMultiplexer.version_error`). A class-level default so an
#: instance that never probed answers None instead of AttributeError.
#: Per-instance and unsynchronized: only a caller that OWNS the instance may
#: read it back (``detect_multiplexers`` builds one per row). The
#: ``get_multiplexer()`` singleton is shared across the TUI's worker threads,
#: and ``mux_usable`` probes ``version()`` on it — a reader there can be
#: handed another thread's failure.
_version_error: str | None = None

def _run(
self,
Expand Down Expand Up @@ -456,11 +465,29 @@ def available(self) -> bool:
return shutil.which(self._BINARY) is not None

def version(self) -> str | None:
# Every exit path rewrites the diagnostic, so it always describes THIS
# call (the seam's read-it-after-version rule) — a probe that recovers
# must not leave the old failure standing for `mux` to warn about.
self._version_error = None
if not shutil.which(self._BINARY):
return None
try:
raw = self._tmux("-V")
except (MultiplexerError, subprocess.SubprocessError, OSError):
# UnicodeError is in the list because _run decodes with the LOCALE codec
# and the strict handler on POSIX (_ENCODING/_ERRORS are None there;
# the Windows leaf sets utf-8/backslashreplace, so this arm is POSIX-
# only). A byte that codec cannot decode — UTF-8 in practice under PEP
# 538/540 — raises: a corrupt install, or a binary emitting text in
# another encoding, exactly what this diagnostic exists for. It is a
# ValueError, outside the SubprocessError/OSError family, so it escaped
# as a raw crash for every caller above to guard.
except (MultiplexerError, subprocess.SubprocessError, OSError, UnicodeError) as exc:
# None stays the seam's answer, but the identity of the failure is
# what separates a crashing binary from one that reports no version
# (#428). On the nonzero-exit arm _run has already folded the probe's
# stderr into the TmuxError text, so str(exc) carries it; the other
# arms carry only the failure itself, which is all there is to carry.
self._version_error = str(exc)
return None
# The seam promises one line (TerminalMultiplexer.version). `-V` is one
# line on tmux, two on psmux (a `tmux X.Y.Z` compat line then its own),
Expand All @@ -469,3 +496,6 @@ def version(self) -> str | None:
# parses the compat segment with an anchored match, so the first
# segment must stay first.
return fold_version(raw)

def version_error(self) -> str | None:
return self._version_error
69 changes: 64 additions & 5 deletions src/bmad_loop/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -623,6 +623,18 @@ def cmd_mux(args: argparse.Namespace) -> int:
"note: AVAILABLE means the binary answers here, not that the backend supports "
f"{sys.platform} — {', '.join(stranded)} can only be reached by forcing the choice"
)
# A VERSION of `-` is the same cell whether the binary reports no version or
# crashed answering (#428). The row can't carry the difference — a table cell
# holds no stderr — so the dropped diagnostic is named here, beside the other
# reason a backend looks absent for no visible cause.
for r in rows:
if r.version_error:
# Whitespace-collapsed: the text carries the probe's own stderr, which
# is routinely multi-line, and a warning that spans lines reads as
# several unrelated ones. Not length-bounded like a table cell (#321)
# — nothing here sizes a column, and the diagnostic IS the payload.
detail = " ".join(r.version_error.split())
print(f"warning: {r.name} version probe failed: {detail}", file=sys.stderr)
# A failed external package is invisible in the table (it never registered),
# so name it here — the one place an operator looks when a backend is missing.
for ep_name, reason in sorted(external_backend_errors().items()):
Expand Down Expand Up @@ -2650,6 +2662,7 @@ def cmd_archive(args: argparse.Namespace) -> int:


def cmd_cleanup(args: argparse.Namespace) -> int:
from .adapters.multiplexer import MultiplexerError
from .tui import launch # pure stdlib; no textual import

project = _project(args)
Expand All @@ -2663,13 +2676,36 @@ def cmd_cleanup(args: argparse.Namespace) -> int:
# holds after the fact too. In JSON mode this lives in the document
# instead (sessions.unverifiable_pid), leaving stderr empty.
print(f"run {run_id}: engine may still be live (unverifiable pid)", file=sys.stderr)
windows = (
launch.prunable_ctl_windows(project) if args.dry_run else launch.prune_ctl_windows(project)
)
# The ctl-window half is raiser-side (its candidate scan probes has_session),
# and the sessions above are ALREADY killed by the time it runs. Letting the
# raise reach main()'s backstop prints an error and returns 1 with stdout
# empty — which in --json mode destroys the record of those kills, leaving a
# consumer unable to tell "killed nothing" from "killed three, lost the
# receipt". The repair succeeded; only the observation failed, and
# observation degrades. Mirrors what the TUI worker already does.
#
# dry-run kills nothing, so there is no kill outcome to partition: the
# candidate list IS the plan, and the other two arms stay empty.
try:
if args.dry_run:
windows, survived, unverifiable = launch.prunable_ctl_windows(project), [], []
else:
windows, survived, unverifiable = launch.prune_ctl_windows(project)
except MultiplexerError as e:
# Three empty lists is the honest answer: the raise comes from the
# candidate scan, so no window was killed or even chosen.
print(f"ctl window prune failed: {e}", file=sys.stderr)
windows, survived, unverifiable = [], [], []
if args.json:
machine.emit(
cleanup_document(
dry_run=args.dry_run, killed=killed, live=live, unknown=unknown, windows=windows
dry_run=args.dry_run,
killed=killed,
live=live,
unknown=unknown,
windows=windows,
windows_survived=survived,
windows_unverifiable=unverifiable,
)
)
return 0
Expand All @@ -2684,7 +2720,30 @@ def cmd_cleanup(args: argparse.Namespace) -> int:
if live:
print(f"leaving {len(live)} live session(s) untouched")
return 0
print(f"removed {len(killed)} session(s), {len(windows)} ctl window(s)")
# The count now excludes non-removals, so on stdout alone a smaller number is
# indistinguishable from a quieter sweep — and `cleanup > log` keeps only
# stdout. The marker travels with the count; the names stay on stderr, the
# unverifiable_pid precedent.
unaccounted = len(survived) + len(unverifiable)
print(
f"removed {len(killed)} session(s), {len(windows)} ctl window(s)"
+ (f" ({unaccounted} not verified — see stderr)" if unaccounted else "")
)
# Only ever printed when a kill did not verifiably land — silence on the
# normal path, and the count above now excludes these rather than counting
# them as removed (#435). Both are retried by the next cleanup.
if survived:
# 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)
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
# of what is unknown here.
print(
f"ctl window(s) kill attempted, outcome unverifiable: {', '.join(unverifiable)}",
file=sys.stderr,
)
if live:
print(f"left {len(live)} live session(s) untouched")
return 0
Expand Down
Loading