Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
9142677
fix(tui): resolve a run's control window by identity, not listing order
dracic Aug 9, 2026
cc26fb8
fix(tui): harden the ctl-window record's failure paths
dracic Aug 9, 2026
251f77e
fix(tui): address review — trim the #482 changelog entry, drop a usel…
dracic Aug 9, 2026
50bab0e
fix(tui): survive a non-OSError from the ctl-window record write
Aug 10, 2026
d31f08b
fix(tui): refuse a symlinked ctl-window record instead of writing thr…
Aug 10, 2026
d5acf47
fix(tui): replace the ctl-window record's name, never a link's target
Aug 10, 2026
d203f4e
fix(tui): bound and shape-check the ctl-window record read
Aug 10, 2026
c2fdffe
fix(tui): prove the ctl-window record against the live listing
Aug 10, 2026
b2a0b81
fix(tui): scope the ctl-window lookup to its project
Aug 10, 2026
bd64a93
test(tui): pin tmux in the resolve record-warning test
Aug 10, 2026
f1c60e1
fix(tui): confine the record path and prefer tagged windows
Aug 10, 2026
ae67ea4
fix(platform): inherit nothing when replacing a name no-follow
Aug 10, 2026
7ecb190
fix(tui): match the whole run id when resolving a control window
Aug 10, 2026
385fa22
fix(tui): anchor the ctl-window record write at a directory descriptor
Aug 10, 2026
80ae01e
fix(tui): anchor the ctl-window record removal at a directory descriptor
Aug 11, 2026
4f084c5
fix(tui): see junctions when checking that a run dir is confined
Aug 11, 2026
899d81a
fix(tui): forget the previous record when recording is skipped
Aug 11, 2026
c251c20
fix(mux): keep delimiters out of the project-tag comparison
Aug 11, 2026
597631c
fix(mux): reject every line separator in a project tag, not just LF
Aug 11, 2026
fdbb684
fix(mux): encode a project tag the listing cannot carry
Aug 11, 2026
f765f9e
chore: hoist _TMP_NAME_ATTEMPTS and drop a stray paren
Aug 11, 2026
2cfd10a
fix(mux): encode a filename byte the listing cannot decode either
Aug 11, 2026
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
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,36 @@ whose seams had diverged enough that several ports needed a different fix, and t

### Fixed

- **A tab in the project path no longer truncates the project tag a window listing carries.**
The multiplexer listing is tab-delimited and the tag holds a resolved filesystem path, where a tab
is a legal byte — so the parse split one row into extra fields and dropped the tail, leaving a
truncated tag that reads as another project's. The prune scan then skipped the project's own
parked control windows. The last requested field now keeps its delimiters.

- **A project path a window listing cannot carry no longer strands — or crashes — the scans over it.**
Listings are one row per window, split with `str.splitlines()` and decoded strictly, and two kinds
of byte defeat that while being perfectly legal in a POSIX path. A line separator (LF, CR, VT, FF,
FS, GS, RS, NEL, U+2028, U+2029) put the tag on a row of its own, so it never matched and the prune
scan skipped the project's own parked windows and sessions. A byte that is not valid in the
filesystem encoding arrived surrogate-escaped and made the listing read raise `UnicodeDecodeError`
outright. Both are now percent-encoded in the tag; every other path is tagged byte-identically, so
tags already stored on live windows and sessions keep comparing equal. Reading a tag stored raw by
an older version is the decode half, tracked in #380.

- **A run id that is a suffix of another no longer resolves to the neighbour's control window.**
`--run-id` is caller-supplied and may contain `-`, so `run-other-RID` satisfied the lookup for
`RID` — and sorted ahead of it, so `x` could kill the neighbouring run's live orchestrator.
Window names are parsed and the run id compared whole, as the prune scan already did.

