Skip to content

fix(cron): apply timezone from schedule.TZ for cron expressions#1046

Closed
AaronJny wants to merge 6 commits intosipeed:mainfrom
AaronJny:fix/cron-timezone
Closed

fix(cron): apply timezone from schedule.TZ for cron expressions#1046
AaronJny wants to merge 6 commits intosipeed:mainfrom
AaronJny:fix/cron-timezone

Conversation

@AaronJny
Copy link
Copy Markdown
Contributor

@AaronJny AaronJny commented Mar 4, 2026

📝 Description

Fix cron expressions always being evaluated in UTC, ignoring the schedule.TZ
field. Add a configurable default_timezone in tools.cron config.

Timezone resolution uses a 3-tier fallback:

  1. Job-specific: schedule.TZ from individual cron job
  2. Server-wide: tools.cron.default_timezone from config
  3. Hardcoded: UTC as final fallback

Invalid timezone values are logged with a warning and fall back to UTC.

🗣️ Type of Change

  • 🐞 Bug fix (non-breaking change which fixes an issue)

🤖 AI Code Generation

  • 🛠️ Mostly AI-generated (AI draft, Human verified/modified)

🔗 Related Issue

Fixes #1044

📚 Technical Context (Skip for Docs)

  • Root cause: computeNextRun used time.UnixMilli() which returns UTC.
    Cron expressions like 0 9 * * * were evaluated at UTC 9:00 instead of
    local 9:00.
  • Fix: Convert reference time to the target timezone before passing to
    gronx.NextTickAfter.

🧪 Test Environment

  • Hardware: Linux server (runtime), MacBook Apple Silicon (development)
  • OS: Ubuntu Linux (runtime), macOS (development & testing)
  • Model/Provider: GPT-5.2, GPT-5.3-codex
  • Channels: Discord

☑️ Checklist

  • My code/docs follow the style of this project.
  • I have performed a self-review of my own changes.
  • I have updated the documentation accordingly.

Copilot AI review requested due to automatic review settings March 4, 2026 01:53
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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() for Kind == "cron" using schedule.TZ with fallbacks.
  • Add tools.cron.default_timezone to 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.

Comment on lines +60 to +66
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())
}
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

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).

Copilot uses AI. Check for mistakes.
Comment on lines +270 to +280
// 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)
}
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines +275 to +277
if tz == "" {
tz = "Asia/Shanghai"
}
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Collaborator

@yinwm yinwm left a comment

Choose a reason for hiding this comment

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

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

  1. Accurate problem identification - Correctly identified that time.UnixMilli() returns UTC time
  2. 3-tier fallback design - schedule.TZconfig default"Asia/Shanghai" is logical
  3. Comprehensive tests - New test file covers timezone scenarios and fallback logic well
  4. Thread safety - SetDefaultTimezone() uses proper locking, and all computeNextRun callers 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! 🎉

@AaronJny
Copy link
Copy Markdown
Contributor Author

AaronJny commented Mar 4, 2026

Thank you for the thorough review! Both suggestions have been addressed in 3f4e461:

  1. Warning log for invalid timezone — Added log.Printf("[cron] warning: ...") in the else branch of time.LoadLocation.

  2. Default timezone changed to UTC — Replaced "Asia/Shanghai" with "UTC" as the hard-coded fallback. Updated all comments and tests accordingly.

All 6 timezone tests pass ✅

AaronJny added 3 commits March 5, 2026 16:08
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
Copilot AI review requested due to automatic review settings March 5, 2026 08:10
@AaronJny AaronJny force-pushed the fix/cron-timezone branch from 3f4e461 to c3281a5 Compare March 5, 2026 08:10
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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.

Comment on lines +270 to +277
// Apply timezone: schedule.TZ > service default > UTC
tz := schedule.TZ
if tz == "" {
tz = cs.defaultTZ
}
if tz == "" {
tz = "UTC"
}
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines +9 to +10
// TestComputeNextRun_CronTimezone verifies Patch #4: cron expressions should
// respect the schedule.TZ field instead of always using UTC.
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
// 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.

