Skip to content

Cron: tareas programadas

源码版本v2026.6.11

Responsabilidad

CronService (CronService:15-83) es el servicio de planificación (scheduling) builtin de OpenClaw: lee la cron table, calcula el próximo tiempo de disparo, despierta al agent según lo planificado, entrega el resultado de vuelta al canal, y persiste todo el estado (persistence) a SQLite. No tiene relación con un daemon cron externo — CronService corre dentro del proceso del gateway, impulsado por un NodeJS.Timeout y expresiones croner, sin depender del cron del sistema.

Soporta tres formas de planificación (computeNextRunAtMs:55-119): at (tiempo absoluto de una sola vez), every (intervalo fijo + anchor), cron (expresión estándar de 5 campos + opcional tz/staggerMs). Cada job lleva además un sessionTarget (CronSessionTarget:21) que decide a qué sesión correr: main (sesión principal compartida), isolated (levantar una sesión aislada (isolated) cada vez), current (sesión actual), session:xxx (sesión nombrada).

Motivación de diseño

¿Por qué no usar directamente cron del sistema / systemd timer? Tres razones:

  1. Pertenencia a sesión: la salida de un job cron no es simplemente «correr un shell», sino que debe entrar al contexto de una sesión de agent específica, con memoria, política de herramientas y auth profile. El cron del sistema no conoce sesiones, mientras que CronService posee resolveSessionStorePath y defaultAgentId, y al disparar puede elegir el path de session store adecuado según sessionTarget.
  2. Catch-up y reinicio in-process: al arrancar el gateway, CronService.start() hace dos cosas — reponer los missed jobs que no se ejecutaron, y marcar los active jobs en curso como «generación anterior» para que la nueva generación los tome (getCronActiveJobState:22-56). El cron del sistema no tiene concepto de generación, no puede «dejar que los run viejos caduquen naturalmente al reiniciar pero mantener estado para diagnóstico».
  3. Coordinación wake: muchas tareas de main-session no quieren que un cold-start choque con la sesión activa del usuario en el frontend; poner wakeMode a next-heartbeat hace que esperen al próximo latido (heartbeat) antes de dispararse (CronWakeMode:23). Es semántica específica de sistemas de agent, el cron externo no puede expresarla.

Otro diseño clave es caché LRU de expresiones cron (resolveCachedCron:10-41): croner parsear una expresión de 5 campos es relativamente caro, pero los cron jobs se añaden/eliminan/editan frecuentemente, así que el caché tiene tope CRON_EVAL_CACHE_MAX = 512, con LRU eviction, manteniendo memoria acotada y dando hit a expresiones calientes.

Archivos clave

  • CronService facade:15-83 — facade con estado, mantiene CronServiceState, todas las operaciones delegan a service/ops.js.
  • CronServiceDeps:62-184 — cara de inyección de dependencias: nowMs/log/storePath/cronEnabled/defaultAgentId/runIsolatedAgentJob/runCommandJob/requestHeartbeat/sendCronFailureAlert, etc.
  • ops.ts:42-92 — operaciones CRUD/list/manual run comunes, delega a locked para serializar antes de llamar a timer.ts.
  • locked:13-25 — serializa todas las escrituras por storePath, preserva state-local ordering.
  • timer.ts top:94-153MAX_TIMER_DELAY_MS=60_000, MIN_REFIRE_GAP_MS=2_000, constantes de catch-up en startup.
  • executeJobCoreWithTimeout:162-220AbortController + operatorCancellationPromise + wall-clock timeout opcional, ejecutor central.
  • computeNextRunAtMs:55-119 — cálculo del próximo disparo para los tres tipos (at/every/cron), incluye workaround de year-rollback de croner.
  • CronActiveJobMarker:14-56 — tabla de active jobs a nivel proceso, symbol-keyed globalThis singleton, compartido entre reloads de módulos.
  • cron store:35-79 — persistencia SQLite, loadCronJobsStoreWithConfigJobs carga los jobs a memoria en un solo paso al arrancar.
  • isolated-agent run.ts:1-170 — orquestación de un turn de agent aislado (isolated): resolución de sesión, modelo, auth profile, preflight, ejecución, delivery, cleanup.
  • resolveCronAgentSessionKey:7-26 — canonicalize el alias de main key, evitando que agent:xxx:main no matchee con el mainKey configurado y la sesión quede huérfana.
  • cron-task-cancel:12-77 — handle de cancelación y settlement grace para active cron task run a nivel proceso.

Flujo de datos

El núcleo del loop de planificación es computeNextRunAtMs (computeNextRunAtMs:55): toma un CronSchedule y el tiempo actual, devuelve el timestamp absoluto del próximo disparo. Los tres tipos tienen semántica distinta:

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;  // tiempo pasado no dispara
  }
  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;       // alinea por anchor, evita drift
  }
  // expresión cron va por croner, con caché LRU
  const cron = resolveCachedCron(expr, resolveCronTimezone(schedule.tz));
  const next = cron.nextRun(new Date(nowMs));
  // ... incluye workaround de year-rollback (ver abajo)
}

El diseño de anchor de every es clave: sin anchor, usa nowMs como ancla, el job se dispara una vez justo al crearse y luego cada everyMs; con anchor, se alinea por anchor, así aunque el proceso se reinicie a mitad puede calcular «debía dispararse en t1, ya pasó t2, el próximo es t3», y combinado con catch-up no se dispara dos veces.

