{"repo":"NousResearch/hermes-agent","public_id":"17855","display_label":"Cron jobs misreporting agent API failures as successful","summary":"One issue reports that a cron job's API failure was recorded as last_status=ok and that no error notification was delivered. Two related pull requests propose changes so agent failures, including run_conversation failure flags, are reported as job failures and error notifications are not hidden.","subsystem":null,"counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":17859,"merged_at":"2026-04-30T10:27:38+00:00"},"resolution":{"state":"resolved","by":"pr-17859"},"freshness":{"state":"stale","last_verified_at":"2026-04-30T10:27:38+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":17855,"pr_number":17859,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-04-30T10:27:38+00:00"}],"members":[{"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":17859,"title":"fix(cron): surface agent run_conversation failure flags as job failure","url":null,"state":"merged","close_reason":{"category":"merged"},"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}}