-
-
Notifications
You must be signed in to change notification settings - Fork 52.7k
Closed
Description
Bug Report: Recurring Cron Jobs Never Execute
Summary
Recurring cron jobs (both schedule.kind: "cron" and schedule.kind: "every") never execute. The timer fires correctly and nextRunAtMs advances, but the job execution is skipped every time.
Environment
- OpenClaw Version: v2026.2.3-1
- Node.js: v22.22.0
- OS: Ubuntu Linux 6.8.0-94-generic (x64)
- Cron enabled: Yes (
cron statusshowsenabled: true)
Steps to Reproduce
- Create a recurring cron job:
# Using cron expression
openclaw cron add --name "Test every minute" \
--schedule '{"kind":"cron","expr":"* * * * *"}' \
--sessionTarget main \
--payload '{"kind":"systemEvent","text":"TEST FIRED"}'
# Or using "every" interval
openclaw cron add --name "Test every 60s" \
--schedule '{"kind":"every","everyMs":60000}' \
--sessionTarget main \
--payload '{"kind":"systemEvent","text":"TEST FIRED"}'- Check cron status — shows correct
nextWakeAtMs - Wait for the scheduled time to pass
- Check the job again
Expected Behaviour
- Job executes at scheduled time
state.lastRunAtMsandstate.lastStatusare populated- Run history shows execution entries
Actual Behaviour
- Timer fires (confirmed by
nextRunAtMsadvancing) - Job does NOT execute
state.lastRunAtMsremains undefined- No run history entries
nextRunAtMskeeps advancing each interval without any actual execution
Root Cause Analysis
The bug is in onTimer (in the bundled gateway code):
async function onTimer(state) {
await locked(state, async () => {
await ensureLoaded(state, { forceReload: true }); // ← Problem here
await runDueJobs(state);
await persist(state);
armTimer(state);
});
}ensureLoaded calls recomputeNextRuns(state) which iterates all jobs and updates their nextRunAtMs to the next occurrence based on nowMs.
Then runDueJobs checks:
const due = state.store.jobs.filter((j) => {
// ...
return typeof next === "number" && now >= next;
});But by this point, nextRunAtMs has already been updated to a future time, so now >= next is always false.
Timeline Example
- Job scheduled for 10:00:00
- Timer fires at 10:00:01
ensureLoaded→recomputeNextRuns→nextRunAtMsupdated to 10:01:00runDueJobschecks: 10:00:01 >= 10:01:00 → FALSE- Job skipped, timer re-armed for 10:01:00
- Repeat forever
Suggested Fix
Option A: Don't call recomputeNextRuns during timer callback:
async function ensureLoaded(state, opts) {
// ... load store ...
if (!opts?.skipRecompute) {
recomputeNextRuns(state);
}
}
// In onTimer:
await ensureLoaded(state, { forceReload: true, skipRecompute: true });Option B: Check due jobs BEFORE recomputing:
async function onTimer(state) {
await locked(state, async () => {
await ensureLoaded(state, { forceReload: true, skipRecompute: true });
await runDueJobs(state); // Check against OLD nextRunAtMs values
recomputeNextRuns(state); // Then update for next cycle
await persist(state);
armTimer(state);
});
}Workaround
One-shot jobs (schedule.kind: "at") work correctly. Users can create multiple one-shot jobs as a workaround for recurring schedules.
Impact
- Severity: High — recurring cron jobs are completely non-functional
- Affected: All users relying on
cronoreveryschedule types - One-shot
atjobs are unaffected
Additional Notes
- The croner library parses expressions correctly (verified independently)
- Job state persists correctly to disk
- Timer mechanism works (fires at correct times)
- Only the execution step is broken due to the race with
recomputeNextRuns
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
No labels