From 695766e3f96f26331ddd9d853c45c6df716b594d Mon Sep 17 00:00:00 2001 From: John Paul Soliva Date: Sat, 26 Sep 2026 10:31:25 +0900 Subject: [PATCH] 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) --- cron/jobs.py | 9 +++++++++ tests/cron/test_jobs.py | 17 +++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/cron/jobs.py b/cron/jobs.py index e8ece8abe8..d359adb522 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -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) diff --git a/tests/cron/test_jobs.py b/tests/cron/test_jobs.py index 34818dae70..d301c48464 100644 --- a/tests/cron/test_jobs.py +++ b/tests/cron/test_jobs.py @@ -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"]] +