# NousResearch/hermes-agent · Cron jobs misreporting agent API failures as successful

## Open now

- Nothing open on GitHub.

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.

## TL;DR

- 3 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #17859. Verify it addresses your case.
- **Recommended:** read PR #17859 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-17859`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.67`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #17855: Cron: API failure incorrectly reported as last_status=ok, no error notification delivered

## Prior & competing PRs

- **#17859: fix(cron): surface agent run_conversation failure flags as job failure**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `cron/scheduler.py`, `tests/cron/test_scheduler.py`
  - fix hunks:
  - `cron/scheduler.py`

    ```diff
    @@ -1150,6 +1150,21 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
                     f"agent.run_conversation returned {type(result).__name__} instead of dict: {result!r}"
                 )
     
    +        # If the agent itself reported failure (e.g. all retries exhausted on
    +        # API errors, model abort, mid-run interrupt), do not silently mark the
    +        # job as successful. run_agent populates `failed=True`/`completed=False`
    +        # on these paths and may put the error into `final_response`, which
    +        # 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:
    +            _err_text = (
    +                result.get("error")
    +                or (result.get("final_response") or "").strip()
    +                or "agent reported failure"
    +            )
    +            raise RuntimeError(_err_text)
    +
             final_response = result.get("final_response", "") or ""
             # Strip leaked placeholder text that upstream may inject on empty completions.
             if final_response.strip() == "(No response generated)":
    ```
  - `tests/cron/test_scheduler.py`

    ```diff
    @@ -935,6 +935,120 @@ def test_run_job_empty_response_returns_empty_not_placeholder(self, tmp_path):
             # But the output log should show the placeholder
             assert "(No response generated)" in output
     
    +    @pytest.mark.parametrize(
    +        "agent_result,expected_err_substring",
    +        [
    +            (
    +                {
    +                    "final_response": "API call failed after 3 retries: Request timed out.",
    +                    "failed": True,
    +                    "completed": False,
    +                    "error": "API call failed after 3 retries: Request timed out.",
    +                },
    +                "API call failed",
    +            ),
    +            (
    +                {"final_response": None, "completed": False, "failed": True},
    +                "agent reported failure",
    +            ),
    +            (
    +                {"final_response": "", "completed": False},
    +                "agent reported failure",
    +            ),
    +            (
    +                {
    +                    "final_response": "partial reply before crash",
    +                    "failed": True,
    +                    "completed": False,
    +                    "error": "model abort: connection reset",
    +                },
    +                "model abort",
    +            ),
    +        ],
    +    )
    +    def test_run_job_treats_agent_failure_flag_as_failure(
    +        self, tmp_path, agent_result, expected_err_substring
    +    ):
    +        """Issue #17855: run_conversation returns ``failed=True``/``completed=False``
    +        when the agent's API call exhausts retries or aborts mid-run. run_job
    +        must surface this as success=False so cron's last_status reflects the
    +        failure and the user gets an error notification, instead of treating
    +        the (often non-empty) error string in final_response as a legitimate
    +        agent reply.
    +        """
    +        job = {
    +            "id": "failing-api-job",
    +            "name": "failing api",
    +            "prompt": "do something",
    +        }
    +        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 = agent_result
    +            mock_agent_cls.return_value = mock_agent
    +
    +            success, output, final_response, error = run_job(job)
    +
    +        assert success is False
    +        assert final_response == ""
    +        assert error is not None and expected_err_substring in error
    +        # Output should be the FAILED template, not the success template.
    +        assert "(FAILED)" in output
    +        # Ephemeral cron agent must still be closed even on agent-flagged failure.
    +        mock_agent.close.assert_called_once()
    +
    +    def test_run_job_completed_true_without_failed_flag_succeeds(self, tmp_path):
    +        """Regression guard: a normal success result (``completed=True``,
    +        ``failed`` absent) must not trip the failure-flag check.
    +        """
    +        job = {
    +            "id": "ok-job",
    +            "name": "ok",
    +            "prompt": "hello",
    +        }
    +        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": "all good",
    +                "completed": True,
    +            }
    +            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 == "all good"
    +
         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'.
    ```
- **#17882: fix(cron): report agent API failure correctly, never hide error notif…**. closed without merge · fix tier `suggested`
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#17859`, `#17855`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._

## Fix status

- Merged to main in PR #17859 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-04-30`. This confirmation is old and may be out of date (a revert/reopen would not have invalidated it here); re-verify.
