Cuatro crashes en Windows que solo sufren los usuarios con el sistema en otro idioma

Tu máquina tiene Windows en alemán. Instalas Hermes, lo arrancas, y cada conversación nueva se mata sola con una línea que no sabes interpretar: UnicodeEncodeError: 'utf-8' codec can't encode character '\udce9'. Cambias el idioma del sistema a inglés y todo funciona, así que das por hecho que has tenido mala suerte. Otra persona está en Windows en chino y ve cómo su log del escritorio se llena de UnicodeDecodeError: 'gbk' codec can't decode; una tercera se da cuenta de que, cuando el agent escribe C:\...\report.html, el archivo no se puede ni etiquetar ni previsualizar.
Estos cuatro problemas no tienen relación entre sí en el código. Lo que comparten es justo lo que les permitió sobrevivir tanto tiempo: solo aparecen en un Windows localizado, que no esté en inglés, así que casi nunca se reproducen ni se reportan desde un entorno en inglés. Los cuatro se arreglaron en la v0.21.5 (tag v2026.9.24, publicada el 24 de septiembre de 2026). Esto es lo que pasaba en realidad.
1. Un nombre de zona horaria tumbaba la construcción del system prompt
El error tenía este aspecto:
agent/system_prompt.py -> _zone_bits() -> now.strftime("%Z")
UnicodeEncodeError: 'utf-8' codec can't encode character '\udce9' ...: surrogates not allowed
Causa raíz: Windows devuelve los nombres de zona codificados en la página de códigos ANSI, no como Unicode. En Windows en francés, Paris, Madrid (heure d'été) llega como bytes cp1252. Cuando el LC_CTYPE del proceso es UTF-8 (el modo UTF-8 de Python, o un componente como Piper/espeak cambiando el locale de todo el proceso desde otro hilo), esos bytes se decodifican con surrogateescape, así que tzname() acaba conteniendo surrogates sueltos.
El verdadero problema es lo que pasa después: datetime.strftime inserta tzname() en la cadena de formato como UTF-8, así que %Z lanza dentro de strftime, antes de que exista ninguna cadena, lo que significa que la pasada _sanitize_surrogates ya existente nunca llega a limpiar nada.
Y como el system prompt se reconstruye en cada inicio de conversación y en cada compactación, no es un fallo puntual: rechaza cada conversación nueva que abres.
El arreglo (PR #120748): un pequeño helper, hermes_time.safe_strftime(value, fmt), y ahora lo llama cada punto donde se usa strftime para renderizar texto de locale. Se comporta de dos maneras:
- Con un locale sano, la salida es idéntica byte a byte a
value.strftime(fmt). Es un requisito duro, no un adorno: el system prompt tiene que mantenerse estable o la caché de prompt se rompe. Las pruebas comparan ambas directamente para datetimes naive, zonas IANA y nombres de zona válidos con caracteres no ASCII. - Cuando el texto lleva surrogates, y ese texto solo contiene ASCII más bytes con escape de surrogate, los bytes se recuperan y se decodifican a través de la página de códigos ANSI (
locale.getencoding()), lo que devuelveheure d'étécomo texto. Todo lo que no se pueda decodificar cae aU+FFFD, y el resultado siempre se puede serializar a JSON sin problemas.
Los locales que reportaron el problema fueron fr-FR, es-AR y de-DE.
2. Decodificación de procesos hijo en Windows chino
El log tenía este aspecto, una y otra vez:
[gateway-crash] thread Thread-NNN (_readerthread) raised
UnicodeDecodeError: 'gbk' codec can't decode byte 0x85 in position 2: illegal multibyte sequence
Causa raíz: _readerthread es el hilo lector que subprocess.communicate() arranca en Windows. Cuando una captura pasa text=True y ningún encoding=, subprocess decodifica a través de subprocess._text_encoding(), que es locale.getencoding(): cp936/GBK en Windows en chino. Los procesos hijos en cuestión escriben UTF-8, así que cualquier byte ilegal en GBK lanza UnicodeDecodeError en ese hilo, y el threading.excepthook del proceso serve lo imprime como una línea [gateway-crash].
El culpable concreto era hermes_cli/web_routers/files.py::_fs_git_branch, que ejecuta git branch --show-current para /api/fs/default-cwd, un endpoint que el escritorio consulta continuamente. git escribe UTF-8, incluidos los nombres de rama y su propio stderr localizado (el mensaje chino «not a git repository», por ejemplo), así que en un host con GBK cada consulta lanzaba en el hilo lector y el endpoint devolvía una rama vacía.
Merece la pena señalar por qué pasó desapercibido tanto tiempo: los kwargs de esa llamada están en un literal de diccionario ("text": True), y la regla por líneas del proyecto para detectar text=True nunca los encontraba.
El arreglo (PR #120749): esas capturas se fijan explícitamente a UTF-8 (encoding="utf-8" con errors="replace"). Los spawns de tui_gateway/ (slash worker, compute host, cli.exec, pdftoppm) ya estaban fijados a UTF-8; esto cierra la clase de capturas dentro del proceso.
Un detalle relacionado: el lanzador de gateway con consola oculta añadido en la v0.21.0 hizo desaparecer el síntoma en la máquina de quien lo reportó, pero los lectores en sí nunca se arreglaron. Un síntoma que desaparece no es una causa raíz, y este arreglo sí es la causa raíz.
3. Las rutas que empiezan por C:\ no se podían ni etiquetar ni previsualizar
Síntoma: el agent en Windows escribe C:\Users\me\report.html o una ruta UNC \\server\share\..., y el escritorio no muestra ninguna etiqueta de artefacto. Cuando sí aparecía una etiqueta, el rótulo de vista previa salía como \Users\me\report.html; el fallback del renderer unía la ruta al directorio de trabajo y producía /repo/C:\....
Causa raíz: looksLikePath e isPreviewableTarget, en fallback-model/targets.ts, solo aceptaban file://, /, ./ y ~/, así que una ruta absoluta de Windows fallaba la prueba de «¿es una ruta?», toolPreviewTarget no devolvía nada y no aparecía etiqueta. Del lado del renderer, localPreviewTarget trataba como relativo todo lo que no empezara por / y lo unía al cwd. Y previewName parseaba C:\... con new URL(...), donde c: se lee como esquema de URL, así que solo sobrevivía la cola como rótulo.
El arreglo (PR #121074): un nuevo isWindowsAbsolutePath en lib/path-compare.ts acepta una letra de unidad con cualquiera de las dos barras y un prefijo UNC \\; looksLikePath acepta esas rutas; isPreviewableTarget ejecuta su comprobación de HTML sobre todo lo que acepta looksLikePath, así que C:\x.html es previsualizable y C:\x.txt no, igual que el comportamiento POSIX; localPreviewTarget ya no une rutas absolutas de Windows al cwd; previewName las rotula por nombre de archivo. Una copia privada de la misma regex en la comprobación de subida de adjuntos ahora llama a la función compartida (un commit aparte, sin cambio de comportamiento).
4. Una sonda de 10 segundos declaraba fallido un arranque en frío
Síntoma: en un arranque en frío de Windows, el backend local del escritorio responde por HTTP, y luego la aplicación informa de un fallo de arranque, derriba el proceso hijo y registra una cascada que empieza por «Ignoring stale Hermes backend exit (1)».
Causa raíz: en un arranque en frío de Windows, el bucle de eventos del backend local se bloquea entre 12 y 28 segundos mientras el GIL está retenido importando los módulos de plataforma del gateway. El presupuesto fijo de la sonda WebSocket del arranque era de 10 segundos (DEFAULT_CONNECT_TIMEOUT_MS), así que marcaba como fallo un arranque en frío que estaba sano, y todo lo de después venía en cadena.
El arreglo (PR #120750): para un backend lanzado por esta aplicación, la sonda sigue esperando más allá de los 10 segundos de presupuesto base mientras el proceso hijo siga vivo, hasta un tope de 90 segundos: el mismo presupuesto que el paso de anuncio de puerto ya concede a un arranque en frío. La señal de progreso es la vitalidad del proceso, no la frescura de su salida, porque el bloqueo es silencioso: el proceso no imprime nada hasta que su bucle se recupera (el latido del bucle de web_server.py solo registra event loop stalled después).
Las dos formas de fallo siguen siendo distintas: un proceso hijo vivo en loopback que aceptó la conexión TCP pero no ha respondido al upgrade está ocupado, así que la sonda espera; un rechazo de conexión o de auth sigue llegando como error/close inmediato; un proceso hijo que termina falla en la siguiente comprobación de 1 segundo; un backend vivo que nunca responde falla en el tope y saca la superposición de fallo de arranque ya existente, cuyo botón Reintentar recarga la ventana. Los gateways remotos, el botón «Test remote» y el enganche al backend del host mantienen el presupuesto fijo de 10 segundos.
Estado del release para estos cuatro
Los cuatro están en la v0.21.5 (tag v2026.9.24, 24 de septiembre de 2026), así que actualizar a esa versión los trae: no hace falta seguir main. Merece la pena decirlo, porque otra tanda de arreglos se fusionó ese mismo día después de cortar el tag, y esos todavía no están aquí.
Qué comprobar en tu propia instalación
Si te has topado con alguno de los problemas de arriba, esto lo confirma después de actualizar:
hermes --version # esperado v0.21.5 (2026.9.24)
- Crash por nombre de zona: abre una conversación nueva en un Windows localizado: se acabó el
surrogates not allowed, y el system prompt debería mantenerse estable (tu tasa de aciertos de la caché de prompt no debería moverse). - Decodificación del proceso hijo: abre el escritorio en Windows en chino: se acabaron las líneas continuas de
[gateway-crash] ... UnicodeDecodeError. - Rutas de Windows: pide al agent que escriba un
C:\...\report.html: la etiqueta de artefacto debería aparecer y previsualizarse; lo mismo con una ruta UNC. - Arranque en frío: arranca el escritorio en frío después de limpiar el estado del gateway: sin superposición de fallo de arranque y sin cascada «Ignoring stale Hermes backend exit».
Los cuatro están dentro de esa ventana de patch sin notas de versión; la lista más completa está en nuestra página del release v0.21.5 y en la guía de autocomprobación de la ventana v0.21.5. Los arreglos de entrega en streaming fusionados ese mismo día, que todavía no están en esta versión, se cubren en el barrido de entrega.
La versión corta
Lo que estos cuatro comparten es también lo que los hace tan pesados para los usuarios con el sistema en otro idioma: casi nunca se reproducen en un sistema en inglés. Un nombre de zona horaria, la codificación de un proceso hijo, una ruta con letra de unidad y un bloqueo de importación durante el arranque solo salen a la luz en la intersección de «Windows que no está en inglés» con Windows mismo. En una instalación de Windows localizada, actualizar a la v0.21.5 debería eliminar buena parte de los fallos inexplicables.