Jeder Befehl wartete erst 6.7 Sekunden: wie Hermes drei Startarbeiten aus dem kritischen Pfad geholt hat


Ein Shell-Skript, das über ein Dutzend hermes sessions list-Aufrufe läuft, sollte ein paar Sekunden brauchen; auf einer Maschine brauchte es spürbar länger, und das Interessante daran war, dass nichts fehlschlug. Jeder Aufruf zahlte einfach einen Wegezoll, bevor er irgendetwas Nützliches tat. Nimmt man das TUI-Backend, hermes serve, das gateway und jeden Subprozess dazu, den Hermes startet, dann ist „dieses Tool fühlt sich schwer an“ kein Gefühl mehr, sondern eine Rechenaufgabe. Am 26.10.2026 sind drei Pull Requests in main gemergt, die jeweils eines dieser Konten geschlossen haben. Keiner davon ist in einem Release — mehr dazu am Ende.

Was „Startkosten“ tatsächlich bedeutet

Jeder Hermes-Prozess — die CLI, das Backend hinter der TUI, hermes serve, das gateway und jedes Kind, das es ableitet — erledigt dieselben vorbereitenden Arbeiten, bevor er deine Anfrage erreicht. Startkosten sind alles zwischen exec und der ersten nützlichen Sache: Config lesen, Module importieren, herausfinden, welche Version des Codes gerade läuft, und, wie sich zeigte, alte Verzeichnisse löschen.

Eine Sekunde bei einem Befehl, den du zweihundertmal am Tag ausführst, sind drei Minuten — nur erlebst du es nie als eine Wartezeit. Alle drei Arbeiten unten lagen aus Gründen im kritischen Pfad, die nichts mit Korrektheit zu tun hatten: jede war einfach der erste Aufrufer.

Rechnung eins: die Scratch-Aufräumaktion, die vor allem anderen lief

Hermes gibt jedem profile ein Scratch-Verzeichnis unter ~/.hermes/cache/scratch und richtet TMPDIR/TMP/TEMP darauf — aber nur, wenn diese drei Variablen nicht bereits gesetzt waren.

Das Problem war, wo das Aufräumen stattfand. get_scratch_dir() hat standardmäßig prune=True, und in fast jedem Prozess ist ihr erster Aufrufer der Startup-Hook: hermes_bootstrap → export_scratch_tmp_env → apply_scratch_tmp_env. Also lief „die untätigen Einträge finden und rmtree darauf anwenden“ inline vor allem anderen. Der Autor des PR notierte hermes --worktree mit 13 Sekunden Wartezeit, während ein Scratch-Eintrag von 34 GB gelöscht wurde. Kaputt war nichts; der Befehl wartete auf ein rmtree.

Der Fix behält die Policy und verschiebt die Arbeit: der Durchlauf läuft weiterhin einmal pro Prozess und höchstens einmal pro Stunde, aber ein abgekoppelter Kindprozess erledigt ihn (python -I, nach dem Ende des Befehls wieder an den Session-Manager des Benutzers angehängt), sodass der Befehl sofort zurückkehrt. Mit echten Prozessen gemessen, einem gefälschten HOME/HERMES_HOME und zwei git archive-Bäumen ging hermes sessions list von 6.74 s auf 0.35 s, als ein untätiger Eintrag 600.000 Dateien hielt.

0.35 s sind kein Kompromiss: ein Kontrolllauf ohne einen solchen Eintrag braucht ebenfalls 0.35 s, und der Eintrag war 5.8 s nach dem Start vom abgekoppelten Kind gelöscht. Bei vier untätigen Einträgen mit je 15.000 Dateien und einem Prozess, der sofort endet, war der Sprung von 0.64 s auf 0.05 s — und alle vier Einträge waren noch da, als der Prozess endete.

Unverändert ist der Teil, der dich schützt. Ein Verzeichnis, das du hineingelegt oder mit altem mtime entpackt hast, bleibt erhalten. Ein nicht beanspruchtes sleep in einem untätigen Eintrag bekommt weiterhin TERM, bevor das Verzeichnis geht. Das Verzeichnis der Shell, die Hermes gestartet hat, und dessen Eltern bleiben erhalten. Hintergrundprozesse, gateways und CLI-Sessions, die Hermes selbst verfolgt, werden nie gestoppt. Und lässt sich psutil nicht importieren, wird überhaupt nichts gelöscht — der Durchlauf schlägt geschlossen fehl und loggt process check failed …, nothing deleted this pass.

