Schedule Linting - ptweezy/cronstable GitHub Wiki
cronstable lints every cron schedule at load time. After the parser accepts an expression, the linter flags likely mistakes and behavior to check, including schedules that can never run again.
Config loading, the HTTP API, and the terminal dashboard share the rules in cronstable/croninfo.py (lint_schedule).
Each finding has a stable code, a level, and a one-line message. A warning marks a probable mistake; a note explains behavior that may be intentional.
| Code | Level | What it means |
|---|---|---|
never-fires |
warning | The schedule has no future occurrence: a fixed year in the past, or a date that never exists (0 0 30 2 *). The job stays loaded but never runs. |
day-fields-both-restricted |
warning | Day-of-month and day-of-week are both restricted. In cronstable, a day must satisfy both (0 0 13 * 5 is Friday the 13th). Classic Vixie cron instead fires when either matches, so a schedule imported from a system crontab fires less often here than it did there. See Schedules and Timezones. |
uneven-step |
warning | A */n or H/n step where n does not divide the field's span. */7 in the minute field fires at :56 and then :00 four minutes later, because star steps restart at the wrap. |
uneven-step (day-of-month) |
note | Any */n day-of-month step: values restart at day 1 every month and month lengths differ, so */2 is not "every 48 hours". |
skipped-months |
warning | The smallest selected day of month never occurs in one of the selected months (0 0 31 1,4 * never fires in April), so that month is skipped entirely. |
leap-day-only |
note | February runs can only match day 29, which exists only in leap years. |
dst-skipped-time |
note | The scheduled wall time falls in a spring-forward gap of the job's time zone (with the actual date, such as 2027-03-14 for America/New_York). The run is not lost: it fires at the shifted wall time. |
dst-repeated-time |
note | The scheduled wall time occurs twice on a fall-back date. The run fires on the first occurrence only. |
hashed-slot |
note | The schedule uses the H hash form. The note names the exact expression it resolved to for this job, and states that renaming the job re-hashes the slot. |
The DST rules run in the job's frame: its explicit timezone:, or the host's local clock for a utc: false job without one, where the notes name the host's own transitions and so depend on the machine running the lint. A fixed-offset frame like UTC never transitions. The rules are also skipped for schedules with unrestricted hours, which fire straight through a transition with nothing to call out.
-
Config load. Every finding is logged when the job parses (
warningfindings atWARNING,notefindings atINFO), so the load or reload that introduces a risky schedule says so immediately:WARNING:cronstable.config:job 'parked': schedule '0 0 1 1 * 2020': [never-fires] no future runs: the last year in this schedule is 2020 -
The HTTP API.
GET /jobscarries each job's findings verbatim (schedule_findings, a list of{code, level, message}) plus a computednever_firesboolean.GET /statusmarks dead schedules withnever_fires: true(and saysno future runs (check the schedule)in the plain-text form).GET /schedule/previewlints arbitrary expressions before they become jobs. See HTTP API. -
The terminal dashboard. The schedule preview (
x) lints as you type, and a job's schedule drawer shows findings in the job's own time zone, so DST notes carry real dates. See the terminal dashboard.
A never-fires finding does not reject the configuration: existing configs may use a past year (schedule: "0 0 1 1 * 2020") to disable a job. Besides the load-time warning, the scheduler logs once per load or reload when it removes such a schedule from its fire index:
WARNING:cronstable:job 'parked': schedule '0 0 1 1 * 2020' has no future runs; update the schedule or disable the job (status: never_fires)
Status responses report never_fires until the schedule changes. To disable a job explicitly, use enabled: false.
GET /schedule/preview?expr=<expression>&tz=<zone>&count=<n> parses, describes, previews, and lints any expression with the daemon's own engine, the same engine the sandboxes use. See HTTP API for the full response shape. AI agents get the same payload through the cron_validate_schedule and cron_explain_schedule MCP tools.
The linter judges a schedule in the abstract. GET /schedule/why judges it against one concrete timestamp, decomposing the match test field by field ("day-of-week Tuesday is not in Monday and Friday") with notes for the AND day rule and DST effects at that instant. See Why Didn't It Run?.