Skip to content

Cron scheduler race condition: recomputeNextRuns overwrites due jobs before execution #10713

@st0mich

Description

@st0mich

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 * * * and 0 18 * * *)
  • Both jobs skipped on the same day

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions