fix(cron): prevent silent ticker stall (17h no-heartbeat incident) - #71
Merged
Merged
Conversation
…very timeout The cron ticker could silently stall (observed ~17h with no jobs firing while the gateway stayed up). Root cause: an unbounded await in the tick path (platform delivery send) could wedge the ticker with no timeout and no liveness signal, and an exception escaping the tick body would kill the loop with nothing to restart it. Fix: - run_cron_tick_once records a liveness heartbeat at the top of every tick before any job work, and isolates per-tick exceptions (logged, loop continues) - platform delivery send wrapped in asyncio.wait_for (_DELIVERY_TIMEOUT=60s) - _cron_ticker_supervisor watchdog restarts the ticker when the heartbeat goes stale (>5x tick interval) - tests cover exception isolation, heartbeat-before-tick, stale detection, and supervisor restart/no-restart behavior Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PQKCc5mDedYAiCNyXnTezh
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
🔎 Lint report:
|
| Rule | Count |
|---|---|
unsupported-operator |
2 |
invalid-assignment |
2 |
First entries
cron/scheduler.py:79: [unsupported-operator] unsupported-operator: Operator `+=` is not supported between objects of type `None` and `Literal[1]`
cron/scheduler.py:77: [invalid-assignment] invalid-assignment: Invalid subscript assignment with key of type `Literal["monotonic"]` and value of type `int | float` on object of type `dict[str, None | int]`
tests/cron/test_scheduler.py:2611: [unsupported-operator] unsupported-operator: Operator `<` is not supported between objects of type `int | float | None` and `float`
cron/scheduler.py:78: [invalid-assignment] invalid-assignment: Invalid subscript assignment with key of type `Literal["wall"]` and value of type `str` on object of type `dict[str, None | int]`
✅ Fixed issues (2):
| Rule | Count |
|---|---|
unresolved-attribute |
1 |
not-subscriptable |
1 |
First entries
cron/scheduler.py:753: [unresolved-attribute] unresolved-attribute: Attribute `get` is not defined on `_T@run & ~AlwaysFalsy` in union `(Unknown & ~AlwaysFalsy) | (_T@run & ~AlwaysFalsy)`
cron/scheduler.py:754: [not-subscriptable] not-subscriptable: Cannot subscript object of type `_T@run` with no `__getitem__` method
Unchanged: 4578 pre-existing issues carried over.
Diagnostics are surfaced as warnings — this check never fails the build.
github-actions Bot
pushed a commit
that referenced
this pull request
Jul 2, 2026
PR #71 made the cron ticker self-heal after a stall, but the restart was silent — the original 17h stall went unnoticed precisely because nothing announced it. This surfaces the event: when the supervisor detects a stale heartbeat and restarts the ticker, it now sends a home-channel alert (reusing the existing cron home-channel delivery path), gated behind cron.supervisor_alerts (default on) with a 15-min cooldown to prevent alert storms on a flapping ticker. The alert send is fully exception- isolated so it can never delay or block ticker recovery. Claude-Session: https://claude.ai/code/session_01PQKCc5mDedYAiCNyXnTezh Co-authored-by: Claude <noreply@anthropic.com>
github-actions Bot
pushed a commit
that referenced
this pull request
Jul 2, 2026
… mark arc complete (#76) Session close-out audit found the Outcome section only mentioned PR #67. PR #69 explicitly self-describes as "follow-up to #67" (unsigned-commit git fallback) and PR #68 (AGENTS.md docs) is also a direct follow-up; neither was recorded. Also clarifies that #71/#72 (cron ticker heartbeat/stall fix) are an unrelated arc shipped the same day, not part of this project. Claude-Session: https://claude.ai/code/session_01PQKCc5mDedYAiCNyXnTezh Co-authored-by: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Incident
The cron ticker could silently stall: in one observed incident it ran ~17 hours with no jobs firing while the gateway process stayed up and healthy. No error was surfaced — cron simply stopped ticking.
Root cause
The gateway drives cron from a single background thread (
gateway/run.py_start_cron_ticker). Two problems combined:cron/scheduler.py_deliver_result) awaited_send_to_platform(...)with no timeout. A platform HTTP send that never returns wedges the entire ticker thread — cron stops firing.logger.debug(gateway/run.pyold_start_cron_ticker:except Exception as e: logger.debug("Cron tick error: %s", e)), and there was nothing watching whether the loop was still alive. A wedged or dead ticker was never detected or restarted.Fix
cron/scheduler.py::run_cron_tick_oncerecords a liveness heartbeat viarecord_ticker_heartbeat()at the top of every tick, before any job work, then callstick()inside a try/except that logs escaped exceptions at ERROR (with traceback) and continues — a single bad tick can no longer kill the loop or make it look dead. The gateway loop now callsrun_cron_tick_onceinstead ofcron_tickdirectly.cron/scheduler.py::_deliver_resultwraps the standalone send inasyncio.wait_for(..., timeout=_DELIVERY_TIMEOUT)(_DELIVERY_TIMEOUT = 60s), and the thread-pool fallback usesfuture.result(timeout=_DELIVERY_TIMEOUT + 5). A delivery that never returns now fails as an error instead of hanging forever.gateway/run.py::_cron_ticker_supervisorwatchdog runs in its own daemon thread, checksticker_heartbeat_is_stale(interval, stale_multiplier=5.0), and restarts the ticker via a mutable holder +_spawn_cron_ticker()when the heartbeat is older than 5x the tick interval. It does not restart while the heartbeat is healthy, and does not thrash before the first tick (stale check returns False when no heartbeat has been recorded yet). The tick() file lock prevents old/new ticker threads from overlapping.The heartbeat uses a monotonic clock for age math (immune to wall-clock jumps) and mirrors a human-readable timestamp to a
.ticker_heartbeatfile for operator diagnostics (best-effort; never raises into the loop).Test plan
New
TestTickerHeartbeatAndSupervisorintests/cron/test_scheduler.py(5 tests):test_tick_exception_does_not_kill_loop— an exception intick()is caught/logged andrun_cron_tick_oncereturns without propagating.test_heartbeat_recorded_before_tick_even_when_job_hangs— heartbeat is recorded before/independent of job work (loop liveness, not job success).test_heartbeat_stale_detection—ticker_heartbeat_is_stalereturns False when healthy / never recorded, True once past the threshold.test_supervisor_restarts_stale_ticker— supervisor callsrestart_tickerwhen the heartbeat is stale.test_supervisor_does_not_restart_when_healthy— supervisor does not restart a healthy ticker.Full
tests/cron/test_scheduler.pysuite: 132 passed.🤖 Generated with Claude Code
Generated by Claude Code