-
-
Notifications
You must be signed in to change notification settings - Fork 52.7k
Description
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
- Create an
everyjob (e.g.,everyMs: 120000) or acronjob (e.g.,expr: "*/2 * * * *") - Restart the gateway
- Wait past the scheduled
nextRunAtMs - 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 + ε.