Nothing open on GitHub. Fix merged in #59438. Check it covers your case.
The short answer
- 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
Open PRs0
Fixes on record
-
#59438 fix(cron): reject one-shot jobs with a past timestamp at create + update (#59395)
files:
cron/jobs.py,tests/cron/test_cron_script.py,tests/cron/test_jobs.pyShow 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)
files:
cron/jobs.py,scripts/release.py,tests/cron/test_jobs.pyShow 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
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.00means 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