{"repo":"NousResearch/hermes-agent","public_id":"28166","display_label":"Cron one-shot past timestamp validation fixes","summary":"These issues and PRs address cron jobs silently accepting past timestamps for one-shot jobs, leaving them scheduled with no next_run_at. The work involves adding or improving rejection logic at job creation, update, and resume paths.","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":59438},"resolution":{"state":"partially_resolved","by":"pr-59438"},"freshness":{"state":"verified","last_verified_at":"2026-07-06T08:17:19+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.25523809523809526},"fix_edges":[{"issue_number":59395,"pr_number":59438,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":59395,"pr_number":59447,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":28166,"title":"Cron accepts one-shot jobs in the past and leaves them scheduled with no next_run_at","url":null,"state":"closed"},{"kind":"issue","number":59395,"title":"cron: create_job silently accepts past one-shot timestamps, returns success with next_run_at=null","url":null,"state":"closed"},{"kind":"pr","number":59410,"title":"fix(cron): reject one-shot jobs with a past timestamp at creation time","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"4976d3c38","number":0},{"kind":"pr","value":"#59412","number":59412},{"kind":"pr","value":"#59412","number":59412},{"kind":"pr","value":"#59438","number":59438}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":59412,"title":"fix: reject stale one-shot cron jobs","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"848089ac9","number":0},{"kind":"pr","value":"#59438","number":59438},{"kind":"pr","value":"#59410","number":59410}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":59428,"title":"fix(cron): reject past one-shot timestamps in update_job and resume_job","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"8def4ccb4","number":0},{"kind":"pr","value":"#59447","number":59447},{"kind":"pr","value":"#59438","number":59438},{"kind":"pr","value":"#59438","number":59438},{"kind":"pr","value":"#59395","number":59395}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":59438,"title":"fix(cron): reject one-shot jobs with a past timestamp at create + update (#59395)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cron/jobs.py","tests/cron/test_cron_script.py","tests/cron/test_jobs.py"],"fix_hunks":[{"file":"cron/jobs.py","hunks":["@@ -978,6 +978,20 @@ def create_job(\n         no_agent=normalized_no_agent,\n     )\n \n+    next_run_at = compute_next_run(parsed_schedule)\n+    if parsed_schedule.get(\"kind\") == \"once\" and next_run_at is None:\n+        run_at = parsed_schedule.get(\"run_at\") or schedule\n+        logger.warning(\n+            \"Rejecting one-shot cron job '%s': run_at %s is outside the %ss grace window\",\n+            name or label_source[:50].strip(),\n+            run_at,\n+            ONESHOT_GRACE_SECONDS,\n+        )\n+        raise ValueError(\n+            f\"Requested one-shot time {run_at} is more than \"\n+            f\"{ONESHOT_GRACE_SECONDS}s in the past and cannot be scheduled.\"\n+        )\n+\n     job = {\n         \"id\": job_id,\n         \"name\": name or label_source[:50].strip(),","@@ -1006,7 +1020,7 @@ def create_job(\n         \"paused_at\": None,\n         \"paused_reason\": None,\n         \"created_at\": now,\n-        \"next_run_at\": compute_next_run(parsed_schedule),\n+        \"next_run_at\": next_run_at,\n         \"last_run_at\": None,\n         \"last_status\": None,\n         \"last_error\": None,","@@ -1137,7 +1151,29 @@ def update_job(job_id: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]\n                     updated_schedule.get(\"display\", updated.get(\"schedule_display\")),\n                 )\n                 if updated.get(\"state\") != \"paused\":\n-                    updated[\"next_run_at\"] = compute_next_run(updated_schedule)\n+                    updated_next_run = compute_next_run(updated_schedule)\n+                    # Same guard as create_job: an UPDATE that sets a one-shot\n+                    # to a time >ONESHOT_GRACE_SECONDS in the past would store\n+                    # next_run_at=None with state=\"scheduled\", re-creating the\n+                    # ghost job that never fires (#59395). Reject it here too so\n+                    # the bug can't re-enter through the update door.\n+                    if (\n+                        updated_next_run is None\n+                        and updated_schedule.get(\"kind\") == \"once\"\n+                    ):\n+                        run_at = updated_schedule.get(\"run_at\") or updated_schedule\n+                        logger.warning(\n+                            \"Rejecting one-shot cron job update '%s': run_at %s \"\n+                            \"is outside the %ss grace window\",\n+                            updated.get(\"name\", job_id),\n+                            run_at,\n+                            ONESHOT_GRACE_SECONDS,\n+                        )\n+                        raise ValueError(\n+                            f\"Requested one-shot time {run_at} is more than \"\n+                            f\"{ONESHOT_GRACE_SECONDS}s in the past and cannot be scheduled.\"\n+                        )\n+                    updated[\"next_run_at\"] = updated_next_run\n \n             if inference_fields_changed:\n                 provider_snapshot, model_snapshot = _compute_provider_model_snapshots("]},{"file":"tests/cron/test_cron_script.py","hunks":["@@ -11,6 +11,7 @@\n import os\n import sys\n import textwrap\n+from datetime import datetime, timedelta, timezone\n from pathlib import Path\n \n import pytest","@@ -86,6 +87,19 @@ def test_update_job_clear_script(self, cron_env):\n         assert updated.get(\"script\") is None\n \n \n+def test_cronjob_tool_rejects_stale_past_one_shot(cron_env, monkeypatch):\n+    from tools.cronjob_tools import cronjob\n+\n+    now = datetime(2026, 3, 18, 4, 30, 0, tzinfo=timezone.utc)\n+    monkeypatch.setattr(\"cron.jobs._hermes_now\", lambda: now)\n+    stale = (now - timedelta(minutes=5)).isoformat()\n+\n+    result = json.loads(cronjob(action=\"create\", prompt=\"Too late\", schedule=stale))\n+\n+    assert result[\"success\"] is False\n+    assert \"past and cannot be scheduled\" in result[\"error\"]\n+\n+\n class TestRunJobScript:\n     \"\"\"Test the _run_job_script() function.\"\"\"\n "]},{"file":"tests/cron/test_jobs.py","hunks":["@@ -305,6 +305,26 @@ def test_auto_repeat_for_once(self, tmp_cron_dir):\n         job = create_job(prompt=\"One-shot\", schedule=\"1h\")\n         assert job[\"repeat\"][\"times\"] == 1\n \n+    def test_rejects_stale_past_one_shot_at_creation(self, tmp_cron_dir, monkeypatch):\n+        now = datetime(2026, 3, 18, 4, 30, 0, tzinfo=timezone.utc)\n+        monkeypatch.setattr(\"cron.jobs._hermes_now\", lambda: now)\n+        stale = (now - timedelta(minutes=5)).isoformat()\n+\n+        with pytest.raises(ValueError, match=\"past and cannot be scheduled\"):\n+            create_job(prompt=\"Too late\", schedule=stale)\n+\n+        assert load_jobs() == []\n+\n+    def test_recent_past_one_shot_within_grace_still_creates(self, tmp_cron_dir, monkeypatch):\n+        now = datetime(2026, 3, 18, 4, 30, 30, tzinfo=timezone.utc)\n+        monkeypatch.setattr(\"cron.jobs._hermes_now\", lambda: now)\n+        recent = (now - timedelta(seconds=30)).isoformat()\n+\n+        job = create_job(prompt=\"Still valid\", schedule=recent)\n+\n+        assert job[\"next_run_at\"] == recent\n+        assert load_jobs()[0][\"id\"] == job[\"id\"]\n+\n     def test_interval_no_auto_repeat(self, tmp_cron_dir):\n         job = create_job(prompt=\"Recurring\", schedule=\"every 1h\")\n         assert job[\"repeat\"][\"times\"] is None","@@ -354,6 +374,33 @@ def test_update_schedule(self, tmp_cron_dir):\n         assert fetched[\"schedule\"][\"minutes\"] == 120\n         assert fetched[\"schedule_display\"] == \"every 120m\"\n \n+    def test_update_to_past_oneshot_rejected(self, tmp_cron_dir, monkeypatch):\n+        \"\"\"Updating a job's schedule to a one-shot >ONESHOT_GRACE_SECONDS in the\n+        past must raise ValueError — otherwise the ghost-job bug (#59395) re-enters\n+        through the update door (next_run_at=None stored with state='scheduled').\n+        The original job must be left unchanged on disk.\"\"\"\n+        now = datetime(2026, 7, 6, 12, 0, 0, tzinfo=timezone.utc)\n+        monkeypatch.setattr(\"cron.jobs._hermes_now\", lambda: now)\n+        job = create_job(prompt=\"Recurring\", schedule=\"every 1h\", deliver=\"local\")\n+        past = parse_schedule((now - timedelta(minutes=10)).isoformat())\n+        with pytest.raises(ValueError, match=\"past and cannot be scheduled\"):\n+            update_job(job[\"id\"], {\"schedule\": past})\n+        # Original job unchanged — still the recurring interval, still scheduled.\n+        fetched = get_job(job[\"id\"])\n+        assert fetched[\"schedule\"][\"kind\"] == \"interval\"\n+        assert fetched[\"next_run_at\"] is not None\n+\n+    def test_update_to_future_oneshot_accepted(self, tmp_cron_dir, monkeypatch):\n+        \"\"\"Updating to a FUTURE one-shot still works — only past ones are rejected.\"\"\"\n+        now = datetime(2026, 7, 6, 12, 0, 0, tzinfo=timezone.utc)\n+        monkeypatch.setattr(\"cron.jobs._hermes_now\", lambda: now)\n+        job = create_job(prompt=\"Recurring\", schedule=\"every 1h\", deliver=\"local\")\n+        future = parse_schedule((now + timedelta(hours=2)).isoformat())\n+        updated = update_job(job[\"id\"], {\"schedule\": future})\n+        assert updated is not None\n+        assert updated[\"schedule\"][\"kind\"] == \"once\"\n+        assert updated[\"next_run_at\"] is not None\n+\n     def test_update_enable_disable(self, tmp_cron_dir):\n         job = create_job(prompt=\"Toggle me\", schedule=\"every 1h\")\n         assert job[\"enabled\"] is True\n"]}]},{"kind":"pr","number":59447,"title":"fix(cron): reject past one-shot in update_job fallback + resume_job (#59395)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["cron/jobs.py","scripts/release.py","tests/cron/test_jobs.py"],"fix_hunks":[{"file":"cron/jobs.py","hunks":["@@ -1186,7 +1186,14 @@ def update_job(job_id: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]\n                 updated[\"model_snapshot\"] = model_snapshot\n \n             if updated.get(\"enabled\", True) and updated.get(\"state\") != \"paused\" and not updated.get(\"next_run_at\"):\n-                updated[\"next_run_at\"] = compute_next_run(updated[\"schedule\"])\n+                next_run = compute_next_run(updated[\"schedule\"])\n+                if next_run is None and updated[\"schedule\"].get(\"kind\") == \"once\":\n+                    run_at = updated[\"schedule\"].get(\"run_at\", \"unknown\")\n+                    raise ValueError(\n+                        f\"Requested one-shot time {run_at} is in the past \"\n+                        f\"(grace window: {ONESHOT_GRACE_SECONDS}s) and cannot be scheduled.\"\n+                    )\n+                updated[\"next_run_at\"] = next_run\n \n             jobs[i] = updated\n             save_jobs(jobs)","@@ -1217,6 +1224,12 @@ def resume_job(job_id: str) -> Optional[Dict[str, Any]]:\n         return None\n \n     next_run_at = compute_next_run(job[\"schedule\"])\n+    if next_run_at is None and job[\"schedule\"].get(\"kind\") == \"once\":\n+        run_at = job[\"schedule\"].get(\"run_at\", \"unknown\")\n+        raise ValueError(\n+            f\"Cannot resume: one-shot time {run_at} is in the past \"\n+            f\"(grace window: {ONESHOT_GRACE_SECONDS}s) and will never fire.\"\n+        )\n     return update_job(\n         job[\"id\"],\n         {"]},{"file":"scripts/release.py","hunks":["@@ -45,6 +45,7 @@\n \n # Auto-extracted from noreply emails + manual overrides\n AUTHOR_MAP = {\n+    \"[redacted:email]\": \"isheng-eqi\",  # PR #59428 salvage (cron: reject past one-shot timestamps in update_job fallback + resume_job; #59395)\n     \"[redacted:email]\": \"derek2000139\",  # PR #57838 salvage (desktop/windows: pre-write update marker before quit dwell so the renderer's waitForUpdateToFinish gate parks instead of respawning a backend that re-locks venv .pyd files mid-update)\n     \"[redacted:email]\": \"AndreasHiltner\",  # PR #56854 salvage (gateway: route multiplex profile responses through the profile's own adapter — 53-site _adapter_for_source sweep)\n     \"[redacted:email]\": \"ManniBr\",  # PR #57417 partial salvage (gateway: fail-closed adapter resolution for unregistered secondary profiles)"]},{"file":"tests/cron/test_jobs.py","hunks":["@@ -444,6 +444,35 @@ def test_resume_reenables_job(self, tmp_cron_dir):\n         assert resumed[\"paused_at\"] is None\n         assert resumed[\"paused_reason\"] is None\n \n+    def test_resume_rejects_past_oneshot(self, tmp_cron_dir, monkeypatch):\n+        \"\"\"Resuming a paused one-shot whose time is now in the past must raise\n+        ValueError — the revived job would silently never fire.\"\"\"\n+        now = datetime(2026, 7, 6, 12, 0, 0, tzinfo=timezone.utc)\n+        monkeypatch.setattr(\"cron.jobs._hermes_now\", lambda: now)\n+        # Create directly — bypass create_job's past-oneshot guard so we can\n+        # test the resume path independently.\n+        job = {\n+            \"id\": \"test-resume-past\",\n+            \"name\": \"test-resume-past\",\n+            \"prompt\": \"Past one-shot\",\n+            \"schedule\": {\"kind\": \"once\", \"run_at\": (now - timedelta(minutes=5)).isoformat(), \"display\": \"once\"},\n+            \"repeat\": {\"times\": 1, \"completed\": 0},\n+            \"enabled\": False,\n+            \"state\": \"paused\",\n+            \"paused_at\": now.isoformat(),\n+            \"paused_reason\": \"test\",\n+            \"next_run_at\": None,\n+            \"last_run_at\": None,\n+            \"last_status\": None,\n+            \"last_error\": None,\n+            \"last_delivery_error\": None,\n+            \"created_at\": (now - timedelta(hours=1)).isoformat(),\n+            \"deliver\": \"local\",\n+        }\n+        save_jobs([job])\n+        with pytest.raises(ValueError, match=\"in the past\"):\n+            resume_job(\"test-resume-past\")\n+\n \n class TestResolveJobRef:\n     \"\"\"Name-based job lookup for CLI/tool callers (PR #2627, @buntingszn).\"\"\"\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}