# NousResearch/hermes-agent · Cron one-shot past timestamp validation fixes

## Open now

- Nothing open on GitHub.

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.

## TL;DR

- 7 member(s): 0 open issue(s), 2 closed issue(s), 0 open PR(s), 2 merged PR(s), 3 PR(s) closed without merging.
- A fix was merged to main in PR #59438. Verify it addresses your case.
- **Recommended:** read PR #59438 (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: `partially_resolved` · deciding PR(s): `pr-59438`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.26`: 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

- #28166: Cron accepts one-shot jobs in the past and leaves them scheduled with no next_run_at
- #59395: cron: create_job silently accepts past one-shot timestamps, returns success with next_run_at=null

## Prior & competing PRs

- **#59438: fix(cron): reject one-shot jobs with a past timestamp at create + update (#59395)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `cron/jobs.py`, `tests/cron/test_cron_script.py`, `tests/cron/test_jobs.py`
  - fix hunks:
  - `cron/jobs.py`

    ```diff
    @@ -978,6 +978,20 @@ def create_job(
             no_agent=normalized_no_agent,
         )
     
    +    next_run_at = compute_next_run(parsed_schedule)
    +    if parsed_schedule.get("kind") == "once" and next_run_at is None:
    +        run_at = parsed_schedule.get("run_at") or schedule
    +        logger.warning(
    +            "Rejecting one-shot cron job '%s': run_at %s is outside the %ss grace window",
    +            name or label_source[:50].strip(),
    +            run_at,
    +            ONESHOT_GRACE_SECONDS,
    +        )
    +        raise ValueError(
    +            f"Requested one-shot time {run_at} is more than "
    +            f"{ONESHOT_GRACE_SECONDS}s in the past and cannot be scheduled."
    +        )
    +
         job = {
             "id": job_id,
             "name": name or label_source[:50].strip(),
    ```

    ```diff
    @@ -1006,7 +1020,7 @@ def create_job(
             "paused_at": None,
             "paused_reason": None,
             "created_at": now,
    -        "next_run_at": compute_next_run(parsed_schedule),
    +        "next_run_at": next_run_at,
             "last_run_at": None,
             "last_status": None,
             "last_error": None,
    ```

    ```diff
    @@ -1137,7 +1151,29 @@ def update_job(job_id: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]
                         updated_schedule.get("display", updated.get("schedule_display")),
                     )
                     if updated.get("state") != "paused":
    -                    updated["next_run_at"] = compute_next_run(updated_schedule)
    +                    updated_next_run = compute_next_run(updated_schedule)
    +                    # Same guard as create_job: an UPDATE that sets a one-shot
    +                    # to a time >ONESHOT_GRACE_SECONDS in the past would store
    +                    # next_run_at=None with state="scheduled", re-creating the
    +                    # ghost job that never fires (#59395). Reject it here too so
    +                    # the bug can't re-enter through the update door.
    +                    if (
    +                        updated_next_run is None
    +                        and updated_schedule.get("kind") == "once"
    +                    ):
    +                        run_at = updated_schedule.get("run_at") or updated_schedule
    +                        logger.warning(
    +                            "Rejecting one-shot cron job update '%s': run_at %s "
    +                            "is outside the %ss grace window",
    +                            updated.get("name", job_id),
    +                            run_at,
    +                            ONESHOT_GRACE_SECONDS,
    +                        )
    +                        raise ValueError(
    +                            f"Requested one-shot time {run_at} is more than "
    +                            f"{ONESHOT_GRACE_SECONDS}s in the past and cannot be scheduled."
    +                        )
    +                    updated["next_run_at"] = updated_next_run
     
                 if inference_fields_changed:
                     provider_snapshot, model_snapshot = _compute_provider_model_snapshots(
    ```
  - `tests/cron/test_cron_script.py`

    ```diff
    @@ -11,6 +11,7 @@
     import os
     import sys
     import textwrap
    +from datetime import datetime, timedelta, timezone
     from pathlib import Path
     
     import pytest
    ```

    ```diff
    @@ -86,6 +87,19 @@ def test_update_job_clear_script(self, cron_env):
             assert updated.get("script") is None
     
     
    +def test_cronjob_tool_rejects_stale_past_one_shot(cron_env, monkeypatch):
    +    from tools.cronjob_tools import cronjob
    +
    +    now = datetime(2026, 3, 18, 4, 30, 0, tzinfo=timezone.utc)
    +    monkeypatch.setattr("cron.jobs._hermes_now", lambda: now)
    +    stale = (now - timedelta(minutes=5)).isoformat()
    +
    +    result = json.loads(cronjob(action="create", prompt="Too late", schedule=stale))
    +
    +    assert result["success"] is False
    +    assert "past and cannot be scheduled" in result["error"]
    +
    +
     class TestRunJobScript:
         """Test the _run_job_script() function."""
     
    ```
  - `tests/cron/test_jobs.py`

    ```diff
    @@ -305,6 +305,26 @@ def test_auto_repeat_for_once(self, tmp_cron_dir):
             job = create_job(prompt="One-shot", schedule="1h")
             assert job["repeat"]["times"] == 1
     
    +    def test_rejects_stale_past_one_shot_at_creation(self, tmp_cron_dir, monkeypatch):
    +        now = datetime(2026, 3, 18, 4, 30, 0, tzinfo=timezone.utc)
    +        monkeypatch.setattr("cron.jobs._hermes_now", lambda: now)
    +        stale = (now - timedelta(minutes=5)).isoformat()
    +
    +        with pytest.raises(ValueError, match="past and cannot be scheduled"):
    +            create_job(prompt="Too late", schedule=stale)
    +
    +        assert load_jobs() == []
    +
    +    def test_recent_past_one_shot_within_grace_still_creates(self, tmp_cron_dir, monkeypatch):
    +        now = datetime(2026, 3, 18, 4, 30, 30, tzinfo=timezone.utc)
    +        monkeypatch.setattr("cron.jobs._hermes_now", lambda: now)
    +        recent = (now - timedelta(seconds=30)).isoformat()
    +
    +        job = create_job(prompt="Still valid", schedule=recent)
    +
    +        assert job["next_run_at"] == recent
    +        assert load_jobs()[0]["id"] == job["id"]
    +
         def test_interval_no_auto_repeat(self, tmp_cron_dir):
             job = create_job(prompt="Recurring", schedule="every 1h")
             assert job["repeat"]["times"] is None
    ```

    ```diff
    @@ -354,6 +374,33 @@ def test_update_schedule(self, tmp_cron_dir):
             assert fetched["schedule"]["minutes"] == 120
             assert fetched["schedule_display"] == "every 120m"
     
    +    def test_update_to_past_oneshot_rejected(self, tmp_cron_dir, monkeypatch):
    +        """Updating a job's schedule to a one-shot >ONESHOT_GRACE_SECONDS in the
    +        past must raise ValueError — otherwise the ghost-job bug (#59395) re-enters
    +        through the update door (next_run_at=None stored with state='scheduled').
    +        The original job must be left unchanged on disk."""
    +        now = datetime(2026, 7, 6, 12, 0, 0, tzinfo=timezone.utc)
    +        monkeypatch.setattr("cron.jobs._hermes_now", lambda: now)
    +        job = create_job(prompt="Recurring", schedule="every 1h", deliver="local")
    +        past = parse_schedule((now - timedelta(minutes=10)).isoformat())
    +        with pytest.raises(ValueError, match="past and cannot be scheduled"):
    +            update_job(job["id"], {"schedule": past})
    +        # Original job unchanged — still the recurring interval, still scheduled.
    +        fetched = get_job(job["id"])
    +        assert fetched["schedule"]["kind"] == "interval"
    +        assert fetched["next_run_at"] is not None
    +
    +    def test_update_to_future_oneshot_accepted(self, tmp_cron_dir, monkeypatch):
    +        """Updating to a FUTURE one-shot still works — only past ones are rejected."""
    +        now = datetime(2026, 7, 6, 12, 0, 0, tzinfo=timezone.utc)
    +        monkeypatch.setattr("cron.jobs._hermes_now", lambda: now)
    +        job = create_job(prompt="Recurring", schedule="every 1h", deliver="local")
    +        future = parse_schedule((now + timedelta(hours=2)).isoformat())
    +        updated = update_job(job["id"], {"schedule": future})
    +        assert updated is not None
    +        assert updated["schedule"]["kind"] == "once"
    +        assert updated["next_run_at"] is not None
    +
         def test_update_enable_disable(self, tmp_cron_dir):
             job = create_job(prompt="Toggle me", schedule="every 1h")
             assert job["enabled"] is True
    ```
