From 8d34bcf32e7e9045e08b9cacfd4a72a095002697 Mon Sep 17 00:00:00 2001 From: Tes Sal Date: Wed, 28 Jan 2026 13:03:17 +0000 Subject: [PATCH] feat: use userTimezone as default for cron schedules Fixes #3318 Previously, cron schedules without an explicit `tz` field would use the system timezone, which could cause confusion if the server is in a different timezone than the user. Now, `agents.defaults.userTimezone` from the config is used as the default timezone for cron schedule computations when the schedule doesn't specify its own `tz`. Changes: - Added `defaultTimezone` option to `computeNextRunAtMs()` - Added `defaultTimezone` to `CronServiceDeps` - Gateway passes `userTimezone` from config to cron service - All job computations now respect the default timezone --- src/cron/schedule.ts | 15 +++++++++++++-- src/cron/service/jobs.ts | 14 ++++++++++---- src/cron/service/state.ts | 2 ++ src/gateway/server-cron.ts | 4 ++++ 4 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/cron/schedule.ts b/src/cron/schedule.ts index 5d70d55c5..049194f80 100644 --- a/src/cron/schedule.ts +++ b/src/cron/schedule.ts @@ -1,7 +1,16 @@ import { Cron } from "croner"; import type { CronSchedule } from "./types.js"; -export function computeNextRunAtMs(schedule: CronSchedule, nowMs: number): number | undefined { +export type ComputeNextRunOpts = { + /** Default timezone to use when schedule.tz is not specified. */ + defaultTimezone?: string; +}; + +export function computeNextRunAtMs( + schedule: CronSchedule, + nowMs: number, + opts?: ComputeNextRunOpts, +): number | undefined { if (schedule.kind === "at") { return schedule.atMs > nowMs ? schedule.atMs : undefined; } @@ -17,8 +26,10 @@ export function computeNextRunAtMs(schedule: CronSchedule, nowMs: number): numbe const expr = schedule.expr.trim(); if (!expr) return undefined; + // Use schedule.tz if specified, otherwise fall back to default timezone + const timezone = schedule.tz?.trim() || opts?.defaultTimezone?.trim() || undefined; const cron = new Cron(expr, { - timezone: schedule.tz?.trim() || undefined, + timezone, catch: false, }); const next = cron.nextRun(new Date(nowMs)); diff --git a/src/cron/service/jobs.ts b/src/cron/service/jobs.ts index 132156a0c..b002b954d 100644 --- a/src/cron/service/jobs.ts +++ b/src/cron/service/jobs.ts @@ -33,19 +33,24 @@ export function findJobOrThrow(state: CronServiceState, id: string) { return job; } -export function computeJobNextRunAtMs(job: CronJob, nowMs: number): number | undefined { +export function computeJobNextRunAtMs( + job: CronJob, + nowMs: number, + opts?: { defaultTimezone?: string }, +): number | undefined { if (!job.enabled) return undefined; if (job.schedule.kind === "at") { // One-shot jobs stay due until they successfully finish. if (job.state.lastStatus === "ok" && job.state.lastRunAtMs) return undefined; return job.schedule.atMs; } - return computeNextRunAtMs(job.schedule, nowMs); + return computeNextRunAtMs(job.schedule, nowMs, { defaultTimezone: opts?.defaultTimezone }); } export function recomputeNextRuns(state: CronServiceState) { if (!state.store) return; const now = state.deps.nowMs(); + const defaultTimezone = state.deps.defaultTimezone; for (const job of state.store.jobs) { if (!job.state) job.state = {}; if (!job.enabled) { @@ -61,7 +66,7 @@ export function recomputeNextRuns(state: CronServiceState) { ); job.state.runningAtMs = undefined; } - job.state.nextRunAtMs = computeJobNextRunAtMs(job, now); + job.state.nextRunAtMs = computeJobNextRunAtMs(job, now, { defaultTimezone }); } } @@ -77,6 +82,7 @@ export function nextWakeAtMs(state: CronServiceState) { export function createJob(state: CronServiceState, input: CronJobCreate): CronJob { const now = state.deps.nowMs(); + const defaultTimezone = state.deps.defaultTimezone; const id = crypto.randomUUID(); const job: CronJob = { id, @@ -97,7 +103,7 @@ export function createJob(state: CronServiceState, input: CronJobCreate): CronJo }, }; assertSupportedJobSpec(job); - job.state.nextRunAtMs = computeJobNextRunAtMs(job, now); + job.state.nextRunAtMs = computeJobNextRunAtMs(job, now, { defaultTimezone }); return job; } diff --git a/src/cron/service/state.ts b/src/cron/service/state.ts index ab094c20b..28bc14391 100644 --- a/src/cron/service/state.ts +++ b/src/cron/service/state.ts @@ -24,6 +24,8 @@ export type CronServiceDeps = { log: Logger; storePath: string; cronEnabled: boolean; + /** Default timezone (IANA) for cron schedules that don't specify their own. */ + defaultTimezone?: string; enqueueSystemEvent: (text: string, opts?: { agentId?: string }) => void; requestHeartbeatNow: (opts?: { reason?: string }) => void; runHeartbeatOnce?: (opts?: { reason?: string }) => Promise; diff --git a/src/gateway/server-cron.ts b/src/gateway/server-cron.ts index 9a0c0ca98..f3b560560 100644 --- a/src/gateway/server-cron.ts +++ b/src/gateway/server-cron.ts @@ -43,9 +43,13 @@ export function buildGatewayCronService(params: { return { agentId, cfg: runtimeConfig }; }; + // Use userTimezone from config as default for cron schedules + const defaultTimezone = params.cfg.agents?.defaults?.userTimezone; + const cron = new CronService({ storePath, cronEnabled, + defaultTimezone, enqueueSystemEvent: (text, opts) => { const { agentId, cfg: runtimeConfig } = resolveCronAgent(opts?.agentId); const sessionKey = resolveAgentMainSessionKey({