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

### Fixed

- **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: resuming a
parked run left `a` attaching to the dead `run-<rid>` window, the return pane stamped on it (so the
live window had no way back to the operator's origin pane), and `x` killing it while the live one
ran on. Each launch now records the window id it minted in the run dir and the lookup prefers it,
but only while the live listing still shows that id under this run id; with no record the answer is
unchanged. A resume whose window id was not captured warns instead of 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 — both bundled backends agree; one
that does not degrades to the ambiguous by-name resolve rather than mistargeting.

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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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
15 changes: 12 additions & 3 deletions src/bmad_loop/tui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -415,7 +415,7 @@ def action_attach(self) -> None:
self.notify("no run selected", severity="warning")
return
session = runs.session_name(run_id)
win_id = launch.ctl_window_id(run_id)
win_id = launch.ctl_window_id(self.project, run_id)
ok, agent_live = self._mux_guarded(lambda: launch.session_exists(session))
if not ok:
return
Expand Down Expand Up @@ -709,10 +709,19 @@ def _do_resume(self, run_id: str) -> None:
self.notify(f"run {run_id} may still be live — stop it first", severity="warning")
return
try:
launch.resume_detached(self.project, run_id)
win_id = launch.resume_detached(self.project, run_id)
except launch.LaunchError as e:
self.notify(str(e), severity="error")
return
if not win_id:
# The resume itself is running; only the disambiguation record is
# lost, so `a`/`x` may target an older same-run_id window (#482's
# symptom). Warn instead of masking it behind the success toast.
self.notify(
"resume launched but its window id was not captured — "
"attach/stop may target an older window for this run",
severity="warning",
)
self.notify(f"resume of {run_id} launched (control session {launch.CTL_SESSION})")

def _do_replan(self, run_id: str, spec_path: Path) -> None:
Expand Down Expand Up @@ -874,7 +883,7 @@ def done(ok: bool | None) -> None:
def _stop_run_worker(self, run_id: str, run_dir: Path) -> None:
try:
runs.stop_run(run_dir)
launch.kill_ctl_window(run_id)
launch.kill_ctl_window(self.project, run_id)
except (OSError, StopRunError, ProcessHostError) as e:
self.call_from_thread(self.notify, f"stop failed: {e}", severity="error")
return
Expand Down
Loading