web: cron triggers match server-local time, not UTC; v0.9.51 - #197
Conversation
croniter given a bare epoch start matches cron fields against UTC wall
time, so "0 22 * * *" fired at 22:00 UTC while the UI's humanized
trigger ("daily at 22:00"), crontab convention, and the 'at' trigger's
documented local semantics all promise the server's local clock. Models
compensated by writing UTC-shifted expressions, which the schedule
modal then displayed at the wrong hour.
Pass an aware local datetime to croniter so cron fields mean local wall
time; say so in the schedule_run tool description (so models stop
converting to UTC) and the docs; add a TZ-independent regression test.
Existing cron schedules written against the old UTC semantics fire at
their expression's local hour after this — edit their expressions once.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adjusts the web scheduler’s cron trigger semantics so cron fields are interpreted against the server’s local wall clock (crontab convention), aligning the scheduler’s behavior with the web UI’s cron humanization and the documented scheduling contract.
Changes:
- Fix
_croniter_nextto pass an aware localdatetimeintocroniter, eliminating unintended UTC-based matching for cron fields. - Clarify
schedule_run’strigger_exprguidance and update docs (EN/ZH) to state cron matching is based on server-local time. - Add a regression test covering the local-wall-clock contract; bump version to
v0.9.51.
Reviewed changes
Copilot reviewed 7 out of 8 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
lovia/web/scheduler.py |
Passes a timezone-aware local datetime to croniter so cron fields match server-local wall time. |
lovia/web/scheduling.py |
Updates tool guidance to explicitly state cron is matched against server-local wall clock. |
tests/web/test_scheduler.py |
Adds a regression test asserting cron hour/minute fields match local time. |
docs/en/web-server.md |
Documents that cron expressions are matched against server local time. |
docs/zh/web-server.md |
Documents that cron expressions are matched against server local time (中文). |
pyproject.toml |
Bumps project version to 0.9.51. |
lovia/__init__.py |
Bumps __version__ to 0.9.51. |
uv.lock |
Updates locked package version to 0.9.51. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| def test_cron_fields_mean_local_wall_time() -> None: | ||
| # A cron's hour/minute fields are the server's local wall clock — the | ||
| # contract the UI's humanized description ("daily at 06:30") relies on. | ||
| # With croniter's bare-epoch start they silently meant UTC, shifting | ||
| # every fire by the server's UTC offset. | ||
| pytest.importorskip("croniter") | ||
| from datetime import datetime | ||
|
|
||
| now = time.time() | ||
| for fn in (initial_next_fire, advance_next_fire): | ||
| nxt = fn("cron", "30 6 * * *", now=now) | ||
| assert nxt is not None and nxt > now | ||
| local = datetime.fromtimestamp(nxt).astimezone() | ||
| assert (local.hour, local.minute) == (6, 30) | ||
|
|
There was a problem hiding this comment.
Good catch — the test only guarded non-UTC environments, and CI is UTC. Fixed in 9412074: the test now pins TZ=Asia/Shanghai (no DST, deterministic) via tzset for its duration and restores the previous zone after. Verified it fails against the old bare-epoch implementation and passes against the fix. (Also skips on platforms without time.tzset.)
In a UTC environment (typical CI) local and UTC wall time coincide, so the old bare-epoch bug would have produced 06:30 "local" too and the test guarded nothing. Pin Asia/Shanghai (no DST, deterministic) via TZ + tzset for the duration; verified the test now fails against the old implementation and passes against the fix. Addresses Copilot review on #197. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bug
A schedule described as "每晚 22:00(北京时间)" showed 触发时间:每天 14:00 in the detail modal while actually firing at 22:00 Beijing time (下次运行 / run history both 22:00).
Root cause:
_croniter_nextpassed a bare epoch float to croniter, which then matches cron fields against UTC wall time. Everything else promises server-local semantics — the UI's hand-rolled cron humanizer (每天 {t}), crontab convention, and theattrigger's documented "a bare time is read in the server's timezone". So the model had to write a UTC-compensated0 14 * * *to hit 22:00 Beijing, and the modal then humanized that expression at face value: 14:00.Fix
scheduler.py:_croniter_nextconverts the epoch to an aware local datetime before handing it to croniter — cron fields now mean the server's local wall clock.scheduling.py:schedule_run'strigger_exprdescription states cron is matched against the server's LOCAL wall clock and tells the model not to convert to UTC.test_cron_fields_mean_local_wall_time— TZ-independent (asserts the fired slot's local hour/minute equals the cron fields); fails on the old code in any non-UTC timezone.__init__/ uv.lock.Behavior change
Existing cron schedules whose expressions were written to compensate for the UTC matching will shift by the server's UTC offset — e.g. the task above (
0 14 * * *) would now fire at 14:00 local. Edit such expressions once to the intended local hour (0 22 * * *). No migration is attempted: intent isn't recoverable from the stored expression.every/attriggers are unaffected (pure epoch math).Verification
check_docs.pyall clean.initial_next_fire('cron', '0 22 * * *', now=…)→2026-08-12T22:00:00+08:00✓🤖 Generated with Claude Code