Copilot uses AI. Check for mistakes.
Comment on lines +76 to +85
// 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")

Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

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

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).

Copilot uses AI. Check for mistakes.
- Remove 'Patch sipeed#4' reference that is meaningless in-repo
- Correct DefaultTZ_AppliesWhenSet doc to match actual test behavior
@AaronJny AaronJny requested a review from yinwm March 5, 2026 08:27
@CLAassistant
Copy link
Copy Markdown

CLAassistant commented Mar 5, 2026

CLA assistant check
All committers have signed the CLA.

@AaronJny
Copy link
Copy Markdown
Contributor Author

AaronJny commented Mar 6, 2026

recheck

@yinwm
Copy link
Copy Markdown
Collaborator

yinwm commented Mar 6, 2026

Code Review Feedback

Thanks for the contribution! This PR fixes an important issue with cron timezone handling. Here are a few suggestions:

🔴 Issues to Fix

1. 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 cs.defaultTZ twice, so the if branch is never entered. Should be:

tz := schedule.TZ
if tz == "" {
    tz = cs.defaultTZ
}

2. Hardcoded Default Value Scattered

The string "UTC" is hardcoded in multiple places in service.go:

  • Lines 71-73: initialization
  • Lines 276-279: in computeNextRun

Suggestion: Define a constant at the top of the file:

const defaultTimezone = "UTC"

✅ What's Done Well

  1. Correct fix logic: converting reference time to target timezone before computing next tick
  2. Reasonable 3-tier fallback: schedule.TZ → config → UTC
  3. Proper error handling: logs warning for invalid timezone and falls back to UTC

Recommend fixing the test bug and unifying the constant before merging.

@yinwm
Copy link
Copy Markdown
Collaborator

yinwm commented Mar 6, 2026

Please fix Linter bug @AaronJny

Per reviewer suggestion, replace hardcoded "UTC" with a package-level
constant for maintainability.
Copilot AI review requested due to automatic review settings March 7, 2026 02:21
@AaronJny
Copy link
Copy Markdown
Contributor Author

AaronJny commented Mar 7, 2026

Thanks for the thorough review!

Regarding #1: I think there may be a small misread in the code snippet. The actual code (service.go L271-278) is:

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 schedule.TZ (not cs.defaultTZ), so the two == "" checks serve different purposes in the fallback chain:

  • 1st check: schedule.TZ is empty → fall back to cs.defaultTZ
  • 2nd check: cs.defaultTZ is also empty (e.g. no server-wide default configured) → fall back to "UTC"

Each tier's value can independently be empty, so each check is necessary. The TestComputeNextRun_ServiceDefaultTZ test validates this chain works correctly.

Regarding #2: Good catch! Extracted const defaultTimezone = "UTC" in d784651.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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.

Comment on lines +41 to +44
name: "US/Eastern timezone",
tz: "US/Eastern",
wantHour: 14, // 9:00 EST = 14:00 UTC (or 13:00 during DST)
},
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

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

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).

Copilot uses AI. Check for mistakes.
Comment on lines +115 to +117
// Set service-level default to US/Eastern
cs.SetDefaultTimezone("US/Eastern")

Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Copilot AI review requested due to automatic review settings March 7, 2026 02:29
@AaronJny AaronJny force-pushed the fix/cron-timezone branch from c5913ef to 879b765 Compare March 7, 2026 02:29
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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"
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
"time"
"time"
_ "time/tzdata"

Copilot uses AI. Check for mistakes.
Comment on lines +62 to +66
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())
}
Copy link

Copilot AI Mar 7, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
@sipeed-bot
Copy link
Copy Markdown

sipeed-bot bot commented Mar 25, 2026

@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.

@sipeed-bot sipeed-bot bot closed this Mar 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] Cron expressions ignore schedule.TZ field, always evaluate in UTC

4 participants