{"repo":"NousResearch/hermes-agent","public_id":"16265","display_label":"Cron jobs broken when croniter dependency missing","summary":"These issues and PRs concern recurring cron jobs being silently disabled or marked completed when the croniter library is absent, and proposals to fix this by either handling the missing dependency differently or promoting croniter to a required core dependency.","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":16368,"merged_at":"2026-04-27T04:47:33+00:00"},"resolution":{"state":"resolved","by":"pr-16368"},"freshness":{"state":"stale","last_verified_at":"2026-04-27T04:47:33+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":16265,"pr_number":16368,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-04-27T04:47:33+00:00"}],"members":[{"kind":"issue","number":16265,"title":"Recurring cron jobs are marked completed/off when croniter is missing","url":null,"state":"closed"},{"kind":"pr","number":16268,"title":"[codex] Fix cron jobs when croniter is missing","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"7c63c2461","number":0},{"kind":"pr","value":"#16368","number":16368},{"kind":"pr","value":"#16265","number":16265}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":16368,"title":"fix(cron): don't silently disable recurring cron jobs when croniter is missing","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cron/jobs.py","tests/cron/test_jobs.py"],"fix_hunks":[{"file":"cron/jobs.py","hunks":["@@ -311,6 +311,12 @@ def compute_next_run(schedule: Dict[str, Any], last_run_at: Optional[str] = None\n \n     elif schedule[\"kind\"] == \"cron\":\n         if not HAS_CRONITER:\n+            logger.warning(\n+                \"Cannot compute next run for cron schedule %r: 'croniter' \"\n+                \"is not installed. Install the 'cron' extra (pip install \"\n+                \"'hermes-agent[cron]') to re-enable recurring cron jobs.\",\n+                schedule.get(\"expr\"),\n+            )\n             return None\n         cron = croniter(schedule[\"expr\"], now)\n         next_run = cron.get_next(datetime)","@@ -698,10 +704,32 @@ def mark_job_run(job_id: str, success: bool, error: Optional[str] = None,\n                 # Compute next run\n                 job[\"next_run_at\"] = compute_next_run(job[\"schedule\"], now)\n \n-                # If no next run (one-shot completed), disable\n+                # If no next run, decide whether this is terminal completion\n+                # (one-shot) or a transient failure (recurring schedule couldn't\n+                # compute — e.g. 'croniter' missing from the runtime env).\n+                # Recurring jobs must NEVER be silently disabled: that turns a\n+                # missing runtime dep into \"job completed\" and the user's\n+                # schedule quietly goes off. See issue #16265.\n                 if job[\"next_run_at\"] is None:\n-                    job[\"enabled\"] = False\n-                    job[\"state\"] = \"completed\"\n+                    kind = job.get(\"schedule\", {}).get(\"kind\")\n+                    if kind in (\"cron\", \"interval\"):\n+                        job[\"state\"] = \"error\"\n+                        if not job.get(\"last_error\"):\n+                            job[\"last_error\"] = (\n+                                \"Failed to compute next run for recurring \"\n+                                \"schedule (is the 'croniter' package \"\n+                                \"installed in the gateway's Python env?)\"\n+                            )\n+                        logger.error(\n+                            \"Job '%s' (%s) could not compute next_run_at; \"\n+                            \"leaving enabled and marking state=error so the \"\n+                            \"job is not silently disabled.\",\n+                            job.get(\"name\", job[\"id\"]),\n+                            kind,\n+                        )\n+                    else:\n+                        job[\"enabled\"] = False\n+                        job[\"state\"] = \"completed\"\n                 elif job.get(\"state\") != \"paused\":\n                     job[\"state\"] = \"scheduled\"\n "]},{"file":"tests/cron/test_jobs.py","hunks":["@@ -369,6 +369,88 @@ def test_both_agent_and_delivery_error(self, tmp_cron_dir):\n         assert updated[\"last_error\"] == \"model timeout\"\n         assert updated[\"last_delivery_error\"] == \"platform 'discord' not enabled\"\n \n+    def test_recurring_cron_not_disabled_when_croniter_missing(self, tmp_cron_dir, monkeypatch):\n+        \"\"\"Regression test for issue #16265.\n+\n+        If the gateway runs in an env where `croniter` went missing after a\n+        recurring cron job was persisted, `compute_next_run()` returns None.\n+        `mark_job_run()` must NOT treat that as terminal completion — the job\n+        has to stay enabled with state=error so the user notices, rather than\n+        silently flipping to enabled=false, state=completed.\n+        \"\"\"\n+        pytest.importorskip(\"croniter\")  # need it to create the job\n+        job = create_job(prompt=\"Recurring\", schedule=\"0 7,15,23 * * *\")\n+        assert job[\"schedule\"][\"kind\"] == \"cron\"\n+\n+        # Simulate the runtime env having lost croniter between job creation\n+        # and this run.\n+        monkeypatch.setattr(\"cron.jobs.HAS_CRONITER\", False)\n+\n+        mark_job_run(job[\"id\"], success=True)\n+\n+        updated = get_job(job[\"id\"])\n+        assert updated is not None, \"recurring cron job was deleted\"\n+        assert updated[\"enabled\"] is True, (\n+            \"recurring cron job was disabled despite croniter-missing being \"\n+            \"a runtime dep issue, not a terminal completion\"\n+        )\n+        assert updated[\"state\"] == \"error\"\n+        assert updated[\"state\"] != \"completed\"\n+        assert updated[\"next_run_at\"] is None\n+        assert updated[\"last_error\"]\n+        assert \"croniter\" in updated[\"last_error\"].lower()\n+\n+    def test_recurring_interval_not_disabled_when_next_run_is_none(self, tmp_cron_dir, monkeypatch):\n+        \"\"\"Defensive sibling of the cron test — any recurring schedule that\n+        somehow yields next_run_at=None must stay enabled with state=error.\n+        \"\"\"\n+        job = create_job(prompt=\"Recurring\", schedule=\"every 1h\")\n+        assert job[\"schedule\"][\"kind\"] == \"interval\"\n+\n+        # Force compute_next_run to return None for this call — simulates\n+        # any future regression where a recurring schedule loses its\n+        # next-run computation (missing dep, corrupt schedule, etc.).\n+        monkeypatch.setattr(\"cron.jobs.compute_next_run\", lambda *a, **kw: None)\n+\n+        mark_job_run(job[\"id\"], success=True)\n+\n+        updated = get_job(job[\"id\"])\n+        assert updated is not None\n+        assert updated[\"enabled\"] is True\n+        assert updated[\"state\"] == \"error\"\n+        assert updated[\"state\"] != \"completed\"\n+\n+    def test_oneshot_still_completes_when_next_run_is_none(self, tmp_cron_dir):\n+        \"\"\"One-shot jobs must still flip to enabled=false, state=completed\n+        when next_run_at cannot be computed — the #16265 fix must not\n+        regress this path. We bypass create_job and craft a minimal\n+        one-shot record directly so that the repeat-limit branch doesn't\n+        pop the job before we observe the terminal-completion branch.\n+        \"\"\"\n+        jobs = [{\n+            \"id\": \"oneshot-test\",\n+            \"prompt\": \"Once\",\n+            \"schedule\": {\"kind\": \"once\", \"run_at\": \"2020-01-01T00:00:00+00:00\", \"display\": \"once\"},\n+            \"repeat\": {\"times\": None, \"completed\": 0},\n+            \"enabled\": True,\n+            \"state\": \"scheduled\",\n+            \"next_run_at\": \"2020-01-01T00:00:00+00:00\",\n+            \"last_run_at\": None,\n+            \"last_status\": None,\n+            \"last_error\": None,\n+            \"last_delivery_error\": None,\n+            \"created_at\": \"2020-01-01T00:00:00+00:00\",\n+        }]\n+        save_jobs(jobs)\n+\n+        mark_job_run(\"oneshot-test\", success=True)\n+\n+        updated = get_job(\"oneshot-test\")\n+        assert updated is not None\n+        assert updated[\"next_run_at\"] is None\n+        assert updated[\"enabled\"] is False\n+        assert updated[\"state\"] == \"completed\"\n+\n \n class TestAdvanceNextRun:\n     \"\"\"Tests for advance_next_run() — crash-safety for recurring jobs.\"\"\"\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}