From 111aea1a01d9559cbf77a2ab1fcf7c6ebab16d87 Mon Sep 17 00:00:00 2001 From: Konstantin Khlopkov Date: Wed, 9 Sep 2026 01:30:48 +0300 Subject: [PATCH] fix(cron): re-derive repeat defaults when a schedule update flips the kind --- cron/jobs.py | 33 +++++++++++++++++++++++++ tests/cron/test_jobs.py | 55 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/cron/jobs.py b/cron/jobs.py index ebd51bca41..5c8808505d 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -1897,6 +1897,38 @@ def _normalize_job_updates(job: Dict[str, Any], updates: Dict[str, Any]) -> None updates["repeat"] = {"times": normalize_repeat_value(_rp), "completed": completed} +def _rederive_repeat_for_schedule_change( + job: Dict[str, Any], updates: Dict[str, Any] +) -> None: + """Re-derive the ``repeat`` default when a schedule update flips the kind. + + ``create_job`` derives it from the schedule kind (once -> 1, recurring -> forever); the update + path must honour the same contract, otherwise a one-shot turned recurring keeps its ``times=1`` + budget and retires after one fire, while a recurring job turned one-shot never completes. An + explicit ``repeat`` in the same update wins; a same-kind schedule edit leaves ``repeat`` alone. + """ + if "schedule" not in updates or "repeat" in updates: + return + new_schedule = updates["schedule"] + if isinstance(new_schedule, str): + new_schedule = parse_schedule(new_schedule) + updates["schedule"] = new_schedule + old_kind = (job.get("schedule") or {}).get("kind") + new_kind = new_schedule.get("kind") + if old_kind == new_kind: + return + repeat = dict(job.get("repeat") or {}) + times = repeat.get("times") + if new_kind == "once" and times is None: + repeat["times"] = 1 + elif new_kind != "once" and old_kind == "once" and times == 1: + repeat["times"] = None + else: + return + repeat.setdefault("completed", 0) + updates["repeat"] = repeat + + def _apply_schedule_update(updated: Dict[str, Any], updates: Dict[str, Any], job_id: str) -> None: """Parse a string schedule, refresh ``schedule_display`` and (unless paused) ``next_run_at``.""" updated_schedule = updated["schedule"] @@ -1936,6 +1968,7 @@ def update_job(job_id: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]] raise ValueError(f"Cron job field(s) cannot be updated: {', '.join(sorted(bad_fields))}") def apply(jobs, i, job): + _rederive_repeat_for_schedule_change(job, updates) _normalize_job_updates(job, updates) previous_inference_axes = _normalized_inference_axes(job) updated = _apply_skill_fields({**job, **updates}) diff --git a/tests/cron/test_jobs.py b/tests/cron/test_jobs.py index f993928890..d8e869fc25 100644 --- a/tests/cron/test_jobs.py +++ b/tests/cron/test_jobs.py @@ -438,6 +438,61 @@ class TestJobCRUD: with pytest.raises(ValueError, match="Invalid repeat"): update_job(job["id"], {"repeat": "banana"}) + def test_oneshot_turned_recurring_becomes_forever(self, tmp_cron_dir): + """A one-shot budget must not survive a schedule change to a recurring kind. + + create_job derives repeat from the schedule kind; update_job must honour + the same contract when the kind flips, otherwise a job "fixed" from a + one-shot to `every 15m` keeps times=1 and retires after its first fire. + """ + from cron.jobs import update_job + + job = create_job(prompt="poll", schedule="in 15m") + assert job["repeat"]["times"] == 1 + + updated = update_job(job["id"], {"schedule": "every 15m"}) + assert updated["schedule"]["kind"] == "interval" + assert updated["repeat"]["times"] is None + + def test_recurring_turned_oneshot_becomes_once(self, tmp_cron_dir): + """The reverse flip must gain the one-shot default instead of staying forever.""" + from cron.jobs import update_job + + job = create_job(prompt="poll", schedule="every 15m") + assert job["repeat"]["times"] is None + + updated = update_job(job["id"], {"schedule": "in 15m"}) + assert updated["schedule"]["kind"] == "once" + assert updated["repeat"]["times"] == 1 + + def test_explicit_repeat_in_same_update_wins(self, tmp_cron_dir): + """An explicit repeat beats the re-derived default on kind flips.""" + from cron.jobs import update_job + + job = create_job(prompt="poll", schedule="in 15m") + + updated = update_job(job["id"], {"schedule": "every 15m", "repeat": 3}) + assert updated["repeat"]["times"] == 3 + + def test_same_kind_schedule_edit_keeps_repeat(self, tmp_cron_dir): + """Re-pointing a recurring schedule at another cadence is not a kind flip.""" + from cron.jobs import update_job + + job = create_job(prompt="poll", schedule="every 15m", repeat=5) + + updated = update_job(job["id"], {"schedule": "every 30m"}) + assert updated["schedule"]["kind"] == "interval" + assert updated["repeat"]["times"] == 5 + + def test_explicit_finite_oneshot_keeps_budget_turned_recurring(self, tmp_cron_dir): + """An explicitly finite one-shot (repeat=2) carries its budget to the recurring side.""" + from cron.jobs import update_job + + job = create_job(prompt="poll", schedule="in 15m", repeat=2) + + updated = update_job(job["id"], {"schedule": "every 15m"}) + assert updated["repeat"]["times"] == 2 + 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)