Skip to content

Cron: scheduled tasks

源码版本v2026.6.11

Responsibilities

CronService (CronService:L15-L83) is OpenClaw's built-in scheduling service: it reads the cron table, computes next-trigger time, wakes the agent on schedule, delivers results back to channels, and persists all state to SQLite. It has nothing to do with the external cron daemon — CronService runs inside the gateway process itself, driven by a NodeJS.Timeout timer and croner expressions, not depending on system cron.

Three schedule forms are supported (computeNextRunAtMs:L55-L119): at (single absolute time), every (fixed interval + anchor), cron (standard 5-field expression + optional tz/staggerMs). Each job also carries a sessionTarget (CronSessionTarget:L21) deciding which session it runs on: main (shared main session), isolated (fresh isolated session each time), current (current session), session:xxx (named session).

Design motivation

Why not just use system cron / systemd timer? Three reasons:

  1. session affinity: a cron job's output isn't just "run a shell"; it has to enter a specific agent session's context, carrying memory, tool policies, and auth profile. System cron doesn't know sessions; CronService directly holds resolveSessionStorePath and defaultAgentId, so on schedule trigger it picks the right session store path by sessionTarget.
  2. catch-up and in-process restart: on gateway restart, CronService.start() does two things — catches up the last round's missed jobs, and marks the currently-running active jobs as "previous generation" so the new generation can take over (getCronActiveJobState:L22-L56). System cron has no generation concept; it can't "let old runs naturally invalidate on restart but keep state for diagnostics".
  3. wake coordination: many main-session tasks don't want to cold-start directly colliding with the user's active frontend session; setting wakeMode to next-heartbeat waits for the next heartbeat to trigger (CronWakeMode:L23). This is agent-system-specific semantics that external cron can't express.

Another key design is LRU-caching cron expressions (resolveCachedCron:L10-L41): parsing a 5-field expression with croner is relatively expensive, but cron jobs can be frequently added/removed/modified, so the cache cap CRON_EVAL_CACHE_MAX = 512 with LRU eviction keeps memory bounded while hot expressions hit.

Key files

  • CronService facade:L15-L83 — stateful service facade, holds CronServiceState; all operations delegate to service/ops.js.
  • CronServiceDeps:L62-L184 — dependency injection surface: nowMs/log/storePath/cronEnabled/defaultAgentId/runIsolatedAgentJob/runCommandJob/requestHeartbeat/sendCronFailureAlert etc.
  • ops.ts:L42-L92 — common CRUD/list/manual run operations, delegating to locked serialization then timer.ts.
  • locked:L13-L25 — serializes all writes by storePath, preserving state-local ordering.
  • timer.ts top:L94-L153MAX_TIMER_DELAY_MS=60_000, MIN_REFIRE_GAP_MS=2_000, startup catch-up constants.
  • executeJobCoreWithTimeout:L162-L220AbortController + operatorCancellationPromise + optional wall-clock timeout; core executor.
  • computeNextRunAtMs:L55-L119 — next-trigger computation for the three schedule kinds (at/every/cron), including a croner year-rollback workaround.
  • CronActiveJobMarker:L14-L56 — process-level active job table; symbol-keyed globalThis singleton, shared across module reloads.
  • cron store:L35-L79 — SQLite-backed persistence; loadCronJobsStoreWithConfigJobs loads all jobs into memory at startup.
  • isolated-agent run.ts:L1-L170 — orchestration of one isolated agent turn: session resolution, model selection, auth profile, preflight, execution, delivery, cleanup.
  • resolveCronAgentSessionKey:L7-L26 — canonicalizes main key aliases, preventing agent:xxx:main from mismatching the configured mainKey and orphaning the session.
  • cron-task-cancel:L12-L77 — cancellation handle and settlement grace for process-level active cron task runs.

Data flow

The core of the scheduling loop is computeNextRunAtMs (computeNextRunAtMs:L55): it takes a CronSchedule and the current time, returns the absolute millisecond timestamp of the next trigger. The three kinds differ in semantics:

typescript
export function computeNextRunAtMs(schedule: CronSchedule, nowMs: number): number | undefined {
  if (schedule.kind === "at") {
    const atMs = parseAbsoluteTimeMs(schedule.at);
    if (atMs === null) return undefined;
    return atMs > nowMs ? atMs : undefined;  // past time doesn't trigger
  }
  if (schedule.kind === "every") {
    const everyMs = Math.max(1, Math.floor(everyMsRaw));
    const anchor = Math.max(0, Math.floor(anchorMs ?? nowMs));
    if (nowMs < anchor) return anchor;
    const elapsed = nowMs - anchor;
    const steps = Math.floor(elapsed / everyMs) + 1;
    return anchor + steps * everyMs;       // align to anchor, preventing drift
  }
  // cron expression goes through croner, with LRU cache
  const cron = resolveCachedCron(expr, resolveCronTimezone(schedule.tz));
  const next = cron.nextRun(new Date(nowMs));
  // ... includes year-rollback workaround (see below)
}

