fix(cron): apply timezone from schedule.TZ for cron expressions#1046
fix(cron): apply timezone from schedule.TZ for cron expressions#1046AaronJny wants to merge 6 commits intosipeed:mainfrom
Conversation
There was a problem hiding this comment.
Pull request overview
Fixes cron schedules being evaluated in UTC by applying a resolved timezone (per-schedule TZ, then server config default, then a hard fallback) before calling gronx.NextTickAfter().
Changes:
- Apply timezone conversion in
CronService.computeNextRun()forKind == "cron"usingschedule.TZwith fallbacks. - Add
tools.cron.default_timezoneto config and wire it into gateway cron tool setup. - Add unit tests covering timezone behavior and the 3-tier fallback.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
pkg/cron/service.go |
Applies timezone selection/conversion for cron next-run calculation; adds defaultTZ + setter. |
pkg/config/config.go |
Adds tools.cron.default_timezone config field (JSON + env). |
cmd/picoclaw/internal/gateway/helpers.go |
Sets cron service default timezone from loaded config. |
pkg/cron/timezone_test.go |
Adds tests asserting cron timezone evaluation and fallback behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| nextTime := time.UnixMilli(*nextMS).UTC() | ||
|
|
||
| if tt.name == "US/Eastern timezone" { | ||
| // Allow for DST variation (13 or 14) | ||
| if nextTime.Hour() != 13 && nextTime.Hour() != 14 { | ||
| t.Errorf("next run hour = %d, want 13 or 14 (UTC)", nextTime.Hour()) | ||
| } |
There was a problem hiding this comment.
In this test the reference date is 2026-03-04, when US/Eastern is not in DST, so the next run should be deterministically 14:00 UTC. Allowing 13 or 14 makes the assertion weaker and could let an off-by-one-hour bug slip through; consider asserting the exact expected hour (or computing it from the loaded location for the chosen date).
pkg/cron/service.go
Outdated
| // Apply timezone: schedule.TZ > service default > "Asia/Shanghai" | ||
| tz := schedule.TZ | ||
| if tz == "" { | ||
| tz = cs.defaultTZ | ||
| } | ||
| if tz == "" { | ||
| tz = "Asia/Shanghai" | ||
| } | ||
| if loc, err := time.LoadLocation(tz); err == nil { | ||
| now = now.In(loc) | ||
| } |
There was a problem hiding this comment.
The 3-tier fallback is not applied when time.LoadLocation(tz) fails (e.g., an invalid/LLM-generated schedule.TZ like "UTC+8" or extra whitespace). In that case now stays in UTC and the job silently schedules in UTC rather than falling back to cs.defaultTZ / "Asia/Shanghai". Consider trimming TZ strings and, on LoadLocation error, trying the next fallback(s) and emitting a warning log so misconfigurations don’t silently change scheduling semantics.
| if tz == "" { | ||
| tz = "Asia/Shanghai" | ||
| } |
There was a problem hiding this comment.
The default timezone literal "Asia/Shanghai" is duplicated in both the scheduling logic and method comment. Defining a single package-level constant (and reusing it in tests) would reduce the chance of docs/tests drifting from behavior if this fallback is ever changed.
yinwm
left a comment
There was a problem hiding this comment.
Code Review Summary
This PR correctly fixes the timezone issue in cron expressions. The 3-tier fallback design is sensible, and test coverage is solid.
✅ What's Good
- Accurate problem identification - Correctly identified that
time.UnixMilli()returns UTC time - 3-tier fallback design -
schedule.TZ→config default→"Asia/Shanghai"is logical - Comprehensive tests - New test file covers timezone scenarios and fallback logic well
- Thread safety -
SetDefaultTimezone()uses proper locking, and allcomputeNextRuncallers hold locks
⚠️ Suggestions
1. Silent fallback on invalid timezone (Medium Priority)
if loc, err := time.LoadLocation(tz); err == nil {
now = now.In(loc)
}When time.LoadLocation fails (e.g., invalid timezone like "Invalid/Zone"), the code silently continues with UTC. Consider adding a warning log:
if loc, err := time.LoadLocation(tz); err == nil {
now = now.In(loc)
} else {
log.Printf("[cron] warning: failed to load timezone '%s': %v, using UTC", tz, err)
}This helps users debug misconfiguration.
2. Default timezone choice for international projects
For open-source projects targeting a global audience, UTC is the industry standard default (used by Kubernetes CronJob, AWS EventBridge, GitHub Actions, etc.):
- No daylight saving time complications
- Globally neutral
- Consistent behavior regardless of server location
Consider changing the hard-coded default from "Asia/Shanghai" to "UTC", or at least document this design decision clearly.
📝 Verdict
Approve with minor suggestions. The core logic is correct and well-tested. The suggestions above are improvements, not blockers.
Nice work! 🎉
|
Thank you for the thorough review! Both suggestions have been addressed in
All 6 timezone tests pass ✅ |
CronSchedule has a TZ field but computeNextRun() ignores it, using time.UnixMilli() which returns UTC. This causes cron expressions like '0 9 * * *' (daily at 9am) to trigger at UTC 9:00 = CST 17:00. Now loads schedule.TZ (defaulting to Asia/Shanghai when empty) and converts the reference time to the target timezone before computing the next tick with gronx.
Refactor the hardcoded 'Asia/Shanghai' default timezone to be
configurable. Timezone resolution follows a 3-tier fallback:
1. schedule.TZ (per-job, set by LLM via cron_expr)
2. tools.cron.default_timezone in config.json (server-wide)
3. 'Asia/Shanghai' as ultimate fallback
Changes:
- config: add DefaultTimezone field to CronToolsConfig
- cron: add SetDefaultTimezone() method to CronService
- cron: update computeNextRun() to use 3-tier fallback
- gateway: wire config.Tools.Cron.DefaultTimezone to CronService
- test: add TestComputeNextRun_ServiceDefaultTZ to verify 3-tier
Usage in config.json:
{"tools": {"cron": {"default_timezone": "US/Eastern"}}}
Address reviewer feedback (yinwm): - Change hard-coded default from Asia/Shanghai to UTC, which is the industry standard for open-source projects (Kubernetes, AWS, etc.) - Add warning log when time.LoadLocation fails for a configured timezone to help users debug misconfiguration - Update tests to reflect UTC default
3f4e461 to
c3281a5
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Apply timezone: schedule.TZ > service default > UTC | ||
| tz := schedule.TZ | ||
| if tz == "" { | ||
| tz = cs.defaultTZ | ||
| } | ||
| if tz == "" { | ||
| tz = "UTC" | ||
| } |
There was a problem hiding this comment.
PR description/issue mentions a 3-tier fallback ending with "Asia/Shanghai" when both schedule.TZ and config default are empty, but the implementation falls back to "UTC" (tz == "" => "UTC"). Either update the code to match the described fallback (or time.Local), or update the PR description/tests/docs to reflect that the ultimate fallback is UTC.
pkg/cron/timezone_test.go
Outdated
| // TestComputeNextRun_CronTimezone verifies Patch #4: cron expressions should | ||
| // respect the schedule.TZ field instead of always using UTC. |
There was a problem hiding this comment.
Test comment refers to "Patch #4", which doesn’t seem meaningful in-repo and will likely confuse future maintainers. Consider rewording the comment to describe the behavior under test (cron TZ handling) without referencing an external patch number.
| // TestComputeNextRun_CronTimezone verifies Patch #4: cron expressions should | |
| // respect the schedule.TZ field instead of always using UTC. | |
| // TestComputeNextRun_CronTimezone verifies that cron expressions respect the | |
| // schedule.TZ field instead of always using UTC. |
pkg/cron/timezone_test.go
Outdated
| // TestComputeNextRun_DefaultTZ_AppliesWhenSet verifies that when TZ is empty | ||
| // and no service default is set, the computation uses UTC. | ||
| func TestComputeNextRun_DefaultTZ_AppliesWhenSet(t *testing.T) { | ||
| tmpDir := t.TempDir() | ||
| storePath := filepath.Join(tmpDir, "jobs.json") | ||
| cs := NewCronService(storePath, nil) | ||
|
|
||
| // Set a non-UTC default to verify it takes effect | ||
| cs.SetDefaultTimezone("Asia/Shanghai") | ||
|
|
There was a problem hiding this comment.
This test’s doc comment says "no service default is set" and "uses UTC", but the test immediately sets a non-UTC default via SetDefaultTimezone("Asia/Shanghai"). Please update the comment to match what the test actually verifies (service default TZ applies when schedule.TZ is empty, and explicit schedule.TZ overrides it).
- Remove 'Patch sipeed#4' reference that is meaningless in-repo - Correct DefaultTZ_AppliesWhenSet doc to match actual test behavior
|
recheck |
Code Review FeedbackThanks for the contribution! This PR fixes an important issue with cron timezone handling. Here are a few suggestions: 🔴 Issues to Fix1. Bug in Test Code (timezone_test.go:49-50)tz := cs.defaultTZ
if tz == "" {
tz = cs.defaultTZ // ← BUG! Should be schedule.TZ
}This condition checks tz := schedule.TZ
if tz == "" {
tz = cs.defaultTZ
}2. Hardcoded Default Value ScatteredThe string
Suggestion: Define a constant at the top of the file: const defaultTimezone = "UTC"✅ What's Done Well
Recommend fixing the test bug and unifying the constant before merging. |
|
Please fix Linter bug @AaronJny |
Per reviewer suggestion, replace hardcoded "UTC" with a package-level constant for maintainability.
|
Thanks for the thorough review! Regarding #1: I think there may be a small misread in the code snippet. The actual code ( tz := schedule.TZ // tier 1: job-specific TZ
if tz == "" {
tz = cs.defaultTZ // tier 2: server-wide default
}
if tz == "" {
tz = defaultTimezone // tier 3: hardcoded "UTC"
}The first line assigns
Each tier's value can independently be empty, so each check is necessary. The Regarding #2: Good catch! Extracted |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| name: "US/Eastern timezone", | ||
| tz: "US/Eastern", | ||
| wantHour: 14, // 9:00 EST = 14:00 UTC (or 13:00 during DST) | ||
| }, |
There was a problem hiding this comment.
The test uses the legacy alias timezone ID US/Eastern. That alias isn’t guaranteed to exist across all zoneinfo distributions, which can make the test fail on some environments even though the code is correct. Prefer a canonical IANA zone like America/New_York (and then you can assert the exact expected UTC hour for the chosen date).
| // Set service-level default to US/Eastern | ||
| cs.SetDefaultTimezone("US/Eastern") | ||
|
|
There was a problem hiding this comment.
cs.SetDefaultTimezone("US/Eastern") relies on a legacy timezone alias that may not be present in all tzdata bundles. Using a canonical IANA name like America/New_York makes the test (and the intended config value) more portable.
c5913ef to
879b765
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| import ( | ||
| "path/filepath" | ||
| "testing" | ||
| "time" |
There was a problem hiding this comment.
The tests rely on the host OS zoneinfo database being present (time.LoadLocation("Asia/Shanghai"/"US/Eastern")). In minimal containers or some developer environments this can be missing, causing these tests to fail even though the production logic is correct. Consider importing _ "time/tzdata" in this test file (or skipping the affected subtests when LoadLocation fails) to make the suite hermetic.
| "time" | |
| "time" | |
| _ "time/tzdata" |
| if tt.name == "US/Eastern timezone" { | ||
| // Allow for DST variation (13 or 14) | ||
| if nextTime.Hour() != 13 && nextTime.Hour() != 14 { | ||
| t.Errorf("next run hour = %d, want 13 or 14 (UTC)", nextTime.Hour()) | ||
| } |
There was a problem hiding this comment.
This subtest special-cases the US/Eastern case by comparing on tt.name, which is easy to break if the display name changes. Consider branching on tt.tz (or adding a boolean in the test cases) so the behavior under test is keyed off the timezone value itself.
879b765 to
6a5ee16
Compare
|
@AaronJny Hi! This PR has had no activity for over 2 weeks, so I'm closing it for now to keep things organized. Feel free to reopen anytime if you'd like to continue. |
📝 Description
Fix cron expressions always being evaluated in UTC, ignoring the
schedule.TZfield. Add a configurable
default_timezoneintools.cronconfig.Timezone resolution uses a 3-tier fallback:
schedule.TZfrom individual cron jobtools.cron.default_timezonefrom configUTCas final fallbackInvalid timezone values are logged with a warning and fall back to UTC.
🗣️ Type of Change
🤖 AI Code Generation
🔗 Related Issue
Fixes #1044
📚 Technical Context (Skip for Docs)
computeNextRunusedtime.UnixMilli()which returns UTC.Cron expressions like
0 9 * * *were evaluated at UTC 9:00 instead oflocal 9:00.
gronx.NextTickAfter.🧪 Test Environment
☑️ Checklist