fix(cron): treat null repeat.completed as 0 in update_job and job definitions

#122114 guarded mark_job_run/claim paths against a hand-edited
"completed": null, but update_job and the job_definition merge still used
.get("completed", 0), which returns None when the key is present, so the
null was carried forward into the store. Use `or 0` there too, and pin the
behaviour with one test covering mark_job_run and update_job.

Fixes #123281

Co-authored-by: John Paul Soliva <soliva.johnpaul@icloud.com>
Co-authored-by: JoaoMarcos44 <joaomarcosdias444@gmail.com>
Co-authored-by: mochamgx <1114149@qq.com>
This commit is contained in:
kshitijk4poor
2026-09-26 18:27:23 +05:30
committed by kshitij
parent b653fd724a
commit ca7eded74c
3 changed files with 21 additions and 2 deletions

View File

@@ -26,7 +26,7 @@ def merge_job_definition(local: Dict[str, Any], authored: Dict[str, Any]) -> Dic
merged = {key: value for key, value in local.items() if key not in JOB_DEFINITION_FIELDS}
merged.update((key, authored[key]) for key in JOB_DEFINITION_FIELDS if key in authored)
merged["repeat"] = {
"completed": (local.get("repeat") or {}).get("completed", 0),
"completed": (local.get("repeat") or {}).get("completed") or 0,
"times": (authored.get("repeat") or {}).get("times"),
}
if isinstance(merged.get("schedule"), str):

View File

@@ -1973,7 +1973,7 @@ def _normalize_job_updates(job: Dict[str, Any], updates: Dict[str, Any]) -> None
updates[key] = norm(updates[key])
if "repeat" in updates:
_rp = updates["repeat"]
completed = (job.get("repeat") or {}).get("completed", 0)
completed = (job.get("repeat") or {}).get("completed") or 0
if isinstance(_rp, dict):
_rp = dict(_rp)
_rp["times"] = normalize_repeat_value(_rp.get("times"))

View File

@@ -438,6 +438,25 @@ class TestJobCRUD:
with pytest.raises(ValueError, match="Invalid repeat"):
update_job(job["id"], {"repeat": "banana"})
def test_null_repeat_completed_counts_as_zero(self, tmp_cron_dir):
"""A hand-edited "completed": null must not kill mark_job_run (None += 1) or be
carried forward by update_job."""
import json
from cron.jobs import JOBS_FILE, get_job, mark_job_run, update_job
job = create_job(prompt="t", schedule="every 1h", repeat=3)
def null_completed():
payload = json.loads(JOBS_FILE.read_text(encoding="utf-8"))
payload["jobs"][0]["repeat"]["completed"] = None
JOBS_FILE.write_text(json.dumps(payload), encoding="utf-8")
null_completed()
mark_job_run(job["id"], success=True)
assert get_job(job["id"])["repeat"]["completed"] == 1
null_completed()
assert update_job(job["id"], {"repeat": {"times": 5}})["repeat"]["completed"] == 0
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.