The every schedule's anchor design is key: no anchor means use nowMs as the anchor — the task triggers once immediately after creation, then every everyMs. With an anchor, alignment is by anchor; even if the process restarts mid-way, it can compute "should have triggered at t1, now past t2, next should be t3"; combined with catch-up, no double-trigger.

The executor executeJobCoreWithTimeout (executeJobCoreWithTimeout:L162) races three things:

typescript
export async function executeJobCoreWithTimeout(state, job, opts) {
  const runAbortController = new AbortController();
  const operatorCancellationMarker = Symbol("cron-operator-cancelled");
  // ... registerActiveCronTaskRun registers the controller into the process-level table;
  //     on gateway restart, all active runs can be aborted in one shot
  if (typeof jobTimeoutMs !== "number") {
    const corePromise = executeJobCore(state, job, runAbortController.signal);
    trackActiveCronTaskRunSettlement(corePromise);
    const first = await Promise.race([corePromise, operatorCancellationPromise]);
    // ... operator cancel returns cancelled outcome, otherwise core result
  }
  // with timeout, add a timeoutPromise, three-way race
}

operatorCancellationPromise is a Promise that never self-resolves; it only settles when the external onCancel registered by registerActiveCronTaskRun fires resolveOperatorCancellation(marker) — this is the unified channel for "interrupt active cron runs on gateway restart".

Startup catch-up splits missed jobs into two batches (catch-up constants:L106-L108): immediately trigger up to DEFAULT_MAX_MISSED_JOBS_PER_RESTART=5, stagger the rest by DEFAULT_MISSED_JOB_STAGGER_MS=5_000 ms. Agent-required (non-command-only) missed jobs are further delayed by DEFAULT_STARTUP_DEFERRED_MISSED_AGENT_JOB_DELAY_MS=2*60_000 ms, avoiding competing with the channel-connect window for model/tool bootstrap resources.

Boundaries and failures

  • LRU cache boundary: cronEvalCache capped at 512 (CRON_EVAL_CACHE_MAX:L10); on hit, delete then set to maintain LRU order. After expression add/remove/modify, old entries naturally evict, never reading stale Cron objects.
  • croner year-rollback bug (year-rollback workaround:L93-L116): in some timezones (like Asia/Shanghai), nextRun returns a past year. The code first computes by nowMs; if the result <= nowMs, retries with "next second", then "UTC tomorrow midnight"; only if both fail does it return undefined.
  • MIN_REFIRE_GAP_MS = 2_000 (MIN_REFIRE_GAP_MS:L104): at least 2 seconds between two triggers of the same job, preventing computeJobNextRunAtMs from returning same-second timestamps and causing a spin-loop (#17821).
  • active job marker generation: markCronJobActive carries the current generation; on gateway restart, state.generation++ invalidates all previous-generation markers (isCronActiveJobMarkerCurrent returns false). executeJobCoreWithTimeout checks the marker at entry; on mismatch it immediately abort("Gateway restarting.") returns a cancelled outcome. Jobs on the main session get preserveAcrossGenerationAdvance: true, because main-session runs should continue across generations rather than be interrupted.
  • operator cancellation vs timeout: two independent interrupt paths. Operator cancellation goes through abortActiveCronTaskRuns (abortActiveCronTaskRuns:L50); timeout goes through setTimeout race. Both terminate core execution via the same runAbortController.abort(reason); the difference is the returned status: cancelled vs timed_out.
  • session key canonicalize: resolveCronAgentSessionKey rewrites agent:xxx:main to agent:xxx:<configuredMainKey>; otherwise when cfg.session.mainKey !== "main", the session key cron writes won't match the read path's key, and the session gets orphaned (#29683).
  • startup catch-up staggering: missed jobs don't all run at once, avoiding a flock of agent runs crashing the freshly-started gateway; agent-type missed jobs are further delayed 2 minutes, yielding model/tool bootstrap to the channel-connect window.
  • store lock: locked serializes by storePath (locked:L13); state.op and storeLocks.get(storePath) both join the Promise.all chain, ensuring state-local ordering (operations on the same state queue by call order) and cross-state concurrency (different storePaths can run concurrently).

Summary

CronService compresses scheduling, execution, persistence, and delivery into one facade, but the implementation is layered: schedule.ts only computes time; active-jobs.ts only manages the process-level active table; timer.ts handles execution + timeout + cancellation; isolated-agent/ handles spawning one isolated agent turn; store.ts handles SQLite persistence. This layering turns cron from "run a script on schedule" into "wake the agent on schedule + deliver results back to channels" — this is the upstream of Tasks: persistent tasks. The actual mechanism for triggering the agent is in Agent main loop; config options (timeout/retry/missedJobStagger) are in openclaw.json.