Skip to content

Bug Report: Recurring Cron Jobs Never Execute #10573

@arctic-ice-cool

Description

@arctic-ice-cool

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 status shows enabled: true)

Steps to Reproduce

  1. 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"}'
  1. Check cron status — shows correct nextWakeAtMs
  2. Wait for the scheduled time to pass
  3. Check the job again

Expected Behaviour

  • Job executes at scheduled time
  • state.lastRunAtMs and state.lastStatus are populated
  • Run history shows execution entries

Actual Behaviour

  • Timer fires (confirmed by nextRunAtMs advancing)
  • Job does NOT execute
  • state.lastRunAtMs remains undefined
  • No run history entries
  • nextRunAtMs keeps 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

  1. Job scheduled for 10:00:00
  2. Timer fires at 10:00:01
  3. ensureLoadedrecomputeNextRunsnextRunAtMs updated to 10:01:00
  4. runDueJobs checks: 10:00:01 >= 10:01:00 → FALSE
  5. Job skipped, timer re-armed for 10:01:00
  6. 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 cron or every schedule types
  • One-shot at jobs 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

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