Skip to content

Cron: tâches planifiées

源码版本v2026.6.11

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:

  1. 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; CronService détient resolveSessionStorePath et defaultAgentId, peut choisir le bon chemin de session store selon sessionTarget au moment du déclenchement.
  2. 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 ».
  3. 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-heartbeat permet 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étient CronServiceState, 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/sendCronFailureAlert etc.
  • ops.ts:42-92 — Opérations CRUD/list/manual run publiques, délègue à locked pour sérialisation puis appelle timer.ts.
  • locked:13-25 — Sérialise toutes les écritures par storePath, préserve state-local ordering.
  • timer.ts top:94-153MAX_TIMER_DELAY_MS=60_000, MIN_REFIRE_GAP_MS=2_000, constantes de startup catch-up.
  • executeJobCoreWithTimeout:162-220AbortController + 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, loadCronJobsStoreWithConfigJobs charge 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 que agent:xxx:main et le mainKey configuré 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:

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;  // 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:

typescript
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), nextRun peut renvoyer une année passée. Le code calcule d'abord par nowMs, si le résultat <= nowMs, retry par « la seconde suivante », puis retry par « demain 0h UTC », si les deux échouent renvoie undefined.
  • 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êcher computeJobNextRunAtMs de renvoyer un timestamp de la même seconde et de partir en spin-loop (#17821).
  • active job marker generation: markCronJobActive porte la generation courante; au restart gateway, state.generation++, tous les markers de génération précédente sont invalidés (isCronActiveJobMarkerCurrent renvoie false), executeJobCoreWithTimeout à l'entrée vérifie le marker, si non matché abort immédiat avec abort("Gateway restarting.") renvoyant un outcome cancelled. Les jobs main session ont preserveAcrossGenerationAdvance: 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 par setTimeout race. Les deux terminent le core via le même runAbortController.abort(reason), la différence est le status retourné: cancelled vs timed_out.
  • Canonicalize session key: resolveCronAgentSessionKey réécrit agent:xxx:main en agent:xxx:<configuredMainKey>, sinon quand cfg.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: locked sérialise par storePath (locked:13), state.op et storeLocks.get(storePath) ajoutés à la Promise.all chain, 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.