fix(cron): re-derive repeat defaults when a schedule update flips the kind

This commit is contained in:
Konstantin Khlopkov
2026-09-09 01:30:48 +03:00
committed by Teknium
parent 9c722ca94f
commit 111aea1a01
2 changed files with 88 additions and 0 deletions
+33
View File
@@ -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})
+55
View File
@@ -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)