Einmal zum Test ausgelöst — und die Erinnerung kam nie: Manuelle Cron-Läufe sind jetzt ein Extra-Fire

Du legst eine Erinnerung für in 30 Minuten an — Pfanne vom Herd nehmen — und willst sicher sein, dass das Ding wirklich funktioniert. Also klickst du im Dashboard auf Run. Es feuert sofort, die Ausgabe sieht richtig aus, du schließt den Tab. Eine halbe Stunde später riecht die Küche nach verbranntem Metall: Die Erinnerung kam nie.
Der Scheduler war nicht kaputt. Dein Test hat den einzigen Slot des One-Shots verbraucht. Der Job betrachtete sich als erledigt und zog sich still zurück.
Früher zählte ein manueller Lauf als der Lauf
Zwei Felder in einem Cron-Record werden leicht verwechselt: wann er als Nächstes feuern soll (next_run_at) und wie viel des Budgets verbraucht ist (repeat.completed von repeat.times). Ein One-Shot ist einfach ein Job mit einem Budget von eins.
Der alte Code behandelte einen manuellen Trigger wie eine normale Ausführung: Er lief, dann wurde die Buchhaltung geschrieben — repeat.completed hochgezählt. Bei einem One-Shot hieß das 1 von 1, also wurde der Record auf completed gestempelt, enabled=false, next_run_at=null. Das ist ein Endzustand. Der echte geplante Lauf hatte keine Chance. Wiederkehrende Jobs verloren Läufe auf dieselbe Weise: repeat.times=3 ließ dir nach einem einzigen manuellen Test nur zwei übrig.
Die neue Regel: Ein manueller Lauf ist ein Extra-Fire
Das Verhalten ist jetzt wörtlich: Ein Run-now verbraucht nicht das geplante Vorkommen und rührt das Budget nicht an. Aus jobs.json zurückgelesen sehen dieselben zwei Jobs so aus:
| Job | Vorher (nach einem manuellen Lauf) | Jetzt |
|---|---|---|
in 30m One-Shot-Erinnerung |
completed, enabled=false, next_run_at=null, Budget 1/1 — der geplante Lauf feuert nie |
scheduled, enabled=true, next_run_at weiterhin auf der ursprünglichen Zeit, Budget 0/1 |
every 60m, repeat.times=3 |
Budget als 1/3 verbucht, next_run_at auf „now“ geschoben |
Budget 0/3, next_run_at unverändert |
Ein manueller Lauf ist Überstunden: eine zusätzliche Schicht obendrauf, während die geplante Schicht trotzdem erscheint und das Budget unangetastet bleibt.
Der eine Fall, in dem er doch der geplante Lauf ist
Wenn der Slot des Jobs bereits fällig ist, während du auf Run drückst, ist dieser Lauf das geplante Vorkommen — zwei Ausführungen bekommst du nicht. Die Regeln in der Praxis:
- Trigger vor Fälligkeit des Slots → Extra-Fire, der Slot überlebt;
- Trigger nach Fälligkeit → das ist der geplante Lauf, kein Duplikat;
- der Slot wird fällig, während dein manueller Lauf noch läuft → ein One-Shot feuert unmittelbar danach, ein wiederkehrender Job lässt den verstrichenen Slot fallen und nimmt sein nächstes natürliches Vorkommen, genau wie ein geplanter Lauf.
So verwendest du es
# jetzt einmal feuern; der nächste geplante Lauf bleibt erhalten
hermes cron run <job_id>
# Jobs inspizieren: hier stehen next_run_at und die Repeat-Zähler
hermes cron list
# einen One-Shot anlegen
hermes cron create "in 30m" "Take the pan off the heat before you keep coding"
Der Run-Button im Dashboard, ein Agent, der das Run-Tool cronjob_manage aufruft, und ein vom Gateway weitergeleiteter Lauf gehen alle durch denselben Pfad — es gibt also keinen Unterschied zwischen „in der CLI sicher, in der UI destruktiv“.
Warum das schwieriger ist, als es klingt
Die Schwierigkeit ist ein Crash zwischen Entscheidung und Aufzeichnung. Der Claim passiert einmal, unter dem Jobs-Lock: Wenn der Fire ein explizites Run-now ist, während der gespeicherte Slot noch in der Zukunft liegt, stempelt der Claim diesen Slot auf den Fire-Record und setzt next_run_at im selben Write zurück. Stirbt der Prozess nach dem Claim, aber vor dem Abschluss, zeigt der Datenträger weiterhin auf den korrekten zukünftigen Zeitpunkt — kein Vorkommen geht verloren.
Der vom Gateway weitergeleitete Pfad (trigger_job) hat früher next_run_at mit „now“ überschrieben und keine Kopie behalten; jetzt speichert er den überschriebenen Slot und stellt ihn zum Claim-Zeitpunkt wieder her, sodass wiederholte Trigger den Zeitplan nicht verschieben können.
Die Änderung hat außerdem eine Sichtbarkeitslücke geschlossen: Ein One-Shot, dessen Dispatch geclaimt wurde, dessen Lauf aber nie abgeschlossen wurde, verschwand früher ohne Ausgabe, ohne Fehler und ohne Aufzeichnung. Jetzt schreibt er eine kleine Diagnose ins Output-Verzeichnis des Jobs — Dispatch geclaimt, Lauf nie abgeschlossen, Prozess wahrscheinlich gekillt —, damit das Verschwinden wenigstens erklärbar ist.
Zwei Details, die man kennen sollte
- Ein globales Pause blockiert manuelle Läufe nicht. Solange
hermes pauseaktiv ist, startet nichts Geplantes, aber ein explizites Run-now ist ein Operator-Override und wird trotzdem ausgeführt. Das ist ein weiterer Grund, warum es das Budget nicht verbrauchen darf: Während einer Pause von Hand nachzuholen, sollte nicht den echten Lauf danach auffressen. - Ein manueller Lauf ist kein „weiter zum nächsten“. Er schiebt
next_run_atnicht vor. Um ein Vorkommen zu überspringen,pause/resumeoder den Zeitplanediten.
Für einen Check über die ganze Flotte: cron doctor. Zeitpläne über Zeitzonen hinweg stehen in Zeitzone und DST, profilbezogene Identität und Secret-Scope in Secret-Isolation bei gerouteten Fires. Die Flag-Liste steht auf der hermes cron Referenzseite.
Release-Status
Am 28. Oktober 2026 in main gemerged (PR #146113). Das aktuelle Release v0.22.5 (23. Oktober 2026) hat noch die alten Semantiken, auf einem älteren Build kann ein Test-Fire einen One-Shot also weiterhin sofort erledigen.
Bevor man einer Erinnerung traut, will man den Beweis, dass sie noch klingeln wird. Diese Prämisse stimmt jetzt endlich.