Moving a cron job across midnight: fix the weekday gate too
Date: 2026-09-20
Lane: hermes
Source: observed on Zappo while moving kids-bedtime-lightsout from 20:00 to 00:00
The trap
A job at 0 20 * * 0-4 ("Sun-Thu evenings") does not become 0 0 * * 0-4 when you move it
to midnight. Cron weekdays name the day the job RUNS, not the evening it belongs to:
0 0 * * 0-4fires 00:00 Sunday, which is Saturday night -> wrong, fires on a non-school night.- It also never fires at 00:00 Friday, so Thursday night is skipped.
The correct expression for Sun-Thu nights at midnight is 0 0 * * 1-5: 00:00 Monday through
Friday, which is the evening of Sunday through Thursday.
The second half: the script's own weekday check
The script also gated itself with is_school_night() reading datetime.now().weekday().
That is correct at 20:00 and wrong at 00:00, because the weekday at 00:00 is the morning
after. Fixing only the cron expression leaves the script rejecting Thursday night and
accepting Friday night.
Fix: shift the anchor back across the midnight boundary before reading the weekday.
def is_school_night(now=None):
now = now or datetime.now()
anchor = now - timedelta(hours=5) # decide from the preceding evening
return anchor.weekday() in (0, 1, 2, 3, 6) # Mon-Thu + Sun evenings
Pass now in and the function becomes testable without freezing the clock process-wide.
Rules
- Moving a scheduled job across midnight changes which day it fires on. Re-derive the cron weekday set from the EVENING it belongs to, and re-check any in-script day/date gate.
- Fix both halves or neither: the cron expression and the script's own gate must agree.
- Verify by falsification. Write the weekday assertions, run them against the OLD function, and confirm they FAIL before trusting them. Here: 4 failures on old code, 16/16 pass on new.
- A dry run at the wrong hour proves nothing about the real fire time. The dry run at 02:26 correctly printed "Not a school night (Fri/Sat) - skipping", which shows the gate works but says nothing about Monday 00:00. Freeze the clock and assert the HA calls instead.
Verify the action set, not just the gate
For the lights-out phase, assert the calls made: 7 light.turn_off, ZERO light.turn_on
(turn_on would be the wind-down dimming behaviour leaking into lights-out), and one telegram
notice. A passing school-night gate with the wrong action set is still a broken job.
Result
kids-bedtime-lightsout f700b9defbeb: 0 20 * * 0-4 -> 0 0 * * 1-5, resumed, next run
2026-09-21 00:00 AEST. kids-bedtime-winddown 2f46dc57b611 left paused at 30 19 * * 0-4
(its 19:30 time still needs the unshifted weekday, which the 5-hour anchor preserves).
Script fix committed in the OpenClaw workspace as 1805e98.