diff --git a/cmd/claw/compose_up.go b/cmd/claw/compose_up.go index b90109c..8523f2e 100644 --- a/cmd/claw/compose_up.go +++ b/cmd/claw/compose_up.go @@ -410,6 +410,12 @@ func runComposeUp(podFile string) error { if err != nil { return err } + for _, rc := range resolvedClaws { + if rc == nil { + continue + } + rc.Timezone = resolveAgentTimezone(rc.Environment, runtimeEnv) + } proxies := make([]pod.CllamaProxyConfig, 0) cllamaDashboardPort := envOrDefault("CLLAMA_UI_PORT", "8181") if cllamaEnabled { @@ -511,7 +517,6 @@ func runComposeUp(podFile string) error { return fmt.Errorf("service %q: read AGENTS.md for cllama context: %w", name, err) } - agentTimezone := resolveAgentTimezone(rc.Environment, runtimeEnv) if rc.Count > 1 { for i := 0; i < rc.Count; i++ { ordinalName := fmt.Sprintf("%s-%d", name, i) @@ -548,7 +553,7 @@ func runComposeUp(podFile string) error { "pod": p.Name, "type": rc.ClawType, "token": tokens[ordinalName], - "timezone": agentTimezone, + "timezone": rc.Timezone, }, rc.Models), }) } @@ -585,7 +590,7 @@ func runComposeUp(podFile string) error { "pod": p.Name, "type": rc.ClawType, "token": tokens[name], - "timezone": agentTimezone, + "timezone": rc.Timezone, }, rc.Models), }) } diff --git a/cmd/claw/compose_up_test.go b/cmd/claw/compose_up_test.go index 303182c..0f2957c 100644 --- a/cmd/claw/compose_up_test.go +++ b/cmd/claw/compose_up_test.go @@ -554,6 +554,20 @@ func TestReadDotEnvFileParsesQuotedValuesAndComments(t *testing.T) { } } +func TestResolveAgentTimezoneUsesResolvedTZ(t *testing.T) { + got := resolveAgentTimezone(map[string]string{"TZ": "${BOT_TZ}"}, map[string]string{"BOT_TZ": "America/New_York"}) + if got != "America/New_York" { + t.Fatalf("expected America/New_York, got %q", got) + } +} + +func TestResolveAgentTimezoneFallsBackToUTCOnInvalidTZ(t *testing.T) { + got := resolveAgentTimezone(map[string]string{"TZ": "Mars/Olympus"}, map[string]string{}) + if got != "UTC" { + t.Fatalf("expected UTC fallback, got %q", got) + } +} + func TestValidateCllamaEnvFilesRejectsProviderKeys(t *testing.T) { tmpDir := t.TempDir() envPath := filepath.Join(tmpDir, "bot.env") diff --git a/cmd/claw/schedule_manifest.go b/cmd/claw/schedule_manifest.go index b11ec25..e96817d 100644 --- a/cmd/claw/schedule_manifest.go +++ b/cmd/claw/schedule_manifest.go @@ -50,6 +50,10 @@ func buildScheduleManifest(p *pod.Pod, resolved map[string]*driver.ResolvedClaw) if rc == nil { continue } + serviceTimezone := strings.TrimSpace(rc.Timezone) + if serviceTimezone == "" { + serviceTimezone = "UTC" + } for _, inv := range rc.Invocations { if inv.Origin != driver.OriginPod { continue @@ -61,7 +65,7 @@ func buildScheduleManifest(p *pod.Pod, resolved map[string]*driver.ResolvedClaw) continue } - timezone := "UTC" + timezone := serviceTimezone if inv.When != nil { cal, err := schedule.LookupCalendar(inv.When.Calendar) if err != nil { diff --git a/cmd/claw/schedule_manifest_test.go b/cmd/claw/schedule_manifest_test.go index 4a9b502..4c579c6 100644 --- a/cmd/claw/schedule_manifest_test.go +++ b/cmd/claw/schedule_manifest_test.go @@ -72,7 +72,34 @@ func TestBuildScheduleManifestIncludesOnlyPodOriginInvocations(t *testing.T) { } } -func TestBuildScheduleManifestUsesUTCWithoutCalendar(t *testing.T) { +func TestBuildScheduleManifestUsesServiceTimezoneWithoutCalendar(t *testing.T) { + manifest, err := buildScheduleManifest(&pod.Pod{Name: "ops"}, map[string]*driver.ResolvedClaw{ + "bot": { + ServiceName: "bot", + ClawType: "nullclaw", + Timezone: "America/New_York", + Invocations: []driver.Invocation{ + { + ID: "podjob01", + Schedule: "*/10 * * * *", + Message: "Heartbeat", + Origin: driver.OriginPod, + }, + }, + }, + }) + if err != nil { + t.Fatalf("buildScheduleManifest returned error: %v", err) + } + if len(manifest.Invocations) != 1 { + t.Fatalf("expected one invocation, got %d", len(manifest.Invocations)) + } + if manifest.Invocations[0].Timezone != "America/New_York" { + t.Fatalf("expected service timezone, got %q", manifest.Invocations[0].Timezone) + } +} + +func TestBuildScheduleManifestFallsBackToUTCWithoutServiceTimezone(t *testing.T) { manifest, err := buildScheduleManifest(&pod.Pod{Name: "ops"}, map[string]*driver.ResolvedClaw{ "bot": { ServiceName: "bot", @@ -94,7 +121,7 @@ func TestBuildScheduleManifestUsesUTCWithoutCalendar(t *testing.T) { t.Fatalf("expected one invocation, got %d", len(manifest.Invocations)) } if manifest.Invocations[0].Timezone != "UTC" { - t.Fatalf("expected UTC default timezone, got %q", manifest.Invocations[0].Timezone) + t.Fatalf("expected UTC fallback timezone, got %q", manifest.Invocations[0].Timezone) } } diff --git a/internal/driver/microclaw/driver.go b/internal/driver/microclaw/driver.go index d350ba6..2718d9d 100644 --- a/internal/driver/microclaw/driver.go +++ b/internal/driver/microclaw/driver.go @@ -262,7 +262,7 @@ func generateConfig(rc *driver.ResolvedClaw) (map[string]interface{}, error) { "skills_dir": "/claw-data/skills", "working_dir": "/claw-data/working_dir", "working_dir_isolation": "chat", - "timezone": "UTC", + "timezone": resolvedTimezone(rc.Timezone), "web_enabled": true, "web_host": "127.0.0.1", "web_port": 10961, @@ -351,6 +351,14 @@ func generateConfig(rc *driver.ResolvedClaw) (map[string]interface{}, error) { return cfg, nil } +func resolvedTimezone(raw string) string { + timezone := strings.TrimSpace(raw) + if timezone == "" { + return "UTC" + } + return timezone +} + func discordAllowedChannels(h *driver.HandleInfo) []uint64 { if h == nil { return nil diff --git a/internal/driver/microclaw/driver_test.go b/internal/driver/microclaw/driver_test.go index 59de829..97640ba 100644 --- a/internal/driver/microclaw/driver_test.go +++ b/internal/driver/microclaw/driver_test.go @@ -76,6 +76,7 @@ func TestMaterializeWritesConfigAndSeededMemory(t *testing.T) { rc, tmp := newTestRC(t) rc.Models = map[string]string{"primary": "openrouter/anthropic/claude-sonnet-4"} rc.Environment["OPENROUTER_API_KEY"] = "or-key" + rc.Timezone = "America/New_York" runtimeDir := filepath.Join(tmp, "runtime") if err := os.MkdirAll(runtimeDir, 0o700); err != nil { @@ -138,6 +139,9 @@ func TestMaterializeWritesConfigAndSeededMemory(t *testing.T) { if got := cfg["data_dir"]; got != "/claw-data" { t.Fatalf("expected data_dir=/claw-data, got %v", got) } + if got := cfg["timezone"]; got != "America/New_York" { + t.Fatalf("expected timezone=America/New_York, got %v", got) + } channels, _ := cfg["channels"].(map[string]interface{}) web, _ := channels["web"].(map[string]interface{}) diff --git a/internal/driver/openclaw/jobs.go b/internal/driver/openclaw/jobs.go index abcddf4..6a289fa 100644 --- a/internal/driver/openclaw/jobs.go +++ b/internal/driver/openclaw/jobs.go @@ -62,6 +62,10 @@ type jobState struct { func GenerateJobsJSON(rc *driver.ResolvedClaw) ([]byte, error) { now := time.Now().UnixMilli() jobs := make([]job, 0, len(rc.Invocations)) + timezone := strings.TrimSpace(rc.Timezone) + if timezone == "" { + timezone = "UTC" + } for _, inv := range rc.Invocations { name := inv.Name if name == "" { @@ -78,7 +82,7 @@ func GenerateJobsJSON(rc *driver.ResolvedClaw) ([]byte, error) { Enabled: inv.Origin != driver.OriginPod, CreatedAtMs: now, UpdatedAtMs: now, - Schedule: jobSchedule{Expr: inv.Schedule, TZ: "UTC", Kind: "cron"}, + Schedule: jobSchedule{Expr: inv.Schedule, TZ: timezone, Kind: "cron"}, SessionTarget: "isolated", WakeMode: "now", Payload: jobPayload{Kind: "agentTurn", Message: inv.Message, TimeoutSeconds: 300}, diff --git a/internal/driver/openclaw/jobs_test.go b/internal/driver/openclaw/jobs_test.go index 86c5eee..56561b3 100644 --- a/internal/driver/openclaw/jobs_test.go +++ b/internal/driver/openclaw/jobs_test.go @@ -57,6 +57,9 @@ func TestGenerateJobsJSONSingleInvocation(t *testing.T) { if schedule["expr"] != "15 8 * * 1-5" { t.Errorf("expected schedule.expr=%q, got %v", "15 8 * * 1-5", schedule["expr"]) } + if schedule["tz"] != "UTC" { + t.Errorf("expected schedule.tz=UTC fallback, got %v", schedule["tz"]) + } payload := j["payload"].(map[string]interface{}) if payload["kind"] != "agentTurn" { @@ -183,3 +186,26 @@ func TestGenerateJobsJSONDisablesPodOriginJobs(t *testing.T) { t.Fatalf("expected explicit invocation id to be preserved, got %v", jobs[0]["id"]) } } + +func TestGenerateJobsJSONUsesResolvedTimezone(t *testing.T) { + rc := &driver.ResolvedClaw{ + ServiceName: "tiverton", + Timezone: "America/New_York", + Invocations: []driver.Invocation{ + { + Schedule: "15 8 * * 1-5", + Message: "Pre-market synthesis", + }, + }, + } + data, err := GenerateJobsJSON(rc) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + jobs := parseJobStore(t, data) + schedule := jobs[0]["schedule"].(map[string]interface{}) + if schedule["tz"] != "America/New_York" { + t.Fatalf("expected schedule.tz=America/New_York, got %v", schedule["tz"]) + } +} diff --git a/internal/driver/types.go b/internal/driver/types.go index 081a5c8..8fe6b5b 100644 --- a/internal/driver/types.go +++ b/internal/driver/types.go @@ -72,6 +72,7 @@ type ResolvedClaw struct { Invocations []Invocation // scheduled agent tasks from image labels + pod x-claw.invoke Count int // from pod x-claw (default 1) Environment map[string]string // from pod environment block + Timezone string // resolved service timezone from TZ env (falls back to UTC) Cllama []string // ordered cllama proxy types (e.g., ["passthrough"]) CllamaToken string // per-agent bearer token injected when cllama is active }