feat(cron): alert when ticker supervisor restarts a stalled ticker - #72
Merged
Merged
Conversation
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. 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 |
|---|---|
unresolved-import |
1 |
First entries
tests/gateway/test_cron_supervisor_alert.py:12: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
✅ Fixed issues: none
Unchanged: 4582 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
… 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.
Motivation
A cron ticker stall once went unnoticed for 17 hours — the gateway stayed up while scheduled jobs silently stopped firing. PR #71 fixed the recovery half of that failure mode by adding a watchdog (
_cron_ticker_supervisor) that detects a stale heartbeat and restarts the ticker thread. But the restart itself is silent: nothing announces that a stall happened and self-healed. The original incident was invisible precisely because nothing surfaced it — so a self-heal we never hear about leaves the same observability gap.This PR closes that gap: when the supervisor restarts a stalled ticker, it now sends a home-channel alert.
What changed
gateway/run.py_cron_ticker_supervisor(...)(~L17925) now acceptsadaptersandloop(mirroring what_spawn_cron_tickeralready receives). On a stale heartbeat it restarts the ticker first (recovery is priority), then sends the alert._send_cron_supervisor_alert(adapters, loop, stall_age_s, threshold_s)(~L17869) resolves home-channel targets viacron.scheduler._iter_home_target_platforms()/_get_home_target_chat_id()/_get_home_target_thread_id()and delivers via the live adapter — reusing the existing cron home-channel delivery path._cron_supervisor_alerts_enabled()(~L17869) reads the config knob.runner.adaptersand_cron_loop.Config knob + cooldown
cron.supervisor_alerts(bool, defaultTrue) inconfig.yaml, read viahermes_cli.config.load_config()— the same pattern ascron.script_timeout_seconds/cron.wrap_response. WhenFalse, the ticker still restarts and logs, but no alert is sent. Config-getter chosen over an env var because thecron.*config pattern is already established and trivial to follow; it fails open (a config read error still alerts).alert_cooldown=900s, tracked withtime.monotonic()): a flapping ticker still restarts + logs every time, but only alerts once per cooldown window to prevent alert storms.Thread/loop safety
Both the ticker and supervisor run in worker threads, so the alert send is scheduled onto the gateway event loop with
safe_schedule_threadsafe(adapter.send(...), loop)then bounded byfuture.result(timeout=30)(guardsfuture is None,future.cancel()onTimeoutError) — the exact pattern_deliver_resultuses incron/scheduler.py. The entire helper is wrapped in try/except: a missing loop, unresolved targets, or a hung send is logged and swallowed, never propagated, so the alert can never delay or block ticker recovery.Test plan
New
tests/gateway/test_cron_supervisor_alert.py(9 tests; supervisor-loop cases mock the alert helper, send-helper cases mock adapters/loop/scheduling — no real event loop or network):cron.supervisor_alertsdisabled skips the alert but still restarts.6-9. Send-helper direct tests: quiet no-op without loop / without adapters; resolves + sends to home channel; timeout cancels the future without raising.
Results:
tests/gateway/test_cron_supervisor_alert.py9 passed;tests/cron/test_scheduler.py132 passed (existing supervisor tests still green under the new signature).🤖 Generated with Claude Code
https://claude.ai/code/session_01PQKCc5mDedYAiCNyXnTezh
Generated by Claude Code