Quatre crashes sous Windows que seuls les utilisateurs en langue localisée rencontraient

Votre machine tourne sous Windows en allemand. Vous installez Hermes, vous le lancez, et chaque nouvelle conversation se tue avec une ligne que vous ne savez pas interpréter : UnicodeEncodeError: 'utf-8' codec can't encode character '\udce9'. Changez la langue du système en anglais et tout fonctionne — vous en concluez que vous n’avez pas eu de chance. Quelqu’un d’autre est sous Windows chinois et voit son log de desktop se remplir de UnicodeDecodeError: 'gbk' codec can't decode ; une troisième personne remarque que quand l’agent écrit C:\...\report.html, le fichier ne peut être ni étiqueté ni prévisualisé.
Ces quatre problèmes sont sans rapport dans le code. Ce qu’ils partagent, c’est ce qui leur a permis de survivre si longtemps : ils n’apparaissent que sous un Windows localisé, non anglophone, donc ils ne sont presque jamais reproduits ni signalés depuis un environnement anglais. Les quatre ont été corrigés dans v0.21.5 (tag v2026.9.24, publié le 24 septembre 2026). Voici ce qui se passait réellement.
1. Un nom de fuseau horaire faisait planter la construction du system prompt
L’erreur ressemblait à ceci :
agent/system_prompt.py -> _zone_bits() -> now.strftime("%Z")
UnicodeEncodeError: 'utf-8' codec can't encode character '\udce9' ...: surrogates not allowed
Cause racine : Windows renvoie les noms de fuseaux encodés dans la page de code ANSI, pas en Unicode. Sous Windows français, Paris, Madrid (heure d'été) arrive en octets cp1252. Quand le LC_CTYPE du processus est en UTF-8 (le mode UTF-8 de Python, ou un composant comme Piper/espeak qui change la locale globale du processus depuis un autre thread), ces octets sont décodés avec surrogateescape, donc tzname() finit par contenir des surrogates isolés.
Le vrai problème est ce qui suit : datetime.strftime insère tzname() dans la chaîne de format en UTF-8, donc %Z lève à l’intérieur de strftime — avant qu’aucune chaîne n’existe, ce qui signifie que la passe existante _sanitize_surrogates n’a jamais la moindre chance de nettoyer quoi que ce soit.
Et comme le system prompt est reconstruit à chaque début de conversation et à chaque compaction, ce n’est pas un incident isolé : il refuse chaque nouvelle conversation que vous ouvrez.
Le correctif (PR #120748) : un petit helper, hermes_time.safe_strftime(value, fmt), et chaque site strftime qui rend du texte de locale l’appelle désormais. Il se comporte selon deux modes :
- Sur une locale saine, la sortie est identique octet pour octet à
value.strftime(fmt). C’est une exigence stricte, pas un raffinement — le system prompt doit rester stable, sinon le cache de prompt casse. Les tests comparent directement les deux pour les datetimes naïves, les fuseaux IANA et les noms de fuseaux non-ASCII valides. - Quand le texte porte des surrogates, et que ce texte n’est composé que d’ASCII plus des octets échappés en surrogates, les octets sont récupérés et décodés via la page de code ANSI (
locale.getencoding()), ce qui redonneheure d'étésous forme de texte. Tout ce qui n’est pas décodable retombe surU+FFFD, et le résultat est toujours sérialisable en JSON de façon sûre.
Les locales signalées étaient fr-FR, es-AR et de-DE.
2. Décodage des processus enfants sous Windows chinois
Le log ressemblait à ceci, en boucle :
[gateway-crash] thread Thread-NNN (_readerthread) raised
UnicodeDecodeError: 'gbk' codec can't decode byte 0x85 in position 2: illegal multibyte sequence
Cause racine : _readerthread est le thread lecteur que subprocess.communicate() démarre sous Windows. Quand une capture passe text=True et aucun encoding=, subprocess décode via subprocess._text_encoding(), qui vaut locale.getencoding() — cp936/GBK sous Windows chinois. Les enfants concernés écrivent en UTF-8, donc tout octet illégal en GBK lève une UnicodeDecodeError dans ce thread, que le threading.excepthook du processus serve affiche ensuite comme une ligne [gateway-crash].
Le fautif précis était hermes_cli/web_routers/files.py::_fs_git_branch, qui lance git branch --show-current pour /api/fs/default-cwd — un endpoint que Desktop interroge en continu. git écrit en UTF-8, y compris les noms de branches et son propre stderr localisé (le message chinois « not a git repository », par exemple), donc sur un hôte GBK chaque poll levait une exception dans le thread lecteur et l’endpoint renvoyait une branche vide.
Cela vaut la peine de noter pourquoi c’est passé inaperçu si longtemps : les kwargs de cet appel se trouvent dans un littéral de dict ("text": True), et la règle ligne par ligne du projet pour repérer text=True ne les matchait jamais.
Le correctif (PR #120749) : ces captures sont explicitement épinglées en UTF-8 (encoding="utf-8" avec errors="replace"). Les spawns de tui_gateway/ (slash worker, compute host, cli.exec, pdftoppm) étaient déjà épinglés en UTF-8 ; celui-ci ferme la classe des captures in-process.
Un détail lié : le lanceur de gateway à console cachée ajouté dans v0.21.0 faisait disparaître le symptôme sur la machine du rapporteur, mais les lecteurs eux-mêmes n’ont jamais été corrigés. Un symptôme qui disparaît n’est pas une cause racine, et ce correctif est la cause racine.
3. Les chemins commençant par C:\ ne pouvaient être ni étiquetés ni prévisualisés
Symptôme : l’agent sous Windows écrit C:\Users\me\report.html ou un chemin UNC \\server\share\..., et Desktop n’affiche aucun tag d’artefact. Quand un tag apparaissait, l’étiquette d’aperçu ressortait en \Users\me\report.html ; le repli du renderer concaténait le chemin au répertoire de travail et produisait /repo/C:\....
Cause racine : looksLikePath et isPreviewableTarget dans fallback-model/targets.ts n’acceptaient que file://, /, ./ et ~/, donc un chemin absolu Windows échouait au test « est-ce un chemin », toolPreviewTarget ne renvoyait rien et aucun tag n’apparaissait. Côté renderer, localPreviewTarget traitait tout ce qui ne commence pas par / comme relatif et le concaténait au cwd. Et previewName analysait C:\... avec new URL(...), où c: se lit comme un schéma d’URL, donc seule la fin survivait comme étiquette.
Le correctif (PR #121074) : un nouveau isWindowsAbsolutePath dans lib/path-compare.ts accepte une lettre de lecteur avec l’un ou l’autre slash et un préfixe UNC \\ ; looksLikePath accepte ces chemins ; isPreviewableTarget exécute son contrôle HTML sur tout ce que looksLikePath accepte, donc C:\x.html est prévisualisable et C:\x.txt ne l’est pas, conformément au comportement POSIX ; localPreviewTarget ne concatène plus les chemins absolus Windows au cwd ; previewName les étiquette par nom de fichier. Une copie privée de la même regex dans le contrôle d’upload de pièces jointes appelle désormais la fonction partagée (commit séparé, aucun changement de comportement).
4. Une sonde de 10 secondes déclarait un démarrage à froid en échec
Symptôme : lors d’un démarrage à froid Windows, le backend local de Desktop répond en HTTP, puis l’application signale un échec de démarrage, tue l’enfant et journalise une cascade commençant par « Ignoring stale Hermes backend exit (1) ».
Cause racine : lors d’un démarrage à froid Windows, la boucle d’événements du backend local se bloque pendant 12–28 secondes pendant que le GIL est tenu par l’import des modules de plateforme du gateway. Le budget fixe de la sonde WebSocket au démarrage était de 10 secondes (DEFAULT_CONNECT_TIMEOUT_MS), donc elle enregistrait un démarrage à froid sain comme un échec, et tout ce qui suit en découlait.
Le correctif (PR #120750) : pour un backend que cette application a lancé, la sonde continue d’attendre au-delà du budget de base de 10 secondes tant que le processus enfant est vivant, jusqu’à un plafond de 90 secondes — le même budget que l’étape d’annonce de port autorise déjà pour un démarrage à froid. Le signal de progression est la vivacité du processus, pas la fraîcheur de la sortie, parce que le blocage est silencieux : le processus n’imprime rien tant que sa boucle n’a pas repris (le heartbeat de boucle de web_server.py ne journalise event loop stalled qu’après coup).
Les deux formes d’échec restent distinctes : un enfant loopback vivant qui a accepté la connexion TCP mais n’a pas répondu à l’upgrade est occupé, donc la sonde attend ; un refus ou un rejet d’authentification arrive toujours comme error/close immédiat ; un enfant qui se termine échoue au contrôle suivant d’une seconde ; un backend vivant qui ne répond jamais échoue au plafond et affiche l’overlay d’échec de démarrage existant, dont le Retry recharge la fenêtre. Les gateways distants, le bouton « Test remote » et l’attachement d’un backend hôte conservent le budget fixe de 10 secondes.
État de release pour ces quatre
Les quatre sont dans v0.21.5 (tag v2026.9.24, 24 septembre 2026), donc passer à cette version les apporte — pas besoin de suivre main. Cela vaut la peine d’être dit, parce qu’une série d’autres correctifs ont été fusionnés le même jour après la découpe du tag, et ceux-là n’y sont pas encore.
Ce qu’il faut vérifier sur votre installation
Si vous avez rencontré l’un des cas ci-dessus, ceci le confirme après la mise à niveau :
hermes --version # attendez v0.21.5 (2026.9.24)
- Crash du nom de fuseau : ouvrez une nouvelle conversation sous un Windows localisé — plus de
surrogates not allowed, et le system prompt doit rester stable (votre taux de succès du cache de prompt ne doit pas bouger). - Décodage des enfants : ouvrez Desktop sous Windows chinois — plus de lignes
[gateway-crash] ... UnicodeDecodeErroren continu. - Chemins Windows : demandez à l’agent d’écrire un
C:\...\report.html— le tag d’artefact doit apparaître et l’aperçu fonctionner ; idem pour un chemin UNC. - Démarrage à froid : démarrez Desktop à froid après avoir vidé l’état du gateway — pas d’overlay d’échec de démarrage et pas de cascade « Ignoring stale Hermes backend exit ».
Les quatre se situent dans cette fenêtre corrective sans release notes ; la liste plus complète est sur notre page de release v0.21.5 et dans le guide d’auto-vérification de la fenêtre v0.21.5. Les correctifs de livraison en streaming fusionnés le même jour, qui ne sont pas encore dans cette version, sont traités dans le balayage de la livraison.
En bref
Ce que ces quatre partagent est aussi ce qui les rend pénibles pour les utilisateurs en langue localisée : ils ne se reproduisent presque jamais sur un système anglais. Un nom de fuseau horaire, l’encodage d’un processus enfant, un chemin à lettre de lecteur et un blocage d’import au démarrage n’apparaissent qu’à l’intersection de « Windows non anglophone » et de Windows lui-même. Sur une installation Windows localisée, passer à v0.21.5 devrait faire disparaître beaucoup d’échecs inexplicables.