Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions cmd/claw/compose_up.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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),
})
}
Expand Down Expand Up @@ -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),
})
}
Expand Down
14 changes: 14 additions & 0 deletions cmd/claw/compose_up_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
6 changes: 5 additions & 1 deletion cmd/claw/schedule_manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down
31 changes: 29 additions & 2 deletions cmd/claw/schedule_manifest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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)
}
}

Expand Down
10 changes: 9 additions & 1 deletion internal/driver/microclaw/driver.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions internal/driver/microclaw/driver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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{})
Expand Down
6 changes: 5 additions & 1 deletion internal/driver/openclaw/jobs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "" {
Expand All @@ -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},
Expand Down
26 changes: 26 additions & 0 deletions internal/driver/openclaw/jobs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand Down Expand Up @@ -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"])
}
}
1 change: 1 addition & 0 deletions internal/driver/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down