Skip to content

[Bug]: every and cron scheduled jobs never fire #10653

@colosieve

Description

@colosieve

Bug: every and cron scheduled jobs never fire

Summary

every and cron schedule kinds never execute. The timer fires on time, but recomputeNextRuns() runs before runDueJobs() inside onTimer, advancing nextRunAtMs to the next future interval before the due-check happens. The job is perpetually pushed forward and never found due.

at (one-shot) jobs are unaffected because they return a fixed timestamp that doesn't change on recompute.

Steps to reproduce

  1. Create an every job (e.g., everyMs: 120000) or a cron job (e.g., expr: "*/2 * * * *")
  2. Restart the gateway
  3. Wait past the scheduled nextRunAtMs
  4. Job does not execute — repeat through multiple cycles, still never fires

Both every and cron kinds are affected. at (one-shot) is not.

Expected behavior

The job should execute each time the timer fires at nextRunAtMs, then reschedule for the next interval.

Actual behavior

The timer fires correctly, but ensureLoaded(forceReload) calls recomputeNextRuns() which advances nextRunAtMs past now before runDueJobs() gets to check. The job is never found due. This repeats every cycle, forever.

Why it happens

onTimer calls ensureLoaded(forceReload) before runDueJobs:

async function onTimer(state) {
    ...
    await ensureLoaded(state, { forceReload: true }); // recomputes FIRST
    await runDueJobs(state);                          // checks due SECOND
    ...
}

ensureLoaded(forceReload) reloads from disk then calls recomputeNextRuns(state), which does:

job.state.nextRunAtMs = computeJobNextRunAtMs(job, now);

For every, computeNextRunAtMs computes ceil(elapsed / everyMs) * everyMs — always the next future boundary when now is even 1ms past the current one.

For cron, new Cron(expr).nextRun(new Date(nowMs)) returns the next occurrence after now — same effect.

For at, parseAbsoluteTimeMs(job.schedule.at) returns a fixed timestamp — unaffected by recompute.

The race:

Timer armed for T = anchor + k * everyMs
Timer fires at T + ε  (setTimeout always fires slightly late)

recomputeNextRuns():
    now = T + ε
    nextRunAtMs = T + everyMs   ← pushed to next interval

runDueJobs():
    (T + ε) >= (T + everyMs)?  →  FALSE  →  not due

armTimer() re-arms for T + everyMs → same thing happens forever

Suggested fix

In recomputeNextRuns, skip jobs that are already due — let runDueJobs handle them:

 function recomputeNextRuns(state) {
     ...
     for (const job of state.store.jobs) {
         ...
+        const oldNext = job.state.nextRunAtMs;
+        if (typeof oldNext === "number" && now >= oldNext) continue;
         job.state.nextRunAtMs = computeJobNextRunAtMs(job, now);
     }
 }

Safe because executeJob.finish() already advances nextRunAtMs after execution. Due jobs stay due, get picked up by runDueJobs, then advance normally.

Environment

  • Clawdbot version: 2026.2.3-1
  • OS: macOS Darwin 25.2.0
  • Install method: npm (global), Node.js v22.20.0

Logs or screenshots

N/A — bug is deterministic from code inspection. The race is inherent: setTimeout always fires at T + ε, and recomputing at T + ε always yields T + everyMs > T + ε.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions