Skip to content

Commit 53b18be

Browse files
author
t
committed
fix(sweep): bind resumed options to launch state
1 parent 6426c4f commit 53b18be

6 files changed

Lines changed: 177 additions & 26 deletions

File tree

‎src/bmad_loop/cli.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2750,6 +2750,11 @@ def _prepare_resume_locked(project: Path, run_dir: Path):
27502750
sweep_options = runsetup.load_sweep_resume_options(
27512751
run_dir,
27522752
required=state.sweep_options_version >= runsetup.SWEEP_OPTIONS_VERSION,
2753+
expected_digest=(
2754+
state.sweep_options_digest
2755+
if state.sweep_options_version == runsetup.SWEEP_OPTIONS_VERSION
2756+
else None
2757+
),
27532758
)
27542759
except runsetup.SweepOptionsError as exc:
27552760
print(f"cannot resume {run_dir.name}: {exc}", file=sys.stderr)

‎src/bmad_loop/model.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -764,6 +764,10 @@ class RunState:
764764
# shape. Selector-capable sweeps stamp the current nonzero version at launch,
765765
# so losing sweep.json cannot silently widen them on resume.
766766
sweep_options_version: int = 0
767+
# SHA-256 of the exact sweep.json bytes published for the current format.
768+
# State and options are separate atomic files; this binding makes replacing
769+
# a targeted run's valid options with an unrestricted document fail closed.
770+
sweep_options_digest: str = ""
767771
# story-queue source (policy.StoriesPolicy.source), pinned at run start so
768772
# resume/resolve rebuild the right engine (StoriesEngine vs the sprint Engine)
769773
# without re-reading policy — a policy edit mid-run must not switch a live run's
@@ -862,6 +866,7 @@ def to_dict(self) -> dict[str, Any]:
862866
"crash_error": self.crash_error,
863867
"run_type": self.run_type,
864868
"sweep_options_version": self.sweep_options_version,
869+
"sweep_options_digest": self.sweep_options_digest,
865870
"source": self.source,
866871
"spec_folder": self.spec_folder,
867872
"sweep_cycle": self.sweep_cycle,
@@ -895,6 +900,7 @@ def from_dict(cls, d: dict[str, Any]) -> "RunState":
895900
crash_error=d.get("crash_error"),
896901
run_type=str(d.get("run_type", "story")),
897902
sweep_options_version=int(d.get("sweep_options_version", 0)),
903+
sweep_options_digest=str(d.get("sweep_options_digest", "")),
898904
source=str(d.get("source", "sprint-status")),
899905
spec_folder=str(d.get("spec_folder", "")),
900906
sweep_cycle=int(d.get("sweep_cycle", 1)),

‎src/bmad_loop/runsetup.py‎

