fix(cron): re-derive repeat defaults when a schedule update flips the kind
This commit is contained in:
committed by
Teknium
parent
9c722ca94f
commit
111aea1a01
@@ -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})
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user