Skip to content

[Bug]: Cron jobs with 'every' schedule never execute - recomputeNextRuns() pushes times forward #10934

@andrew867

Description

@andrew867

Summary

Cron jobs with schedule.kind: "every" never execute automatically because recomputeNextRuns() always recalculates nextRunAtMs based on current time, pushing all scheduled times forward before runDueJobs() can find any due jobs.

Steps to reproduce

  1. Create a cron job with schedule: { kind: "every", everyMs: 1020000 } (17 minutes)
  2. Wait for the scheduled time to arrive
  3. Observe that the job never executes automatically
  4. Check nextWakeAtMs - it keeps getting pushed forward by exactly the interval

Expected behavior

Cron jobs should execute automatically when nextRunAtMs is reached.

Actual behavior

The timer fires, but recomputeNextRuns() is called in onTimer() before runDueJobs(), which:

  1. Recalculates all nextRunAtMs based on current time
  2. For "every" schedules without anchorMs, uses current time as anchor (line 2932)
  3. Pushes all nextRunAtMs forward
  4. runDueJobs() finds no jobs due because times were just pushed forward
  5. armTimer() is called with the new (pushed forward) nextWakeAtMs

Root cause: recomputeNextRuns() at line 3025 always recalculates nextRunAtMs for all jobs, even if they already have valid future times.

Environment

  • Clawdbot version: 2026.2.3-1
  • OS: Windows 10 (build 26200)
  • Install method: npm (global)

Proposed Fix

recomputeNextRuns() should preserve existing nextRunAtMs if they are valid and in the future, only recalculating if:

  • The job does not have a nextRunAtMs
  • The nextRunAtMs is in the past
  • The job was just modified

Suggested fix in src/cron/service/jobs.ts:

function recomputeNextRuns(state) {
  if (!state.store) return;
  const now = state.deps.nowMs();
  for (const job of state.store.jobs) {
    if (!job.state) job.state = {};
    if (!job.enabled) {
      job.state.nextRunAtMs = void 0;
      job.state.runningAtMs = void 0;
      continue;
    }
    const runningAt = job.state.runningAtMs;
    if (typeof runningAt === "number" && now - runningAt > STUCK_RUN_MS) {
      state.deps.log.warn({
        jobId: job.id,
        runningAtMs: runningAt
      }, "cron: clearing stuck running marker");
      job.state.runningAtMs = void 0;
    }
    // PRESERVE existing nextRunAtMs if valid and in the future
    const existingNext = job.state.nextRunAtMs;
    if (typeof existingNext === "number" && existingNext > now) {
      continue; // Keep existing valid future time
    }
    // Only recalculate if missing or in the past
    job.state.nextRunAtMs = computeJobNextRunAtMs(job, now);
  }
}

Workaround

Set anchorMs for all "every" schedules to prevent recalculation from pushing times forward:

{
  "schedule": {
    "kind": "every",
    "everyMs": 1020000,
    "anchorMs": 1770381715751
  }
}

This ensures computeNextRunAtMs() uses a fixed anchor instead of current time. Use createdAtMs or lastRunAtMs as the anchor value.

Code References

  • Bug location: dist/gateway-cli-D_8miTjF.js line 3025 in recomputeNextRuns()
  • Related: dist/gateway-cli-D_8miTjF.js line 2932 in computeNextRunAtMs() (uses nowMs as anchor if anchorMs not set)
  • Timer flow: onTimer()ensureLoaded()recomputeNextRuns()runDueJobs()armTimer()

Logs

The timer fires (evidenced by nextWakeAtMs changing), but no jobs execute. No onTimer, runDueJobs, or executeJob entries in logs around scheduled times.

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