Cron: scheduled tasks
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:
- 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;
CronServicedirectly holdsresolveSessionStorePathanddefaultAgentId, so on schedule trigger it picks the right session store path by sessionTarget. - 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". - wake coordination: many main-session tasks don't want to cold-start directly colliding with the user's active frontend session; setting
wakeModetonext-heartbeatwaits 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, holdsCronServiceState; all operations delegate toservice/ops.js.CronServiceDeps:L62-L184— dependency injection surface:nowMs/log/storePath/cronEnabled/defaultAgentId/runIsolatedAgentJob/runCommandJob/requestHeartbeat/sendCronFailureAlertetc.ops.ts:L42-L92— common CRUD/list/manual run operations, delegating tolockedserialization thentimer.ts.locked:L13-L25— serializes all writes bystorePath, preserving state-local ordering.timer.ts top:L94-L153—MAX_TIMER_DELAY_MS=60_000,MIN_REFIRE_GAP_MS=2_000, startup catch-up constants.executeJobCoreWithTimeout:L162-L220—AbortController+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;loadCronJobsStoreWithConfigJobsloads 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, preventingagent:xxx:mainfrom mismatching the configuredmainKeyand 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:
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:
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:
cronEvalCachecapped 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),nextRunreturns a past year. The code first computes bynowMs; if the result<= nowMs, retries with "next second", then "UTC tomorrow midnight"; only if both fail does it returnundefined. - MIN_REFIRE_GAP_MS = 2_000 (
MIN_REFIRE_GAP_MS:L104): at least 2 seconds between two triggers of the same job, preventingcomputeJobNextRunAtMsfrom returning same-second timestamps and causing a spin-loop (#17821). - active job marker generation:
markCronJobActivecarries the currentgeneration; on gateway restart,state.generation++invalidates all previous-generation markers (isCronActiveJobMarkerCurrentreturns false).executeJobCoreWithTimeoutchecks the marker at entry; on mismatch it immediatelyabort("Gateway restarting.")returns a cancelled outcome. Jobs on themainsession getpreserveAcrossGenerationAdvance: 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 throughsetTimeoutrace. Both terminate core execution via the samerunAbortController.abort(reason); the difference is the returnedstatus:cancelledvstimed_out. - session key canonicalize:
resolveCronAgentSessionKeyrewritesagent:xxx:maintoagent:xxx:<configuredMainKey>; otherwise whencfg.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:
lockedserializes bystorePath(locked:L13);state.opandstoreLocks.get(storePath)both join thePromise.allchain, 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.