From 5741e40c1414251d4dfa64ffbbde6c838a75d63c Mon Sep 17 00:00:00 2001 From: ZC Date: Tue, 28 Apr 2026 17:16:27 +0800 Subject: [PATCH] fix(cron): clarify local timezone cron expressions (#73372) * fix(cron): clarify local timezone cron expressions * fix: clarify cron timezone guidance --------- Co-authored-by: Altay --- CHANGELOG.md | 1 + .../tools/cron-tool.flat-params.test.ts | 26 +++++++++++++++++++ src/agents/tools/cron-tool.schema.test.ts | 20 ++++++++++++++ src/agents/tools/cron-tool.test.ts | 13 ++++++++++ src/agents/tools/cron-tool.ts | 23 ++++++++++++---- src/cli/cron-cli.test.ts | 10 +++++++ src/cli/cron-cli/register.cron-add.ts | 6 ++++- src/cli/cron-cli/register.cron-edit.ts | 5 +++- 8 files changed, 97 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index db0e1899415..048572ce3d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Gateway/nodes: allow Windows companion nodes to use safe declared commands such as canvas, camera list, location, device info, and screen snapshot by default while keeping dangerous media commands opt-in. (#71884) Thanks @shanselman. +- Agents/cron: clarify agent-tool and CLI cron timezone guidance so supplied `tz` values use local wall-clock cron fields and omitted cron `tz` falls back to the Gateway host local timezone. Fixes #53669; carries forward #46177. (#73372) Thanks @chen-zhang-cs-code and @maranello-o. ## 2026.4.27 diff --git a/src/agents/tools/cron-tool.flat-params.test.ts b/src/agents/tools/cron-tool.flat-params.test.ts index c93a68a7bd4..aa7db094638 100644 --- a/src/agents/tools/cron-tool.flat-params.test.ts +++ b/src/agents/tools/cron-tool.flat-params.test.ts @@ -70,6 +70,32 @@ describe("cron tool flat-params", () => { }); }); + it("passes local cron wall-clock expression and timezone through add", async () => { + const tool = createCronTool(undefined, { callGatewayTool: callGatewayToolMock }); + + await tool.execute("call-local-cron-add", { + action: "add", + name: "shanghai reminder", + cron: "0 18 * * *", + tz: "Asia/Shanghai", + message: "send reminder", + }); + + const [method, _gatewayOpts, params] = callGatewayToolMock.mock.calls[0] as [ + string, + unknown, + { + schedule?: unknown; + }, + ]; + expect(method).toBe("cron.add"); + expect(params.schedule).toEqual({ + kind: "cron", + expr: "0 18 * * *", + tz: "Asia/Shanghai", + }); + }); + it("recovers flat cron schedule shorthand for update", async () => { const tool = createCronTool(undefined, { callGatewayTool: callGatewayToolMock }); diff --git a/src/agents/tools/cron-tool.schema.test.ts b/src/agents/tools/cron-tool.schema.test.ts index 4f592787c6a..8e6371a079f 100644 --- a/src/agents/tools/cron-tool.schema.test.ts +++ b/src/agents/tools/cron-tool.schema.test.ts @@ -82,6 +82,26 @@ describe("CronToolSchema", () => { expect(patchStagger?.description).toBe("Random jitter in ms (kind=cron)"); }); + it("describes cron expressions as local wall-clock time in the supplied timezone", () => { + const jobExpr = propertyAt(schemaRecord, "job.schedule.expr"); + const patchExpr = propertyAt(schemaRecord, "patch.schedule.expr"); + const jobTz = propertyAt(schemaRecord, "job.schedule.tz"); + const patchTz = propertyAt(schemaRecord, "patch.schedule.tz"); + + for (const prop of [jobExpr, patchExpr]) { + expect(prop?.description).toMatch(/wall-clock time/i); + expect(prop?.description).toMatch(/do not convert/i); + expect(prop?.description).toContain("Gateway host local timezone"); + expect(prop?.description).toContain("0 18 * * *"); + expect(prop?.description).toContain("Asia/Shanghai"); + } + for (const prop of [jobTz, patchTz]) { + expect(prop?.description).toMatch(/wall-clock fields/i); + expect(prop?.description).toContain("Gateway host local timezone"); + expect(prop?.description).toContain("Asia/Shanghai"); + } + }); + it("job.delivery exposes mode, channel, to, threadId, bestEffort, accountId, failureDestination", () => { expect(keysAt(schemaRecord, "job.delivery")).toEqual( [ diff --git a/src/agents/tools/cron-tool.test.ts b/src/agents/tools/cron-tool.test.ts index 9b615dcd2ea..0ec02fa1e03 100644 --- a/src/agents/tools/cron-tool.test.ts +++ b/src/agents/tools/cron-tool.test.ts @@ -58,6 +58,19 @@ describe("cron tool", () => { return call.params; } + it("tells models to keep cron expressions in local wall-clock time for tz", () => { + const tool = createTestCronTool(); + + expect(tool.description).toContain("local wall-clock time"); + expect(tool.description).toContain("do not convert the requested local time to UTC first"); + expect(tool.description).toContain("Gateway host local timezone"); + expect(tool.description).toContain( + 'For schedule.kind="at", ISO timestamps without an explicit timezone are treated as UTC.', + ); + expect(tool.description).toContain('"expr": "0 18 * * *"'); + expect(tool.description).toContain('"tz": "Asia/Shanghai"'); + }); + function buildReminderAgentTurnJob(overrides: Record = {}): { name: string; schedule: { at: string }; diff --git a/src/agents/tools/cron-tool.ts b/src/agents/tools/cron-tool.ts index ef67bb49658..6d0cb2d689b 100644 --- a/src/agents/tools/cron-tool.ts +++ b/src/agents/tools/cron-tool.ts @@ -163,8 +163,18 @@ const CronScheduleSchema = Type.Optional( anchorMs: Type.Optional( Type.Number({ description: "Optional start anchor in milliseconds (kind=every)" }), ), - expr: Type.Optional(Type.String({ description: "Cron expression (kind=cron)" })), - tz: Type.Optional(Type.String({ description: "IANA timezone (kind=cron)" })), + expr: Type.Optional( + Type.String({ + description: + 'Cron expression (kind=cron) written in the supplied tz\'s local wall-clock time, or the Gateway host local timezone when tz is omitted; do not convert the requested local time to UTC first. Example: 6pm Shanghai daily is "0 18 * * *" with tz "Asia/Shanghai".', + }), + ), + tz: Type.Optional( + Type.String({ + description: + 'IANA timezone for interpreting cron wall-clock fields (kind=cron), e.g. "Asia/Shanghai"; if omitted, cron uses the Gateway host local timezone.', + }), + ), staggerMs: Type.Optional(Type.Number({ description: "Random jitter in ms (kind=cron)" })), }, { additionalProperties: true }, @@ -569,10 +579,13 @@ SCHEDULE TYPES (schedule.kind): { "kind": "at", "at": "" } - "every": Recurring interval { "kind": "every", "everyMs": , "anchorMs": } -- "cron": Cron expression - { "kind": "cron", "expr": "", "tz": "" } +- "cron": Cron expression evaluated in the supplied timezone, or the Gateway host local timezone when tz is omitted + { "kind": "cron", "expr": "", "tz": "" } + Write expr in the selected timezone's local wall-clock time; do not convert the requested local time to UTC first. + If tz is omitted, do not assume UTC; the Gateway host local timezone is used. + Example: "Remind me every day at 6pm Shanghai time" -> { "kind": "cron", "expr": "0 18 * * *", "tz": "Asia/Shanghai" } -ISO timestamps without an explicit timezone are treated as UTC. +For schedule.kind="at", ISO timestamps without an explicit timezone are treated as UTC. PAYLOAD TYPES (payload.kind): - "systemEvent": Injects text as system event into session diff --git a/src/cli/cron-cli.test.ts b/src/cli/cron-cli.test.ts index 111bcda04ea..b3834bc3eaa 100644 --- a/src/cli/cron-cli.test.ts +++ b/src/cli/cron-cli.test.ts @@ -247,6 +247,16 @@ async function runCronRunAndCaptureExit(params: { } describe("cron cli", () => { + it("documents the gateway-host timezone default for cron --tz help", () => { + const program = buildProgram(); + const cronCommand = program.commands.find((command) => command.name() === "cron"); + const addCommand = cronCommand?.commands.find((command) => command.name() === "add"); + const editCommand = cronCommand?.commands.find((command) => command.name() === "edit"); + + expect(addCommand?.helpInformation()).toContain("Gateway host local timezone"); + expect(editCommand?.helpInformation()).toContain("Gateway host local timezone"); + }); + it.each([ { name: "exits 0 for cron run when job executes successfully", diff --git a/src/cli/cron-cli/register.cron-add.ts b/src/cli/cron-cli/register.cron-add.ts index 0381bffdb29..0b6e7930d3f 100644 --- a/src/cli/cron-cli/register.cron-add.ts +++ b/src/cli/cron-cli/register.cron-add.ts @@ -85,7 +85,11 @@ export function registerCronAddCommand(cron: Command) { ) .option("--every ", "Run every duration (e.g. 10m, 1h)") .option("--cron ", "Cron expression (5-field or 6-field with seconds)") - .option("--tz ", "Timezone for cron expressions (IANA)", "") + .option( + "--tz ", + "Timezone for cron expressions (IANA; cron default: Gateway host local timezone)", + "", + ) .option("--stagger ", "Cron stagger window (e.g. 30s, 5m)") .option("--exact", "Disable cron staggering (set stagger to 0)", false) .option("--system-event ", "System event payload (main session)") diff --git a/src/cli/cron-cli/register.cron-edit.ts b/src/cli/cron-cli/register.cron-edit.ts index df6fcaf77d6..77c6a3a879f 100644 --- a/src/cli/cron-cli/register.cron-edit.ts +++ b/src/cli/cron-cli/register.cron-edit.ts @@ -81,7 +81,10 @@ export function registerCronEditCommand(cron: Command) { .option("--at ", "Set one-shot time (ISO) or duration like 20m") .option("--every ", "Set interval duration like 10m") .option("--cron ", "Set cron expression") - .option("--tz ", "Timezone for cron expressions (IANA)") + .option( + "--tz ", + "Timezone for cron expressions (IANA; cron default: Gateway host local timezone)", + ) .option("--stagger ", "Cron stagger window (e.g. 30s, 5m)") .option("--exact", "Disable cron staggering (set stagger to 0)") .option("--system-event ", "Set systemEvent payload")