{"repo":"NousResearch/hermes-agent","public_id":"8585","display_label":"Cron job status misreported on agent failures","summary":"A bug where cron jobs incorrectly report last_status as 'ok' when the agent returns an empty response or encounters API failures, along with multiple fix attempts addressing how these error conditions are surfaced and reported.","subsystem":null,"counts":{"members":7,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":2,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":11044},"resolution":{"state":"resolved","by":"pr-11044,pr-17859"},"freshness":{"state":"verified","last_verified_at":"2026-04-30T10:27:38+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.2552380952380952},"fix_edges":[{"issue_number":8585,"pr_number":11044,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":17855,"pr_number":17859,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":8585,"title":"[Bug]: Cron scheduler reports last_status \"ok\" when agent produces empty response","url":null,"state":"closed"},{"kind":"issue","number":17855,"title":"Cron: API failure incorrectly reported as last_status=ok, no error notification delivered","url":null,"state":"closed"},{"kind":"pr","number":8595,"title":"fix(cron): report empty agent response as 'warning' instead of 'ok'","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#11044","number":11044},{"kind":"pr","value":"#8634","number":8634}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":8634,"title":"fix(cron): treat empty agent response as error in last_status (fixes #8585)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#11044","number":11044}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":11044,"title":"fix(cron): treat empty agent response as error in last_status","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cron/scheduler.py","scripts/release.py","tests/cron/test_scheduler.py"],"fix_hunks":[{"file":"cron/scheduler.py","hunks":["@@ -979,6 +979,13 @@ def tick(verbose: bool = True, adapters=None, loop=None) -> int:\n                         delivery_error = str(de)\n                         logger.error(\"Delivery failed for job %s: %s\", job[\"id\"], de)\n \n+                # Treat empty final_response as a soft failure so last_status\n+                # is not \"ok\" — the agent ran but produced nothing useful.\n+                # (issue #8585)\n+                if success and not final_response:\n+                    success = False\n+                    error = \"Agent completed but produced empty response (model error, timeout, or misconfiguration)\"\n+\n                 mark_job_run(job[\"id\"], success, error, delivery_error=delivery_error)\n                 executed += 1\n "]},{"file":"scripts/release.py","hunks":["@@ -180,6 +180,7 @@\n     \"[redacted:email]\": \"lesterli\",\n     \"[redacted:email]\": \"MagicRay1217\",\n     \"[redacted:email]\": \"GenKoKo\",\n+    \"[redacted:email]\": \"iacker\",\n     \"[redacted:email]\": \"spniyant\",\n     \"[redacted:email]\": \"olafthiele\",\n     \"[redacted:email]\": \"sprmn24\","]},{"file":"tests/cron/test_scheduler.py","hunks":["@@ -675,7 +675,7 @@ def test_run_job_passes_session_db_and_cron_platform(self, tmp_path):\n \n     def test_run_job_empty_response_returns_empty_not_placeholder(self, tmp_path):\n         \"\"\"Empty final_response should stay empty for delivery logic (issue #2234).\n-        \n+\n         The placeholder '(No response generated)' should only appear in the\n         output log, not in the returned final_response that's used for delivery.\n         \"\"\"","@@ -693,7 +693,7 @@ def test_run_job_empty_response_returns_empty_not_placeholder(self, tmp_path):\n              patch(\n                  \"hermes_cli.runtime_provider.resolve_runtime_provider\",\n                  return_value={\n-                     \"api_key\": \"test-key\",\n+                     \"api_key\": \"***\",\n                      \"base_url\": \"https://example.invalid/v1\",\n                      \"provider\": \"openrouter\",\n                      \"api_mode\": \"chat_completions\",","@@ -714,6 +714,43 @@ def test_run_job_empty_response_returns_empty_not_placeholder(self, tmp_path):\n         # But the output log should show the placeholder\n         assert \"(No response generated)\" in output\n \n+    def test_tick_marks_empty_response_as_error(self, tmp_path):\n+        \"\"\"When run_job returns success=True but final_response is empty,\n+        tick() should mark the job as error so last_status != 'ok'.\n+        (issue #8585)\n+        \"\"\"\n+        from cron.scheduler import tick\n+        from cron.jobs import load_jobs, save_jobs\n+\n+        job = {\n+            \"id\": \"empty-job\",\n+            \"name\": \"empty-test\",\n+            \"prompt\": \"do something\",\n+            \"schedule\": \"every 1h\",\n+            \"enabled\": True,\n+            \"next_run_at\": \"2020-01-01T00:00:00\",\n+            \"deliver\": \"local\",\n+            \"last_status\": None,\n+        }\n+\n+        fake_db = MagicMock()\n+\n+        with patch(\"cron.scheduler._hermes_home\", tmp_path), \\\n+             patch(\"cron.scheduler.get_due_jobs\", return_value=[job]), \\\n+             patch(\"cron.scheduler.advance_next_run\"), \\\n+             patch(\"cron.scheduler.mark_job_run\") as mock_mark, \\\n+             patch(\"cron.scheduler.save_job_output\", return_value=\"/tmp/out.md\"), \\\n+             patch(\"cron.scheduler._resolve_origin\", return_value=None), \\\n+             patch(\"cron.scheduler.run_job\", return_value=(True, \"output\", \"\", None)):\n+            tick(verbose=False)\n+\n+        # Should be called with success=False because final_response is empty\n+        mock_mark.assert_called_once()\n+        call_args = mock_mark.call_args\n+        assert call_args[0][0] == \"empty-job\"\n+        assert call_args[0][1] is False  # success should be False\n+        assert \"empty\" in call_args[0][2].lower()  # error should mention empty\n+\n     def test_run_job_sets_auto_delivery_env_from_dotenv_home_channel(self, tmp_path, monkeypatch):\n         job = {\n             \"id\": \"test-job\",\n"]}]},{"kind":"pr","number":17859,"title":"fix(cron): surface agent run_conversation failure flags as job failure","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cron/scheduler.py","tests/cron/test_scheduler.py"],"fix_hunks":[{"file":"cron/scheduler.py","hunks":["@@ -1150,6 +1150,21 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:\n                 f\"agent.run_conversation returned {type(result).__name__} instead of dict: {result!r}\"\n             )\n \n+        # If the agent itself reported failure (e.g. all retries exhausted on\n+        # API errors, model abort, mid-run interrupt), do not silently mark the\n+        # job as successful. run_agent populates `failed=True`/`completed=False`\n+        # on these paths and may put the error into `final_response`, which\n+        # would otherwise be delivered as if it were the agent's reply and the\n+        # job's `last_status` set to \"ok\". Raise so the except handler below\n+        # builds the proper failure tuple. (issue #17855)\n+        if result.get(\"failed\") is True or result.get(\"completed\") is False:\n+            _err_text = (\n+                result.get(\"error\")\n+                or (result.get(\"final_response\") or \"\").strip()\n+                or \"agent reported failure\"\n+            )\n+            raise RuntimeError(_err_text)\n+\n         final_response = result.get(\"final_response\", \"\") or \"\"\n         # Strip leaked placeholder text that upstream may inject on empty completions.\n         if final_response.strip() == \"(No response generated)\":"]},{"file":"tests/cron/test_scheduler.py","hunks":["@@ -935,6 +935,120 @@ def test_run_job_empty_response_returns_empty_not_placeholder(self, tmp_path):\n         # But the output log should show the placeholder\n         assert \"(No response generated)\" in output\n \n+    @pytest.mark.parametrize(\n+        \"agent_result,expected_err_substring\",\n+        [\n+            (\n+                {\n+                    \"final_response\": \"API call failed after 3 retries: Request timed out.\",\n+                    \"failed\": True,\n+                    \"completed\": False,\n+                    \"error\": \"API call failed after 3 retries: Request timed out.\",\n+                },\n+                \"API call failed\",\n+            ),\n+            (\n+                {\"final_response\": None, \"completed\": False, \"failed\": True},\n+                \"agent reported failure\",\n+            ),\n+            (\n+                {\"final_response\": \"\", \"completed\": False},\n+                \"agent reported failure\",\n+            ),\n+            (\n+                {\n+                    \"final_response\": \"partial reply before crash\",\n+                    \"failed\": True,\n+                    \"completed\": False,\n+                    \"error\": \"model abort: connection reset\",\n+                },\n+                \"model abort\",\n+            ),\n+        ],\n+    )\n+    def test_run_job_treats_agent_failure_flag_as_failure(\n+        self, tmp_path, agent_result, expected_err_substring\n+    ):\n+        \"\"\"Issue #17855: run_conversation returns ``failed=True``/``completed=False``\n+        when the agent's API call exhausts retries or aborts mid-run. run_job\n+        must surface this as success=False so cron's last_status reflects the\n+        failure and the user gets an error notification, instead of treating\n+        the (often non-empty) error string in final_response as a legitimate\n+        agent reply.\n+        \"\"\"\n+        job = {\n+            \"id\": \"failing-api-job\",\n+            \"name\": \"failing api\",\n+            \"prompt\": \"do something\",\n+        }\n+        fake_db = MagicMock()\n+\n+        with patch(\"cron.scheduler._hermes_home\", tmp_path), \\\n+             patch(\"cron.scheduler._resolve_origin\", return_value=None), \\\n+             patch(\"dotenv.load_dotenv\"), \\\n+             patch(\"hermes_state.SessionDB\", return_value=fake_db), \\\n+             patch(\n+                 \"hermes_cli.runtime_provider.resolve_runtime_provider\",\n+                 return_value={\n+                     \"api_key\": \"***\",\n+                     \"base_url\": \"https://example.invalid/v1\",\n+                     \"provider\": \"openrouter\",\n+                     \"api_mode\": \"chat_completions\",\n+                 },\n+             ), \\\n+             patch(\"run_agent.AIAgent\") as mock_agent_cls:\n+            mock_agent = MagicMock()\n+            mock_agent.run_conversation.return_value = agent_result\n+            mock_agent_cls.return_value = mock_agent\n+\n+            success, output, final_response, error = run_job(job)\n+\n+        assert success is False\n+        assert final_response == \"\"\n+        assert error is not None and expected_err_substring in error\n+        # Output should be the FAILED template, not the success template.\n+        assert \"(FAILED)\" in output\n+        # Ephemeral cron agent must still be closed even on agent-flagged failure.\n+        mock_agent.close.assert_called_once()\n+\n+    def test_run_job_completed_true_without_failed_flag_succeeds(self, tmp_path):\n+        \"\"\"Regression guard: a normal success result (``completed=True``,\n+        ``failed`` absent) must not trip the failure-flag check.\n+        \"\"\"\n+        job = {\n+            \"id\": \"ok-job\",\n+            \"name\": \"ok\",\n+            \"prompt\": \"hello\",\n+        }\n+        fake_db = MagicMock()\n+\n+        with patch(\"cron.scheduler._hermes_home\", tmp_path), \\\n+             patch(\"cron.scheduler._resolve_origin\", return_value=None), \\\n+             patch(\"dotenv.load_dotenv\"), \\\n+             patch(\"hermes_state.SessionDB\", return_value=fake_db), \\\n+             patch(\n+                 \"hermes_cli.runtime_provider.resolve_runtime_provider\",\n+                 return_value={\n+                     \"api_key\": \"***\",\n+                     \"base_url\": \"https://example.invalid/v1\",\n+                     \"provider\": \"openrouter\",\n+                     \"api_mode\": \"chat_completions\",\n+                 },\n+             ), \\\n+             patch(\"run_agent.AIAgent\") as mock_agent_cls:\n+            mock_agent = MagicMock()\n+            mock_agent.run_conversation.return_value = {\n+                \"final_response\": \"all good\",\n+                \"completed\": True,\n+            }\n+            mock_agent_cls.return_value = mock_agent\n+\n+            success, output, final_response, error = run_job(job)\n+\n+        assert success is True\n+        assert error is None\n+        assert final_response == \"all good\"\n+\n     def test_tick_marks_empty_response_as_error(self, tmp_path):\n         \"\"\"When run_job returns success=True but final_response is empty,\n         tick() should mark the job as error so last_status != 'ok'.\n"]}]},{"kind":"pr","number":17882,"title":"fix(cron): report agent API failure correctly, never hide error notif…","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#17859","number":17859},{"kind":"pr","value":"#17855","number":17855}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true}],"trust":{"default_tier":"verified","includes_suggested":false}}