Chaque commande attendait d'abord 6,7 secondes : comment Hermes a sorti trois corvées de démarrage du chemin critique


Un script shell qui boucle sur une douzaine d’appels hermes sessions list devrait prendre quelques secondes ; sur une machine, il prenait nettement plus longtemps, et le plus intéressant était que rien n’échouait. Chaque appel payait simplement un péage avant de faire quoi que ce soit d’utile. Ajoutez le backend de la TUI, hermes serve, le gateway et chaque sous-processus lancé par Hermes, et « cet outil paraît lourd » cesse d’être une impression pour devenir un problème de comptabilité. Le 2026-10-26, trois pull requests ont été fusionnées dans main, chacune soldant un de ces comptes. Aucune n’est encore dans une release — on y revient à la fin.

Ce que « coût de démarrage » veut vraiment dire

Chaque processus Hermes — le CLI, le backend derrière la TUI, hermes serve, le gateway et chaque enfant qu’il dérive — exécute les mêmes corvées préparatoires avant d’atteindre votre requête. Le coût de démarrage, c’est tout ce qui sépare exec de la première chose utile : lire la config, importer des modules, déterminer quelle version du code tourne et, comme on l’a découvert, supprimer de vieux répertoires.

Une seconde sur une commande que vous lancez deux cents fois par jour, c’est trois minutes, mais vous ne la vivez jamais comme une seule attente. Les trois corvées ci-dessous se trouvaient sur le chemin critique pour des raisons sans rapport avec la correction : chacune était simplement le premier appelant.

Facture un : le nettoyage du scratch qui passait avant tout le reste

Hermes donne à chaque profile un répertoire scratch à ~/.hermes/cache/scratch et pointe TMPDIR/TMP/TEMP dessus — mais seulement si ces trois variables n’étaient pas déjà définies.

Le problème était l’endroit du balayage. get_scratch_dir() vaut prune=True par défaut, et dans presque tous les processus son premier appelant est le hook de démarrage : hermes_bootstrap → export_scratch_tmp_env → apply_scratch_tmp_env. Donc « trouver les entrées inactives et les rmtree » était inliné devant tout le reste. L’auteur de la PR a relevé hermes --worktree immobilisé pendant 13 secondes à supprimer une entrée scratch de 34 Go. Rien n’était cassé ; la commande attendait un rmtree.

Le correctif garde la politique et déplace le travail : la passe s’exécute toujours une fois par processus et au plus une fois par heure, mais c’est un processus enfant détaché qui la fait (python -I, rattaché au gestionnaire de session de l’utilisateur après la sortie de la commande), si bien que la commande rend la main immédiatement. Mesuré avec de vrais processus, un faux HOME/HERMES_HOME et deux arborescences git archive, hermes sessions list est passé de 6,74 s à 0,35 s quand une entrée inactive contenait 600 000 fichiers.

0,35 s n’est pas un compromis : une exécution de contrôle sans une telle entrée prend elle aussi 0,35 s, et l’entrée a été supprimée par l’enfant détaché 5,8 s après le démarrage. Avec quatre entrées inactives de 15 000 fichiers chacune et un processus qui sort immédiatement, la baisse était de 0,64 s à 0,05 s — et les quatre entrées étaient toujours là à la sortie du processus.

Ce qui n’a pas changé, c’est la partie qui vous protège. Un répertoire que vous avez déposé ou décompressé avec un vieux mtime est toujours conservé. Un sleep non réclamé à l’intérieur d’une entrée inactive reçoit toujours TERM avant que le répertoire ne parte. Le répertoire du shell qui a lancé Hermes, et ses parents, sont conservés. Les processus d’arrière-plan, les gateways et les sessions CLI qu’Hermes suit lui-même ne sont jamais arrêtés. Et si psutil ne peut pas être importé, rien n’est supprimé du tout — la passe échoue en mode fermé et journalise process check failed …, nothing deleted this pass.

La politique explique pourquoi le répertoire semble parfois négligé : une entrée ne part qu’après 24 heures d’inactivité, toute écriture où que ce soit à l’intérieur dans les dernières 24 heures la conserve, et les hardlinks ne comptent pas comme des écritures — délibérément, pour qu’un git clone --local partageant ses fichiers pack avec sa source ne maintienne pas le clone vivant indéfiniment. Chaque suppression, arrêt et conservation atterrit dans ~/.hermes/logs/scratch-prune.log. Les entrées que vous ne pouvez ni lire ni écrire — typiquement une sortie de conteneur écrite en root dans un bind mount — ne sont pas supprimées à l’aveugle : vous obtenez une indication et un sudo rm -rf '<path>' suggéré. Le contexte de cette plomberie : le parcours du répertoire temporaire du terminal.

Facture deux : une recherche complète de version git à chaque commande

Lancez Hermes depuis un checkout git et chaque commande gagne environ 1 seconde : hermes sessions list de 1,453 s à 0,435 s (moyenne de six exécutions entrelacées dans chaque sens), et les sous-processus git par commande de 15 à 2.

Deux causes racines, le même bug sous des habits différents. Chacune exécutait une recherche de version complète — sept appels git, dont git tag --merged HEAD, qui coûte environ 0,35 s sur un gros dépôt — sur le chemin de démarrage.

La première est une avidité à l’import. import hermes_cli.config énumère chaque plugin de provider, et plusieurs construisent un User-Agent HermesAgent/<release> au niveau du module : Fireworks, GMI, xAI, router, Kimi, OpenCode. Pour mettre la release dans cette chaîne, ils résolvaient la version depuis git pendant l’import, et auth_constants, models, model_catalog, auxiliary_client, gemini_native_adapter, web_server_messaging, Slack, Yuanbao et Perplexity faisaient de même. Un seul import de config, un tas de sous-processus git, avant même que votre commande ait lu un seul argument. Ce coût de la couche d’import fait l’objet de l’analyse du cache de config.

