diff --git a/strix/fix/prepare.py b/strix/fix/prepare.py index 9d9eb3eb..de135e6c 100644 --- a/strix/fix/prepare.py +++ b/strix/fix/prepare.py @@ -446,6 +446,50 @@ def _verification_gaps( return list(dict.fromkeys(gaps)) +def _verification_fingerprint( + repair_outcome: RepairOutcome, + checks: list[CheckResult], + reproduction: CheckResult | None, + verifier: VerifierResult, +) -> str: + payload = { + "repair": { + "status": repair_outcome.status, + "gaps": repair_outcome.gaps, + }, + "checks": [ + { + "name": result.name, + "argv": result.argv, + "status": result.status, + "exit_code": result.exit_code, + "output": result.output, + "required": result.required, + } + for result in checks + ], + "reproduction": ( + { + "name": reproduction.name, + "argv": reproduction.argv, + "status": reproduction.status, + "exit_code": reproduction.exit_code, + "output": reproduction.output, + } + if reproduction is not None + else None + ), + "verifier": { + "decision": verifier.decision, + "security_invariant_closed": verifier.security_invariant_closed, + "reproduction_executed": verifier.reproduction_executed, + "gaps": verifier.gaps, + }, + } + encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode() + return hashlib.sha256(encoded).hexdigest() + + async def prepare_fix( # noqa: PLR0915 request: FixPreparationRequestV1, workspace: Path, @@ -513,6 +557,7 @@ async def prepare_fix( # noqa: PLR0915 verifier: VerifierResult | None = None attempt_history: list[FixPreparationAttempt] = [] previous_workspace_digest: str | None = None + previous_verification_fingerprint: str | None = None reproduction_command = ( context.candidate.reproduction.command if context.candidate.reproduction is not None @@ -545,6 +590,12 @@ async def prepare_fix( # noqa: PLR0915 context.feedback = list(attempt_history) gaps = _verification_gaps(repair_outcome, checks, reproduction, verifier) gaps = list(dict.fromkeys(gaps)) + verification_fingerprint = _verification_fingerprint( + repair_outcome, + checks, + reproduction, + verifier, + ) if repair_outcome.status is RepairStatus.BLOCKED: manifest, summary, artifact_ref = await manifest_builder(workspace) @@ -606,7 +657,10 @@ async def prepare_fix( # noqa: PLR0915 started=started, ) - if previous_workspace_digest == workspace_digest: + if ( + previous_workspace_digest == workspace_digest + and previous_verification_fingerprint == verification_fingerprint + ): manifest, summary, artifact_ref = await manifest_builder(workspace) return _result( context, @@ -623,6 +677,7 @@ async def prepare_fix( # noqa: PLR0915 started=started, ) previous_workspace_digest = workspace_digest + previous_verification_fingerprint = verification_fingerprint assert verifier is not None manifest, summary, artifact_ref = await manifest_builder(workspace) diff --git a/tests/test_fix_preparation.py b/tests/test_fix_preparation.py index 82261211..616eaaaf 100644 --- a/tests/test_fix_preparation.py +++ b/tests/test_fix_preparation.py @@ -462,6 +462,58 @@ async def test_prepare_fix_stops_after_repeated_repository_state(tmp_path: Path) assert "no repository progress" in result.stop_reason +@pytest.mark.asyncio +async def test_prepare_fix_retries_when_unchanged_state_has_new_feedback( + tmp_path: Path, +) -> None: + workspace, commit = _workspace(tmp_path) + verifier_calls = 0 + + async def repair( + context: PreparationContext, + _checks: list[CheckResult], + ) -> RepairOutcome: + if context.attempt == 3: + with (workspace / "app.py").open("a", encoding="utf-8") as handle: + handle.write("# deployment invariant\n") + 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="The source guard is incomplete.", + gaps=["Inspect the deployment configuration."], + ) + if verifier_calls == 2: + return VerifierResult( + decision=VerificationDecision.REJECTED, + summary="The deployment invariant is not enforced.", + gaps=["Set and enforce the production environment marker."], + ) + return await _verified(_context, _checks, _reproduction) + + result = await prepare_fix( + _request(_candidate(commit), attempts=4), + workspace, + repair=repair, + verify=verify, + ) + + assert result.state is PreparationState.READY + assert result.attempts == 3 + assert len(result.attempt_history) == 3 + + @pytest.mark.asyncio async def test_prepare_fix_evaluates_budget_exhausted_patch(tmp_path: Path) -> None: workspace, commit = _workspace(tmp_path)