diff --git a/cron/job_definition.py b/cron/job_definition.py index 96560ef43b..44cc4a3a93 100644 --- a/cron/job_definition.py +++ b/cron/job_definition.py @@ -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): diff --git a/cron/jobs.py b/cron/jobs.py index 734f3cb215..d34aa38db2 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -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")) diff --git a/tests/cron/test_jobs.py b/tests/cron/test_jobs.py index 434b8fabb0..98d58a445b 100644 --- a/tests/cron/test_jobs.py +++ b/tests/cron/test_jobs.py @@ -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.