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
39 changes: 39 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,17 @@ breaking changes may land in a minor release.

## [Unreleased]

### Added

- **A refused auto-sweep is now visible outside the journal (#501).** A run whose deferred-work
sweep was refused ended looking exactly like one that swept, and under `[sweep] auto = "run-end"`
there is one trigger per run that is never re-asked once the run finishes — so the journal was
the only trace. Runs now record `sweeps_refused` (trigger → reason), surfaced by the end-of-run
summary, `bmad-loop status`, `status --json` and `bmad-loop diagnose`; the follow-up they name
is `bmad-loop sweep`, which needs a clean worktree. The reason is a fixed slug — `not-started`,
`failed` or `dirty` — never an exception message, which `diagnose` would refuse to emit at all.
The `--json` key is additive and always present, so `STATUS_SCHEMA_VERSION` is unchanged.

### Changed

- **Files the orchestrator replaces by name now land at `0600`.** Those writes pass
Expand Down Expand Up @@ -39,6 +50,34 @@ breaking changes may land in a minor release.

### Fixed

- **Launching with a `--run-id` that already names a run is refused (#602).** The flag pointed at an
existing run adopted that run's directory and published its own `state.json` over it. The id is
now claimed exclusively as the directory is created, so a collision aborts the launch before
anything is written and the earlier run is left untouched.

- **A launch that aborts while standing up its adapters no longer strands an empty run (#602).** An
adapter failure left a run directory with no `run-start` that nothing reconciled, so it lingered
in `bmad-loop list` looking resumable. Composition is now atomic from the first published
artifact: on any escape the run directory and its out-of-tree state are removed and the original
error is re-raised unchanged. The removal keeps the live-session guard, and a removal that itself
fails is now reported instead of passing silently.

- **A failing auto-sweep can no longer kill its parent run, and a stop during one is no longer
swallowed (#600, #601).** A `SystemExit` from the child — what an unusable multiplexer or an
unresolvable profile raises — escaped every handler and ended the process at exit 1, leaving the
parent neither `finished` nor `crashed` with an orphaned agent session. In the other direction a
stop was recorded as a child failure, letting the parent run on to `finished` and leaving it
unstoppable. `KeyboardInterrupt` deliberately still escapes.

- **A run's `sweeps_triggered` records only auto-sweeps that actually started (#501).** The trigger
was spent before anything had been attempted, so every way a child could decline to launch
consumed it — including a plain `git status` timeout, which fails closed and could silently burn
a run's one and only sweep. The tree check now runs ahead of the record and journals a `reason`
distinguishing a git fault from real local changes. Not a retry: what this buys is that
`bmad-loop diagnose` stops reporting sweeps that never ran. A child that fails after its run
directory exists still spends the trigger; the never-launched case journals
`sweep-auto-not-started` and keeps its own notification.

- **A failed write can no longer truncate the sprint board, a story spec, your CLI settings or your
policy file (#379).** Seven writers read a file, merged into it, and wrote the whole thing back
through a truncating `Path.write_*` — so a fault partway through (ENOSPC, EIO, a quota) published
Expand Down
3 changes: 2 additions & 1 deletion docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,8 @@ See [README.md](../README.md) for the narrative overview and [setup-guide.md](se
- Bundles run the full pipeline (dev `--dw-bundle` → review → verify → commit); the review gate checks every bundle entry is `status: done`.
- Interactive decision walkthrough (build / close / keep-open per option, with a recommendation); answers written back as `decision:` lines. Unattended runs leave decisions open.
- Answer skipped/missed decisions out of band with `bmad-loop decisions` (or `d` in the TUI): reconstructed from past triage output, saved to `.bmad-loop/decisions.json`, and consumed by the next sweep with no re-prompt (build → bundle, close → closed, keep-open → recorded).
- Auto-sweep at epic boundaries or run-end (`[sweep] auto`); a failed/paused child sweep never interrupts the parent run.
- Auto-sweep at epic boundaries or run-end (`[sweep] auto`); a failed or paused child sweep is journaled + notified and leaves the parent running — including the `SystemExit` an unusable multiplexer or an unresolvable profile raises, which used to end the parent at exit 1 with an orphaned session (#600). A stop (`bmad-loop stop`) or a Ctrl-C delivered through the child is the deliberate exception and propagates, so a parent stays stoppable while a child sweep is mid-flight (#601).
- A trigger is spent only once its child has actually started, so a refusal no longer consumes it (#501) — but nothing re-asks it either, since both call sites close their boundary within a few statements of the refusal. What the run keeps instead is a record: `sweeps_refused` (trigger → `not-started` / `failed` / `dirty`), surfaced by the end-of-run summary, `bmad-loop status`, `status --json` and `bmad-loop diagnose`, naming `bmad-loop sweep` (which needs a clean worktree) as the human-present follow-up.
- Repeat mode (`--repeat` / `[sweep] repeat`): re-triages after each cycle to absorb newly generated deferred work, stopping when a cycle does nothing addressable or hits `max_cycles`.
- Sweeps are their own resumable runs (`bmad-loop resume <id>`). An escalated bundle resolves like a story escalation, including intent-gap patch-restore: `bmad-loop resolve <id> --restore-patch <path>` re-arms the bundle spec to `in-review` and the re-driven bundle session resumes review on the re-applied patch instead of re-implementing.

Expand Down
44 changes: 31 additions & 13 deletions src/bmad_loop/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@
from .sweep import SweepEngine

if TYPE_CHECKING:
from collections.abc import Callable

# Type-only: annotate the profile-lookup map without a module-level adapter
# import (cli.py imports the adapter package lazily inside functions).
from .adapters.profile import CLIProfile
Expand Down Expand Up @@ -216,10 +218,10 @@ def _launch_profiles(pol, project: Path) -> dict[str, CLIProfile]:
baseline a session cannot reach (`_sweep_factory` holds every later auto-sweep
to it from MEMORY, unlike resume's disk-backed advisory), so a pin describing
bytes the run did not launch makes those children refuse the config the parent
has been running all along. `engine._maybe_auto_sweep` appends the trigger to
`sweeps_triggered` BEFORE calling the factory and returns early on an
already-recorded one, so that refusal burns the trigger for the life of the run
— it is not retried, not even across a resume.
has been running all along — every trigger, for the life of the run. The
refusal no longer *burns* each trigger (#501: `sweeps_triggered` records only
a child that started), but that buys nothing here: a wrong pin refuses the next
trigger exactly as it refused the last.

`cmd_sweep` deliberately keeps its fresh read: a human started it, and the pin
it stamps gates no child.
Expand Down Expand Up @@ -1920,6 +1922,7 @@ def _start_sweep(
trigger: str,
run_id: str | None = None,
profiles=None,
on_started: Callable[[], None] | None = None,
) -> int:
# The composition (run dir + state + pid + sweep.json + adapters + engine)
# lives in runsetup; this stays compose -> render. SweepEngine and
Expand All @@ -1930,6 +1933,10 @@ def _start_sweep(
# both stamps the pin and builds the adapters, so neither re-reads
# profiles/*.toml after the gate compared it. `cmd_sweep` passes None: a human
# started that one, so a fresh read is the point.
#
# `on_started` is the auto-sweep parent's latch, likewise absent for
# `cmd_sweep`; `compose_sweep` fires it at the boundary where this child owns a
# published, resumable run dir.
composed = runsetup.compose_sweep(
project=project,
paths=paths,
Expand All @@ -1945,6 +1952,7 @@ def _start_sweep(
sweep_engine_cls=SweepEngine,
trusted_config_digest=_trusted_config_digest(pol, project, profiles=profiles),
profiles=profiles,
on_started=on_started,
)
print(f"sweep {composed.run_id} starting (attach: bmad-loop attach)")
summary = composed.engine.run()
Expand All @@ -1956,14 +1964,18 @@ def _sweep_factory(project: Path, paths: bmadconfig.ProjectPaths, trusted_digest
"""Child-sweep launcher injected into story-run engines. Auto-triggered
sweeps are unattended: never prompt, never run decision bundles.

The returned callable implements :class:`engine.SweepFactory`: every refusal
below raises *before* the keyword-only ``started`` thunk can fire, which is
what leaves the parent run's trigger unspent for a child that never launched.

``trusted_digest`` is the caller's launch-time :func:`runsetup.config_digest`
— the integrity pin for the config this factory re-reads from disk below.
Required, with no default: an omitted baseline would silently disable the
refusal, which is the failure mode a security gate can least afford. The
re-read happens once and is frozen, so the gate validates the bytes the child
actually launches from rather than a separate read of the same files."""

def factory(trigger: str) -> None:
def factory(trigger: str, *, started: Callable[[], None]) -> None:
pol = policy_mod.load(_policy_path(project))
# Read the agent-writable config EXACTLY ONCE, here, and run the child off
# these two objects: `pol` and `profiles` are threaded through the gate
Expand Down Expand Up @@ -1995,14 +2007,15 @@ def factory(trigger: str) -> None:
" Run `bmad-loop sweep` yourself to proceed under the new config."
)
# Raise rather than return the rc the other three sites return. By the time
# the engine calls this it has already latched the trigger and journaled
# `sweep-auto-trigger`, and it reads a plain return as success — so a bare
# decline would be recorded as `sweep-auto-finished`, which `engine.py`
# defines as "a clean completion from the parent's perspective": a child
# sweep that ran and finished when none was ever launched. Raising lands on
# the same `sweep-auto-failed` + notify path the `load` above already takes
# on an unparseable policy.toml, which is the same kind of event — the
# config on disk changed under a run that had already started.
# the engine calls this it has journaled `sweep-auto-trigger`, and it reads
# a plain return as a child that ran — latching the trigger on one whether
# or not `started` fired — so a bare decline would be recorded as
# `sweep-auto-finished`, which `engine.py` defines as "a clean completion
# from the parent's perspective": a child sweep that ran and finished when
# none was ever launched. Raising lands on the `sweep-auto-not-started` +
# notify path the `load` above already takes on an unparseable policy.toml,
# which is the same kind of event — the config on disk changed under a run
# that had already started.
conflict = bmadconfig.worktree_isolation_conflict(paths, pol.scm.isolation)
if conflict is not None:
raise RuntimeError(conflict)
Expand All @@ -2015,6 +2028,7 @@ def factory(trigger: str) -> None:
max_bundles=None,
trigger=trigger,
profiles=profiles,
on_started=started,
)

return factory
Expand Down Expand Up @@ -2967,6 +2981,10 @@ def cmd_status(args: argparse.Namespace) -> int:
print("status: in progress — graceful stop pending (will stop after the current item)")
else:
print("status: in progress (or interrupted)")
if state.sweeps_refused:
detail = ", ".join(f"{trigger} ({why})" for trigger, why in state.sweeps_refused.items())
print(f"auto-sweep not run: {detail} — deferred work is untouched")
print(" run `bmad-loop sweep` with a clean worktree")
raw_total, weighted_total, weight = run_token_totals(state)
if raw_total:
print(
Expand Down
20 changes: 20 additions & 0 deletions src/bmad_loop/diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ class RunDiag:
current_epic: int | None
sweep_cycle: int
sweeps_triggered: list[str]
sweeps_refused: dict[str, str]
plugin_shared_keys: int
policy: dict
n_tasks: int
Expand Down Expand Up @@ -596,6 +597,17 @@ def collect_run(run_dir: Path, *, pseudo: sanitize.Pseudonymizer, cap: int) -> R
s if sanitize.looks_like_identifier(str(s)) else "<redacted:str>"
for s in state.sweeps_triggered
],
# BOTH halves are filtered. The value is a closed SWEEP_REFUSED_* slug by
# construction, but the key is a trigger string off state.json — the same
# untrusted footing as sweeps_triggered above — and a hand-edited or
# foreign state file must not be able to route a home path into a report
# that `sanitize.guard` would then refuse to emit at all.
sweeps_refused={
(k if sanitize.looks_like_identifier(str(k)) else "<redacted:str>"): (
v if sanitize.looks_like_identifier(str(v)) else "<redacted:str>"
)
for k, v in state.sweeps_refused.items()
},
plugin_shared_keys=len(state.plugin_shared),
policy=_scrub_policy(state.policy_snapshot),
n_tasks=len(tasks),
Expand Down Expand Up @@ -645,6 +657,7 @@ def _unreadable_run(run_dir: Path, err: Exception) -> RunDiag:
current_epic=None,
sweep_cycle=0,
sweeps_triggered=[],
sweeps_refused={},
plugin_shared_keys=0,
policy={},
n_tasks=0,
Expand Down Expand Up @@ -745,6 +758,13 @@ def render_markdown(
out.append(_fmt_kv("epic / sweep_cycle", f"{r.current_epic} / {r.sweep_cycle}"))
if r.sweeps_triggered:
out.append(_fmt_kv("sweeps_triggered", ", ".join(f"`{s}`" for s in r.sweeps_triggered)))
if r.sweeps_refused:
out.append(
_fmt_kv(
"sweeps_refused",
", ".join(f"`{k}`: {v}" for k, v in r.sweeps_refused.items()),
)
)
out.append(_fmt_kv("tasks", r.n_tasks))
out.append(_fmt_kv("phase histogram", _dict_inline(r.phase_histogram)))
out.append(_fmt_kv("token totals", _dict_inline(r.token_totals)))
Expand Down
6 changes: 6 additions & 0 deletions src/bmad_loop/documents.py
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,12 @@ def status_document(state: RunState, *, graceful_stop_pending: bool = False) ->
"weighted": weighted_total,
},
"adapters": adapters,
# auto-sweep triggers the run did not deliver, trigger -> reason slug
# (model.SWEEP_REFUSED_*). Always present, `{}` when nothing was refused:
# a key that appears only on the failing run makes "absent" ambiguous
# between "swept fine" and "old state.json". Additive per machine.py, so
# STATUS_SCHEMA_VERSION does not move.
"sweeps_refused": dict(state.sweeps_refused),
"tasks": tasks,
}

Expand Down
Loading