diff --git a/CHANGELOG.md b/CHANGELOG.md index 277e4b83312..4f6d9a7a40d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,10 @@ Docs: https://docs.openclaw.ai - ClawHub/skills: keep updating already-tracked legacy Unicode slugs after the ASCII-only slug hardening, so older installs do not get stuck behind `Invalid skill slug` errors during `openclaw skills update`. (#53206) Thanks @drobison00. - Infra/exec trust: preserve shell-multiplexer wrapper binaries for policy checks without breaking approved-command reconstruction, so BusyBox/ToyBox allowlist and audit flows bind to the real wrapper while execution plans stay coherent. (#53134) Thanks @vincentkoc. - LINE/runtime-api: pre-export overlapping runtime symbols before the `line-runtime` star export so jiti no longer throws `TypeError: Cannot redefine property` on startup. (#53221) Thanks @Drickon. +- CLI/cron: make `openclaw cron add|edit --at ... --tz ` honor the requested local wall-clock time for offset-less one-shot datetimes, including DST boundaries, and keep `--tz` rejected for `--every`. (#53224) Thanks @RolfHegr. + - Infra/exec trust: preserve shell-multiplexer wrapper binaries for policy checks without breaking approved-command reconstruction, so BusyBox/ToyBox allowlist and audit flows bind to the real wrapper while execution plans stay coherent. (#53134) Thanks @vincentkoc. + - LINE/runtime-api: pre-export overlapping runtime symbols before the `line-runtime` star export so jiti no longer throws `TypeError: Cannot redefine property` on startup. (#53221) Thanks @Drickon. + - CLI/cron: make `openclaw cron add|edit --at ... --tz ` honor the requested local wall-clock time for offset-less one-shot datetimes, including DST boundaries, and keep `--tz` rejected for `--every`. (#53224) Thanks @RolfHegr. ## 2026.3.23 diff --git a/docs/automation/cron-jobs.md b/docs/automation/cron-jobs.md index eccc0b8b945..ac68d0e5d63 100644 --- a/docs/automation/cron-jobs.md +++ b/docs/automation/cron-jobs.md @@ -363,7 +363,7 @@ Recurring job in a custom persistent session: Notes: - `schedule.kind`: `at` (`at`), `every` (`everyMs`), or `cron` (`expr`, optional `tz`). -- `schedule.at` accepts ISO 8601 (timezone optional; treated as UTC when omitted). +- `schedule.at` accepts ISO 8601. Tool/API values without a timezone are treated as UTC; the CLI also accepts `openclaw cron add|edit --at "" --tz ` for local wall-clock one-shots. - `everyMs` is milliseconds. - `sessionTarget`: `"main"`, `"isolated"`, `"current"`, or `"session:"`. - `"current"` is resolved to `"session:"` at creation time. diff --git a/docs/automation/troubleshooting.md b/docs/automation/troubleshooting.md index 9190855dd59..12a00f9c422 100644 --- a/docs/automation/troubleshooting.md +++ b/docs/automation/troubleshooting.md @@ -107,7 +107,7 @@ Quick rules: - `Config path not found: agents.defaults.userTimezone` means the key is unset; heartbeat falls back to host timezone (or `activeHours.timezone` if set). - Cron without `--tz` uses gateway host timezone. - Heartbeat `activeHours` uses configured timezone resolution (`user`, `local`, or explicit IANA tz). -- ISO timestamps without timezone are treated as UTC for cron `at` schedules. +- Cron `at` schedules treat ISO timestamps without timezone as UTC unless you used CLI `--at "" --tz `. Common signatures: diff --git a/docs/cli/cron.md b/docs/cli/cron.md index 6ee25859749..ce0abe6dce9 100644 --- a/docs/cli/cron.md +++ b/docs/cli/cron.md @@ -21,6 +21,9 @@ output internal. `--deliver` remains as a deprecated alias for `--announce`. Note: one-shot (`--at`) jobs delete after success by default. Use `--keep-after-run` to keep them. +Note: for one-shot CLI jobs, offset-less `--at` datetimes are treated as UTC unless you also pass +`--tz `, which interprets that local wall-clock time in the given timezone. + Note: recurring jobs now use exponential retry backoff after consecutive errors (30s → 1m → 5m → 15m → 60m), then return to normal schedule after the next successful run. Note: `openclaw cron run` now returns as soon as the manual run is queued for execution. Successful responses include `{ ok: true, enqueued: true, runId }`; use `openclaw cron runs --id ` to follow the eventual outcome. diff --git a/src/cli/cron-cli.test.ts b/src/cli/cron-cli.test.ts index 5656ea60339..6a29f9f94c3 100644 --- a/src/cli/cron-cli.test.ts +++ b/src/cli/cron-cli.test.ts @@ -658,6 +658,27 @@ describe("cron cli", () => { expect(params.schedule.at).toBe("2026-03-23T21:00:00.000Z"); }); + it("applies --tz to --at correctly across DST boundaries on cron add", async () => { + await runCronCommand([ + "cron", + "add", + "--name", + "tz-at-dst-test", + "--at", + "2026-03-29T01:30:00", + "--tz", + "Europe/Oslo", + "--session", + "isolated", + "--message", + "test", + ]); + + const params = getGatewayCallParams<{ schedule: { kind: string; at: string } }>("cron.add"); + expect(params.schedule.kind).toBe("at"); + expect(params.schedule.at).toBe("2026-03-29T00:30:00.000Z"); + }); + it("sets explicit stagger for cron edit", async () => { await runCronCommand(["cron", "edit", "job-1", "--cron", "0 * * * *", "--stagger", "30s"]); @@ -685,6 +706,24 @@ describe("cron cli", () => { await expectCronEditWithScheduleLookupExit({ kind: "every", everyMs: 60_000 }, ["--exact"]); }); + it("applies --tz to --at for offset-less datetimes on cron edit", async () => { + const patch = await runCronEditAndGetPatch([ + "--at", + "2026-03-23T23:00:00", + "--tz", + "Europe/Oslo", + ]); + + expect(patch?.patch?.schedule).toEqual({ + kind: "at", + at: "2026-03-23T22:00:00.000Z", + }); + }); + + it("rejects --tz with --every on cron edit", async () => { + await expectCronCommandExit(["cron", "edit", "job-1", "--every", "10m", "--tz", "UTC"]); + }); + it("patches failure alert settings on cron edit", async () => { callGatewayFromCli.mockClear(); diff --git a/src/cli/cron-cli/register.cron-edit.ts b/src/cli/cron-cli/register.cron-edit.ts index 328d05766e9..e30448f51ca 100644 --- a/src/cli/cron-cli/register.cron-edit.ts +++ b/src/cli/cron-cli/register.cron-edit.ts @@ -158,6 +158,9 @@ export function registerCronEditCommand(cron: Command) { if (scheduleChosen > 1) { throw new Error("Choose at most one schedule change"); } + if (typeof opts.tz === "string" && opts.every) { + throw new Error("--tz is only valid with --cron or offset-less --at"); + } if (requestedStaggerMs !== undefined && (opts.at || opts.every)) { throw new Error("--stagger/--exact are only valid for cron schedules"); } diff --git a/src/cli/cron-cli/shared.ts b/src/cli/cron-cli/shared.ts index d0576605425..1264f8315cf 100644 --- a/src/cli/cron-cli/shared.ts +++ b/src/cli/cron-cli/shared.ts @@ -89,6 +89,8 @@ export function parseCronStaggerMs(params: { return parsed; } +const OFFSETLESS_ISO_DATETIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?(\.\d+)?$/; + /** * Parse a one-shot `--at` value into an ISO string (UTC). * @@ -104,20 +106,10 @@ export function parseAt(input: string, tz?: string): string | null { // If a timezone is provided and the input looks like an offset-less ISO datetime, // resolve it in the given IANA timezone so users get the time they expect. - if (tz) { - const isoNoOffset = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2})?(\.\d+)?$/.test(raw); - if (isoNoOffset) { - try { - // Use Intl to find the UTC offset for the given tz at the specified local time. - // We first parse naively as UTC to get a rough Date, then compute the real offset. - const naiveMs = new Date(`${raw}Z`).getTime(); - if (!Number.isNaN(naiveMs)) { - const offset = getTimezoneOffsetMs(naiveMs, tz); - return new Date(naiveMs - offset).toISOString(); - } - } catch { - // Fall through to default parsing if tz is invalid - } + if (tz && OFFSETLESS_ISO_DATETIME_RE.test(raw)) { + const resolved = parseOffsetlessAtInTimezone(raw, tz); + if (resolved) { + return resolved; } } @@ -132,6 +124,26 @@ export function parseAt(input: string, tz?: string): string | null { return null; } +function parseOffsetlessAtInTimezone(raw: string, tz: string): string | null { + try { + new Intl.DateTimeFormat("en-US", { timeZone: tz }).format(new Date()); + + const naiveMs = new Date(`${raw}Z`).getTime(); + if (Number.isNaN(naiveMs)) { + return null; + } + + // Re-check the offset at the first candidate instant so DST boundaries + // land on the intended wall-clock time instead of drifting by one hour. + const firstOffsetMs = getTimezoneOffsetMs(naiveMs, tz); + const candidateMs = naiveMs - firstOffsetMs; + const finalOffsetMs = getTimezoneOffsetMs(candidateMs, tz); + return new Date(naiveMs - finalOffsetMs).toISOString(); + } catch { + return null; + } +} + /** * Get the UTC offset in milliseconds for a given IANA timezone at a given UTC instant. * Positive means ahead of UTC (e.g. +3600000 for CET). @@ -160,7 +172,7 @@ function getTimezoneOffsetMs(utcMs: number, tz: string): number { get("year"), get("month") - 1, get("day"), - get("hour") === 24 ? 0 : get("hour"), + get("hour"), get("minute"), get("second"), );