Cron: tâches planifiées
Responsabilités
CronService (CronService:15-83) est le service d'ordonnancement (scheduling) builtin d'OpenClaw: lire la table cron, calculer la prochaine heure de déclenchement, réveiller l'agent selon le plan, livrer le résultat au canal, et persister (persistence) tout état en SQLite. Il n'a rien à voir avec le daemon cron externe — CronService tourne dans le process gateway, piloté par un timer NodeJS.Timeout et des expressions croner, sans dépendre au cron système.
Trois types de planification supportés (computeNextRunAtMs:55-119): at (temps absolu ponctuel), every (intervalle fixe + ancre anchor), cron (expression standard 5 segments + tz/staggerMs optionnels). Chaque job porte aussi un sessionTarget (CronSessionTarget:21) qui décide sur quelle session il tourne: main (session principale partagée), isolated (session indépendante (isolated) à chaque fois), current (session courante), session:xxx (session nommée).
Motivation de conception
Pourquoi ne pas utiliser cron système / systemd timer? Trois raisons:
- Attache à une session: la sortie d'un cron job n'est pas « lancer un shell », mais entrer dans le contexte d'une session agent, avec mémoire, stratégie d'outils, auth profile. Le cron système ne connaît pas la session;
CronServicedétientresolveSessionStorePathetdefaultAgentId, peut choisir le bon chemin de session store selon sessionTarget au moment du déclenchement. - Catch-up et redémarrage in-process: au redémarrage gateway,
CronService.start()fait deux choses — rattraper les missed jobs non terminés, et marquer les active job en cours comme « génération précédente » pour que la nouvelle génération prenne le relais (getCronActiveJobState:22-56). Le cron système n'a pas de concept de génération, ne sait pas « au redémarrage, laisser l'ancien run échouer naturellement mais garder l'état pour diag ». - Coordination wake: beaucoup de tâches main-session ne veulent pas冷 démarrer en collision avec la session active de l'utilisateur frontend; wakeMode réglé sur
next-heartbeatpermet d'attendre la prochaine heartbeat (CronWakeMode:23). C'est une sémantique propre aux systèmes d'agent, que le cron externe ne sait pas exprimer.
L'autre design clé est le cache LRU des expressions cron (resolveCachedCron:10-41): croner parser une expression 5 segments est relativement coûteuse, mais les cron jobs peuvent être ajoutés/supprimés/modifiés fréquemment, donc cache avec plafond CRON_EVAL_CACHE_MAX = 512, LRU évince l'entrée la plus ancienne, pour garantir une mémoire contrôlée tout en hit sur les expressions chaudes.
Fichiers clés
CronService facade:15-83— Facade avec état, détientCronServiceState, délègue toutes les ops àservice/ops.js.CronServiceDeps:62-184— Interface d'injection de dépendances:nowMs/log/storePath/cronEnabled/defaultAgentId/runIsolatedAgentJob/runCommandJob/requestHeartbeat/sendCronFailureAlertetc.ops.ts:42-92— Opérations CRUD/list/manual run publiques, délègue àlockedpour sérialisation puis appelletimer.ts.locked:13-25— Sérialise toutes les écritures parstorePath, préserve state-local ordering.timer.ts top:94-153—MAX_TIMER_DELAY_MS=60_000,MIN_REFIRE_GAP_MS=2_000, constantes de startup catch-up.executeJobCoreWithTimeout:162-220—AbortController+operatorCancellationPromise+ timeout wall-clock optionnel, exécuteur core.computeNextRunAtMs:55-119— Calcul de prochaine heure de déclenchement pour les trois types (at/every/cron), incluant le workaround croner year-rollback.CronActiveJobMarker:14-56— Table d'active jobs au niveau process, globalThis singleton symbol-keyed, partagé cross-module reload.cron store:35-79— Persistance SQLite-backed,loadCronJobsStoreWithConfigJobscharge tous les jobs en mémoire au démarrage.isolated-agent run.ts:1-170— Orchestration d'un tour d'agent isolé (isolated): résolution session, choix modèle, auth profile, preflight, exécution, delivery, cleanup.resolveCronAgentSessionKey:7-26— Canonicalize les alias main key, pour éviter queagent:xxx:mainet lemainKeyconfiguré ne matchent pas et laissent la session orpheline.cron-task-cancel:12-77— Handles d'annulation et settlement grace pour les active cron task runs au niveau process.
Flux de données
Le cœur de la boucle de scheduling est computeNextRunAtMs (computeNextRunAtMs:55): il accepte CronSchedule et le temps courant, renvoie le timestamp absolu en ms du prochain déclenchement. Sémantique différente pour les trois types:
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; // temps passé ne déclenche pas
}
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é sur anchor, anti drift
}
// expression cron via croner, avec cache LRU
const cron = resolveCachedCron(expr, resolveCronTimezone(schedule.tz));
const next = cron.nextRun(new Date(nowMs));
// ... inclut workaround year-rollback (voir ci-dessous)
}Le design anchor de every est clé: sans anchor, on prend nowMs comme ancre, le job déclenche une fois immédiatement à la création, puis toutes les everyMs; avec anchor, on aligne sur anchor, même après restart en cours de route on peut calculer « aurait dû déclencher à t1, maintenant on est à t2, le prochain est t3 », avec catch-up sans redéclencher en double.
L'exécuteur executeJobCoreWithTimeout (executeJobCoreWithTimeout:162) lance trois race simultanément:
export async function executeJobCoreWithTimeout(state, job, opts) {
const runAbortController = new AbortController();
const operatorCancellationMarker = Symbol("cron-operator-cancelled");
// ... registerActiveCronTaskRun enregistre le controller dans la table au niveau process,
// au restart gateway tous les active runs peuvent être abort en une fois
if (typeof jobTimeoutMs !== "number") {
const corePromise = executeJobCore(state, job, runAbortController.signal);
trackActiveCronTaskRunSettlement(corePromise);
const first = await Promise.race([corePromise, operatorCancellationPromise]);
// ... operator cancel renvoie cancelled outcome, sinon renvoie le core result
}
// si timeout, on ajoute un timeoutPromise, trois en race
}operatorCancellationPromise est une Promise qui ne se resolve jamais elle-même; seul un appel externe à registerActiveCronTaskRun enregistré via onCancel qui déclenche resolveOperatorCancellation(marker) la settles — c'est le canal unifié pour « au restart gateway, interrompre les active cron runs ».
Le catch-up au démarrage partitionne les missed jobs en deux batches (catch-up constants:106-108): déclenche immédiatement jusqu'à DEFAULT_MAX_MISSED_JOBS_PER_RESTART=5, les autres étalés sur DEFAULT_MISSED_JOB_STAGGER_MS=5_000 ms; les missed jobs qui nécessitent de tirer un agent (non command-only) sont encore différés de DEFAULT_STARTUP_DEFERRED_MISSED_AGENT_JOB_DELAY_MS=2*60_000 ms, pour éviter d'écraser les ressources de bootstrap modèle/outils pendant la fenêtre de connexion channel.
Limites et modes d'échec
- Bornes du cache LRU: plafond
cronEvalCacheà 512 (CRON_EVAL_CACHE_MAX:10); sur hit, supprime puis set pour maintenir l'ordre LRU. Quand les expressions sont ajoutées/supprimées/modifiées, les anciennes entrées sont naturellement évincées, pas de lecture d'un objet Cron périmé. - Bug croner year-rollback (
year-rollback workaround:93-116): dans certains fuseaux (ex Asia/Shanghai),nextRunpeut renvoyer une année passée. Le code calcule d'abord parnowMs, si le résultat<= nowMs, retry par « la seconde suivante », puis retry par « demain 0h UTC », si les deux échouent renvoieundefined. - MIN_REFIRE_GAP_MS = 2_000 (
MIN_REFIRE_GAP_MS:104): entre deux déclenchements du même job au moins 2 secondes, pour empêchercomputeJobNextRunAtMsde renvoyer un timestamp de la même seconde et de partir en spin-loop (#17821). - active job marker generation:
markCronJobActiveporte lagenerationcourante; au restart gateway,state.generation++, tous les markers de génération précédente sont invalidés (isCronActiveJobMarkerCurrentrenvoie false),executeJobCoreWithTimeoutà l'entrée vérifie le marker, si non matché abort immédiat avecabort("Gateway restarting.")renvoyant un outcome cancelled. Les jobsmainsession ontpreserveAcrossGenerationAdvance: true, car les runs de session principale doivent traverser les générations plutôt que d'être interrompus. - operator cancellation vs timeout: deux chemins d'interruption indépendants. Operator cancellation passe par
abortActiveCronTaskRuns(abortActiveCronTaskRuns:50), timeout passe parsetTimeoutrace. Les deux terminent le core via le mêmerunAbortController.abort(reason), la différence est lestatusretourné:cancelledvstimed_out. - Canonicalize session key:
resolveCronAgentSessionKeyréécritagent:xxx:mainenagent:xxx:<configuredMainKey>, sinon quandcfg.session.mainKey !== "main"la session écrite par cron et la clé lue ne correspondent pas, la session devient « orpheline » et introuvable (#29683). - Étalement startup catch-up: les missed jobs ne sont pas tous lancés d'un coup, pour éviter qu'au démarrage gateway une nuée de agent runs ne crashe; les missed jobs agent sont en plus décalés de 2 minutes, pour céder la fenêtre de bootstrap modèle/outils à la fenêtre de connexion channel.
- store lock:
lockedsérialise parstorePath(locked:13),state.opetstoreLocks.get(storePath)ajoutés à laPromise.allchain, garantissent state-local ordering (sur un même state, les ops queue dans l'ordre d'appel) et cross-state concurrency (différents storePath peuvent être concurrents).
Résumé
CronService compresse scheduling, exécution, persistance, delivery en une facade, mais l'implémentation est éclatée en couches: schedule.ts ne calcule que le temps, active-jobs.ts ne gère que la table active au niveau process, timer.ts gère exécution + timeout + cancel, isolated-agent/ gère le tirage d'un tour d'agent isolated, store.ts gère la persistance SQLite. Cette stratification fait que cron n'est plus « lancer un script à l'heure » mais « réveiller l'agent selon le plan + livrer le résultat au canal » — c'est l'amont de Tasks: tâches persistées, le mécanisme réel de déclenchement de l'agent dans boucle principale de l'agent, les options de config (timeout/retry/missedJobStagger) dans openclaw.json.