diff --git a/cron/scheduler.py b/cron/scheduler.py index fafcbfab95..2cb1547ad3 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -123,9 +123,19 @@ _LOCK_FILE = _LOCK_DIR / ".tick.lock" def _resolve_origin(job: dict) -> Optional[dict]: - """Extract origin info from a job, preserving any extra routing metadata.""" + """Extract origin info from a job, preserving any extra routing metadata. + + Treats non-dict origins (free-form provenance strings, ints, lists from + migration scripts or hand-edited jobs.json) as missing instead of + crashing with ``AttributeError`` on ``origin.get(...)``. Without this + guard, a job tagged with e.g. ``"combined-digest-replaces-x-and-y"`` + crashed every fire attempt with + ``'str' object has no attribute 'get'`` — ``mark_job_run`` recorded the + failure, but the next tick re-loaded the same poisoned origin and + crashed identically until the field was patched manually (#18722). + """ origin = job.get("origin") - if not origin: + if not isinstance(origin, dict): return None platform = origin.get("platform") chat_id = origin.get("chat_id") diff --git a/tests/cron/test_scheduler.py b/tests/cron/test_scheduler.py index 8c204d9a51..b12bb578a3 100644 --- a/tests/cron/test_scheduler.py +++ b/tests/cron/test_scheduler.py @@ -46,6 +46,29 @@ class TestResolveOrigin: job = {"origin": {}} assert _resolve_origin(job) is None + @pytest.mark.parametrize( + "non_dict_origin", + [ + "combined-digest-replaces-x-and-y-20260503", + 123, + ["telegram", "12345"], + ("platform", "chat_id"), + 42.0, + ], + ) + def test_non_dict_origin_returns_none_instead_of_crashing(self, non_dict_origin): + """Non-dict origins (provenance strings from hand-edited or migrated + jobs.json) must be treated as missing instead of crashing the + scheduler tick on ``origin.get('platform')`` with + ``'str' object has no attribute 'get'`` (#18722). + + Before this guard a job in this state crashed every fire attempt + forever; ``mark_job_run`` recorded the error but the next tick + re-loaded the poisoned origin and crashed identically. + """ + job = {"origin": non_dict_origin} + assert _resolve_origin(job) is None + class TestResolveDeliveryTarget: def test_origin_delivery_preserves_thread_id(self):