Cron: Zeitgesteuerte Aufgaben
Verantwortung
CronService (CronService:15-83) ist der eingebaute Scheduling-Dienst (scheduling) von OpenClaw: liest die Cron-Tabelle, berechnet die nächste Triggerzeit, weckt den Agenten nach Plan, liefert Ergebnisse an den Kanal zurück und persistiert (persistence) den gesamten Zustand in SQLite. Es hat nichts mit dem externen cron-Daemon zu tun — CronService läuft selbst im Gateway-Prozess, angetrieben von einem NodeJS.Timeout-Timer und croner-Ausdrücken, ohne System-cron.
Drei Scheduler-Typen werden unterstützt (computeNextRunAtMs:55-119): at (einmalige absolute Zeit), every (festes Intervall + anchor), cron (Standard-5-Segment-Ausdruck + optionales tz/staggerMs). Jeder Job trägt zudem sessionTarget (CronSessionTarget:21), das entscheidet, auf welche Session er läuft: main (geteilte Hauptsession), isolated (jeweils eine unabhängige (isolated) Session), current (aktuelle Session), session:xxx (benannte Session).
Designmotivation
Warum nicht direkt System-cron / systemd-timer nutzen? Drei Gründe:
- Session-Zugehörigkeit: Der Output eines Cron-Jobs ist nicht einfach „eine Shell ausführen", sondern tritt in den Kontext einer bestimmten Agent-Session ein, mit Memory, Werkzeugstrategie und Auth-Profil. System-cron kennt keine Sessions;
CronServicehältresolveSessionStorePathunddefaultAgentIddirekt und kann beim Trigger nach sessionTarget den passenden Session-Store-Pfad wählen. - Catch-up und In-Process-Restart: Beim Gateway-Neustart muss
CronService.start()zwei Dinge tun — verpasste Jobs nachholen und laufende aktive Jobs als „vorherige Generation" markieren, damit eine neue Generation sie übernehmen kann (getCronActiveJobState:22-56). System-cron hat keinen Generation-Begriff und kann „beim Neustart alte Runs natürlich verfallen lassen, aber Zustand für Diagnose behalten" nicht. - Wake-Koordination: Viele Main-Session-Jobs sollen beim Kaltstart nicht direkt auf die aktive Frontend-Session des Nutzers prallen;
wakeMode: next-heartbeatlässt sie bis zum nächsten Heartbeat warten (CronWakeMode:23). Das ist eine agentsystemspezifische Semantik, die externes cron nicht ausdrücken kann.
Ein weiteres Schlüsseldesign ist LRU-Cache für cron-Ausdrücke (resolveCachedCron:10-41): Das Parsen eines 5-Segment-Ausdrucks durch croner ist relativ teuer, aber cron-Jobs werden häufig hinzugefügt/geändert/gelöscht; deshalb ist das Cache-Limit CRON_EVAL_CACHE_MAX = 512, LRU verdrängt den ältesten Eintrag, während heiße Ausdrücke treffen.
Schlüsseldateien
CronService Fassade:15-83— Zustandsbehaftete Service-Fassade, hältCronServiceState; alle Operationen gehen anservice/ops.js.CronServiceDeps:62-184— Dependency-Injection-Oberfläche:nowMs/log/storePath/cronEnabled/defaultAgentId/runIsolatedAgentJob/runCommandJob/requestHeartbeat/sendCronFailureAlertu. a.ops.ts:42-92— Öffentliche CRUD/list/manual-run-Operationen; gehen nachlocked-Serialisierung antimer.ts.locked:13-25— Serialisiert alle Schreiboperationen nachstorePath; bewahrt state-local-ordering.timer.ts oben:94-153—MAX_TIMER_DELAY_MS=60_000,MIN_REFIRE_GAP_MS=2_000, startup catch-up-Konstanten.executeJobCoreWithTimeout:162-220—AbortController+operatorCancellationPromise+ optionales Wall-Clock-Timeout; Kern-Executor.computeNextRunAtMs:55-119— Berechnung der nächsten Triggerzeit für die drei Scheduler-Typen (at/every/cron); inklusive croner-Jahres-Rollback-Workaround.CronActiveJobMarker:14-56— Prozesslevel aktive-Job-Tabelle; symbol-keyed globalThis-Singleton, modulübergreifend bei Reloads geteilt.cron store:35-79— SQLite-Persistenz;loadCronJobsStoreWithConfigJobslädt beim Start einmalig alle Jobs in den Speicher.isolated-agent run.ts:1-170— Orchestrierung eines einzelnen Turns eines isolierten (isolated) Agenten: Session-Auflösung, Modellauswahl, Auth-Profil, Preflight, Ausführung, Delivery, Aufräum.resolveCronAgentSessionKey:7-26— Kanonisiert main-key-Aliase; verhindert, dassagent:xxx:mainund der konfiguriertemainKeyauseinanderlaufen und die Session vereinsamt.cron-task-cancel:12-77— Cancel-Handle und settlement grace für prozesslevel aktive cron-task-runs.
Datenfluss
Im Zentrum des Scheduler-Loops steht computeNextRunAtMs (computeNextRunAtMs:55): nimmt CronSchedule und aktuelle Zeit, liefert den absoluten Millisekunden-Zeitstempel des nächsten Triggers. Die drei Typen haben unterschiedliche Semantik:
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; // vergangene Zeit triggert nicht
}
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; // an anchor ausrichten, drift vermeiden
}
// cron-Ausdruck geht durch croner, mit LRU-Cache
const cron = resolveCachedCron(expr, resolveCronTimezone(schedule.tz));
const next = cron.nextRun(new Date(nowMs));
// ... inklusive year-rollback-Workaround (siehe unten)
}Das anchor-Design von every ist kritisch: Ohne anchor gilt nowMs als Anker — der Job triggert einmal sofort nach dem Erstellen, dann alle everyMs; mit anchor wird an anchor ausgerichtet, sodass selbst ein Prozessneustart „sollte bei t1 triggern, jetzt bereits bei t2, nächster ist t3" berechnen kann — zusammen mit catch-up kein Doppelauslösen.
Der Executor executeJobCoreWithTimeout (executeJobCoreWithTimeout:162) spannt drei Race auf:
export async function executeJobCoreWithTimeout(state, job, opts) {
const runAbortController = new AbortController();
const operatorCancellationMarker = Symbol("cron-operator-cancelled");
// ... registerActiveCronTaskRun registriert den controller in der prozesslevel-Tabelle,
// beim Gateway-Neustart können alle aktiven runs mit einem Mal abgebrochen werden
if (typeof jobTimeoutMs !== "number") {
const corePromise = executeJobCore(state, job, runAbortController.signal);
trackActiveCronTaskRunSettlement(corePromise);
const first = await Promise.race([corePromise, operatorCancellationPromise]);
// ... operator-cancel liefert cancelled outcome, sonst core-Ergebnis
}
// mit timeout zusätzlich timeoutPromise, drei Race
}operatorCancellationPromise ist ein Promise, das sich nie selbst resolvt; nur wenn externes registerActiveCronTaskRun registriertes onCancel resolveOperatorCancellation(marker) auslöst, settled es — das ist der einheitliche Kanal „beim Gateway-Neustart aktive cron-runs abbrechen".
Beim Start teilt catch-up missed jobs in zwei Chargen (catch-up Konstanten:106-108): sofort bis zu DEFAULT_MAX_MISSED_JOBS_PER_RESTART=5, die restlichen gestaffelt um DEFAULT_MISSED_JOB_STAGGER_MS=5_000 ms; missed Agent-jobs (nicht command-only) werden zusätzlich um DEFAULT_STARTUP_DEFERRED_MISSED_AGENT_JOB_DELAY_MS=2*60_000 ms verzögert, um im Channel-Connect-Fenster nicht Modell-/Werkzeug-Bootstrap-Ressourcen zu belegen.
Grenzen und Fehler
- LRU-Cache-Grenze:
cronEvalCacheLimit 512 (CRON_EVAL_CACHE_MAX:10); bei Treffer wird gelöscht und neu gesetzt, um die LRU-Reihenfolge zu halten. Nach Änderungen an Ausdrücken werden alte Einträge natürlich verdrängt; es wird nie ein veraltetes Cron-Objekt gelesen. - croner year-rollback-Bug (
year-rollback workaround:93-116): In manchen Zeitzonen (z. B. Asia/Shanghai) liefertnextRunein vergangenes Jahr. Der Code rechnet zuerst mitnowMs; ist das Ergebnis<= nowMs, wird ein Retry mit „nächste Sekunde" versucht, dann mit „UTC morgen 0 Uhr"; schlagen beide fehl, liefert erundefined. - MIN_REFIRE_GAP_MS = 2_000 (
MIN_REFIRE_GAP_MS:104): Zwischen zwei Triggern desselben Jobs liegen mindestens 2 Sekunden; verhindert, dasscomputeJobNextRunAtMsdenselben Sekunden-Zeitstempel liefert und es zu einer Spin-Loop kommt (#17821). - active job marker generation:
markCronJobActiveträgt die aktuellegeneration; beim Gateway-Neustart wirdstate.generation++inkrementiert, alle Marker der vorherigen Generation verfallen (isCronActiveJobMarkerCurrentliefert false); der Eintritt vonexecuteJobCoreWithTimeoutprüft den Marker und bricht sofort mitabort("Gateway restarting.")als cancelled outcome ab. Jobs dermain-Session tragenpreserveAcrossGenerationAdvance: true, da ein Main-Session-Run generationübergreifend fortgesetzt statt unterbrochen werden soll. - operator cancellation vs. timeout: Zwei unabhängige Abbruchpfade. operator cancellation läuft über
abortActiveCronTaskRuns(abortActiveCronTaskRuns:50); timeout übersetTimeout-Race. Beide beenden die core-Ausführung über dasselberunAbortController.abort(reason), der Unterschied liegt im zurückgegebenenstatus:cancelledvs.timed_out. - session key canonicalize:
resolveCronAgentSessionKeyschreibtagent:xxx:mainzuagent:xxx:<configuredMainKey>um, sonst stimmen beicfg.session.mainKey !== "main"die von cron geschriebenen Session-Keys nicht mit dem Lese-Key überein, und die Session wird vereinsamt (#29683). - startup catch-up staggering: missed jobs werden nicht auf einmal laufen gelassen, sonst stürzen beim Gateway-Start viele agent-runs gleichzeitig an; agent-missed-jobs zusätzlich 2 Minuten verzögert, um dem Channel-Connect-Fenster Vorrang vor Modell-/Werkzeug-Bootstrap zu geben.
- store lock:
lockedserialisiert nachstorePath(locked:13);state.opundstoreLocks.get(storePath)werden beide in diePromise.all-Chain aufgenommen, um state-local-ordering (Operationen auf demselben state reihen sich nach Aufrufreihenfolge) und cross-state-Nebenläufigkeit (verschiedene storePaths können parallel) zu garantieren.
Zusammenfassung
CronService presst Scheduling, Ausführung, Persistenz und Delivery in eine Fassade, aber die Implementierung ist nach Schichten zerlegt: schedule.ts berechnet nur Zeiten, active-jobs.ts verwaltet nur die prozesslevel aktive Tabelle, timer.ts ist für Ausführung + Timeout + Cancel, isolated-agent/ zieht einen isolierten Agent-Turn hoch, store.ts macht SQLite-Persistenz. Diese Schichtung macht cron nicht mehr zu „zeitig Skript ausführen", sondern zu „Agent nach Plan wecken + Ergebnis an Kanal liefern" — das ist der Upstream von Tasks: persistente Aufgaben; wie der Agent tatsächlich getriggert wird, siehe Agent-Hauptschleife; Konfiguration (Timeout/Retry/missedJobStagger) siehe openclaw.json.