El ejecutor executeJobCoreWithTimeout (executeJobCoreWithTimeout:162) cuelga tres race a la vez:

typescript
export async function executeJobCoreWithTimeout(state, job, opts) {
  const runAbortController = new AbortController();
  const operatorCancellationMarker = Symbol("cron-operator-cancelled");
  // ... registerActiveCronTaskRun registra el controller en la tabla proceso,
  //     así al reiniciar el gateway se abortan todos los active run de una vez
  if (typeof jobTimeoutMs !== "number") {
    const corePromise = executeJobCore(state, job, runAbortController.signal);
    trackActiveCronTaskRunSettlement(corePromise);
    const first = await Promise.race([corePromise, operatorCancellationPromise]);
    // ... operator cancel devuelve outcome cancelled, si no devuelve resultado core
  }
  // si hay timeout, se añade un timeoutPromise más, race de tres
}

operatorCancellationPromise es una Promise que nunca se resuelve por sí misma, solo settle cuando se invoca resolveOperatorCancellation(marker) desde onCancel registrado en registerActiveCronTaskRun — es el canal unificado de «interrumpir active cron run al reiniciar el gateway».

El catch-up de arranque divide los missed jobs en dos lotes (catch-up constants:106-108): dispara inmediatamente hasta DEFAULT_MAX_MISSED_JOBS_PER_RESTART=5, el resto se escalona con DEFAULT_MISSED_JOB_STAGGER_MS=5_000 ms; los missed jobs que requieren levantar un agent (no command-only) se retrasan adicionalmente DEFAULT_STARTUP_DEFERRED_MISSED_AGENT_JOB_DELAY_MS=2*60_000 ms, evitando competir con la ventana de bootstrap de modelo/herramientas durante el channel connect.

Límites y fallos

  • Límites del caché LRU: cronEvalCache con tope 512 (CRON_EVAL_CACHE_MAX:10), al hacer hit se borra y se vuelve a set para mantener orden LRU. Tras añadir/eliminar/editar expresiones, las viejas se evictan naturalmente, no se lee un objeto Cron caducado.
  • year-rollback bug de croner (year-rollback workaround:93-116): en algunas zonas horarias (como Asia/Shanghai), nextRun puede devolver un año pasado. El código primero calcula según nowMs, si el resultado <= nowMs reintenta con «el próximo segundo», luego reintenta con «UTC mañana a medianoche», y si ambos fallan devuelve undefined.
  • MIN_REFIRE_GAP_MS = 2_000 (MIN_REFIRE_GAP_MS:104): entre dos disparos del mismo job al menos 2 segundos, previene que computeJobNextRunAtMs devuelva timestamps del mismo segundo y cause spin-loop (#17821).
  • active job marker generation: markCronJobActive lleva la generation actual; al reiniciar el gateway, state.generation++, todos los markers de la generación previa caducan (isCronActiveJobMarkerCurrent devuelve false), y la entrada de executeJobCoreWithTimeout chequea que si el marker no matchea, aborta inmediatamente con abort("Gateway restarting.") devolviendo outcome cancelled. Los jobs de sesión main llevan preserveAcrossGenerationAdvance: true, porque los run de la sesión principal deben continuar entre generaciones en lugar de cortarse.
  • operator cancellation vs timeout: dos rutas de interrupción independientes. operator cancellation pasa por abortActiveCronTaskRuns (abortActiveCronTaskRuns:50), timeout pasa por race con setTimeout. Ambas terminan el core vía el mismo runAbortController.abort(reason), y difieren en el status devuelto: cancelled vs timed_out.
  • canonicalize de session key: resolveCronAgentSessionKey reescribe agent:xxx:main a agent:xxx:<configuredMainKey>, si no, cuando cfg.session.mainKey !== "main" la sesión que cron escribe y la key que lee por la ruta de lectura no coinciden, y la sesión queda huérfana (#29683).
  • Stagger de catch-up en startup: los missed jobs no se corren todos de golpe, evita que el gateway recién arrancado se caiga bajo una oleada de runs de agent; los missed jobs tipo agent además se retrasan 2 minutos extra, cediendo la ventana de bootstrap de modelo/herramientas a la conexión de canales.
  • store lock: locked serializa por storePath (locked:13), state.op y storeLocks.get(storePath) se añaden al mismo Promise.all chain, garantizando state-local ordering (operaciones sobre el mismo state se encolan por orden de llamada) y conciencia cross-state (distintos storePath pueden correr en paralelo).

Resumen

CronService combina planificación, ejecución, persistencia y delivery en un único facade, pero la implementación se descompone por capas: schedule.ts solo calcula tiempos, active-jobs.ts solo gestiona la tabla activa a nivel proceso, timer.ts se ocupa de ejecución + timeout + cancel, isolated-agent/ levanta un turn de agent aislado, y store.ts hace la persistencia SQLite. Esta estratificación convierte cron de «correr un script en un momento dado» en «despertar al agent según plan + entregar el resultado al canal» — es el upstream de Tasks: tareas persistentes; el mecanismo concreto de disparar el agent en Runner embebido; opciones de config (timeout/retry/missedJobStagger) en openclaw.json.