fix: land cron tz one-shot handling and prerelease config warnings (#53224) (thanks @RolfHegr)

This commit is contained in:
Peter Steinberger 2026-03-23 19:25:03 -07:00
parent 9aac5582d6
commit 0cbf6d5fed
7 changed files with 78 additions and 17 deletions

View File

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

View File

@ -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 "<offset-less-iso>" --tz <iana>` for local wall-clock one-shots.
- `everyMs` is milliseconds.
- `sessionTarget`: `"main"`, `"isolated"`, `"current"`, or `"session:<custom-id>"`.
- `"current"` is resolved to `"session:<sessionKey>"` at creation time.

View File

@ -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 "<offset-less-iso>" --tz <iana>`.
Common signatures:

View File

@ -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 <iana>`, 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 <job-id>` to follow the eventual outcome.

View File

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

View File

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

View File

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