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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,14 @@ breaking changes may land in a minor release.
- Parse plugin manifests in `validate` (`plugins.manifests`) without importing
plugin code; a malformed `plugin.toml` fails validate instead of engine start (#765).

- Scope the sweep skill's `open_ids` and partition validation rules to the session's
triage universe, so a `--only` or `--min-severity` triage no longer lists every open
entry and burns a retry (#824).

- Show a sweep run's effective `max_bundles`, `repeat` and `max_cycles` in text `status`,
labelled launch override or policy snapshot, plus its selector; unreadable or
tampered `sweep.json` reports `unverifiable` (#815).

- Replace stale installed relay hooks when a project moves between Windows and POSIX.

- Report stale or unverifiable Codex hook trust in `validate` and `probe-adapter`
Expand Down
2 changes: 1 addition & 1 deletion docs/FEATURES.md

Large diffs are not rendered by default.

55 changes: 55 additions & 0 deletions src/bmad_loop/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,11 @@
from .sweep import (
DW_ID_RE,
SEVERITY_ORDER,
SWEEP_OVERRIDE_KEYS,
SweepEngine,
decimal_digits_key,
increment_decimal_digits,
resolve_sweep_override,
select_entries,
)

Expand Down Expand Up @@ -4375,6 +4377,57 @@ def cmd_decisions(args: argparse.Namespace) -> int:
return 0


def _sweep_options_line(run_dir: Path, state: RunState) -> str:
"""The text-status line naming a sweep run's effective options (#815).

`policy_snapshot` alone reads as the enforced cap, but a launch override in
`sweep.json` wins over it (`resolve_sweep_override`, as `SweepEngine.__init__`
applies it). The policy half comes from the run's snapshot, never live
policy.toml: the engine loads policy once, and resume re-stamps the snapshot
to the policy it reloads. `sweep.json` is read the way resume reads it —
bounded, version-checked and digest-bound — but status only observes, so a
refusal degrades to "unverifiable" rather than failing the command."""
version = state.sweep_options_version
try:
runsetup.validate_sweep_options_version(version)
current = version == runsetup.SWEEP_OPTIONS_VERSION
options = runsetup.load_sweep_resume_options(
run_dir,
required=version >= runsetup.SWEEP_OPTIONS_VERSION,
expected_digest=state.sweep_options_digest if current else None,
)
runsetup.validate_sweep_options_binding(version, state.sweep_options_digest, options)
except runsetup.SweepOptionsError as exc:
return f"sweep options: unverifiable — {exc}"
if options.digest is None:
# The legacy loader's tolerant empty shape: no readable sweep.json, so the
# launch overrides were never recorded (resume would run on policy alone).
return "sweep options: unknown — legacy run with no readable sweep.json"
raw_policy = state.policy_snapshot.get("sweep")
snapshot: dict[str, Any] = raw_policy if isinstance(raw_policy, dict) else {}
parts: list[str] = []
for key in SWEEP_OVERRIDE_KEYS:
override = options.values.get(key)
from_policy = snapshot.get(key)
if override is None and from_policy is None:
parts.append(f"{key} unknown (no override; not in the policy snapshot)")
continue
value = json.dumps(resolve_sweep_override(override, from_policy))
if override is None:
source = "policy"
elif from_policy is None:
source = "override"
else:
source = f"override; policy {json.dumps(from_policy)}"
parts.append(f"{key} {value} ({source})")
if options.only_ids is not None:
parts.append(f"only {','.join(options.only_ids)}")
if options.min_severity is not None:
parts.append(f"min_severity {options.min_severity}")
legacy = " [legacy options format]" if version == 0 else ""
return f"sweep options: {', '.join(parts)}{legacy}"


def cmd_status(args: argparse.Namespace) -> int:
project = _project(args)
if args.run_id:
Expand Down Expand Up @@ -4414,6 +4467,8 @@ 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.run_type == "sweep":
print(_sweep_options_line(run_dir, state))
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")
Expand Down
12 changes: 8 additions & 4 deletions src/bmad_loop/data/skills/bmad-loop-sweep/automation-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,14 @@ field-by-field, and will kill this session after your final turn.

- Validation rules the orchestrator enforces (a violation fails the whole
result and burns a retry):
- `open_ids` must list exactly the ledger's `status: open` entries — the
orchestrator parses the ledger itself and compares.
- Every open id appears in exactly ONE of already_resolved / bundles /
blocked / skip / decisions. No misses, no duplicates, no invented ids.
- `open_ids` must list exactly this session's triage universe: every
`status: open` entry in the ledger or, when the invocation carries
`--only DW-1,DW-2,...`, exactly those named ids — the orchestrator
parses the ledger itself, applies the same selection, and compares.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- Every id in the triage universe appears in exactly ONE of
already_resolved / bundles / blocked / skip / decisions. No misses, no
duplicates, no invented ids — an open entry outside the `--only`
selection counts as invented.
- Bundle names: `^[a-z0-9][a-z0-9-]{1,39}\Z`, unique, non-empty `dw_ids`,
non-empty `intent`. An otherwise-valid overlong bundle name or decision
option `bundle_name` is truncated to 40 characters and journaled before
Expand Down
24 changes: 20 additions & 4 deletions src/bmad_loop/sweep.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
import unicodedata
from dataclasses import dataclass, replace
from pathlib import Path
from typing import Any, Callable, Iterable, Literal, NoReturn, assert_never
from typing import Any, Callable, Iterable, Literal, NoReturn, TypeVar, assert_never

from . import deferredwork, gates, verify
from .engine import (
Expand Down Expand Up @@ -1300,6 +1300,22 @@ def _rearm_generation(task: StoryTask) -> None:
task.generation += 1


# The launch overrides a sweep run resolves against `[sweep]` policy. `sweep.json`
# persists each as nullable (None = "not given"); `SweepEngine.__init__` and
# `bmad-loop status` both resolve through `resolve_sweep_override`.
SWEEP_OVERRIDE_KEYS = ("max_bundles", "repeat", "max_cycles")

_T = TypeVar("_T")


def resolve_sweep_override(override: _T | None, policy_value: _T) -> _T:
"""The value a sweep run enforces for one of `SWEEP_OVERRIDE_KEYS`: its own
override when one was given, else the policy's. Tested with `is not None`,
never truthiness — an explicit `repeat=False` or `max_bundles=0` is an
override, not an absence."""
return override if override is not None else policy_value


class SweepEngine(Engine):
"""Engine variant whose loop processes the deferred-work ledger instead
of sprint-status. Bundles reuse the inherited story pipeline through the
Expand Down Expand Up @@ -1329,9 +1345,9 @@ def __init__(
self.adapters["triage"].journal = self.journal
self.prompting = prompting
self.decisions_only = decisions_only
self.max_bundles = max_bundles if max_bundles is not None else self.policy.sweep.max_bundles
self.repeat = repeat if repeat is not None else self.policy.sweep.repeat
self.max_cycles = max_cycles if max_cycles is not None else self.policy.sweep.max_cycles
self.max_bundles = resolve_sweep_override(max_bundles, self.policy.sweep.max_bundles)
self.repeat = resolve_sweep_override(repeat, self.policy.sweep.repeat)
self.max_cycles = resolve_sweep_override(max_cycles, self.policy.sweep.max_cycles)
self.only_ids = only_ids
self.min_severity = min_severity
self._selection_started = self.state.sweep_cycle > 1 or any(
Expand Down
221 changes: 220 additions & 1 deletion tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
write_sprint,
)

from bmad_loop import cli, deferredwork, envvars, platform_util
from bmad_loop import bmadconfig, cli, deferredwork, envvars, platform_util
from bmad_loop import policy as policy_mod
from bmad_loop import probe as probe_mod
from bmad_loop import runs, runsetup, verify
Expand Down Expand Up @@ -1700,6 +1700,225 @@ def test_status_stories_mode_bad_manifest_is_soft(project, capsys):
assert "no stories.yaml found" in capsys.readouterr().out


# ------------------------------------------- status: a sweep's effective options

# `sweep.json` holds a sweep run's nullable launch overrides; `policy_snapshot`
# holds `[sweep]` from policy.toml. The engine enforces `override ?? snapshot`, so
# the snapshot alone misreports an overridden cap (#815). These rows compose the
# run through the same `runsetup.compose_sweep` `cmd_sweep` uses.

SWEEP_STATUS_POLICY = "[sweep]\nmax_bundles = 5\nrepeat = true\nmax_cycles = 3\n"


class _ComposedSweepEngine:
def __init__(self, *args, **kwargs):
pass


def _compose_sweep_run(project, policy_text=SWEEP_STATUS_POLICY, **overrides):
"""A sweep run composed from the sandbox's real policy.toml. The pid file
`compose_sweep` publishes names this test process, so it is removed: the run
reads as interrupted, which is what `status` and `resume` expect of it."""
_write_policy(project.project, policy_text)
options = {"max_bundles": None, "repeat": None, "max_cycles": None} | overrides
composed = runsetup.compose_sweep(
project=project.project,
paths=bmadconfig.ProjectPaths(
project=project.project,
implementation_artifacts=project.project / "impl",
planning_artifacts=project.project / "plan",
),
policy=policy_mod.load(cli._policy_path(project.project)),
run_id="20260101-000000-sw01",
prompting=False,
decisions_only=False,
trigger="cli",
make_adapters=lambda *a, **k: {role: None for role in runsetup.ROLES},
sweep_engine_cls=_ComposedSweepEngine,
trusted_config_digest="deadbeef",
**options,
)
(composed.run_dir / runs.PID_FILE).unlink()
return composed.run_dir


def _status_sweep_options(project, capsys) -> str:
assert cli.main(["status", "--project", str(project.project)]) == 0
out, err = capsys.readouterr()
assert "Traceback" not in out + err
(line,) = [ln for ln in out.splitlines() if ln.startswith("sweep options:")]
return line


def test_status_sweep_shows_an_override_as_effective(project, capsys):
_compose_sweep_run(project, max_bundles=15)
line = _status_sweep_options(project, capsys)
assert "max_bundles 15 (override; policy 5)" in line


def test_status_sweep_omitted_override_shows_the_snapshot_value(project, capsys):
_compose_sweep_run(project, max_bundles=15)
line = _status_sweep_options(project, capsys)
assert "repeat true (policy)" in line
assert "max_cycles 3 (policy)" in line


def test_status_sweep_explicit_false_override_is_an_override(project, capsys):
"""ABLATION: resolve the override by truthiness and this fails — `repeat=False`
against policy `repeat = true` would read as the policy's `true`."""
_compose_sweep_run(project, repeat=False, max_cycles=0)
line = _status_sweep_options(project, capsys)
assert "repeat false (override; policy true)" in line
assert "max_cycles 0 (override; policy 3)" in line


def test_status_sweep_reads_the_snapshot_not_live_policy(project, capsys):
"""The engine loaded policy once, at launch; a later policy.toml edit does not
reach it, so it must not reach status either.

ABLATION: resolve against live policy.toml instead of `policy_snapshot` and
this fails on the edited `9`."""
_compose_sweep_run(project)
_write_policy(project.project, "[sweep]\nmax_bundles = 9\nrepeat = false\nmax_cycles = 3\n")
line = _status_sweep_options(project, capsys)
assert "max_bundles 5 (policy)" in line
assert "repeat true (policy)" in line


def test_status_sweep_after_resume_reports_the_restamped_policy(project, monkeypatch, capsys):
"""Resume reloads policy.toml, hands it to the rebuilt SweepEngine and re-stamps
`policy_snapshot` to match (#189) — while `sweep.json` is left byte-identical,
so the launch override survives and its digest still binds."""
from conftest import install_base_skills

from bmad_loop.journal import load_state, save_state

install_bmad_config(project)
install_base_skills(project)
write_sprint(project, {})
run_dir = _compose_sweep_run(project, max_bundles=15)
state = load_state(run_dir)
state.paused_reason, state.paused_stage = "escalation", "escalation"
save_state(run_dir, state)
_write_policy(project.project, "[sweep]\nmax_bundles = 6\nrepeat = true\nmax_cycles = 4\n")
monkeypatch.setattr(runs, "kill_session", lambda rid: None)
monkeypatch.setattr(runs, "write_pid", lambda _run_dir: None)
monkeypatch.setattr(cli, "_make_adapters", lambda *a, **k: {r: None for r in cli.ROLES})
monkeypatch.setattr(cli, "SweepEngine", _StubEngine)

assert cli._resume_paused_run(project.project, run_dir) == 0
capsys.readouterr()

line = _status_sweep_options(project, capsys)
assert "max_bundles 15 (override; policy 6)" in line
assert "max_cycles 4 (policy)" in line


def test_status_sweep_shows_the_selector(project, capsys):
_compose_sweep_run(project, only_ids=("DW-3", "DW-1"))
assert _status_sweep_options(project, capsys).endswith(", only DW-3,DW-1")


def test_status_sweep_shows_the_severity_selector(project, capsys):
_compose_sweep_run(project, min_severity="high")
assert _status_sweep_options(project, capsys).endswith(", min_severity high")


def _mark_legacy(run_dir) -> None:
from bmad_loop.journal import load_state, save_state

state = load_state(run_dir)
state.sweep_options_version, state.sweep_options_digest = 0, ""
save_state(run_dir, state)


def test_status_sweep_legacy_run_reports_its_recorded_options(project, capsys):
"""A pre-marker run resumes on its sweep.json limits but never its selectors
(`load_sweep_resume_options(required=False)`), so status reports the same."""
run_dir = _compose_sweep_run(project, max_bundles=15, only_ids=("DW-1",))
_mark_legacy(run_dir)
line = _status_sweep_options(project, capsys)
assert "max_bundles 15 (override; policy 5)" in line
assert "only" not in line
assert line.endswith("[legacy options format]")


def test_status_sweep_legacy_run_without_options_says_so(project, capsys):
run_dir = _compose_sweep_run(project, max_bundles=15)
_mark_legacy(run_dir)
(run_dir / "sweep.json").unlink()
line = _status_sweep_options(project, capsys)
assert line == "sweep options: unknown — legacy run with no readable sweep.json"


@pytest.mark.parametrize("fault", ["corrupt", "digest-mismatch", "missing"])
def test_status_sweep_unverifiable_options_degrade(project, capsys, fault):
"""Status observes; resume would refuse this run, status reports why and still
exits 0 — and prints no policy value, which would read as the enforced one."""
from bmad_loop.journal import load_state, save_state

run_dir = _compose_sweep_run(project, max_bundles=15)
options = run_dir / "sweep.json"
if fault == "corrupt":
options.write_bytes(b"{not json")
# Re-bind the digest so the parse, not the binding, is what refuses.
state = load_state(run_dir)
state.sweep_options_digest = hashlib.sha256(options.read_bytes()).hexdigest()
save_state(run_dir, state)
reason = "not valid JSON"
elif fault == "digest-mismatch":
# The widening swap the digest exists to catch: valid, but not launch's.
options.write_text(json.dumps({"only": None, "min_severity": None}), encoding="utf-8")
reason = "no longer matches the options bound at launch"
else:
options.unlink()
reason = "sweep.json is missing"

line = _status_sweep_options(project, capsys)
assert line.startswith("sweep options: unverifiable — ")
assert reason in line
assert "policy" not in line


def test_status_json_for_a_sweep_run_is_unchanged(project, capsys):
"""#815 is text-only: `status --json` stays the pure `status_document` projection
of state.json, with its keys exactly as before — no sweep-options key."""
from bmad_loop.documents import status_document
from bmad_loop.journal import load_state

run_dir = _compose_sweep_run(project, max_bundles=15, only_ids=("DW-1",))
assert cli.main(["status", "--json", "--project", str(project.project)]) == 0
document = json.loads(capsys.readouterr().out)
assert document == status_document(load_state(run_dir))
assert set(document) == {
"schema_version",
"run_id",
"run_type",
"source",
"started_at",
"status",
"finished",
"stopped",
"graceful_stop_pending",
"crashed",
"crash_error",
"paused_stage",
"paused_reason",
"paused_story_key",
"cache_read_weight",
"tokens",
"adapters",
"sweeps_refused",
"tasks",
}


def test_status_story_run_prints_no_sweep_options(project, capsys):
_make_run_with_tokens(project, {}, weight=0.1)
assert cli.main(["status", "--project", str(project.project)]) == 0
assert "sweep options" not in capsys.readouterr().out


def test_emit_document_verifies_without_altering_the_bytes(capsys):
"""The helper half the --json commands write through: it must validate, and
it must emit the ORIGINAL string — diagnose's leak self-check verified those
Expand Down
Loading
Loading