Lines changed: 44 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ def __call__(
8282
# actually builds them) and re-exported as ``cli.ROLES``, which `cmd_validate`
8383
# and the test suite resolve.
8484
ROLES = ("dev", "review", "triage")
85-
SWEEP_OPTIONS_VERSION = 1
85+
SWEEP_OPTIONS_VERSION = 2
8686
_MAX_SWEEP_OPTIONS_BYTES = 64 * 1024
8787

8888

@@ -843,9 +843,15 @@ class SweepResumeOptions:
843843
values: dict[str, Any]
844844
only_ids: tuple[str, ...] | None
845845
min_severity: str | None
846+
digest: str | None = None
846847

847848

848-
def load_sweep_resume_options(run_dir: Path, *, required: bool = False) -> SweepResumeOptions:
849+
def load_sweep_resume_options(
850+
run_dir: Path,
851+
*,
852+
required: bool = False,
853+
expected_digest: str | None = None,
854+
) -> SweepResumeOptions:
849855
"""Load bounded, non-redirected sweep.json bytes and validate selectors."""
850856

851857
def corrupt(message: str, *, cause: BaseException | None = None) -> SweepResumeOptions:
@@ -893,6 +899,9 @@ def corrupt(message: str, *, cause: BaseException | None = None) -> SweepResumeO
893899
data = b"".join(chunks)
894900
if len(data) > _MAX_SWEEP_OPTIONS_BYTES:
895901
raise SweepOptionsError(f"sweep.json exceeds {_MAX_SWEEP_OPTIONS_BYTES} bytes")
902+
digest = hashlib.sha256(data).hexdigest()
903+
if expected_digest is not None and digest != expected_digest:
904+
raise SweepOptionsError("sweep.json no longer matches the options bound at launch")
896905
try:
897906
text = data.decode("utf-8")
898907
except UnicodeDecodeError as exc:
@@ -943,24 +952,33 @@ def corrupt(message: str, *, cause: BaseException | None = None) -> SweepResumeO
943952

944953
if only_ids is not None and min_severity is not None:
945954
return corrupt("sweep.json cannot contain both 'only' and 'min_severity'")
946-
# Missing keys are the pre-selector format and remain unrestricted. Explicit
947-
# nulls are the current unrestricted format; both intentionally converge.
948-
if not only_present:
955+
# Version-zero callers predate selectors and must never inherit selector-
956+
# shaped keys from a stray or replaced options file. Current callers pass
957+
# ``required=True`` and bind these exact bytes through RunState.
958+
if not required or not only_present:
949959
only_ids = None
950-
if not min_present:
960+
if not required or not min_present:
951961
min_severity = None
952-
return SweepResumeOptions(opts, only_ids, min_severity)
962+
return SweepResumeOptions(opts, only_ids, min_severity, digest)
953963

954964

955965
def validate_sweep_options_version(version: int) -> None:
956966
"""Refuse state whose sweep options semantics this binary cannot interpret."""
957-
if version < 0 or version > SWEEP_OPTIONS_VERSION:
967+
if version not in (0, SWEEP_OPTIONS_VERSION):
958968
raise SweepOptionsError(
959-
f"unsupported sweep options version {version}; this binary supports "
960-
f"0 through {SWEEP_OPTIONS_VERSION}"
969+
f"unsupported sweep options version {version}; this binary supports legacy 0 "
970+
f"or current {SWEEP_OPTIONS_VERSION}"
961971
)
962972

963973

974+
def validate_sweep_options_binding(
975+
version: int, expected_digest: str, options: SweepResumeOptions
976+
) -> None:
977+
"""Bind current sweep options to the exact bytes published at launch."""
978+
if version == SWEEP_OPTIONS_VERSION and options.digest != expected_digest:
979+
raise SweepOptionsError("sweep.json no longer matches the options bound at launch")
980+
981+
964982
def _claim_run_dir(run_dir: Path) -> os.stat_result:
965983
"""Take exclusive ownership of a fresh run directory, refusing an id that
966984
already names a run.
@@ -1313,6 +1331,7 @@ def compose_sweep(
13131331
"trigger": trigger,
13141332
}
13151333
options_text = json.dumps(options, indent=2)
1334+
options_digest = hashlib.sha256(options_text.encode("utf-8")).hexdigest()
13161335
if len(options_text.encode("utf-8")) > _MAX_SWEEP_OPTIONS_BYTES:
13171336
raise SweepOptionsError(f"sweep options exceed {_MAX_SWEEP_OPTIONS_BYTES} bytes")
13181337

@@ -1334,6 +1353,7 @@ def compose_sweep(
13341353
policy_snapshot=policy.to_dict(),
13351354
run_type="sweep",
13361355
sweep_options_version=SWEEP_OPTIONS_VERSION,
1356+
sweep_options_digest=options_digest,
13371357
trusted_config_digest=trusted_config_digest,
13381358
)
13391359
# Persist the sweep options atomically (tmp + os.replace), the way save_state
@@ -1421,12 +1441,25 @@ def compose_resume(
14211441
if sweep_options is not None
14221442
else (
14231443
load_sweep_resume_options(
1424-
run_dir, required=state.sweep_options_version >= SWEEP_OPTIONS_VERSION
1444+
run_dir,
1445+
required=state.sweep_options_version >= SWEEP_OPTIONS_VERSION,
1446+
expected_digest=(
1447+
state.sweep_options_digest
1448+
if state.sweep_options_version == SWEEP_OPTIONS_VERSION
1449+
else None
1450+
),
14251451
)
14261452
if state.run_type == "sweep"
14271453
else None
14281454
)
14291455
)
1456+
if state.run_type == "sweep":
1457+
assert resolved_sweep_options is not None
1458+
validate_sweep_options_binding(
1459+
state.sweep_options_version,
1460+
state.sweep_options_digest,
1461+
resolved_sweep_options,
1462+
)
14301463
# drop any stale agent session so the run spins up a fresh one (a stopped or
14311464
# interrupted run can leave a lingering bmad-loop-<id> session behind).
14321465
runs.kill_session(run_dir.name)

‎tests/test_cli.py‎

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""CLI command tests — init policy-derived profiles and per-stage dry-run."""
22

33
import argparse
4+
import hashlib
45
import io
56
import json
67
import ntpath
@@ -1833,6 +1834,7 @@ def test_public_resume_refuses_malformed_selectors_before_mutation(
18331834
monkeypatch,
18341835
run_type="sweep",
18351836
sweep_options_version=runsetup.SWEEP_OPTIONS_VERSION,
1837+
sweep_options_digest=hashlib.sha256(contents.encode("utf-8")).hexdigest(),
18361838
)
18371839
(run_dir / "sweep.json").write_text(contents, encoding="utf-8")
18381840
state_before = (run_dir / "state.json").read_bytes()
@@ -6161,6 +6163,7 @@ def test_public_resume_refuses_missing_current_sweep_options_before_mutation(
61616163
monkeypatch,
61626164
run_type="sweep",
61636165
sweep_options_version=runsetup.SWEEP_OPTIONS_VERSION,
6166+
sweep_options_digest=hashlib.sha256(b"{}").hexdigest(),
61646167
)
61656168
(run_dir / "sweep.json").unlink(missing_ok=True)
61666169
state_before = (run_dir / "state.json").read_bytes()
@@ -6207,6 +6210,7 @@ def test_public_resume_refuses_incomplete_current_sweep_options_before_mutation(
62076210
monkeypatch,
62086211
run_type="sweep",
62096212
sweep_options_version=runsetup.SWEEP_OPTIONS_VERSION,
6213+
sweep_options_digest=hashlib.sha256(b"{}").hexdigest(),
62106214
)
62116215
(run_dir / "sweep.json").write_text("{}", encoding="utf-8")
62126216
state_before = (run_dir / "state.json").read_bytes()
@@ -6218,6 +6222,29 @@ def test_public_resume_refuses_incomplete_current_sweep_options_before_mutation(
62186222
assert (run_dir / "state.json").read_bytes() == state_before
62196223

62206224

6225+
def test_public_resume_refuses_replacing_targeted_options_with_unrestricted_json(
6226+
project, monkeypatch, capsys
6227+
):
6228+
targeted = json.dumps({"only": ["DW-1"], "min_severity": None})
6229+
run_dir = _paused_run_for_resume(
6230+
project,
6231+
monkeypatch,
6232+
run_type="sweep",
6233+
sweep_options_version=runsetup.SWEEP_OPTIONS_VERSION,
6234+
sweep_options_digest=hashlib.sha256(targeted.encode("utf-8")).hexdigest(),
6235+
)
6236+
(run_dir / "sweep.json").write_text(
6237+
json.dumps({"only": None, "min_severity": None}), encoding="utf-8"
6238+
)
6239+
state_before = (run_dir / "state.json").read_bytes()
6240+
monkeypatch.setattr(cli, "SweepEngine", lambda **_kwargs: pytest.fail("engine constructed"))
6241+
6242+
assert cli._resume_paused_run(project.project, run_dir) == 1
6243+
6244+
assert "bound at launch" in capsys.readouterr().err
6245+
assert (run_dir / "state.json").read_bytes() == state_before
6246+
6247+
62216248
@pytest.mark.parametrize(
62226249
("only", "expected"),
62236250
[(["DW-9"], 1), (None, 0)],
@@ -6226,16 +6253,15 @@ def test_public_resume_refuses_incomplete_current_sweep_options_before_mutation(
62266253
def test_resume_crash_exit_is_failure_only_for_persisted_named_scope(
62276254
project, monkeypatch, only, expected
62286255
):
6256+
options_text = json.dumps({"only": only, "min_severity": None})
62296257
run_dir = _paused_run_for_resume(
62306258
project,
62316259
monkeypatch,
62326260
run_type="sweep",
62336261
sweep_options_version=runsetup.SWEEP_OPTIONS_VERSION,
6262+
sweep_options_digest=hashlib.sha256(options_text.encode("utf-8")).hexdigest(),
62346263
)
6235-
(run_dir / "sweep.json").write_text(
6236-
json.dumps({"only": only, "min_severity": None}),
6237-
encoding="utf-8",
6238-
)
6264+
(run_dir / "sweep.json").write_text(options_text, encoding="utf-8")
62396265
summary = types.SimpleNamespace(crashed=True, render=lambda: "CRASHED")
62406266
engine = types.SimpleNamespace(run=lambda: summary)
62416267
monkeypatch.setattr(

‎tests/test_model.py‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,15 @@ def test_run_state_stories_fields_default_and_round_trip():
4242

4343

4444
def test_run_state_sweep_options_version_round_trips_and_defaults_legacy():
45-
state = _state(run_type="sweep", sweep_options_version=1)
46-
assert RunState.from_dict(state.to_dict()).sweep_options_version == 1
45+
state = _state(run_type="sweep", sweep_options_version=2, sweep_options_digest="a" * 64)
46+
round_tripped = RunState.from_dict(state.to_dict())
47+
assert round_tripped.sweep_options_version == 2
48+
assert round_tripped.sweep_options_digest == "a" * 64
4749
legacy = state.to_dict()
4850
del legacy["sweep_options_version"]
51+
del legacy["sweep_options_digest"]
4952
assert RunState.from_dict(legacy).sweep_options_version == 0
53+
assert RunState.from_dict(legacy).sweep_options_digest == ""
5054

5155

5256
def test_run_state_code_root_restamp_pending_round_trips_and_defaults_false():

0 commit comments

Comments
 (0)