fix(cron): clarify local timezone cron expressions (#73372)

* fix(cron): clarify local timezone cron expressions

* fix: clarify cron timezone guidance

---------

Co-authored-by: Altay <altay@uinaf.dev>
This commit is contained in:
ZC
2026-04-28 17:16:27 +08:00
committed by GitHub
parent 9cdae734a7
commit 5741e40c14
8 changed files with 97 additions and 7 deletions

View File

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

View File

@@ -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 });

View File

@@ -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(
[

View File

@@ -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<string, unknown> = {}): {
name: string;
schedule: { at: string };

View File

@@ -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": "<ISO-8601 timestamp>" }
- "every": Recurring interval
{ "kind": "every", "everyMs": <interval-ms>, "anchorMs": <optional-start-ms> }
- "cron": Cron expression
{ "kind": "cron", "expr": "<cron-expression>", "tz": "<optional-timezone>" }
- "cron": Cron expression evaluated in the supplied timezone, or the Gateway host local timezone when tz is omitted
{ "kind": "cron", "expr": "<cron-expression>", "tz": "<optional-IANA-timezone>" }
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

View File

@@ -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",

View File

@@ -85,7 +85,11 @@ export function registerCronAddCommand(cron: Command) {
)
.option("--every <duration>", "Run every duration (e.g. 10m, 1h)")
.option("--cron <expr>", "Cron expression (5-field or 6-field with seconds)")
.option("--tz <iana>", "Timezone for cron expressions (IANA)", "")
.option(
"--tz <iana>",
"Timezone for cron expressions (IANA; cron default: Gateway host local timezone)",
"",
)
.option("--stagger <duration>", "Cron stagger window (e.g. 30s, 5m)")
.option("--exact", "Disable cron staggering (set stagger to 0)", false)
.option("--system-event <text>", "System event payload (main session)")

View File

@@ -81,7 +81,10 @@ export function registerCronEditCommand(cron: Command) {
.option("--at <when>", "Set one-shot time (ISO) or duration like 20m")
.option("--every <duration>", "Set interval duration like 10m")
.option("--cron <expr>", "Set cron expression")
.option("--tz <iana>", "Timezone for cron expressions (IANA)")
.option(
"--tz <iana>",
"Timezone for cron expressions (IANA; cron default: Gateway host local timezone)",
)
.option("--stagger <duration>", "Cron stagger window (e.g. 30s, 5m)")
.option("--exact", "Disable cron staggering (set stagger to 0)")
.option("--system-event <text>", "Set systemEvent payload")