fix(cron): skip non-object entries in jobs.json instead of halting every tick

A null, string or number in the canonical {"jobs": [...]} list reached
every reader: the due scan raised AttributeError on each tick, so no job
fired, and list/resolve crashed too. Drop the junk with a warning and
self-heal the file, as the id-keyed map flatten already does.

(cherry picked from commit 2e2438d160967392184b2193cfc2e2ca124c2b7a)
This commit is contained in:
John Paul Soliva
2026-09-26 10:31:25 +09:00
committed by kshitij
parent a0fe806c46
commit 695766e3f9
2 changed files with 26 additions and 0 deletions

View File

@@ -1374,6 +1374,15 @@ def load_jobs() -> List[Dict[str, Any]]:
else:
raise RuntimeError(
f"Cron database corrupted: expected {{'jobs': [...]}}, got {type(data).__name__}")
if isinstance(jobs, list) and not all(isinstance(j, dict) for j in jobs):
# Every reader and the due scan index records as dicts: one junk entry would crash the
# whole tick and freeze every healthy sibling job, so skip it like the id-keyed map does.
junk = [j for j in jobs if not isinstance(j, dict)]
logger.warning(
"Skipping %d non-object entr%s in jobs.json: %s",
len(junk), "y" if len(junk) == 1 else "ies", ", ".join(map(repr, junk[:5])))
jobs = [j for j in jobs if isinstance(j, dict)]
repair = repair or "non-object entries dropped"
if jobs and repair:
save_jobs(jobs)
logger.warning("Auto-repaired jobs.json (%s)", repair)

View File

@@ -1718,6 +1718,23 @@ class TestJobsJsonIdKeyedMap:
assert isinstance(on_disk["jobs"], list)
assert [j["id"] for j in on_disk["jobs"]] == ["goodjob1"]
def test_non_dict_list_entries_do_not_stop_healthy_jobs_firing(self, tmp_cron_dir):
"""A junk entry in the canonical list shape must not abort the due scan for its
healthy siblings (it used to raise on every tick, so no job fired)."""
import json
from cron.jobs import JOBS_FILE
job = create_job(prompt="keep me", schedule="every 1h", name="survivor")
payload = json.loads(JOBS_FILE.read_text(encoding="utf-8"))
payload["jobs"][0]["next_run_at"] = (_hermes_now() - timedelta(seconds=5)).isoformat()
payload["jobs"] += [None, "i am not a job", 42]
JOBS_FILE.write_text(json.dumps(payload), encoding="utf-8")
assert [j["id"] for j in get_due_jobs()] == [job["id"]]
assert [j["id"] for j in list_jobs(include_disabled=True)] == [job["id"]]
on_disk = json.loads(JOBS_FILE.read_text(encoding="utf-8"))
assert [j["id"] for j in on_disk["jobs"]] == [job["id"]]