fix(cron): normalize a null repeat.completed once in load_jobs

A hand-edited "completed": null was patched with `.get("completed") or 0`
at five arithmetic readers. The display readers were still missed: the
cronjob tool listed a never-run one-shot as "1/1", and `hermes cron`
printed "None/3". Any new reader would bring the bug back.

Every reader gets its jobs through load_jobs: mark_job_run, update_job,
claim_dispatch, the due scan, merge_job_definition, list_jobs/get_job for
cronjob_job_args._repeat_display and hermes_cli/cron._job_rows. So reset a
null to 0 once in the load_jobs repair pass, which also persists the fix,
and change the per-site `or 0` patches back to their base form.

Co-authored-by: mochamgx <1114149@qq.com>
Co-authored-by: John Paul Soliva <soliva.johnpaul@icloud.com>
Co-authored-by: JoaoMarcos44 <joaomarcosdias444@gmail.com>
This commit is contained in:
kshitijk4poor
2026-09-26 19:04:47 +05:30
committed by kshitij
parent a7a2684677
commit d2b0b7ea82
2 changed files with 12 additions and 5 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") or 0,
"completed": (local.get("repeat") or {}).get("completed", 0),
"times": (authored.get("repeat") or {}).get("times"),
}
if isinstance(merged.get("schedule"), str):

View File

@@ -1390,6 +1390,13 @@ def load_jobs() -> List[Dict[str, Any]]:
", ".join(sorted({type(j).__name__ for j in junk}))))
jobs = [j for j in jobs if isinstance(j, dict)]
repair = repair or "non-object entries dropped"
for job in jobs:
# A hand-edited "completed": null would crash every counter reader (None += 1, None >= n)
# and render as "None/3"; normalize it once here so readers can trust an int.
rep = job.get("repeat")
if isinstance(rep, dict) and "completed" in rep and rep["completed"] is None:
rep["completed"] = 0
repair = repair or "null repeat.completed reset to 0"
# Persist even an empty result, or an all-junk store repeats the repair on every tick.
if repair:
if not getattr(_jobs_lock_state, "depth", 0):
@@ -1973,7 +1980,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") or 0
completed = (job.get("repeat") or {}).get("completed", 0)
if isinstance(_rp, dict):
_rp = dict(_rp)
_rp["times"] = normalize_repeat_value(_rp.get("times"))
@@ -2360,7 +2367,7 @@ def _advance_after_run(job: Dict[str, Any], now: str) -> None:
if repeat:
times = repeat.get("times")
finite = times is not None and times > 0
completed = repeat.get("completed") or 0
completed = repeat.get("completed", 0)
# Finite one-shots were pre-claimed by claim_dispatch() (completed already incremented) —
# do not double-count; recurring jobs and direct callers still get the increment.
if not (kind == "once" and finite and completed > 0):
@@ -2541,7 +2548,7 @@ def claim_dispatch(job_id: str) -> bool:
# Recurring jobs use advance_next_run(); no/infinite repeat limit always dispatches.
if job.get("schedule", {}).get("kind") != "once" or times is None or times <= 0:
return True
completed = repeat.get("completed") or 0
completed = repeat.get("completed", 0)
label = job.get("name", job.get("id", "?"))
if completed >= times:
if job.get("last_run_at") is not None:
@@ -3098,7 +3105,7 @@ def _oneshot_dispatch_limit_reached(job: Dict[str, Any], scan: _DueScan) -> bool
process is still running it (a run outliving the run_claim TTL is slow, not stale)."""
repeat = job.get("repeat") or {}
times = repeat.get("times")
completed = repeat.get("completed") or 0
completed = repeat.get("completed", 0)
if times is None or times <= 0 or completed < times:
return False
name = job.get("name", job.get("id", "?"))