Skip to content

web: cron triggers match server-local time, not UTC; v0.9.51 - #197

Merged
cymoo merged 2 commits into
mainfrom
fix/cron-local-time
Aug 11, 2026
Merged

web: cron triggers match server-local time, not UTC; v0.9.51#197
cymoo merged 2 commits into
mainfrom
fix/cron-local-time

Conversation

@cymoo

@cymoo cymoo commented Aug 11, 2026

Copy link
Copy Markdown
Owner

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_next passed 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 the at trigger's documented "a bare time is read in the server's timezone". So the model had to write a UTC-compensated 0 14 * * * to hit 22:00 Beijing, and the modal then humanized that expression at face value: 14:00.

croniter('0 22 * * *', time.time()).get_next(float)   # → 06:00 +08:00  (UTC match)
croniter('0 22 * * *', datetime.now().astimezone())…  # → 22:00 +08:00  (local match)

Fix

  • scheduler.py: _croniter_next converts 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's trigger_expr description states cron is matched against the server's LOCAL wall clock and tells the model not to convert to UTC.
  • Docs (en/zh): cron trigger row notes local-time matching.
  • Regression test 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.
  • v0.9.51 in pyproject / __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/at triggers are unaffected (pure epoch math).

Verification

  • Full suite: 2310 passed, 42 skipped; ruff, mypy, check_docs.py all clean.
  • initial_next_fire('cron', '0 22 * * *', now=…)2026-08-12T22:00:00+08:00

🤖 Generated with Claude Code

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>
Copilot AI lite review requested due to automatic review settings August 11, 2026 16:45

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_next to pass an aware local datetime into croniter, eliminating unintended UTC-based matching for cron fields.
  • Clarify schedule_run’s trigger_expr guidance 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.

Comment on lines +145 to +159
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)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@cymoo
cymoo merged commit 303cd1c into main Aug 11, 2026
9 checks passed
@cymoo
cymoo deleted the fix/cron-local-time branch August 11, 2026 16:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants