From 961bde27feae2809ef294608cb8463403305e521 Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Sat, 21 Feb 2026 20:18:11 -0800 Subject: [PATCH] Cron: guard missing expr in schedule parsing --- CHANGELOG.md | 1 + src/cron/schedule.test.ts | 12 ++++++++++++ src/cron/schedule.ts | 6 +++++- .../service/jobs.schedule-error-isolation.test.ts | 15 +++++++++++++++ src/cron/stagger.test.ts | 9 +++++++++ src/cron/stagger.ts | 4 +++- 6 files changed, 45 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21babf4e1ea..a9ab01cd637 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai - Agents/Ollama: preserve unsafe integer tool-call arguments as exact strings during NDJSON parsing, preventing large numeric IDs from being rounded before tool execution. (#23170) Thanks @BestJoester. - Cron/Gateway: keep `cron.list` and `cron.status` responsive during startup catch-up by avoiding a long-held cron lock while missed jobs execute. (#23106) Thanks @jayleekr. - Gateway/Config reload: compare array-valued config paths structurally during diffing so unchanged `memory.qmd.paths` and `memory.qmd.scope.rules` no longer trigger false restart-required reloads. (#23185) Thanks @rex05ai. +- Cron/Scheduling: validate runtime cron expressions before schedule/stagger evaluation so malformed persisted jobs report a clear `invalid cron schedule: expr is required` error instead of crashing with `undefined.trim` failures and auto-disable churn. (#23223) Thanks @asimons81. - TUI/Input: enable multiline-paste burst coalescing on macOS Terminal.app and iTerm so pasted blocks no longer submit line-by-line as separate messages. (#18809) Thanks @fwends. - TUI/Status: request immediate renders after setting `sending`/`waiting` activity states so in-flight runs always show visible progress indicators instead of appearing idle until completion. (#21549) Thanks @13Guinness. - Agents/Fallbacks: treat JSON payloads with `type: "api_error"` + `"Internal server error"` as transient failover errors so Anthropic 500-style failures trigger model fallback. (#23193) Thanks @jarvis-lane. diff --git a/src/cron/schedule.test.ts b/src/cron/schedule.test.ts index 3a4e66f9f15..1bea936b274 100644 --- a/src/cron/schedule.test.ts +++ b/src/cron/schedule.test.ts @@ -13,6 +13,18 @@ describe("cron schedule", () => { expect(next).toBe(Date.parse("2025-12-17T17:00:00.000Z")); }); + it("throws a clear error when cron expr is missing at runtime", () => { + const nowMs = Date.parse("2025-12-13T00:00:00.000Z"); + expect(() => + computeNextRunAtMs( + { + kind: "cron", + } as unknown as { kind: "cron"; expr: string; tz?: string }, + nowMs, + ), + ).toThrow("invalid cron schedule: expr is required"); + }); + it("computes next run for every schedule", () => { const anchor = Date.parse("2025-12-13T00:00:00.000Z"); const now = anchor + 10_000; diff --git a/src/cron/schedule.ts b/src/cron/schedule.ts index 140cbb82936..d80aaa440cb 100644 --- a/src/cron/schedule.ts +++ b/src/cron/schedule.ts @@ -41,7 +41,11 @@ export function computeNextRunAtMs(schedule: CronSchedule, nowMs: number): numbe return anchor + steps * everyMs; } - const expr = schedule.expr.trim(); + const exprSource = (schedule as { expr?: unknown }).expr; + if (typeof exprSource !== "string") { + throw new Error("invalid cron schedule: expr is required"); + } + const expr = exprSource.trim(); if (!expr) { return undefined; } diff --git a/src/cron/service/jobs.schedule-error-isolation.test.ts b/src/cron/service/jobs.schedule-error-isolation.test.ts index 064ff37c1ee..84cd8e0a1e9 100644 --- a/src/cron/service/jobs.schedule-error-isolation.test.ts +++ b/src/cron/service/jobs.schedule-error-isolation.test.ts @@ -186,4 +186,19 @@ describe("cron schedule error isolation", () => { expect(badJob.state.lastError).toMatch(/^schedule error:/); expect(badJob.state.lastError).toBeTruthy(); }); + + it("records a clear schedule error when cron expr is missing", () => { + const badJob = createJob({ + id: "missing-expr", + name: "Missing Expr", + schedule: { kind: "cron" } as unknown as CronJob["schedule"], + }); + const state = createMockState([badJob]); + + recomputeNextRuns(state); + + expect(badJob.state.lastError).toContain("invalid cron schedule: expr is required"); + expect(badJob.state.lastError).not.toContain("Cannot read properties of undefined"); + expect(badJob.state.scheduleErrorCount).toBe(1); + }); }); diff --git a/src/cron/stagger.test.ts b/src/cron/stagger.test.ts index d62e3fe3d61..a2c2cdd60ec 100644 --- a/src/cron/stagger.test.ts +++ b/src/cron/stagger.test.ts @@ -33,4 +33,13 @@ describe("cron stagger helpers", () => { expect(resolveCronStaggerMs({ kind: "cron", expr: "0 * * * *", staggerMs: 0 })).toBe(0); expect(resolveCronStaggerMs({ kind: "cron", expr: "15 * * * *" })).toBe(0); }); + + it("handles missing runtime expr values without throwing", () => { + expect(() => + resolveCronStaggerMs({ kind: "cron" } as unknown as { kind: "cron"; expr: string }), + ).not.toThrow(); + expect( + resolveCronStaggerMs({ kind: "cron" } as unknown as { kind: "cron"; expr: string }), + ).toBe(0); + }); }); diff --git a/src/cron/stagger.ts b/src/cron/stagger.ts index 2eecdd18f33..4b251dfb43c 100644 --- a/src/cron/stagger.ts +++ b/src/cron/stagger.ts @@ -41,5 +41,7 @@ export function resolveCronStaggerMs(schedule: Extract