mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 07:40:44 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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(
|
||||
[
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user