-
-
Notifications
You must be signed in to change notification settings - Fork 52.7k
Description
Bug
Cron jobs are randomly skipped (~50% of the time) because recomputeNextRuns() overwrites nextRunAtMs before runDueJobs() can check it.
Root Cause
In onTimer():
await ensureLoaded(state, { forceReload: true }); // calls recomputeNextRuns
await runDueJobs(state);ensureLoaded calls recomputeNextRuns(state), which sets:
job.state.nextRunAtMs = computeJobNextRunAtMs(job, now);computeNextRunAtMs uses croner's nextRun(new Date(nowMs)), which returns the next occurrence strictly after nowMs. When the setTimeout fires at or after the exact cron time (e.g. 18:00:00.001), nextRun returns tomorrow 18:00 — overwriting today's value before runDueJobs ever sees it.
Reproduction
const { Cron } = require('croner');
// Timer fires 1ms after the cron time
new Cron('0 18 * * *', { timezone: 'Europe/Berlin' })
.nextRun(new Date('2026-02-06T17:00:00.000Z'));
// → 2026-02-07T17:00:00.000Z (tomorrow!)
// Timer fires 1ms before
new Cron('0 18 * * *', { timezone: 'Europe/Berlin' })
.nextRun(new Date('2026-02-06T16:59:59.999Z'));
// → 2026-02-06T17:00:00.000Z (today ✓)Whether the job fires depends on whether setTimeout happens to fire before or after the exact millisecond boundary — a coin flip on every tick.
Suggested Fix
In recomputeNextRuns, preserve nextRunAtMs for jobs that are currently due:
// Before:
job.state.nextRunAtMs = computeJobNextRunAtMs(job, now);
// After:
const prev = job.state.nextRunAtMs;
if (typeof prev !== 'number' || prev > now) {
job.state.nextRunAtMs = computeJobNextRunAtMs(job, now);
}This keeps the stored value for due jobs (where nextRunAtMs <= now), allowing runDueJobs to find and execute them. After execution, executeJob already updates nextRunAtMs to the next occurrence.
Environment
- OpenClaw 2026.2.4
- croner 10.0.1
- macOS (no sleep involved — Mac Studio with Parsec keeping display alive)
- Observed on daily cron jobs (
0 8 * * *and0 18 * * *) - Both jobs skipped on the same day