Skip to content

Commit fe61227

Browse files
authored
Merge pull request #560 from escoffier-labs/feat/475-capture-before-retry
feat(work): capture-before-retry enforcement for failed verifies
2 parents 0939de3 + 24c44f0 commit fe61227

4 files changed

Lines changed: 327 additions & 4 deletions

File tree

src/brigade/config.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,16 @@
1616
CONFIG_REL_PATH = f"{WORKSPACE_DIRNAME}/config.json"
1717
SUPPORTED_VERSIONS = (1,)
1818
DEFAULT_GRAPHTRAIL_DELTA_TIMEOUT_SECONDS = 10.0
19+
CAPTURE_BEFORE_RETRY_MODES = ("warn", "block", "off")
20+
DEFAULT_CAPTURE_BEFORE_RETRY = "warn"
1921

2022

2123
@dataclass
2224
class Config:
2325
version: int
2426
selection: Selection
2527
graphtrail_delta_timeout_seconds: float = DEFAULT_GRAPHTRAIL_DELTA_TIMEOUT_SECONDS
28+
capture_before_retry: str = DEFAULT_CAPTURE_BEFORE_RETRY
2629

2730

2831
def validate_graphtrail_delta_timeout(value: Any) -> float:
@@ -34,6 +37,22 @@ def validate_graphtrail_delta_timeout(value: Any) -> float:
3437
return timeout
3538

3639

40+
def validate_capture_before_retry(value: Any) -> str:
41+
if not isinstance(value, str):
42+
raise ValueError(f"capture_before_retry must be one of: {', '.join(CAPTURE_BEFORE_RETRY_MODES)}")
43+
mode = value.strip().lower()
44+
if mode not in CAPTURE_BEFORE_RETRY_MODES:
45+
raise ValueError(f"capture_before_retry must be one of: {', '.join(CAPTURE_BEFORE_RETRY_MODES)}")
46+
return mode
47+
48+
49+
def resolve_capture_before_retry(target: Path) -> str:
50+
cfg = load_config(target)
51+
if cfg is not None:
52+
return cfg.capture_before_retry
53+
return DEFAULT_CAPTURE_BEFORE_RETRY
54+
55+
3756
def resolve_graphtrail_delta_timeout(target: Path, cli_override: float | None = None) -> float:
3857
if cli_override is not None:
3958
try:
@@ -64,6 +83,9 @@ def write_config(target: Path, cfg: Config) -> None:
6483
}
6584
if graphtrail_timeout != DEFAULT_GRAPHTRAIL_DELTA_TIMEOUT_SECONDS:
6685
payload["graphtrail_delta_timeout_seconds"] = graphtrail_timeout
86+
capture_before_retry = validate_capture_before_retry(cfg.capture_before_retry)
87+
if capture_before_retry != DEFAULT_CAPTURE_BEFORE_RETRY:
88+
payload["capture_before_retry"] = capture_before_retry
6789
path.write_text(json.dumps(payload, indent=2) + "\n")
6890

6991

@@ -90,4 +112,10 @@ def load_config(target: Path) -> Optional[Config]:
90112
sel.validate()
91113
timeout_raw = data.get("graphtrail_delta_timeout_seconds", DEFAULT_GRAPHTRAIL_DELTA_TIMEOUT_SECONDS)
92114
timeout = validate_graphtrail_delta_timeout(timeout_raw)
93-
return Config(version=version, selection=sel, graphtrail_delta_timeout_seconds=timeout)
115+
capture_before_retry = validate_capture_before_retry(data.get("capture_before_retry", DEFAULT_CAPTURE_BEFORE_RETRY))
116+
return Config(
117+
version=version,
118+
selection=sel,
119+
graphtrail_delta_timeout_seconds=timeout,
120+
capture_before_retry=capture_before_retry,
121+
)

src/brigade/work_cmd/verification.py

