Skip to content

Cron: Zeitgesteuerte Aufgaben

源码版本v2026.6.11

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:

  1. 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; CronService hält resolveSessionStorePath und defaultAgentId direkt und kann beim Trigger nach sessionTarget den passenden Session-Store-Pfad wählen.
  2. 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.
  3. Wake-Koordination: Viele Main-Session-Jobs sollen beim Kaltstart nicht direkt auf die aktive Frontend-Session des Nutzers prallen; wakeMode: next-heartbeat lä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ält CronServiceState; alle Operationen gehen an service/ops.js.
  • CronServiceDeps:62-184 — Dependency-Injection-Oberfläche: nowMs/log/storePath/cronEnabled/defaultAgentId/runIsolatedAgentJob/runCommandJob/requestHeartbeat/sendCronFailureAlert u. a.
  • ops.ts:42-92 — Öffentliche CRUD/list/manual-run-Operationen; gehen nach locked-Serialisierung an timer.ts.
  • locked:13-25 — Serialisiert alle Schreiboperationen nach storePath; bewahrt state-local-ordering.
  • timer.ts oben:94-153MAX_TIMER_DELAY_MS=60_000, MIN_REFIRE_GAP_MS=2_000, startup catch-up-Konstanten.
  • executeJobCoreWithTimeout:162-220AbortController + 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; loadCronJobsStoreWithConfigJobs lä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, dass agent:xxx:main und der konfigurierte mainKey auseinanderlaufen 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:

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

typescript
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: cronEvalCache Limit 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) liefert nextRun ein vergangenes Jahr. Der Code rechnet zuerst mit nowMs; ist das Ergebnis <= nowMs, wird ein Retry mit „nächste Sekunde" versucht, dann mit „UTC morgen 0 Uhr"; schlagen beide fehl, liefert er undefined.
  • MIN_REFIRE_GAP_MS = 2_000 (MIN_REFIRE_GAP_MS:104): Zwischen zwei Triggern desselben Jobs liegen mindestens 2 Sekunden; verhindert, dass computeJobNextRunAtMs denselben Sekunden-Zeitstempel liefert und es zu einer Spin-Loop kommt (#17821).
  • active job marker generation: markCronJobActive trägt die aktuelle generation; beim Gateway-Neustart wird state.generation++ inkrementiert, alle Marker der vorherigen Generation verfallen (isCronActiveJobMarkerCurrent liefert false); der Eintritt von executeJobCoreWithTimeout prüft den Marker und bricht sofort mit abort("Gateway restarting.") als cancelled outcome ab. Jobs der main-Session tragen preserveAcrossGenerationAdvance: 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 über setTimeout-Race. Beide beenden die core-Ausführung über dasselbe runAbortController.abort(reason), der Unterschied liegt im zurückgegebenen status: cancelled vs. timed_out.
  • session key canonicalize: resolveCronAgentSessionKey schreibt agent:xxx:main zu agent:xxx:<configuredMainKey> um, sonst stimmen bei cfg.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: locked serialisiert nach storePath (locked:13); state.op und storeLocks.get(storePath) werden beide in die Promise.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.