La seconde est une façon étonnamment coûteuse de comparer une seule chaîne. L’avis « mise à jour en attente de redémarrage » appelle _current_checkout_sha(), qui passe par get_code_identity(refresh=True) — vider le cache et relancer toute la recherche, à chaque démarrage, même quand il n’y a aucun reçu de mise à jour à comparer.

Le correctif tient en trois petites pièces : providers.base.LazyHeaders, une sous-classe de dict qui stocke les clés à l’import et évalue les valeurs appelables à la première lecture, si bien que le User-Agent contenant la release n’est calculé que lorsqu’une requête est réellement construite, tandis que .copy(), |, update, json.dumps, pickle et isinstance(x, dict) voient toujours un vrai dict ; version_info.hermes_user_agent(product), calculé au moment de construire la requête ; et version_info.read_checkout_sha(), qui obtient le même SHA en un appel git au lieu de sept. hermes --version, le User-Agent et les en-têtes d’attribution sont tous inchangés.

Facture trois : le commit-graph était périmé depuis deux mois

git tag --merged HEAD sert à la fois au démarrage et à hermes update. Sur un vrai checkout d’installation, il est passé de 0,39 s à 0,05 s.

Trois choses avaient mal tourné. Sous clone partiel, l’écriture du commit-graph par git lui-même est volontairement désactivée (upstream #127711). La maintenance des packs d’Hermes n’en écrivait pas non plus. Et un objects/info/commit-graph.lock laissé par un processus git tué traînait là depuis deux mois, parce que le balayage des verrous périmés de gitlock ne couvrait pas ce verrou-là. Chaque recherche de tag faisait donc la chose coûteuse, indéfiniment, avec un fichier de verrou garantissant que ça dure.

Le correctif rafraîchit le graphe en arrière-plan après chaque hermes update et à chaque démarrage de hermes -w, via git commit-graph write --reachable --split --no-changed-paths. La même passe efface les verrous commit-graph et multi-pack-index ainsi que les fichiers temporaires tmp_graph_* laissés par des processus git tués, et l’arrêt envoie SIGTERM à l’arbre de processus d’abord, SIGKILL seulement après un délai de grâce de 5 secondes (comportement par défaut inchangé pour les autres appelants), pour que les kills laissent moins de débris.

Mesuré sur la vraie chose : _run_post_update_maintenance est descendu de 2,82 s à 2,01 s, le graphe splitté a été écrit dans les 3 secondes suivant le retour de la phase, et commit-graph verify passe. La maintenance d’arrière-plan sur hermes -w est passée de ne rien faire du tout à écrire le graphe en 3,47 s et à effacer ce verrou vieux de deux mois. Comme tout ceci pend désormais à hermes update, le guide de mise à jour sans casse couvre le reste.

Les trois factures côte à côte

Ce que vous faites Avant Après
hermes sessions list, une entrée inactive de 600 k fichiers 6,74 s 0,35 s
Quatre entrées inactives × 15 k fichiers, sortie immédiate du processus 0,64 s 0,05 s
Recherche de version depuis un checkout (hermes sessions list) 1,453 s 0,435 s
Sous-processus git par commande 15 2
git tag --merged HEAD sur un vrai checkout d’installation 0,39 s 0,05 s
_run_post_update_maintenance (inclut une vraie récupération de tags) 2,82 s 2,01 s

Aucun de ces travaux n’était faux ni inutile. Il était juste au mauvais endroit.

Un plus petit correctif : un saut d’horloge n’expulse plus un gateway en cours

La quatrième PR est petite, mais elle produit un mystère. Après un saut de l’horloge système, le balayage du scratch pouvait juger à tort qu’un gateway en cours était abandonné et le traiter comme tel ; ce n’est plus le cas. Si un service d’arrière-plan de longue durée a déjà disparu juste après la reprise d’un portable ou une correction NTP, voilà une explication plausible — et elle est corrigée dans le même lot.

Ce que cela signifie pour vous aujourd’hui

Les quatre PR ont été fusionnées le 2026-10-26, soit après la v0.22.5 (sortie le 2026-10-23). Elles ne sont dans aucune release — ni la v0.22.5, ni quoi que ce soit d’installable aujourd’hui. Elles n’existent que sur main. Pour profiter de l’amélioration maintenant, suivez main ; sinon, elle arrivera avec la prochaine version.

Si vous restez un moment sur une release, les chiffres restent utiles comme référence : si un seul hermes sessions list prend six ou sept secondes, la cause est presque certainement une grosse entrée inactive sous ~/.hermes/cache/scratch, à inspecter avant de conclure qu’Hermes lui-même est devenu lent. ~/.hermes/logs/scratch-prune.log enregistre chaque suppression, arrêt et conservation, donc « est-ce que quelque chose a mangé mes fichiers temporaires » a une réponse. Depuis un checkout sous ~/.hermes/hermes-agent, un objects/info/commit-graph.lock résiduel est un vrai ralentissement que rien dans la v0.22.5 ne nettoie. Et si vous exportez TMPDIR vous-même, notez qu’Hermes ne redirige les trois variables temporaires que lorsqu’elles ne sont pas définies — vos propres fichiers temporaires n’entrent jamais dans la politique du scratch.

Pour une installation neuve, le guide d’installation couvre à la fois la release et la disposition en checkout, et la distinction compte ici : les gains de recherche de version et de commit-graph ne concernent que les installations en checkout, tandis que le gain du nettoyage du scratch concerne tout le monde.