Die Policy erklärt, warum das Verzeichnis manchmal vernachlässigt aussieht: ein Eintrag geht erst nach 24 Stunden Untätigkeit, jeder Schreibzugriff irgendwo darin am letzten Tag hält ihn am Leben, und Hardlinks zählen bewusst nicht als Schreibzugriff, damit ein git clone --local, das Pack-Dateien mit seiner Quelle teilt, den Clone nicht ewig am Leben hält. Jedes Löschen, Stoppen und Behalten landet in ~/.hermes/logs/scratch-prune.log. Einträge, die du nicht lesen oder schreiben kannst — meist Container-Ausgaben, die als root in einen Bind-Mount geschrieben wurden —, werden nicht blind gelöscht; du bekommst einen Hinweis und ein vorgeschlagenes sudo rm -rf '<path>'. Hintergrund zu dieser Mechanik: der Rundgang durch das temporäre Terminal-Verzeichnis.

Rechnung zwei: eine komplette git-Versionsabfrage bei jedem einzelnen Befehl

Startest du Hermes aus einem git-Checkout, wurde jeder Befehl rund 1 Sekunde schneller: hermes sessions list von 1.453 s auf 0.435 s (Mittel aus sechs abwechselnden Läufen in beide Richtungen), und die git-Subprozesse pro Befehl von 15 auf 2.

Zwei Ursachen, derselbe Bug in anderem Gewand. Beide führten eine vollständige Versionsabfrage aus — sieben git-Aufrufe, von denen einer, git tag --merged HEAD, in einem großen Repository etwa 0.35 s kostet —, und zwar auf dem Startpfad.

Die erste ist Eifer zur Importzeit. import hermes_cli.config zählt jedes provider-plugin auf, und mehrere bauen auf Modulebene einen HermesAgent/<release>-User-Agent: Fireworks, GMI, xAI, router, Kimi, OpenCode. Um das Release in diesen String zu bekommen, lösten sie die Version während des Imports aus git auf, und auth_constants, models, model_catalog, auxiliary_client, gemini_native_adapter, web_server_messaging, Slack, Yuanbao und Perplexity taten dasselbe. Ein Config-Import, ein Haufen git-Subprozesse — bevor dein Befehl ein einziges Argument gelesen hat. Diese Import-Layer-Kosten sind das Thema von der tiefen Analyse des Config-Cache.

Die zweite ist eine erstaunlich teure Art, einen String zu vergleichen. Der Hinweis „Update ausstehend, Neustart nötig“ ruft _current_checkout_sha() auf, das über get_code_identity(refresh=True) läuft — den Cache leeren und die ganze Abfrage neu ausführen, bei jedem Start, selbst wenn es keinen Update-Beleg zum Vergleichen gibt.

Der Fix besteht aus drei kleinen Teilen: providers.base.LazyHeaders, eine dict-Unterklasse, die Keys zur Importzeit speichert und aufrufbare Werte beim ersten Lesen auswertet, sodass der User-Agent mit dem Release erst berechnet wird, wenn tatsächlich eine Anfrage gebaut wird, während .copy(), |, update, json.dumps, pickle und isinstance(x, dict) weiterhin ein echtes dict sehen; version_info.hermes_user_agent(product), berechnet beim Bau der Anfrage; und version_info.read_checkout_sha(), das denselben SHA in einem git-Aufruf statt in sieben bekommt. hermes --version, der User-Agent und die Attribution-Header bleiben unverändert.

Rechnung drei: der commit-graph war zwei Monate veraltet

git tag --merged HEAD wird sowohl beim Start als auch von hermes update verwendet. In einem echten Installations-Checkout ging es von 0.39 s auf 0.05 s.

