Cron: tareas programadas
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:
- 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
CronServiceposeeresolveSessionStorePathydefaultAgentId, y al disparar puede elegir el path de session store adecuado según sessionTarget. - 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». - 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-heartbeathace 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, mantieneCronServiceState, todas las operaciones delegan aservice/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 alockedpara serializar antes de llamar atimer.ts.locked:13-25— serializa todas las escrituras porstorePath, preserva state-local ordering.timer.ts top:94-153—MAX_TIMER_DELAY_MS=60_000,MIN_REFIRE_GAP_MS=2_000, constantes de catch-up en startup.executeJobCoreWithTimeout:162-220—AbortController+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,loadCronJobsStoreWithConfigJobscarga 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 queagent:xxx:mainno matchee con elmainKeyconfigurado 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:
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:
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:
cronEvalCachecon 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),nextRunpuede devolver un año pasado. El código primero calcula segúnnowMs, si el resultado<= nowMsreintenta con «el próximo segundo», luego reintenta con «UTC mañana a medianoche», y si ambos fallan devuelveundefined. - MIN_REFIRE_GAP_MS = 2_000 (
MIN_REFIRE_GAP_MS:104): entre dos disparos del mismo job al menos 2 segundos, previene quecomputeJobNextRunAtMsdevuelva timestamps del mismo segundo y cause spin-loop (#17821). - active job marker generation:
markCronJobActivelleva lagenerationactual; al reiniciar el gateway,state.generation++, todos los markers de la generación previa caducan (isCronActiveJobMarkerCurrentdevuelve false), y la entrada deexecuteJobCoreWithTimeoutchequea que si el marker no matchea, aborta inmediatamente conabort("Gateway restarting.")devolviendo outcome cancelled. Los jobs de sesiónmainllevanpreserveAcrossGenerationAdvance: 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 consetTimeout. Ambas terminan el core vía el mismorunAbortController.abort(reason), y difieren en elstatusdevuelto:cancelledvstimed_out. - canonicalize de session key:
resolveCronAgentSessionKeyreescribeagent:xxx:mainaagent:xxx:<configuredMainKey>, si no, cuandocfg.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:
lockedserializa porstorePath(locked:13),state.opystoreLocks.get(storePath)se añaden al mismoPromise.allchain, 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.