From 848089ac93b086542323c1649d34bb09f4f2c94d Mon Sep 17 00:00:00 2001 From: Flownium <157689911+itsflownium@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:45:50 +1000 Subject: [PATCH] fix: reject stale one-shot cron jobs --- cron/jobs.py | 15 ++++++++++++++- tests/cron/test_cron_script.py | 14 ++++++++++++++ tests/cron/test_jobs.py | 20 ++++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/cron/jobs.py b/cron/jobs.py index fdb99495011..a347ab689f2 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -978,6 +978,19 @@ 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 in the past and cannot be scheduled." + ) + job = { "id": job_id, "name": name or label_source[:50].strip(), @@ -1006,7 +1019,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 --git a/tests/cron/test_cron_script.py b/tests/cron/test_cron_script.py index ee02d043017..1033fb1ee4f 100644 --- a/tests/cron/test_cron_script.py +++ b/tests/cron/test_cron_script.py @@ -11,6 +11,7 @@ import json import os import sys import textwrap +from datetime import datetime, timedelta, timezone from pathlib import Path import pytest @@ -86,6 +87,19 @@ class TestJobScriptField: 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.""" diff --git a/tests/cron/test_jobs.py b/tests/cron/test_jobs.py index 50eb7ab7770..ddf1a7f2fb8 100644 --- a/tests/cron/test_jobs.py +++ b/tests/cron/test_jobs.py @@ -305,6 +305,26 @@ class TestJobCRUD: 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