Retry unchanged repairs when verification changes

This commit is contained in:
yoni 2026-09-25 21:57:34 +00:00
parent d02b74c3c0
commit a41000d5bf
2 changed files with 108 additions and 1 deletions

View file

@ -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)

View file

@ -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)