Lines changed: 107 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,102 @@ def _prune_verify_runs(target: Path, keep: int = VERIFY_RUNS_KEEP) -> int:
344344
return removed
345345

346346

347+
def _normalize_command_identity(command: str | list[str]) -> str:
348+
"""Canonicalize one command without discarding execution-affecting prefixes."""
349+
if isinstance(command, list):
350+
return shlex.join(command)
351+
try:
352+
return shlex.join(shlex.split(command))
353+
except ValueError:
354+
return command
355+
356+
357+
def _planned_commands_display(commands: list[str | list[str]]) -> list[str]:
358+
return [shlex.join(command) if isinstance(command, list) else command for command in commands]
359+
360+
361+
def _planned_commands_identity(commands: list[str | list[str]]) -> list[str]:
362+
return [_normalize_command_identity(command) for command in commands]
363+
364+
365+
def _receipt_planned_commands_identity(receipt: dict[str, Any]) -> list[str] | None:
366+
"""Return normalized command identity from a verify receipt."""
367+
planned = receipt.get("planned_commands")
368+
if isinstance(planned, list) and all(isinstance(item, str) for item in planned):
369+
return [_normalize_command_identity(item) for item in planned]
370+
commands = receipt.get("commands")
371+
if not isinstance(commands, list):
372+
return None
373+
display: list[str] = []
374+
for command in commands:
375+
if isinstance(command, dict):
376+
command_text = command.get("command")
377+
if isinstance(command_text, str):
378+
display.append(_normalize_command_identity(command_text))
379+
return display or None
380+
381+
382+
def _verify_receipt_evidence_ref(receipt: dict[str, Any]) -> str:
383+
path = receipt.get("path")
384+
if isinstance(path, str) and path:
385+
return str(Path(path) / "receipt.json")
386+
run_id = receipt.get("run_id")
387+
target = receipt.get("target")
388+
if isinstance(run_id, str) and run_id and isinstance(target, str) and target:
389+
return str(Path(target) / ".brigade" / "work" / "verify-runs" / run_id / "receipt.json")
390+
return ""
391+
392+
393+
def _verify_receipt_has_outcome_capture(target: Path, receipt: dict[str, Any]) -> bool:
394+
from .. import outcome_cmd
395+
396+
evidence_ref = _verify_receipt_evidence_ref(receipt)
397+
if not evidence_ref:
398+
return False
399+
resolved = Path(evidence_ref).expanduser().resolve()
400+
for record in outcome_cmd.load_records(target):
401+
if not record.evidence_ref:
402+
continue
403+
try:
404+
if Path(record.evidence_ref).expanduser().resolve() == resolved:
405+
return True
406+
except OSError:
407+
if record.evidence_ref == evidence_ref:
408+
return True
409+
return False
410+
411+
412+
def _find_uncaptured_failed_verify_receipt(target: Path, planned_identity: list[str]) -> dict[str, Any] | None:
413+
for receipt in _verify_receipts(target):
414+
if _receipt_planned_commands_identity(receipt) != planned_identity:
415+
continue
416+
if receipt.get("status") != "failed":
417+
return None
418+
if _verify_receipt_has_outcome_capture(target, receipt):
419+
return None
420+
return receipt
421+
return None
422+
423+
424+
def _capture_before_retry_message(run_id: str) -> str:
425+
return f"brigade outcome capture brigade-work --run-id {run_id}"
426+
427+
428+
def _enforce_capture_before_retry(target: Path, planned_identity: list[str], *, mode: str) -> int | None:
429+
"""Block or warn when the latest matching failed receipt has no outcome capture."""
430+
if mode == "off":
431+
return None
432+
failed = _find_uncaptured_failed_verify_receipt(target, planned_identity)
433+
if failed is None:
434+
return None
435+
message = _capture_before_retry_message(str(failed.get("run_id") or ""))
436+
if mode == "block":
437+
print(f"error: {message}", file=sys.stderr)
438+
return 1
439+
print(f"warning: {message}", file=sys.stderr)
440+
return None
441+
442+
347443
def _latest_verify_receipt(target: Path) -> dict[str, Any] | None:
348444
receipts = _verify_receipts(target)
349445
return receipts[0] if receipts else None
@@ -568,7 +664,7 @@ def _run_verify_commands(
568664
"evidence": _verification_evidence_payload(target),
569665
"commands": [],
570666
"tree_fingerprint": _tree_fingerprint(target),
571-
"planned_commands": [shlex.join(c) if isinstance(c, list) else c for c in commands],
667+
"planned_commands": _planned_commands_display(commands),
572668
}
573669
_stamp_harness_session(receipt)
574670
try:
@@ -964,12 +1060,21 @@ def verify_run(
9641060
if not planned:
9651061
print("error: no verification commands found; pass --command", file=sys.stderr)
9661062
return 2
1063+
planned_display = _planned_commands_display(planned)
1064+
planned_identity = _planned_commands_identity(planned)
1065+
try:
1066+
capture_before_retry = config.resolve_capture_before_retry(target)
1067+
except ValueError as exc:
1068+
print(f"error: {exc}", file=sys.stderr)
1069+
return 2
1070+
blocked_rc = _enforce_capture_before_retry(target, planned_identity, mode=capture_before_retry)
1071+
if blocked_rc is not None:
1072+
return blocked_rc
9671073
try:
9681074
receipt = None
9691075
if reuse:
9701076
fingerprint = _tree_fingerprint(target)
9711077
latest = _latest_verify_receipt(target)
972-
planned_display = [shlex.join(c) if isinstance(c, list) else c for c in planned]
9731078
if (
9741079
fingerprint is not None
9751080
and latest is not None

tests/test_config.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,3 +146,53 @@ def test_load_config_rejects_invalid_graphtrail_delta_timeout_seconds(tmp_path,
146146
)
147147
with pytest.raises(ValueError, match="graphtrail_delta_timeout_seconds must be a positive number"):
148148
load_config(tmp_path)
149+
150+
151+
def test_load_config_defaults_capture_before_retry(tmp_path):
152+
sel = Selection(depth="repo", harnesses=["claude"], owner="claude", includes=[])
153+
write_config(tmp_path, Config(version=1, selection=sel))
154+
loaded = load_config(tmp_path)
155+
assert loaded is not None
156+
assert loaded.capture_before_retry == "warn"
157+
158+
159+
def test_load_config_reads_capture_before_retry(tmp_path):
160+
path = tmp_path / ".brigade" / "config.json"
161+
path.parent.mkdir(parents=True)
162+
path.write_text(
163+
json.dumps(
164+
{
165+
"version": 1,
166+
"depth": "repo",
167+
"harnesses": ["claude"],
168+
"owner": "claude",
169+
"includes": [],
170+
"capture_before_retry": "block",
171+
}
172+
)
173+
+ "\n"
174+
)
175+
loaded = load_config(tmp_path)
176+
assert loaded is not None
177+
assert loaded.capture_before_retry == "block"
178+
179+
180+
@pytest.mark.parametrize("value", ["maybe", 1, True])
181+
def test_load_config_rejects_invalid_capture_before_retry(tmp_path, value):
182+
path = tmp_path / ".brigade" / "config.json"
183+
path.parent.mkdir(parents=True)
184+
path.write_text(
185+
json.dumps(
186+
{
187+
"version": 1,
188+
"depth": "repo",
189+
"harnesses": ["claude"],
190+
"owner": "claude",
191+
"includes": [],
192+
"capture_before_retry": value,
193+
}
194+
)
195+
+ "\n"
196+
)
197+
with pytest.raises(ValueError, match="capture_before_retry must be one of"):
198+
load_config(tmp_path)

0 commit comments

Comments
 (0)