From aa979f3ca94443b80fe80b890c1eeda377cd06a6 Mon Sep 17 00:00:00 2001 From: Christian Meyer Date: Fri, 28 Aug 2026 13:15:15 +0200 Subject: [PATCH] Preserve valid agent retry progress --- src/lai_automation/git.py | 9 +++++++ src/lai_automation/worker.py | 14 +++++++--- tests/test_git.py | 32 +++++++++++++++++++++- tests/test_worker.py | 51 +++++++++++++++++++++++++++++++++++- 4 files changed, 101 insertions(+), 5 deletions(-) diff --git a/src/lai_automation/git.py b/src/lai_automation/git.py index 32237aa..2013fce 100644 --- a/src/lai_automation/git.py +++ b/src/lai_automation/git.py @@ -94,6 +94,15 @@ class GitWorkspace: self._git("reset", "--hard", "HEAD") self._git("clean", "-fd") + def checkpoint(self) -> str: + self._git("add", "--all") + return self._git("stash", "create").stdout.strip() + + def restore_checkpoint(self, checkpoint: str | None) -> None: + self.discard_changes() + if checkpoint: + self._git("stash", "apply", checkpoint) + def summary(self) -> ChangeSummary: self._git("add", "-N", "--all") output = self._git("diff", "--numstat").stdout diff --git a/src/lai_automation/worker.py b/src/lai_automation/worker.py index d9cc556..1bd8304 100644 --- a/src/lai_automation/worker.py +++ b/src/lai_automation/worker.py @@ -55,6 +55,7 @@ class AutomationWorker: LOG.info("Report %s branch=%s workspace ready", report.id, workspace.branch) task = build_task(report) metadata["agent_output"] = [] + valid_checkpoint: str | None = None for attempt in range(1, self.settings.max_agent_attempts + 1): LOG.info( "Report %s agent attempt %s/%s starting", @@ -108,15 +109,22 @@ class AutomationWorker: report.id, attempt, ) - workspace.discard_changes() + workspace.restore_checkpoint(valid_checkpoint) + restored_state = ( + "The last syntax-valid changes were restored. Correct them without recreating " + "the malformed edit." + if valid_checkpoint + else "The malformed attempt was discarded. Start again from the clean repository." + ) task = build_retry_task( report, RuntimeError( - f"{exception}\nThe malformed attempt was discarded. Start again from the clean " - "repository, make smaller edits, and run php -l on each changed PHP file." + f"{exception}\n{restored_state} Make smaller edits and run php -l on each " + "changed PHP file." ), ) continue + valid_checkpoint = workspace.checkpoint() try: metadata["tests"] = self._run_tests(workspace, report.id, attempt) LOG.info("Report %s attempt %s validation passed", report.id, attempt) diff --git a/tests/test_git.py b/tests/test_git.py index 8f567f8..eefd575 100644 --- a/tests/test_git.py +++ b/tests/test_git.py @@ -53,4 +53,34 @@ def test_discard_changes_restores_clean_disposable_workspace(tmp_path) -> None: workspace.discard_changes() assert tracked.read_text() == " None: + workspace = GitWorkspace(settings(workspace_root=tmp_path), 4) + workspace.path.mkdir() + subprocess.run(["git", "init"], cwd=workspace.path, check=True, capture_output=True) + subprocess.run( + ["git", "config", "user.email", "test@example.invalid"], + cwd=workspace.path, + check=True, + ) + subprocess.run( + ["git", "config", "user.name", "Test"], + cwd=workspace.path, + check=True, + ) + tracked = workspace.path / "config.php" + tracked.write_text(" None: Mock(returncode=0, stdout="No syntax errors", stderr=""), Mock(returncode=0, stdout="No syntax errors", stderr=""), ] + workspace.checkpoint.return_value = "valid-checkpoint" workspace.commit.return_value = "commit-id" with patch("lai_automation.worker.GitWorkspace", return_value=workspace): @@ -147,10 +148,58 @@ def test_php_syntax_failure_discards_attempt_before_full_validation() -> None: assert worker.agent.run.call_count == 2 assert "unexpected token" in worker.agent.run.call_args_list[1].args[1] assert "clean repository" in worker.agent.run.call_args_list[1].args[1] - workspace.discard_changes.assert_called_once() + workspace.restore_checkpoint.assert_called_once_with(None) worker._run_tests.assert_called_once() +def test_malformed_retry_restores_last_syntax_valid_attempt() -> None: + report = FailureReport( + id=56, + chat_message_id=1, + source="user_feedback", + category="incorrect_answer", + user_feedback="Wrong hours", + feedback_type="wrong_result", + expected_behavior="Use time registration", + prompt="Hours in April", + response="0 hours", + sql_query="SELECT 0", + error_message=None, + context={}, + ) + config = settings(mode="pr", dry_run=False, gitea_token="token", max_agent_attempts=3) + worker = AutomationWorker(config) + worker.reports = Mock() + worker.reports.claim_next.return_value = report + worker.agent = Mock() + worker.agent.run.side_effect = ["valid but failing", "broken retry", "corrected retry"] + worker.gitea = Mock() + worker._run_tests = Mock(side_effect=[RuntimeError("Expected hour rule"), []]) + workspace = Mock() + workspace.path = Path("/tmp/nonexistent-workspace") + workspace.branch = "ai/failure-56" + workspace.summary.return_value = ChangeSummary( + ("config/erp_semantics.php", "tests/Unit/FixTest.php"), 20, 2 + ) + workspace.run_args.side_effect = [ + Mock(returncode=0, stdout="No syntax errors", stderr=""), + Mock(returncode=0, stdout="No syntax errors", stderr=""), + Mock(returncode=255, stdout="Parse error", stderr="unexpected token"), + Mock(returncode=0, stdout="No syntax errors", stderr=""), + Mock(returncode=0, stdout="No syntax errors", stderr=""), + ] + workspace.checkpoint.side_effect = ["checkpoint-1", "checkpoint-3"] + workspace.commit.return_value = "commit-id" + + with patch("lai_automation.worker.GitWorkspace", return_value=workspace): + assert worker.run_once() + + assert worker.agent.run.call_count == 3 + workspace.restore_checkpoint.assert_called_once_with("checkpoint-1") + assert "last syntax-valid changes were restored" in worker.agent.run.call_args_list[2].args[1] + assert worker._run_tests.call_count == 2 + + def test_failed_validation_gets_one_correction_before_pr() -> None: report = FailureReport( id=13,