From 9d525ade7f04f4c2f1d66dd3241ec288e3f719d1 Mon Sep 17 00:00:00 2001 From: yoni Date: Fri, 25 Sep 2026 21:08:38 +0000 Subject: [PATCH] Add bounded fix verification feedback loop --- strix/fix/__init__.py | 6 + strix/fix/contracts.py | 27 ++- strix/fix/prepare.py | 313 ++++++++++++++++++++++------------ tests/test_fix_preparation.py | 233 +++++++++++++++++++++++-- 4 files changed, 448 insertions(+), 131 deletions(-) diff --git a/strix/fix/__init__.py b/strix/fix/__init__.py index c11663424..d368b155e 100644 --- a/strix/fix/__init__.py +++ b/strix/fix/__init__.py @@ -8,9 +8,12 @@ from strix.fix.contracts import ( FileManifestEntry, FixCandidateV1, FixEdit, + FixPreparationAttempt, FixPreparationRequestV1, FixPreparationResultV1, PreparationState, + RepairOutcome, + RepairStatus, ReproductionSpec, SourceIdentity, SourceIdentityKind, @@ -35,12 +38,15 @@ __all__ = [ "FileManifestEntry", "FixCandidateV1", "FixEdit", + "FixPreparationAttempt", "FixPreparationRequestV1", "FixPreparationResultV1", "PreparationCancelledError", "PreparationContext", "PreparationPolicy", "PreparationState", + "RepairOutcome", + "RepairStatus", "ReproductionSpec", "SourceIdentity", "SourceIdentityKind", diff --git a/strix/fix/contracts.py b/strix/fix/contracts.py index 5628d9993..950e87db2 100644 --- a/strix/fix/contracts.py +++ b/strix/fix/contracts.py @@ -47,6 +47,13 @@ class VerificationDecision(StrEnum): INCONCLUSIVE = "inconclusive" +class RepairStatus(StrEnum): + COMPLETE = "complete" + BLOCKED = "blocked" + BUDGET_EXHAUSTED = "budget_exhausted" + INCOMPLETE = "incomplete" + + class SourceIdentity(ContractModel): kind: SourceIdentityKind value: str = Field(min_length=1) @@ -154,7 +161,7 @@ class FixPreparationRequestV1(ContractModel): repository_id: str | None = None candidate: FixCandidateV1 checks: list[CommandSpec] = [] - max_repair_attempts: int = Field(default=2, ge=1, le=5) + max_repair_attempts: int = Field(default=4, ge=1, le=4) timeout_seconds: int = Field(default=1800, ge=30, le=14400) network_allowed: bool = False credentials_allowed: list[str] = [] @@ -181,6 +188,23 @@ class VerifierResult(ContractModel): gaps: list[str] = [] +class RepairOutcome(ContractModel): + status: RepairStatus + summary: str = Field(min_length=1) + gaps: list[str] = [] + reproduction_command: CommandSpec | None = None + turns_used: int = Field(default=0, ge=0) + + +class FixPreparationAttempt(ContractModel): + attempt: int = Field(ge=1) + repair: RepairOutcome + checks: list[CheckResult] = [] + security_reproduction: CheckResult | None = None + verifier: VerifierResult + workspace_digest: str = Field(pattern=r"^[0-9a-f]{64}$") + + class FileManifestEntry(ContractModel): path: str operation: Literal["add", "modify", "delete"] @@ -205,6 +229,7 @@ class FixPreparationResultV1(ContractModel): checks: list[CheckResult] = [] security_reproduction: CheckResult | None = None verifier: VerifierResult | None = None + attempt_history: list[FixPreparationAttempt] = [] gaps: list[str] = [] attempts: int = Field(default=0, ge=0) elapsed_seconds: float = Field(default=0, ge=0) diff --git a/strix/fix/prepare.py b/strix/fix/prepare.py index 0beacb9ef..3a43e36e3 100644 --- a/strix/fix/prepare.py +++ b/strix/fix/prepare.py @@ -5,11 +5,12 @@ from __future__ import annotations import asyncio import functools import hashlib +import json import os import subprocess import time from collections.abc import Awaitable, Callable, Iterable -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path from typing import Literal @@ -19,9 +20,12 @@ from strix.fix.contracts import ( CommandSpec, FileManifestEntry, FixCandidateV1, + FixPreparationAttempt, FixPreparationRequestV1, FixPreparationResultV1, PreparationState, + RepairOutcome, + RepairStatus, VerificationDecision, VerifierResult, ) @@ -39,10 +43,14 @@ CancellationCheck = Callable[[], bool] @dataclass(slots=True) class PreparationPolicy: - max_repair_attempts: int = 2 + max_repair_attempts: int = 4 timeout_seconds: int = 1800 max_output_chars: int = 20000 + def __post_init__(self) -> None: + if not 1 <= self.max_repair_attempts <= 4: + raise ValueError("max_repair_attempts must be between 1 and 4") + @dataclass(slots=True) class PreparationContext: @@ -50,11 +58,12 @@ class PreparationContext: workspace: Path candidate: FixCandidateV1 attempt: int = 0 + feedback: list[FixPreparationAttempt] = field(default_factory=list) RepairAgent = Callable[ [PreparationContext, list[CheckResult]], - Awaitable[None], + Awaitable[RepairOutcome | None], ] IndependentVerifier = Callable[ [PreparationContext, list[CheckResult], CheckResult | None], @@ -306,27 +315,14 @@ async def build_git_manifest( return entries, summary.decode(errors="replace").strip(), None -def _applied_hashes(workspace: Path, candidate: FixCandidateV1) -> dict[Path, str]: - """Content hashes of each edited file, captured right after the draft is applied.""" - applied: dict[Path, str] = {} - for edit in candidate.draft_edits: - path = (workspace / edit.file).resolve() - applied[path] = hashlib.sha256(path.read_bytes()).hexdigest() - return applied - - -def _change_extends_draft( - workspace: Path, - applied_sha256: dict[Path, str], - manifest: list[FileManifestEntry], -) -> bool: - """Whether the verified change set differs from the applied draft edits.""" - final_changes = { - (workspace / entry.path).resolve(): entry.resulting_sha256 for entry in manifest - } - return set(final_changes) != set(applied_sha256) or any( - resulting != applied_sha256[resolved] for resolved, resulting in final_changes.items() - ) +async def _workspace_digest(workspace: Path) -> str: + manifest, _, _ = await build_git_manifest(workspace) + payload = json.dumps( + [entry.model_dump(mode="json") for entry in manifest], + sort_keys=True, + separators=(",", ":"), + ).encode() + return hashlib.sha256(payload).hexdigest() async def _verify_source(context: PreparationContext) -> bool: @@ -371,6 +367,7 @@ def _result( manifest: list[FileManifestEntry] | None = None, diff_summary: str = "", artifact_ref: str | None = None, + attempt_history: list[FixPreparationAttempt] | None = None, ) -> FixPreparationResultV1: return FixPreparationResultV1( state=state, @@ -385,13 +382,71 @@ def _result( checks=checks or [], security_reproduction=reproduction, verifier=verifier, + attempt_history=attempt_history or [], gaps=gaps or [], attempts=context.attempt, elapsed_seconds=time.monotonic() - started, ) -async def prepare_fix( +def _repair_outcome(value: RepairOutcome | None) -> RepairOutcome: + if value is not None: + return value + return RepairOutcome( + status=RepairStatus.COMPLETE, + summary="The repair implementation returned control for independent evaluation.", + ) + + +def _required_checks_pass(checks: list[CheckResult]) -> bool: + required = [result for result in checks if result.required] + return bool(required) and all(result.status is CheckStatus.PASSED for result in required) + + +def _verification_passes( + checks: list[CheckResult], + reproduction: CheckResult | None, + verifier: VerifierResult, +) -> bool: + return ( + _required_checks_pass(checks) + and reproduction is not None + and reproduction.status is CheckStatus.PASSED + and verifier.decision is VerificationDecision.VERIFIED + and verifier.security_invariant_closed + and verifier.reproduction_executed + ) + + +def _verification_gaps( + repair_outcome: RepairOutcome, + checks: list[CheckResult], + reproduction: CheckResult | None, + verifier: VerifierResult, +) -> list[str]: + gaps = list(repair_outcome.gaps) + required = [result for result in checks if result.required] + if not required: + gaps.append("No required repository check was configured.") + gaps.extend( + f"{result.name}: required check {result.status}" + for result in required + if result.status is not CheckStatus.PASSED + ) + gaps.extend( + f"{result.name}: optional check {result.status}" + for result in checks + if not result.required and result.status is not CheckStatus.PASSED + ) + if reproduction is None: + gaps.append("No executable security reproduction was available.") + elif reproduction.status is not CheckStatus.PASSED: + gaps.append(f"{reproduction.name}: security reproduction {reproduction.status}") + gaps.extend(verifier.gaps) + return list(dict.fromkeys(gaps)) + + +async def prepare_fix( # noqa: PLR0915 request: FixPreparationRequestV1, workspace: Path, *, @@ -417,7 +472,7 @@ async def prepare_fix( network_allowed=request.network_allowed, ) - async def execute() -> FixPreparationResultV1: # noqa: PLR0911, PLR0912 + async def execute() -> FixPreparationResultV1: # noqa: PLR0911 if cancelled(): raise PreparationCancelledError if not await source_verifier(context): @@ -452,112 +507,144 @@ async def prepare_fix( ) context.candidate = anchored _apply_edits(workspace, context.candidate) - applied_sha256 = _applied_hashes(workspace, context.candidate) checks: list[CheckResult] = [] reproduction: CheckResult | None = None + verifier: VerifierResult | None = None + attempt_history: list[FixPreparationAttempt] = [] + previous_workspace_digest: str | None = None + reproduction_command = ( + context.candidate.reproduction.command + if context.candidate.reproduction is not None + else None + ) for attempt in range(1, resolved_policy.max_repair_attempts + 1): context.attempt = attempt if cancelled(): raise PreparationCancelledError - await repair(context, checks) + repair_outcome = _repair_outcome(await repair(context, checks)) + if repair_outcome.reproduction_command is not None: + reproduction_command = repair_outcome.reproduction_command checks = [await runner(workspace, check) for check in request.checks] - if context.candidate.reproduction and context.candidate.reproduction.command: - reproduction = await runner( - workspace, - context.candidate.reproduction.command, + reproduction = ( + await runner(workspace, reproduction_command) + if reproduction_command is not None + else None + ) + verifier = await verify(context, checks, reproduction) + workspace_digest = await _workspace_digest(workspace) + attempt_record = FixPreparationAttempt( + attempt=attempt, + repair=repair_outcome, + checks=checks, + security_reproduction=reproduction, + verifier=verifier, + workspace_digest=workspace_digest, + ) + attempt_history.append(attempt_record) + context.feedback = list(attempt_history) + gaps = _verification_gaps(repair_outcome, checks, reproduction, verifier) + gaps = list(dict.fromkeys(gaps)) + + if _verification_passes(checks, reproduction, verifier): + manifest, summary, artifact_ref = await manifest_builder(workspace) + if not manifest: + return _result( + context, + state=PreparationState.NEEDS_REVIEW, + reason="The prepared workspace does not contain a source change.", + checks=checks, + reproduction=reproduction, + verifier=verifier, + gaps=[*gaps, "No prepared source change was produced."], + attempt_history=attempt_history, + started=started, + ) + if gaps: + return _result( + context, + state=PreparationState.READY_WITH_GAPS, + reason=("The fix passed available checks, but verification gaps remain."), + checks=checks, + reproduction=reproduction, + verifier=verifier, + gaps=gaps, + manifest=manifest, + diff_summary=summary, + artifact_ref=artifact_ref, + attempt_history=attempt_history, + started=started, + ) + return _result( + context, + state=PreparationState.READY, + reason="The fix passed required checks and independent verification.", + checks=checks, + reproduction=reproduction, + verifier=verifier, + manifest=manifest, + diff_summary=summary, + artifact_ref=artifact_ref, + attempt_history=attempt_history, + started=started, ) - failed = any( - result.required and result.status is CheckStatus.FAILED for result in checks - ) - reproduction_failed = ( - reproduction is not None and reproduction.status is CheckStatus.FAILED - ) - if not failed and not reproduction_failed: - break - else: - return _result( - context, - state=PreparationState.FAILED, - reason="Required checks still fail after the repair limit.", - checks=checks, - reproduction=reproduction, - started=started, - ) + if repair_outcome.status is RepairStatus.BLOCKED: + manifest, summary, artifact_ref = await manifest_builder(workspace) + return _result( + context, + state=PreparationState.BLOCKED, + reason=repair_outcome.summary, + checks=checks, + reproduction=reproduction, + verifier=verifier, + gaps=gaps, + manifest=manifest, + diff_summary=summary, + artifact_ref=artifact_ref, + attempt_history=attempt_history, + started=started, + ) - verifier = await verify(context, checks, reproduction) + if previous_workspace_digest == workspace_digest: + manifest, summary, artifact_ref = await manifest_builder(workspace) + return _result( + context, + state=PreparationState.NEEDS_REVIEW, + reason="The repair made no repository progress after verification feedback.", + checks=checks, + reproduction=reproduction, + verifier=verifier, + gaps=[*gaps, "Two repair cycles produced the same repository state."], + manifest=manifest, + diff_summary=summary, + artifact_ref=artifact_ref, + attempt_history=attempt_history, + started=started, + ) + previous_workspace_digest = workspace_digest + + assert verifier is not None manifest, summary, artifact_ref = await manifest_builder(workspace) - gaps = [ - result.name - for result in checks - if result.required and result.status is CheckStatus.UNAVAILABLE - ] - gaps.extend( - f"{result.name}: optional check {result.status}" - for result in checks - if not result.required and result.status is not CheckStatus.PASSED - ) - if _change_extends_draft(workspace, applied_sha256, manifest): - gaps.append("The verified change extends beyond the recorded draft edits.") - if reproduction is None and not verifier.reproduction_executed: - gaps.append("No executable security reproduction was available.") - elif reproduction is not None and reproduction.status is CheckStatus.UNAVAILABLE: - gaps.append(reproduction.name) - gaps.extend(verifier.gaps) - - if ( - verifier.decision is not VerificationDecision.VERIFIED - or not verifier.security_invariant_closed - ): - return _result( - context, - state=PreparationState.NEEDS_REVIEW, - reason=verifier.summary, - checks=checks, - reproduction=reproduction, - verifier=verifier, - gaps=gaps, - manifest=manifest, - diff_summary=summary, - artifact_ref=artifact_ref, - started=started, - ) - if not manifest: - return _result( - context, - state=PreparationState.NEEDS_REVIEW, - reason="The prepared workspace does not contain a source change.", - checks=checks, - reproduction=reproduction, - verifier=verifier, - gaps=[*gaps, "No prepared source change was produced."], - started=started, - ) - if gaps: - return _result( - context, - state=PreparationState.READY_WITH_GAPS, - reason="The fix passed available checks, but required verification gaps remain.", - checks=checks, - reproduction=reproduction, - verifier=verifier, - gaps=gaps, - manifest=manifest, - diff_summary=summary, - artifact_ref=artifact_ref, - started=started, - ) return _result( context, - state=PreparationState.READY, - reason="The fix passed required checks and independent verification.", + state=PreparationState.NEEDS_REVIEW, + reason="The fix did not satisfy verification within the repair cycle limit.", checks=checks, reproduction=reproduction, verifier=verifier, + gaps=[ + *_verification_gaps( + attempt_history[-1].repair, + checks, + reproduction, + verifier, + ), + ], manifest=manifest, diff_summary=summary, artifact_ref=artifact_ref, + attempt_history=attempt_history, started=started, ) @@ -569,6 +656,7 @@ async def prepare_fix( context, state=PreparationState.FAILED, reason="Fix preparation was cancelled.", + attempt_history=context.feedback, started=started, ) except TimeoutError: @@ -576,5 +664,6 @@ async def prepare_fix( context, state=PreparationState.FAILED, reason="Fix preparation exceeded its time limit.", + attempt_history=context.feedback, started=started, ) diff --git a/tests/test_fix_preparation.py b/tests/test_fix_preparation.py index 4eb222cb8..c09427927 100644 --- a/tests/test_fix_preparation.py +++ b/tests/test_fix_preparation.py @@ -19,6 +19,8 @@ from strix.fix.contracts import ( FixEdit, FixPreparationRequestV1, PreparationState, + RepairOutcome, + RepairStatus, ReproductionSpec, SourceIdentity, SourceIdentityKind, @@ -67,6 +69,18 @@ def _candidate( *, reproduction: ReproductionSpec | None = None, ) -> FixCandidateV1: + if reproduction is None: + reproduction = ReproductionSpec( + instructions="Confirm that result returns safe.", + command=CommandSpec( + name="security reproduction", + argv=[ + sys.executable, + "-c", + "from app import result; assert result() == 'safe'", + ], + ), + ) return FixCandidateV1( source_identity=SourceIdentity(kind=SourceIdentityKind.COMMIT, value=commit), security_invariant="Return a safe value.", @@ -91,7 +105,7 @@ def _candidate( ) -def _request(candidate: FixCandidateV1, *, attempts: int = 2) -> FixPreparationRequestV1: +def _request(candidate: FixCandidateV1, *, attempts: int = 4) -> FixPreparationRequestV1: return FixPreparationRequestV1( scan_id="scan-1", finding_id="finding-1", @@ -113,8 +127,11 @@ def _request(candidate: FixCandidateV1, *, attempts: int = 2) -> FixPreparationR async def _noop_repair( _context: PreparationContext, _checks: list[CheckResult], -) -> None: - return None +) -> RepairOutcome: + return RepairOutcome( + status=RepairStatus.COMPLETE, + summary="The draft is ready for independent evaluation.", + ) async def _verified( @@ -245,7 +262,7 @@ async def test_prepare_fix_returns_ready_with_manifest(tmp_path: Path) -> None: @pytest.mark.asyncio -async def test_prepare_fix_demotes_ready_when_repair_exceeds_draft( +async def test_prepare_fix_allows_verified_multi_file_repairs( tmp_path: Path, ) -> None: workspace, commit = _workspace(tmp_path) @@ -253,8 +270,12 @@ async def test_prepare_fix_demotes_ready_when_repair_exceeds_draft( async def widening_repair( _context: PreparationContext, _checks: list[CheckResult], - ) -> None: + ) -> RepairOutcome: (workspace / "hardening.py").write_text("HELPER = True\n", encoding="utf-8") + return RepairOutcome( + status=RepairStatus.COMPLETE, + summary="Added the companion hardening module.", + ) result = await prepare_fix( _request(_candidate(commit)), @@ -263,44 +284,82 @@ async def test_prepare_fix_demotes_ready_when_repair_exceeds_draft( verify=_verified, ) - assert result.state is PreparationState.READY_WITH_GAPS - assert any("beyond the recorded draft edits" in gap for gap in result.gaps) + assert result.state is PreparationState.READY + assert {entry.path for entry in result.final_file_manifest} == { + "app.py", + "hardening.py", + } assert result.candidate.digest() == result.candidate_digest @pytest.mark.asyncio async def test_prepare_fix_retries_failed_checks(tmp_path: Path) -> None: workspace, commit = _workspace(tmp_path) - calls = 0 + compile_calls = 0 async def runner(_workspace: Path, command: CommandSpec) -> CheckResult: - nonlocal calls - calls += 1 + nonlocal compile_calls + if command.name == "compile": + compile_calls += 1 + failed = command.name == "compile" and compile_calls == 1 return CheckResult( name=command.name, argv=command.argv, - status=CheckStatus.FAILED if calls == 1 else CheckStatus.PASSED, - exit_code=1 if calls == 1 else 0, + status=CheckStatus.FAILED if failed else CheckStatus.PASSED, + exit_code=1 if failed else 0, duration_seconds=0, required=command.required, ) + repair_calls = 0 + + async def repair( + _context: PreparationContext, + _checks: list[CheckResult], + ) -> RepairOutcome: + nonlocal repair_calls + repair_calls += 1 + if repair_calls == 2: + (workspace / "app.py").write_text( + "def result():\n return 'safe'\n# retry\n", + encoding="utf-8", + ) + return RepairOutcome( + status=RepairStatus.COMPLETE, + summary="Repair cycle complete.", + ) + result = await prepare_fix( _request(_candidate(commit)), workspace, - repair=_noop_repair, + repair=repair, verify=_verified, command_runner=runner, ) assert result.state is PreparationState.READY assert result.attempts == 2 - assert calls == 2 + assert compile_calls == 2 + assert len(result.attempt_history) == 2 @pytest.mark.asyncio async def test_prepare_fix_stops_at_repair_limit(tmp_path: Path) -> None: workspace, commit = _workspace(tmp_path) + repair_calls = 0 + + async def repair( + _context: PreparationContext, + _checks: list[CheckResult], + ) -> RepairOutcome: + nonlocal repair_calls + repair_calls += 1 + with (workspace / "app.py").open("a", encoding="utf-8") as handle: + handle.write(f"# attempt {repair_calls}\n") + return RepairOutcome( + status=RepairStatus.COMPLETE, + summary="Repair cycle complete.", + ) async def runner(_workspace: Path, command: CommandSpec) -> CheckResult: return CheckResult( @@ -313,16 +372,154 @@ async def test_prepare_fix_stops_at_repair_limit(tmp_path: Path) -> None: ) result = await prepare_fix( - _request(_candidate(commit), attempts=2), + _request(_candidate(commit), attempts=4), workspace, - repair=_noop_repair, + repair=repair, verify=_verified, command_runner=runner, ) - assert result.state is PreparationState.FAILED + assert result.state is PreparationState.NEEDS_REVIEW + assert result.attempts == 4 + assert len(result.attempt_history) == 4 + assert "cycle limit" in result.stop_reason + + +@pytest.mark.asyncio +async def test_prepare_fix_feeds_verifier_rejection_into_next_repair( + tmp_path: Path, +) -> None: + workspace, commit = _workspace(tmp_path) + repair_calls = 0 + verifier_calls = 0 + + async def repair( + context: PreparationContext, + _checks: list[CheckResult], + ) -> RepairOutcome: + nonlocal repair_calls + repair_calls += 1 + if repair_calls == 2: + assert context.feedback[0].verifier.gaps == ["Harden the sibling path."] + (workspace / "sibling.py").write_text("SAFE = True\n", encoding="utf-8") + return RepairOutcome( + status=RepairStatus.COMPLETE, + summary="Repair cycle complete.", + ) + + async def verify( + _context: PreparationContext, + _checks: list[CheckResult], + _reproduction: CheckResult | None, + ) -> VerifierResult: + nonlocal verifier_calls + verifier_calls += 1 + if verifier_calls == 1: + return VerifierResult( + decision=VerificationDecision.REJECTED, + summary="A sibling path remains vulnerable.", + gaps=["Harden the sibling path."], + ) + return await _verified(_context, _checks, _reproduction) + + result = await prepare_fix( + _request(_candidate(commit)), + workspace, + repair=repair, + verify=verify, + ) + + assert result.state is PreparationState.READY assert result.attempts == 2 - assert "repair limit" in result.stop_reason + assert result.attempt_history[0].verifier.decision is VerificationDecision.REJECTED + assert result.attempt_history[1].verifier.decision is VerificationDecision.VERIFIED + + +@pytest.mark.asyncio +async def test_prepare_fix_stops_after_repeated_repository_state(tmp_path: Path) -> None: + workspace, commit = _workspace(tmp_path) + + async def rejected( + _context: PreparationContext, + _checks: list[CheckResult], + _reproduction: CheckResult | None, + ) -> VerifierResult: + return VerifierResult( + decision=VerificationDecision.REJECTED, + summary="The fix remains incomplete.", + gaps=["Change the implementation."], + ) + + result = await prepare_fix( + _request(_candidate(commit)), + workspace, + repair=_noop_repair, + verify=rejected, + ) + + assert result.state is PreparationState.NEEDS_REVIEW + assert result.attempts == 2 + assert "no repository progress" in result.stop_reason + + +@pytest.mark.asyncio +async def test_prepare_fix_evaluates_budget_exhausted_patch(tmp_path: Path) -> None: + workspace, commit = _workspace(tmp_path) + + async def exhausted( + _context: PreparationContext, + _checks: list[CheckResult], + ) -> RepairOutcome: + return RepairOutcome( + status=RepairStatus.BUDGET_EXHAUSTED, + summary="The repair agent reached its turn limit.", + turns_used=40, + ) + + result = await prepare_fix( + _request(_candidate(commit)), + workspace, + repair=exhausted, + verify=_verified, + ) + + assert result.state is PreparationState.READY + assert result.attempt_history[0].repair.status is RepairStatus.BUDGET_EXHAUSTED + assert result.attempt_history[0].repair.turns_used == 40 + + +@pytest.mark.asyncio +async def test_prepare_fix_runs_repair_proposed_reproduction(tmp_path: Path) -> None: + workspace, commit = _workspace(tmp_path) + candidate = _candidate(commit).model_copy(update={"reproduction": None}) + + async def repair( + _context: PreparationContext, + _checks: list[CheckResult], + ) -> RepairOutcome: + return RepairOutcome( + status=RepairStatus.COMPLETE, + summary="The patch and reproduction are ready.", + reproduction_command=CommandSpec( + name="repair-proposed security reproduction", + argv=[ + sys.executable, + "-c", + "from app import result; assert result() == 'safe'", + ], + ), + ) + + result = await prepare_fix( + _request(candidate), + workspace, + repair=repair, + verify=_verified, + ) + + assert result.state is PreparationState.READY + assert result.security_reproduction is not None + assert result.security_reproduction.name == "repair-proposed security reproduction" @pytest.mark.asyncio