Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
6d66b79
fix(runs,cli): deliver hard stops over the stop-request channel (#319)
Aug 22, 2026
b1d0f44
fix(engine,sweep): honor a hard-mode stop request mid-run and at item…
Aug 22, 2026
e1c6466
fix(adapters): abort the in-session wait when a hard stop request lan…
Aug 22, 2026
9c76b54
docs(features,readme,changelog): rewrite the hard-stop contract for t…
Aug 22, 2026
b27773a
fix(runs): survive a failed or concurrent stop-request write (#319)
Aug 22, 2026
70d8d2b
fix(cli): fail closed on a stop request resume or cancel cannot remov…
Aug 22, 2026
a997471
docs(adapters,features,changelog): correct the hard-stop teardown bou…
Aug 22, 2026
7fd47a7
fix(runs): refuse to downgrade a concurrent hard stop request (#319)
Aug 23, 2026
dfc8b41
fix(engine): honor a hard stop at run end, and consume it on the sign…
Aug 23, 2026
726359c
fix(runs): keep the lodged stop request until the engine is proved de…
Aug 23, 2026
4c74f12
fix(engine,adapters,runs): reach a nested auto-sweep with the parent'…
Aug 23, 2026
7b0d725
fix(cli): refuse a stale stop request before the journal and the pin …
Aug 23, 2026
2f7893a
fix(runs): arbitrate the graceful stop lodge with O_CREAT|O_EXCL (#319)
Aug 23, 2026
f310325
fix(engine,runs): consume a stop request in one atomic take (#319)
Aug 23, 2026
8610315
fix(cli,tui,runs): report a pending hard request without calling it g…
Aug 23, 2026
9140e2d
docs(changelog,features): say what `fallback=True` proves, not what i…
Aug 23, 2026
df19d10
fix(runs,cli): drop the graceful lodge's rollback, which could delete…
Aug 23, 2026
4e8856b
fix(runs,adapters): stop stamping fallback on an engine-honored stop;…
Aug 23, 2026
8181b58
docs(readme,features,adapters): drop the false ~5s bound, fix the pol…
Aug 23, 2026
c7c8bdc
docs(readme,features): stop equating a force-kill with an unhonored s…
Aug 23, 2026
b7b0e42
fix(runs): refuse the stop when neither channel was delivered (#319)
Aug 23, 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
50 changes: 50 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,20 @@ breaking changes may land in a minor release.
mismatch mislabelled. The decision is unchanged — a byte-identical answer still activates
whatever scope supplied it, every unconfirmed answer still degrades, and the repo-format
flag is still rolled back.
- **A hard stop rides `stop-request.json` with `mode: "hard"` (#319).** It is lodged before the
engine is signalled — the atomic write also supersedes a pending graceful request — and honored
at item boundaries and mid-session, where both real adapter wait loops poll it twice per iteration,
which normally lands well inside the 10s grace window. An iteration blocked on a transport call
or waiting for an artifact can exceed it, and the stop then degrades to the force-kill backstop —
the pre-#319 outcome, never a worse one. SIGTERM is now the POSIX fast path rather than the mechanism, so a hard stop
lands on every platform and multiplexer backend, and reaches a nested auto-sweep through the
owning run's channel — hard-only; `stop <child-id>` is unchanged. A run directory that rejects
the write degrades to the signal path with the stop still delivered.
- **`status --json`'s `graceful_stop_pending` is now mode-exact (#319)** — it reports only
genuinely graceful requests. A modeless pre-#319 request body still reads graceful.
- **`stop --graceful` and the TUI report an already-pending request without calling it graceful
(#319).** The request standing on disk may be a hard one — `stop` leaves one lodged when it
could not prove the engine dead — and the idempotency answer is deliberately mode-blind.

### Removed

Expand All @@ -46,6 +60,42 @@ breaking changes may land in a minor release.

### Fixed

- **Native Windows: `bmad-loop stop` no longer burns the full 10s grace window into a blind
`taskkill /F` (#319).** An inter-process SIGTERM is never delivered to a native-Windows engine,
so every stop completed through the external fallback with no engine teardown at all. The engine
honors the stop request itself now, so it is the single writer of `stopped` again and
`run-stop fallback=True` is no longer stamped on a stop the engine recorded itself — it marks
one this tool had to complete from outside.
- **`bmad-loop stop` no longer reports success when it delivered neither channel (#319).** A run
directory that rejects the request write, followed by a signal the OS refuses, left the CLI
saying the run had stopped — and stamping `run-stop fallback=True` — over an engine that may
still be running with nothing on disk to stop it. It now kills the agent session as a backstop,
records the undelivered attempt, and exits non-zero naming the retry.
- **Two concurrent `stop` invocations against one run no longer collide on a staging temp (#319).**
The write staged through a fixed `stop-request.json.tmp`, so the loser's rename raised
`FileNotFoundError`. It now stages under a per-writer name: the last write wins and neither
caller errors.
- **`resume` no longer re-arms a run whose stale stop request it could not remove (#319).** It read
"could not remove it" as "nothing was pending", wrote the pid, and stopped again at the first
item boundary with nothing printed to say why. Resume now refuses before the pid lands and names
the file, without re-stamping the host-exec integrity pin for a run it never started.
`stop --cancel-graceful` likewise stops reporting "no stop request pending" for a request still
on disk and still honorable — same exit code, accurate message.
- **A `stop` that never proved the engine dead keeps its request lodged (#319).** A `terminate` or
`force_kill` refused with `PermissionError`, or a `taskkill /F /T` that failed silently,
discarded the hard request while reporting the run stopped — throwing away the only channel left
to stop a live engine. Death is now distinguished from refusal, so the stop stays genuinely in
flight.
- **A hard stop arriving as the last item finishes stops the run instead of reporting it completed
(#319).** Covers `max-stories-reached` too; a graceful request at an exhausted queue still
finishes truthfully.
- **`stop --graceful` no longer downgrades a hard request that landed while it ran (#319).** The
graceful lodge is now an atomic `O_CREAT | O_EXCL` create that answers "already pending" for
anything already there, and a symlink planted at the path is refused rather than followed. Two
concurrent graceful asks resolve the same way, which is the documented idempotency. A write that
fails part-way leaves the request standing instead of rolling back — an unlink there resolves the
path, not the file the call created, so it could remove a hard request escalated onto it — and
`stop --graceful` reports it as possibly pending rather than as a clean failure.
- **A policy field of the wrong TOML type now raises `PolicyError` naming `section.key`
(#440).** `loads()` coerced with bare `int()`/`float()`/`bool()`/`str()` outside the
`PolicyError` funnel, so a wrong-typed value escaped every handler written to degrade on
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -592,19 +592,19 @@ Game-engine (Unity) runs read a wider `BMAD_LOOP_UNITY_*` / `BMAD_LOOP_ENGINE_*`

## Run state

Everything about a run lives in `.bmad-loop/runs/<run-id>/` (gitignored): `state.json` (resumable engine state), `journal.jsonl` (every decision), `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), `verify/` (verifier command stdout/stderr, pointed at by the journal's `verify-command-result` records; the retained tail is capped per stream by `[verify] stream_capture_kb`, `0` to keep nothing), `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).
Everything about a run lives in `.bmad-loop/runs/<run-id>/` (gitignored): `state.json` (resumable engine state), `journal.jsonl` (every decision), `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), `verify/` (verifier command stdout/stderr, pointed at by the journal's `verify-command-result` records; the retained tail is capped per stream by `[verify] stream_capture_kb`, `0` to keep nothing), `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 stop request is pending — `stop-request.json` (the control file carrying the requested mode, consumed by the engine when it honors it).

One piece deliberately lives elsewhere: the **hook-event channel** (the session completion signals the orchestrator waits on) sits under the user-scoped state root at `<state root>/<project>/<run-id>/events/`, outside the project tree — a branch switch, a worktree mount or a rollback must not be able to take a live run's control plane away. See `BMAD_LOOP_STATE_DIR` above for where that root resolves. The orchestrator also keeps polling the old in-tree `events/` location, so a project whose installed hook relay predates the move still completes its sessions; re-run `bmad-loop init` to refresh the relay.

That out-of-tree directory is collected with the run: `delete`, `archive` and `clean` remove it alongside the run dir, and `clean` also sweeps this project's orphans there — control planes whose run dir is already gone, e.g. from a hand-removed run (`clean --dry-run` previews the count; `--json` reports it as `state_dirs_swept`). Two consequences worth knowing: an archived run's tarball no longer contains `events/` (transient completion signals, consumed while the run was live), and a project that is deleted, moved or renamed leaves its old subtree behind — the key is derived from the project's resolved path, so after a move the project itself now keys somewhere new and nothing can name the old key to sweep it. Remove it by hand if you care; it is events-sized, not run-sized.

A run can be stopped two ways. A **hard stop** (`bmad-loop stop`, TUI `x`, Ctrl+C) SIGTERMs the engine mid-item and always kills the agent session. A **graceful stop** (`bmad-loop stop --graceful`, TUI `S`) instead writes `stop-request.json` — no signal, so it works on every platform and multiplexer backend — which the engine consumes at the next item boundary: the in-flight story (or sweep bundle) finishes through commit — or, mid-triage, the sweep's triage session completes and no bundles start — the run finalizes as `stopped` (not `finished`) and stays resumable, and pending auto-sweeps are suppressed. Its session teardown follows `cleanup_session_on_finish` like a normal finish, rather than the hard stop's unconditional kill; a hard stop always supersedes a pending graceful request.
A run can be stopped two ways, and both requests travel over the same `stop-request.json` control file — no signal needed, so a stop works on every platform and multiplexer backend. A **hard stop** (`bmad-loop stop`, TUI `x`; Ctrl+C in the run's own terminal does the same thing directly) abandons the in-flight item and always kills the agent session: `stop` lodges the file with `mode: "hard"` _before_ it signals — the same atomic write supersedes any pending graceful request — and the engine honors it at the next item boundary, or mid-item, where each adapter's wait loop reads the file on both sides of the up-to-5s wait it blocks in — so a quiet session normally aborts within a few seconds, while one already waiting out its result grace, or blocked in a transport call, sees the request only when that call returns. SIGTERM still goes out as the POSIX fast path, but the file is what makes the stop land; the force-kill past the 10s grace window — and the `run-stop fallback=True` it stamps — now marks a stop this tool had to finish from outside, which a slow teardown reaches as readily as an engine that never read the request. A **graceful stop** (`bmad-loop stop --graceful`, TUI `S`) lodges the same file in its default `graceful` mode, which the engine consumes at the next item boundary only: the in-flight story (or sweep bundle) finishes through commit — or, mid-triage, the sweep's triage session completes and no bundles start — the run finalizes as `stopped` (not `finished`) and stays resumable, and pending auto-sweeps are suppressed. Its session teardown follows `cleanup_session_on_finish` like a normal finish, rather than the hard stop's unconditional kill.

`journal.jsonl` records a `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 elapsed), `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 (e.g. macOS sleep) that froze the monotonic clock. Every entry whose usage was read also carries `tokens` (raw) and `tokens_weighted` (cache reads at `limits.cache_read_weight`), keeping per-session spend reconstructible; both are `null` when the read failed and 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. Per-session `tokens_weighted` sums to within a token or two of the run total, which rounds per story rather than per session.

Token usage is read from each CLI's local session transcript (selected by the profile's `usage_parser`) and aggregated per story (`bmad-loop status`); the hookless `opencode` profile is the exception — its adapter pulls token usage from the OpenCode server over HTTP just before teardown (server state is sqlite; there is no local transcript).

Each run drives its agents inside a dedicated tmux session, `bmad-loop-<run-id>`. It is torn down automatically when the run finishes (disable with `[adapter] cleanup_session_on_finish = false` to inspect agent windows afterwards), and `stop` always kills it. A paused or interrupted run keeps its session for `resume`, which clears any stale session and spins up a fresh one. Sessions left behind by older runs — or by a `cleanup_session_on_finish = false` policy — can be swept any time with `bmad-loop cleanup` (or `c` in the TUI).
Each run drives its agents inside a dedicated tmux session, `bmad-loop-<run-id>`. It is torn down automatically when the run finishes (disable with `[adapter] cleanup_session_on_finish = false` to inspect agent windows afterwards); a hard `stop` kills it regardless of that setting, while a graceful `stop --graceful` tears it down under the same gate. A paused or interrupted run keeps its session for `resume`, which clears any stale session and spins up a fresh one. Sessions left behind by older runs — or by a `cleanup_session_on_finish = false` policy — can be swept any time with `bmad-loop cleanup` (or `c` in the TUI).

### Scripting `status`

Expand Down
Loading