From ae7e857420bde96875c4889c8332ba08e9bf5e82 Mon Sep 17 00:00:00 2001 From: helix4u <4317663+helix4u@users.noreply.github.com> Date: Mon, 22 Jun 2026 12:49:23 -0600 Subject: [PATCH] fix(cron): deliver max-iteration fallback reports --- cron/scheduler.py | 18 ++++++++++++-- tests/cron/test_scheduler.py | 46 ++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/cron/scheduler.py b/cron/scheduler.py index 99f910d8630..c48935c84a6 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -2189,13 +2189,27 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]: # would otherwise be delivered as if it were the agent's reply and the # job's `last_status` set to "ok". Raise so the except handler below # builds the proper failure tuple. (issue #17855) - if result.get("failed") is True or result.get("completed") is False: + turn_exit_reason = str(result.get("turn_exit_reason") or "") + final_response_text = (result.get("final_response") or "").strip() + max_iteration_summary = ( + result.get("failed") is not True + and result.get("completed") is False + and turn_exit_reason.startswith("max_iterations_reached(") + and bool(final_response_text) + ) + if result.get("failed") is True or (result.get("completed") is False and not max_iteration_summary): _err_text = ( result.get("error") - or (result.get("final_response") or "").strip() + or final_response_text or "agent reported failure" ) raise RuntimeError(_err_text) + if max_iteration_summary: + logger.warning( + "Job '%s' reached the iteration limit but produced a final fallback response; " + "delivering the response instead of failing the cron run", + job_name, + ) final_response = result.get("final_response", "") or "" # Strip leaked placeholder text that upstream may inject on empty completions. diff --git a/tests/cron/test_scheduler.py b/tests/cron/test_scheduler.py index a3c17048bb6..f766d4474f3 100644 --- a/tests/cron/test_scheduler.py +++ b/tests/cron/test_scheduler.py @@ -1394,6 +1394,52 @@ class TestRunJobSessionPersistence: assert error is None assert final_response == "all good" + def test_run_job_delivers_max_iteration_fallback_summary(self, tmp_path): + """Cron should deliver a usable max-iteration fallback summary. + + A cron run can exhaust the iteration budget, get a final text summary + from the no-tools fallback call, and still have ``completed=False`` in + the generic agent result. That should not make cron raise the report + text as a RuntimeError. + """ + job = { + "id": "summary-job", + "name": "summary", + "prompt": "finish the report", + } + fake_db = MagicMock() + + with patch("cron.scheduler._hermes_home", tmp_path), \ + patch("cron.scheduler._resolve_origin", return_value=None), \ + patch("dotenv.load_dotenv"), \ + patch("hermes_state.SessionDB", return_value=fake_db), \ + patch( + "hermes_cli.runtime_provider.resolve_runtime_provider", + return_value={ + "api_key": "***", + "base_url": "https://example.invalid/v1", + "provider": "openrouter", + "api_mode": "chat_completions", + }, + ), \ + patch("run_agent.AIAgent") as mock_agent_cls: + mock_agent = MagicMock() + mock_agent.run_conversation.return_value = { + "final_response": "final fallback report", + "completed": False, + "failed": False, + "turn_exit_reason": "max_iterations_reached(60/60)", + } + mock_agent_cls.return_value = mock_agent + + success, output, final_response, error = run_job(job) + + assert success is True + assert error is None + assert final_response == "final fallback report" + assert "final fallback report" in output + assert "(FAILED)" not in output + def test_tick_marks_empty_response_as_error(self, tmp_path): """When run_job returns success=True but final_response is empty, tick() should mark the job as error so last_status != 'ok'.