- **#59447: fix(cron): reject past one-shot in update_job fallback + resume_job (#59395)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `cron/jobs.py`, `scripts/release.py`, `tests/cron/test_jobs.py`
  - fix hunks:
  - `cron/jobs.py`

    ```diff
    @@ -1186,7 +1186,14 @@ def update_job(job_id: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]
                     updated["model_snapshot"] = model_snapshot
     
                 if updated.get("enabled", True) and updated.get("state") != "paused" and not updated.get("next_run_at"):
    -                updated["next_run_at"] = compute_next_run(updated["schedule"])
    +                next_run = compute_next_run(updated["schedule"])
    +                if next_run is None and updated["schedule"].get("kind") == "once":
    +                    run_at = updated["schedule"].get("run_at", "unknown")
    +                    raise ValueError(
    +                        f"Requested one-shot time {run_at} is in the past "
    +                        f"(grace window: {ONESHOT_GRACE_SECONDS}s) and cannot be scheduled."
    +                    )
    +                updated["next_run_at"] = next_run
     
                 jobs[i] = updated
                 save_jobs(jobs)
    ```

    ```diff
    @@ -1217,6 +1224,12 @@ def resume_job(job_id: str) -> Optional[Dict[str, Any]]:
             return None
     
         next_run_at = compute_next_run(job["schedule"])
    +    if next_run_at is None and job["schedule"].get("kind") == "once":
    +        run_at = job["schedule"].get("run_at", "unknown")
    +        raise ValueError(
    +            f"Cannot resume: one-shot time {run_at} is in the past "
    +            f"(grace window: {ONESHOT_GRACE_SECONDS}s) and will never fire."
    +        )
         return update_job(
             job["id"],
             {
    ```
  - `scripts/release.py`

    ```diff
    @@ -45,6 +45,7 @@
     
     # Auto-extracted from noreply emails + manual overrides
     AUTHOR_MAP = {
    +    "[redacted:email]": "isheng-eqi",  # PR #59428 salvage (cron: reject past one-shot timestamps in update_job fallback + resume_job; #59395)
         "[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)
         "[redacted:email]": "AndreasHiltner",  # PR #56854 salvage (gateway: route multiplex profile responses through the profile's own adapter — 53-site _adapter_for_source sweep)
         "[redacted:email]": "ManniBr",  # PR #57417 partial salvage (gateway: fail-closed adapter resolution for unregistered secondary profiles)
    ```
  - `tests/cron/test_jobs.py`

    ```diff
    @@ -444,6 +444,35 @@ def test_resume_reenables_job(self, tmp_cron_dir):
             assert resumed["paused_at"] is None
             assert resumed["paused_reason"] is None
     
    +    def test_resume_rejects_past_oneshot(self, tmp_cron_dir, monkeypatch):
    +        """Resuming a paused one-shot whose time is now in the past must raise
    +        ValueError — the revived job would silently never fire."""
    +        now = datetime(2026, 7, 6, 12, 0, 0, tzinfo=timezone.utc)
    +        monkeypatch.setattr("cron.jobs._hermes_now", lambda: now)
    +        # Create directly — bypass create_job's past-oneshot guard so we can
    +        # test the resume path independently.
    +        job = {
    +            "id": "test-resume-past",
    +            "name": "test-resume-past",
    +            "prompt": "Past one-shot",
    +            "schedule": {"kind": "once", "run_at": (now - timedelta(minutes=5)).isoformat(), "display": "once"},
    +            "repeat": {"times": 1, "completed": 0},
    +            "enabled": False,
    +            "state": "paused",
    +            "paused_at": now.isoformat(),
    +            "paused_reason": "test",
    +            "next_run_at": None,
    +            "last_run_at": None,
    +            "last_status": None,
    +            "last_error": None,
    +            "last_delivery_error": None,
    +            "created_at": (now - timedelta(hours=1)).isoformat(),
    +            "deliver": "local",
    +        }
    +        save_jobs([job])
    +        with pytest.raises(ValueError, match="in the past"):
    +            resume_job("test-resume-past")
    +
     
     class TestResolveJobRef:
         """Name-based job lookup for CLI/tool callers (PR #2627, @buntingszn)."""
    ```
- **#59410: fix(cron): reject one-shot jobs with a past timestamp at creation time**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `4976d3c38`, `#59412`, `#59412`, `#59438`
- **#59412: fix: reject stale one-shot cron jobs**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `848089ac9`, `#59438`, `#59410`
- **#59428: fix(cron): reject past one-shot timestamps in update_job and resume_job**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `8def4ccb4`, `#59447`, `#59438`, `#59438`, `#59395`

## Fix status

- Merged to main in PR #59438 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-06`.