- **Attach, return-stamp and kill follow the run's live control window, not an older one (#482).**
`<kind>-<run_id>` window names are not unique, so the lookup answered the first match — `a`, the
return stamp and `x` all landed on a parked run's dead window while the live one ran on. Each
launch records the window id it minted and the lookup prefers it while the listing still shows it
under this run id; with no record the answer is unchanged, and a resume whose id was not captured
warns rather than reporting plain success. **Adapter authors:** the re-prove pairs
`new_parked_window`'s id with the `window_id` column of `list_windows`, which the seam previously
left free to diverge — a backend where they differ degrades to the by-name resolve.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
- **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
2 changes: 1 addition & 1 deletion docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se

- Every run is a resumable on-disk state machine: `bmad-loop resume <run-id>` continues from a gate, escalation, or interruption.
- A graceful stop (`stop --graceful` / TUI `S`) is resumable too: unlike a hard stop killed mid-item, it lets the in-flight item finish through commit and finalizes cleanly, ending as a `stopped` run that `resume` picks up at the next item.
- All run state in `.bmad-loop/runs/<run-id>/` (gitignored): `state.json`; `journal.jsonl` (every decision, including the `session-synthesized-from-frontmatter` catch and its `spec-marker-repaired` repair, #276); `events/` (hook signals); `tasks/<id>/` (per-session prompt + `result.json` + breadcrumbs — `session-lifecycle.jsonl` records timeout fires, budget-guard trips (`budget-tripped` / `over-budget-fired`), transport-failure classification (`env-fault-classified`, #194) and the #276 forensics (`spec-status-transition-observed`, `frontmatter-unmodified-refused`, `contract-nudge-sent`); `heartbeat.json` is the wait loop's proof-of-life; `resultless-stops.jsonl` records give-up Stops with a verdict — `no-artifact`, `ambiguous-frontmatter`, `unmodified-since-launch`, `terminal-frontmatter-pending`); `logs/`; `deferred/`; `resolve/`; `ATTENTION`.
- All run state in `.bmad-loop/runs/<run-id>/` (gitignored): `state.json`; `journal.jsonl` (every decision, including the `session-synthesized-from-frontmatter` catch and its `spec-marker-repaired` repair, #276); `events/` (hook signals); `tasks/<id>/` (per-session prompt + `result.json` + breadcrumbs — `session-lifecycle.jsonl` records timeout fires, budget-guard trips (`budget-tripped` / `over-budget-fired`), transport-failure classification (`env-fault-classified`, #194) and the #276 forensics (`spec-status-transition-observed`, `frontmatter-unmodified-refused`, `contract-nudge-sent`); `heartbeat.json` is the wait loop's proof-of-life; `resultless-stops.jsonl` records give-up Stops with a verdict — `no-artifact`, `ambiguous-frontmatter`, `unmodified-since-launch`, `terminal-frontmatter-pending`); `logs/`; `deferred/`; `resolve/`; `ATTENTION`; `ctl-window` (the control-session window id the last TUI launch minted, so attach/stop follow the live window, #482).
- `journal.jsonl` records `session-end` for every session unconditionally — even a teardown that throws still lands one (status `aborted` when the outcome is unknowable). A timed-out session's entry carries `fired_at` (wall time the deadline was declared), `teardown_s` (wall seconds from that fire to this entry — the teardown gap), and `expired_clock` (`monotonic` / `wall` / `both` — `wall` alone fingerprints a host suspend that froze the monotonic clock). Every entry whose usage was read carries `tokens` (raw) and `tokens_weighted` (cache reads at `limits.cache_read_weight`), keeping per-session spend reconstructible; both are `null` when the usage read failed, and both are absent on an `aborted` end. `tokens_weighted` is the end-of-session total — distinct from a tripped session's `budget_weighted`, the guard's mid-session sample at trip time.

### Hook-based transport (no pane-scraping)
Expand Down
6 changes: 5 additions & 1 deletion docs/adapter-authoring-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,11 @@ the backend owns those conditions, and applies them uniformly, so the
Both are replayed opaquely; neither is parsed by core. psmux applies the same
qualification to `new_parked_window`, the `window_id` columns of `list_windows`
and `current_window_id`; the latter two must agree, since the ctl-window prune
compares them to skip its own window.) tmux consumes the token natively (it coincides with tmux exact-match
compares them to skip its own window. To preserve unambiguous lookup,
`new_parked_window` must agree with the `list_windows` column too; a backend that
qualifies one side only remains usable but falls back to resolving parked
windows by name, which is ambiguous whenever several kinds share a run id
(#482). tmux consumes the token natively (it coincides with tmux exact-match
syntax), so `BaseTmuxBackend` passes it straight through. A native-id backend
calls `parse_target()` first — `None` means "already a native id, use as-is",
otherwise resolve `(session, window)` yourself; the herdr adapter's
Expand Down
5 changes: 4 additions & 1 deletion docs/tui-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,10 @@ The TUI never runs an engine in-process. The two halves:
lives in a separate `bmad-loop-<run-id>` session; it is torn down when the run
finishes (unless `[adapter] cleanup_session_on_finish = false`). These parked
`bmad-loop-ctl` windows and any leftover `bmad-loop-<id>` sessions can be
swept with `c` (see [Cleaning up sessions](#cleaning-up-sessions-c)).
swept with `c` (see [Cleaning up sessions](#cleaning-up-sessions-c)). Each
launch over an existing run records the id of the window it minted in the
run dir (`ctl-window`), so attach/stop follow the run's live window even
while an older same-run-id window is still parked (#482).
- **Observer** — the dashboard reads only the artifacts the engine writes
atomically into `.bmad-loop/runs/<run-id>/`: `state.json`, `journal.jsonl`,
`logs/<task-id>.log`, `ATTENTION`, `engine.pid`. It polls the selected run
Expand Down
12 changes: 7 additions & 5 deletions src/bmad_loop/adapters/multiplexer.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,8 @@ def new_parked_window(
"""Create a window that runs ``argv`` then *parks* — waiting on a key so
the exit status stays inspectable instead of the window closing the moment
the process exits — and finally returns an attached client to its origin
(keyed by the per-window ``return_opt``). Returns the native window id."""
(keyed by the per-window ``return_opt``). Returns the native window id;
for its required form see :meth:`list_window_ids`'s note on #482."""

@abstractmethod
def list_window_ids(self, session: str) -> list[str]:
Expand All @@ -172,10 +173,11 @@ def list_window_ids(self, session: str) -> list[str]:
server per session), so a bare ``@N`` replayed as a ``-t`` target
routes by the *caller's* server instead of the owning one.

:meth:`new_parked_window` is *outside* the rule — nothing
membership-tests a parked id, it is only replayed as a ``-t`` target by
the TUI — so a backend MAY mint it in a form this list never carries
(psmux happens to qualify it too, #291).
:meth:`new_parked_window` is outside *this* list's rule. To preserve
#482's unambiguous lookup, however, its id must match the ``window_id``
column of :meth:`list_windows` (psmux qualifies both, #291). A backend
that diverges remains usable, but falls back to the ambiguous by-name
lookup whenever several kinds share a run id.

Raises :class:`MultiplexerError` if the transport itself fails (timeout /
missing binary): an empty list means "no windows" and must not be
Expand Down
11 changes: 10 additions & 1 deletion src/bmad_loop/adapters/tmux_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,16 @@ def list_windows(self, session: str, fields: list[str]) -> list[tuple[str, ...]]
return []
rows: list[tuple[str, ...]] = []
for line in probe.stdout.splitlines():
parts = line.split("\t")
# Bounded split, so the LAST field may itself contain tabs. Fields
# carrying arbitrary text do exist — PROJECT_OPTION holds a resolved
# filesystem path, and a tab is a legal POSIX filename byte — and an
# unbounded split turns one such row into extra parts that the slice
# below then truncates, silently corrupting the field's value.
# Callers requesting a free-text field must therefore ask for it
# last; every current caller does. (A newline in that value still
# splits the row, which no parse here can undo — so runs.project_tag
# encodes a path holding one rather than leaning on this split.)
parts = line.split("\t", len(fields) - 1)
parts += [""] * (len(fields) - len(parts)) # tolerate unset trailing fields
rows.append(tuple(parts[: len(fields)]))
return rows
Expand Down
Loading