Cron one-shot past timestamp validation fixes

NousResearch/hermes-agent · cluster 28166

Data as of

Nothing open on GitHub. Fix merged in #59438. Check it covers your case.

The short answer

0open issues
0open PRs
7items grouped
Known?
Yes. 7 related items grouped here, 2 issues already closed.
Fix event
A fix was merged to main in PR #59438. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PR #59438. That is separate from the fix event.
Still open
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.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #59438 fix(cron): reject one-shot jobs with a past timestamp at create + update (#59395)

    merged to main

    files: cron/jobs.py, tests/cron/test_cron_script.py, tests/cron/test_jobs.py

    Show the diff
    • cron/jobs.py
      @@ -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(),
      @@ -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,
      @@ -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
      @@ -11,6 +11,7 @@
       import os
       import sys
       import textwrap
      +from datetime import datetime, timedelta, timezone
       from pathlib import Path
       
       import pytest
      @@ -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
      @@ -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
      @@ -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

    files: cron/jobs.py, scripts/release.py, tests/cron/test_jobs.py

    Show the diff
    • cron/jobs.py
      @@ -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)
      @@ -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
      @@ -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
      @@ -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)."""

3 PRs closed without merging

From the close comments: merged via another change 3

  • #59410 fix(cron): reject one-shot jobs with a past timestamp at creation time · merged via another change → 4976d3c, #59412, #59438
  • #59412 fix: reject stale one-shot cron jobs · merged via another change → 848089a, #59438, #59410
  • #59428 fix(cron): reject past one-shot timestamps in update_job and resume_job · merged via another change → 8def4cc, #59447, #59438, #59395
Closed issues · 2
  • Closed#28166Cron accepts one-shot jobs in the past and leaves them scheduled with no next_run_at
  • Closed#59395cron: create_job silently accepts past one-shot timestamps, returns success with next_run_at=null
How this cluster was grouped
  • Link evidence (partly structural links): 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 item 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.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Merged PRs
#59438, #59447
Closed issues
2
Attempts
3 PRs closed without merging
Fix link checked
6 Jul 2026

For your agent

The same facts, typed.