Drei Dinge waren schiefgelaufen. Unter partial clone ist gits eigenes Schreiben des commit-graph absichtlich deaktiviert (upstream #127711). Hermes’ Pack-Wartung schrieb auch nie einen. Und ein objects/info/commit-graph.lock, zurückgelassen von einem gekillten git-Prozess, lag dort seit zwei Monaten, weil die Stale-Lock-Bereinigung von gitlock genau diesen Lock nicht abdeckte. Also tat jede Tag-Abfrage das Teure, für immer, mit einer Lock-Datei, die garantierte, dass es so blieb.

Der Fix frischt den Graph im Hintergrund nach jedem hermes update und bei jedem Start von hermes -w auf, mit git commit-graph write --reachable --split --no-changed-paths. Derselbe Durchlauf räumt commit-graph- und multi-pack-index-Locks sowie tmp_graph_*-Tempdateien weg, die gekillte git-Prozesse hinterlassen, und beim Shutdown geht zuerst SIGTERM an den Prozessbaum, SIGKILL erst nach 5 Sekunden Schonfrist (Standardverhalten für andere Aufrufer unverändert), sodass Kills weniger Müll hinterlassen.

Am echten Objekt gemessen: _run_post_update_maintenance fiel von 2.82 s auf 2.01 s, der Split-Graph war innerhalb von 3 Sekunden nach Rückkehr der Phase geschrieben, und commit-graph verify läuft durch. Die Hintergrundwartung bei hermes -w ging davon, gar nichts zu tun, dazu über, den Graph in 3.47 s zu schreiben und diesen zwei Monate alten Lock zu räumen. Da das jetzt an hermes update hängt, deckt die Anleitung zum reibungslosen Upgrade den Rest ab.

Die drei Rechnungen nebeneinander

Was du tust Vorher Nachher
hermes sessions list, ein untätiger Eintrag mit 600k Dateien 6.74 s 0.35 s
Vier untätige Einträge × 15k Dateien, Prozess endet sofort 0.64 s 0.05 s
Versionsabfrage aus einem Checkout (hermes sessions list) 1.453 s 0.435 s
git-Subprozesse pro Befehl 15 2
git tag --merged HEAD in einem echten Installations-Checkout 0.39 s 0.05 s
_run_post_update_maintenance (inklusive echtem Tag-Fetch) 2.82 s 2.01 s

Nichts an dieser Arbeit war falsch oder unnötig. Sie lag nur am falschen Ort.

Ein kleinerer Fix: ein Zeitsprung wirft kein laufendes gateway mehr raus

Der vierte PR ist klein, produziert aber ein Rätsel. Nach einem Sprung der Systemuhr konnte die Scratch-Bereinigung ein laufendes gateway für aufgegeben halten und entsprechend behandeln; das tut sie nicht mehr. Ist dir schon einmal ein langlaufender Hintergrunddienst direkt nach dem Aufwachen eines Laptops oder nach einer NTP-Korrektur verschwunden, ist das eine plausible Erklärung — und im selben Batch behoben.

Was das heute für dich bedeutet

Alle vier PRs wurden am 26.10.2026 gemergt, also später als v0.22.5 (erschienen am 23.10.2026). Sie sind in keinem Release — nicht in v0.22.5, nicht in irgendetwas, das du heute installieren kannst. Sie existieren nur auf main. Willst du die Verbesserung jetzt, folge main; sonst kommt sie mit der nächsten Version.

Bleibst du eine Weile auf einem Release, sind die Zahlen trotzdem als Referenz nützlich: braucht ein einzelnes hermes sessions list sechs oder sieben Sekunden, ist die Ursache fast sicher ein großer untätiger Eintrag unter ~/.hermes/cache/scratch — ein Blick dorthin lohnt sich, bevor du schließt, Hermes selbst sei langsam geworden. ~/.hermes/logs/scratch-prune.log hält jedes Löschen, Stoppen und Behalten fest, „hat da etwas meine Temp-Dateien gefressen“ hat also eine Antwort. In einem Checkout unter ~/.hermes/hermes-agent ist ein zurückgebliebenes objects/info/commit-graph.lock eine echte Bremse, die nichts in v0.22.5 aufräumt. Und wenn du TMPDIR selbst exportierst: Hermes leitet die drei Temp-Variablen nur um, wenn sie nicht gesetzt sind — deine eigenen Temp-Dateien geraten nie in die Scratch-Policy.

Für ein frisches Setup deckt die Installationsanleitung sowohl das Release als auch das Checkout-Layout ab, und der Unterschied ist hier wichtig: die Gewinne bei Versionsabfrage und commit-graph gelten nur für Checkout-Installationen, der Gewinn beim Scratch-Aufräumen gilt für alle.