{"repo":"NousResearch/hermes-agent","public_id":"2611","display_label":"Cron jobs deleted after one run; repeat=-1 normalization","summary":"Three items concern cron jobs being deleted after a single run, apparently when an LLM passes repeat=-1 to mean 'forever'. Two titles propose normalizing repeat values of -1 or less to None, and the third is a bug report describing the deletion.","subsystem":"cron","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":2612,"merged_at":"2026-03-23T13:35:44+00:00"},"resolution":{"state":"resolved","by":"pr-2612"},"freshness":{"state":"stale","last_verified_at":"2026-03-23T13:35:44+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":2611,"pr_number":2612,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-03-23T13:35:44+00:00"}],"members":[{"kind":"issue","number":2611,"title":"[Bug]: Cron jobs getting deleted after one run","url":null,"state":"closed"},{"kind":"pr","number":2612,"title":"fix: normalize repeat<=0 to None — cron jobs deleted after first run when LLM passes -1","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","tools/cronjob_tools.py"],"fix_hunks":[{"file":"cron/jobs.py","hunks":["@@ -383,6 +383,10 @@ def create_job(\n     \"\"\"\n     parsed_schedule = parse_schedule(schedule)\n \n+    # Normalize repeat: treat 0 or negative values as None (infinite)\n+    if repeat is not None and repeat <= 0:\n+        repeat = None\n+\n     # Auto-set repeat=1 for one-shot schedules if not specified\n     if parsed_schedule[\"kind\"] == \"once\" and repeat is None:\n         repeat = 1","@@ -571,7 +575,7 @@ def mark_job_run(job_id: str, success: bool, error: Optional[str] = None):\n                 # Check if we've hit the repeat limit\n                 times = job[\"repeat\"].get(\"times\")\n                 completed = job[\"repeat\"][\"completed\"]\n-                if times is not None and completed >= times:\n+                if times is not None and times > 0 and completed >= times:\n                     # Remove the job (limit reached)\n                     jobs.pop(i)\n                     save_jobs(jobs)"]},{"file":"tests/cron/test_jobs.py","hunks":["@@ -313,6 +313,24 @@ def test_repeat_limit_removes_job(self, tmp_cron_dir):\n         # Job should be removed after hitting repeat limit\n         assert get_job(job[\"id\"]) is None\n \n+    def test_repeat_negative_one_is_infinite(self, tmp_cron_dir):\n+        # LLMs often pass repeat=-1 to mean \"infinite/forever\".\n+        # The job must NOT be deleted after runs when repeat <= 0.\n+        job = create_job(prompt=\"Forever\", schedule=\"every 1h\", repeat=-1)\n+        # -1 should be normalised to None (infinite) at create time\n+        assert job[\"repeat\"][\"times\"] is None\n+        # Running it multiple times should never delete it\n+        for _ in range(3):\n+            mark_job_run(job[\"id\"], success=True)\n+            assert get_job(job[\"id\"]) is not None, \"job was deleted after run despite infinite repeat\"\n+\n+    def test_repeat_zero_is_infinite(self, tmp_cron_dir):\n+        # repeat=0 should also be treated as None (infinite), not \"run zero times\".\n+        job = create_job(prompt=\"ZeroRepeat\", schedule=\"every 1h\", repeat=0)\n+        assert job[\"repeat\"][\"times\"] is None\n+        mark_job_run(job[\"id\"], success=True)\n+        assert get_job(job[\"id\"]) is not None\n+\n     def test_error_status(self, tmp_cron_dir):\n         job = create_job(prompt=\"Fail\", schedule=\"every 1h\")\n         mark_job_run(job[\"id\"], success=False, error=\"timeout\")"]},{"file":"tools/cronjob_tools.py","hunks":["@@ -266,8 +266,10 @@ def cronjob(\n             if base_url is not None:\n                 updates[\"base_url\"] = _normalize_optional_job_value(base_url, strip_trailing_slash=True)\n             if repeat is not None:\n+                # Normalize: treat 0 or negative as None (infinite)\n+                normalized_repeat = None if repeat <= 0 else repeat\n                 repeat_state = dict(job.get(\"repeat\") or {})\n-                repeat_state[\"times\"] = repeat\n+                repeat_state[\"times\"] = normalized_repeat\n                 updates[\"repeat\"] = repeat_state\n             if schedule is not None:\n                 parsed_schedule = parse_schedule(schedule)\n"]}]},{"kind":"pr","number":2616,"title":"fix(cron): normalize repeat=-1 (LLM 'forever' convention) to None to prevent auto-deletion","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#2612","number":2612}],"category":"superseded_by_pr","implication":"superseded"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}