fix(cron): isolate per-job schedule errors so one bad job can't stall… - #51267
Closed
Dharshan2004 wants to merge 1 commit into
Closed
fix(cron): isolate per-job schedule errors so one bad job can't stall…#51267Dharshan2004 wants to merge 1 commit into
Dharshan2004 wants to merge 1 commit into
Conversation
… the scheduler (NousResearch#51021) A single job with corrupt schedule metadata (bad cron expr, unparseable next_run_at, or a field missing from a hand-edited jobs.json) raised out of get_due_jobs() and aborted the entire due-scan on every tick. The ticker kept heartbeating, so `hermes cron status` still reported healthy, but NO job ever fired — the "ticker alive, nothing fires" shape of NousResearch#51021. - _get_due_jobs_locked now evaluates each job inside a single try/except (extracted as _evaluate_due_job), covering every throw site — including the stale-recurring and TZ-migration compute_next_run() calls. A bad job is quarantined (state=error) and the scan continues, so other due jobs fire. - Malformed-but-recoverable next_run_at self-heals: a recurring job recomputes its next fire from the schedule instead of being quarantined (extends NousResearch#50377, which guards only this one parse; this also covers the case where recovery itself raises on a corrupt schedule). - _record_due_scan_error is idempotent: it logs (with exc_info) + rewrites jobs.json only on the transition into error, so a permanently-corrupt record no longer re-logs or rewrites storage every 60s tick. The job stays enabled and is re-evaluated each scan, so repairing the schedule lets it fire again (mark_job_run resets state to scheduled on the next success). - `cron status` now warns when the ticker is heartbeating but has never recorded a successful tick, instead of falsely reporting healthy. Tests: malformed one-shot/recurring jobs no longer block other due jobs; a recoverable next_run_at self-heals while corrupt-schedule cases quarantine; quarantine is idempotent; status reports the missing-success case.
1 task
Collaborator
Related/competing: extends the open PR #50377, which guards only the single |
This was referenced Jul 9, 2026
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.
What does this PR do?
get_due_jobs()scans every cron job on each tick. A single job with corruptschedule metadata — a bad cron expression, an unparseable
next_run_at, or afield missing from a hand-edited
jobs.json— raised an exception that escapedthe scan (the tick's only
try/finallyjust releases the file lock) andaborted it for every job. The ticker keeps writing its liveness heartbeat
regardless, so
hermes cron statusreports healthy while no job ever fires —the "ticker alive, nothing fires" symptom in #51021. The poisoned record reloads
every tick, so it permanently wedges all cron jobs until the file is repaired.
Each job is now evaluated inside a single
try/except, so any malformed fielddegrades that one job instead of the whole tick. A recoverable timestamp
self-heals; an unrecoverable one is quarantined and surfaced.
Related Issue
Fixes #51021
Type of Change
Changes Made
cron/jobs.py: extracted per-job evaluation into_evaluate_due_job()andwrapped the entire per-job body in one
try/exceptin_get_due_jobs_locked(), covering every throw site — including thecompute_next_run()calls in the stale-recurring and TZ-migration paths. Afailing job is quarantined (
state="error",last_errorrecorded) and thescan continues so other due jobs still fire.
cron/jobs.py: a malformed-but-recoverablenext_run_atself-heals — arecurring job recomputes its next fire from the schedule instead of being
quarantined (preserves fix(cron): isolate a malformed next_run_at so one job can't wedge the tick #50377); it only quarantines when recovery is
impossible or the schedule itself is corrupt.
cron/jobs.py:_record_due_scan_error()is idempotent — it logs (withexc_info) and rewritesjobs.jsononly on the transition into the errorstate, so a permanently-corrupt record no longer re-logs or rewrites storage
on every 60s tick. The job stays
enabledand self-heals once repaired(
mark_job_runresetsstatetoscheduledon the next success).hermes_cli/cron.py:cron statusnow warns when the ticker is heartbeatingbut has never recorded a successful tick, instead of falsely reporting healthy.
tests/cron/test_jobs.py,tests/cron/test_scheduler_provider.py: regressiontests for the above.
How to Test
scripts/run_tests.sh tests/cron/— 526 pass, including the new regressiontests:
test_malformed_recurring_schedule_does_not_block_other_due_jobstest_malformed_active_job_does_not_block_other_due_jobstest_malformed_next_run_at_recurring_job_self_healstest_malformed_next_run_at_with_corrupt_schedule_is_quarantinedtest_corrupt_job_quarantine_is_idempotenttest_cron_status_reports_missing_success_markerquarantined.
Checklist
Code
fix(cron):)extends fix(cron): isolate a malformed next_run_at so one job can't wedge the tick #50377 (see note above; also related fix(cron): extend #16265 never-silently-disable defense to get_due_jobs() #18825, fix(cron): reduce polling interval and recover advance_next_run crash window #51061)
scripts/run_tests.sh). The full suite's only failures arepre-existing/environmental (systemd/WSL/macOS
/tmp-symlink), verifiedidentical on
upstream/main; none are in cron.Documentation & Housekeeping
cli-config.yaml.example— N/A (no config keys added/changed)CONTRIBUTING.md/AGENTS.md— N/A (no architecture/workflow change)due-scan, no OS-specific code, file I/O, or process handling added
Screenshots / Logs
Same store in both runs: one corrupt stale recurring job (
{"kind":"cron","expr":"not a cron expr"}) and one valid due one-shot.Before —
upstream/main(no fix): one bad record aborts the whole scan, so the healthy job never fires (#51021).After — this PR: the bad job is quarantined and the healthy job fires.
(
hermes cron listthen showscorrupt-cron [active] … error: Invalid cron schedule metadata: …whilevalid-duestays intact.)