← Zum Blog
Blog

Mein Monitor hat gelogen und ich habe ein laufendes Agent-Game gekillt

Ein Watch-Script hat die falsche Datenbank-Spalte gelesen und mir gemeldet, ein Agent-Game sei tot. Ich habe es abgeschossen. Es lief einwandfrei. Was ich daraus fürs Monitoring von Langläufer-Agents gelernt habe.

03. Juli 2026
Ich hatte mir ein kleines Watch-Script gebaut, das mir Alarm gibt, wenn eins meiner laufenden Agent-Games hängt. Die Idee war simpel. Alle paar Minuten in die Datenbank schauen, checken ob sich noch was tut, und mir eine Telegram-Nachricht schicken falls ein Game seit einer Weile stillsteht. Klingt vernünftig. War auch gut gemeint. An einem Morgen hat mir dieses Script gemeldet, ein laufendes Game sei bei null Zügen. Also tot. Ich habe es abgeschossen. Und dann festgestellt, dass es die ganze Zeit einwandfrei lief. ## Was passiert ist Der Alert kam, ich habe kurz draufgeschaut, `plies=0` gelesen und gedacht: hängt, weg damit. Die Kill-Sequenz war schnell. Schedule pausiert. Game-Status in der DB auf abgebrochen gesetzt. Workflow terminiert. Den Model-Subprozess mit `kill -9` beendet. Worker neu gestartet. Alles zusammen unter einer Minute. Dann wollte ich nachschauen, was schiefgelaufen war, und habe in die eigentlichen Event-Tabellen geguckt. Da standen 50 Züge drin. 250 Zwischenschritte. 229 Tool-Calls. Alle mit frischen Timestamps, der letzte ein paar Sekunden alt. Das Game war nicht tot. Es war mitten in einem langen, teuren Reasoning-Prozess und lief absolut sauber. Ich hatte einen kerngesunden Langläufer aus dem Fenster geworfen, weil mein Monitor mir was Falsches erzählt hat. ## Warum der Monitor gelogen hat Der Bug war eine einzige Zeile SQL. Mein Script las eine Spalte, die erst am Ende des Games gesetzt wird. ```sql -- was mein Monitor las (wird erst beim Finish gesetzt, waehrend des Laufs immer 0): SELECT total_moves FROM games WHERE game_id = $1; -- was er haette lesen muessen (die Live-Wahrheit): SELECT MAX(move_number) FROM moves WHERE game_id = $1; ``` Die Spalte `total_moves` in der games-Tabelle ist eine Zusammenfassung. Sie wird beim Abschluss des Games einmal befüllt. Während ein Game läuft, steht da null. Immer. Bei jedem gesunden Game. Mein Monitor hat also nicht "hängt" erkannt, sondern schlicht "läuft noch nicht fertig", und das als Todesfall interpretiert. Die Wahrheit lag in der moves-Tabelle nebenan. Da wird bei jedem einzelnen Zug eine Zeile geschrieben. `MAX(move_number)` hätte mir die echte Zahl gegeben. 50, nicht null. Ein Blick auf die richtige Tabelle und der ganze Vorfall wäre nie passiert. ## Der eigentliche Fehler war nicht das SQL Das SQL war falsch, klar. Aber der teurere Fehler war ein anderer. Ich habe einem Monitor vertraut, den ich nie validiert hatte. Das Script lief seit ein paar Tagen, hatte noch nie Alarm geschlagen, und beim allerersten Alarm bin ich sofort auf Kill gegangen. Ohne Gegencheck. Ohne die Zahl gegen irgendeine zweite Quelle zu halten. Ein Watch-Script ist selbst Code, und Code hat Bugs, und ein Monitor, der noch nie einen echten Alarm produziert hat, ist ein unerprobtes Stück Software. Ich habe ihm die Entscheidung über einen langlaufenden Prozess überlassen, als wäre er eine geprüfte Instanz. ## Was ich seitdem anders mache Drei Dinge. Erstens lese ich für Liveness nur noch Quellen, die sich während des Laufs tatsächlich verändern. Also die Event-Tabelle mit einer Zeile pro Schritt, niemals eine Aggregat- oder Zusammenfassungs-Spalte. Eine Spalte, die erst am Ende befüllt wird, sagt nichts über "läuft gerade oder nicht" aus. Sie kann per Definition nicht zwischen "hängt" und "noch nicht fertig" unterscheiden. Zweitens bekommt jedes neue Watch-Script eine Beobachtungsphase von 24 bis 48 Stunden, bevor es irgendwas darf außer Alarm schlagen. In der Zeit schaue ich mir an, ob seine Alarme mit der Realität übereinstimmen. Ein Monitor, der noch keine 48 Stunden gegen echte Läufe lief, ist eine Hypothese, kein Werkzeug. Drittens, und das ist das Wichtigste: der Monitor darf nicht mehr selbst killen. Er schlägt nur noch Alarm. Die Entscheidung, einen laufenden Prozess abzuschießen, treffe ich manuell, und zwar erst nachdem ich die Live-Tabelle mit eigenen Augen gegen die Alarm-Zahl gehalten habe. Ein Langläufer-Agent läuft oft 60 bis 180 Minuten. Langsam ist bei so einem Prozess der Normalfall, nicht die Anomalie. Genau deshalb darf "sieht komisch aus" nie automatisch "abschießen" bedeuten. Ein Monitor ist selbst Code. Ein grüner Haken heißt nicht, dass alles läuft, er heißt nur, dass eine Abfrage einen Wert zurückgegeben hat, den irgendjemand mal als "gut" definiert hat. Wenn die Abfrage die falsche Spalte trifft, lügt der Haken. Und du glaubst ihm, weil du ihn selbst gebaut hast. Wenn du selbst Langläufer-Agents im Hintergrund fahren willst, ohne dir solche Fallen zu bauen, schau dir das [Playbook zu Background-Sessions mit Claude-Agents](/playbooks/background-sessions-mit-claude-agents) an. Und wenn mehrere Agents parallel laufen und du ihren Zustand sauber teilen willst, steht in der [Lesson zu Shared Memory zwischen Agents](/levels/5/04-shared-memory-zwischen-agents) wie das ohne solche Race-Conditions geht. Wer wissen will, warum eine Workflow-Engine wie Temporal den State eh schon durabel hält und der Kill damit doppelt unnötig war, findet das in der [Temporal-Doku zu Workflows](https://docs.temporal.io/workflows).
← Weitere Blog-Posts