Quatro crashes no Windows que só atingem quem usa o sistema em outro idioma

Sua máquina roda Windows em alemão. Você instala o Hermes, inicia, e cada conversa nova se mata com uma linha que você não consegue interpretar: UnicodeEncodeError: 'utf-8' codec can't encode character '\udce9'. Troque o idioma do sistema para inglês e tudo funciona — então você conclui que deu azar. Alguém está no Windows em chinês e vê o log do desktop se encher de UnicodeDecodeError: 'gbk' codec can't decode; uma terceira pessoa percebe que, quando o agent escreve C:\...\report.html, o arquivo não pode ser nem etiquetado nem pré-visualizado.
Esses quatro problemas não têm relação entre si no código. O que eles compartilham é justamente o que os fez sobreviver tanto tempo: só aparecem em um Windows localizado, que não esteja em inglês, então quase nunca são reproduzidos ou reportados a partir de um ambiente em inglês. Todos os quatro foram corrigidos na v0.21.5 (tag v2026.9.24, lançada em 24 de setembro de 2026). Aqui está o que realmente acontecia.
1. Um nome de fuso horário derrubava a construção do system prompt
O erro tinha este aspecto:
agent/system_prompt.py -> _zone_bits() -> now.strftime("%Z")
UnicodeEncodeError: 'utf-8' codec can't encode character '\udce9' ...: surrogates not allowed
Causa raiz: o Windows devolve nomes de fuso codificados na ANSI code page, não como Unicode. No Windows em francês, Paris, Madrid (heure d'été) chega como bytes cp1252. Quando o LC_CTYPE do processo é UTF-8 (o modo UTF-8 do Python, ou um componente como Piper/espeak trocando o locale do processo inteiro a partir de outra thread), esses bytes são decodificados com surrogateescape, então tzname() acaba guardando surrogates isolados.
O problema real é o que vem depois: datetime.strftime costura o tzname() na string de formato como UTF-8, então %Z levanta dentro do strftime — antes de existir qualquer string, o que significa que a passagem _sanitize_surrogates existente nunca tem a chance de limpar nada.
E como o system prompt é reconstruído a cada início de conversa e a cada compactação, isso não é um soluço isolado: ele recusa toda conversa nova que você abre.
A correção (PR #120748): um pequeno helper, hermes_time.safe_strftime(value, fmt), e todo ponto de strftime que renderiza texto dependente de locale agora o chama. Ele se comporta de duas formas:
- Em um locale saudável, a saída é idêntica byte a byte a
value.strftime(fmt). Isso é um requisito duro, não um capricho — o system prompt precisa permanecer estável ou o cache de prompt quebra. Os testes comparam os dois diretamente para datetimes naive, zonas IANA e nomes de fuso não-ASCII válidos. - Quando o texto carrega surrogates, e esse texto é apenas ASCII mais bytes escapados como surrogate, os bytes são recuperados e decodificados pela ANSI code page (
locale.getencoding()), o que devolveheure d'étécomo texto. Qualquer coisa indecifrável cai paraU+FFFD, e o resultado é sempre seguro para serializar em JSON.
Os locales reportados foram fr-FR, es-AR e de-DE.
2. Decodificação de processos filhos no Windows em chinês
O log tinha este aspecto, repetidamente:
[gateway-crash] thread Thread-NNN (_readerthread) raised
UnicodeDecodeError: 'gbk' codec can't decode byte 0x85 in position 2: illegal multibyte sequence
Causa raiz: o _readerthread é a thread leitora que o subprocess.communicate() inicia no Windows. Quando uma captura passa text=True e nenhum encoding=, o subprocess decodifica via subprocess._text_encoding(), que é locale.getencoding() — cp936/GBK no Windows em chinês. Os processos filhos em questão escrevem UTF-8, então qualquer byte ilegal em GBK levanta UnicodeDecodeError nessa thread, e o threading.excepthook do processo serve imprime isso como uma linha [gateway-crash].
O vilão específico era hermes_cli/web_routers/files.py::_fs_git_branch, que roda git branch --show-current para /api/fs/default-cwd — um endpoint que o Desktop consulta continuamente. O git escreve UTF-8, incluindo nomes de branch e o próprio stderr localizado (a mensagem chinesa de “não é um repositório git”, por exemplo), então em um host GBK cada consulta levantava na thread leitora e o endpoint devolvia uma branch vazia.
Vale notar por que isso passou despercebido tanto tempo: os kwargs dessa chamada estão em um dict literal ("text": True), e a regra do projeto baseada em linhas para achar text=True nunca os encontrava.
A correção (PR #120749): essas capturas são fixadas explicitamente em UTF-8 (encoding="utf-8" com errors="replace"). Os spawns de tui_gateway/ (slash worker, compute host, cli.exec, pdftoppm) já fixavam UTF-8; isto fecha a classe de captura in-process.
Um detalhe relacionado: o launcher do gateway com console oculto, adicionado na v0.21.0, fez o sintoma sumir na máquina de quem reportou, mas os leitores em si nunca foram corrigidos. Sintoma que desaparece não é causa raiz, e esta correção é a causa raiz.
3. Caminhos começando com C:\ não podiam ser etiquetados nem pré-visualizados
Sintoma: o agent no Windows escreve C:\Users\me\report.html ou um caminho UNC \\server\share\..., e o Desktop não mostra nenhuma etiqueta de artifact. Quando uma etiqueta aparecia, o rótulo de pré-visualização saía como \Users\me\report.html; o fallback do renderer juntava o caminho ao diretório de trabalho e produzia /repo/C:\....
Causa raiz: looksLikePath e isPreviewableTarget em fallback-model/targets.ts só aceitavam file://, /, ./ e ~/, então um caminho absoluto do Windows falhava no teste de “isto é um caminho”, toolPreviewTarget não devolvia nada e nenhuma etiqueta aparecia. Do lado do renderer, localPreviewTarget tratava qualquer coisa que não começasse com / como relativa e a juntava ao cwd. E previewName fazia o parse de C:\... com new URL(...), onde c: é lido como esquema de URL, então só a cauda sobrevivia como rótulo.
A correção (PR #121074): um novo isWindowsAbsolutePath em lib/path-compare.ts aceita uma letra de unidade com qualquer barra e um prefixo UNC \\; looksLikePath aceita esses caminhos; isPreviewableTarget roda sua checagem de HTML em tudo que looksLikePath aceita, então C:\x.html é pré-visualizável e C:\x.txt não é, igual ao comportamento POSIX; localPreviewTarget não junta mais caminhos absolutos do Windows ao cwd; previewName os rotula pelo nome do arquivo. Uma cópia privada da mesma regex na checagem de upload de anexos agora chama a função compartilhada (um commit separado, sem mudança de comportamento).
4. Uma sonda de 10 segundos declarava falha um cold start
Sintoma: em um cold start do Windows, o backend local do Desktop responde HTTP, depois o app reporta uma falha de boot, derruba o processo filho e registra uma cascata que começa com “Ignoring stale Hermes backend exit (1)”.
Causa raiz: em um cold start do Windows, o event loop do backend local trava por 12–28 segundos enquanto o GIL fica preso importando os módulos de plataforma do gateway. O orçamento fixo da sonda WebSocket de boot era de 10 segundos (DEFAULT_CONNECT_TIMEOUT_MS), então ela marcou um cold start saudável como falha e tudo o que vinha depois seguiu o mesmo caminho.
A correção (PR #120750): para um backend que este app mesmo iniciou, a sonda continua esperando além do orçamento base de 10 segundos enquanto o processo filho estiver vivo, até um teto de 90 segundos — o mesmo orçamento que a etapa de anúncio de porta já permite para um cold start. O sinal de progresso é a liveness do processo, não a recência da saída, porque a trava é silenciosa: o processo não imprime nada até o loop se recuperar (o heartbeat de loop do web_server.py só registra event loop stalled depois).
As duas formas de falha continuam distintas: um processo filho de loopback vivo que aceitou o connect TCP mas ainda não respondeu ao upgrade está ocupado, então a sonda espera; uma recusa ou rejeição de auth ainda chega como error/close imediato; um filho que sai falha na checagem seguinte, de 1 segundo; um backend vivo que nunca responde falha no teto e exibe o overlay de falha de boot já existente, cujo Retry recarrega a janela. Gateways remotos, o botão “Test remote” e o attach de backend no host mantêm o orçamento fixo de 10 segundos.
Status da release desses quatro
Todos os quatro estão na v0.21.5 (tag v2026.9.24, 24 de setembro de 2026), então atualizar para essa versão resolve — não é preciso acompanhar o main. Vale dizer isso, porque um lote de outras correções foi mesclado no mesmo dia depois de a tag ser cortada, e essas ainda não estão aqui.
O que checar na sua própria instalação
Se você bateu em algum dos casos acima, isto confirma depois de atualizar:
hermes --version # expect v0.21.5 (2026.9.24)
- Crash por nome de fuso: abra uma conversa nova em um Windows localizado — não há mais
surrogates not allowed, e o system prompt deve permanecer estável (sua taxa de acerto do cache de prompt não deve mudar). - Decodificação de filhos: abra o Desktop no Windows em chinês — não há mais linhas contínuas de
[gateway-crash] ... UnicodeDecodeError. - Caminhos do Windows: peça ao agent para escrever um
C:\...\report.html— a etiqueta de artifact deve aparecer e a pré-visualização funcionar; o mesmo vale para um caminho UNC. - Cold start: faça um cold start do Desktop depois de limpar o estado do gateway — sem overlay de falha de boot e sem cascata “Ignoring stale Hermes backend exit”.
Todos os quatro estão dentro daquela janela de patch sem release notes; a lista mais completa está na nossa página da release v0.21.5 e no guia de autoverificação da janela v0.21.5. As correções de entrega em streaming, mescladas no mesmo dia e que ainda não estão nesta versão, estão na varredura de entrega.
A versão curta
O que esses quatro têm em comum é também o que os torna miseráveis para usuários com sistema localizado: eles quase nunca se reproduzem em um sistema em inglês. Um nome de fuso horário, a codificação de um processo filho, um caminho com letra de unidade e uma trava de import durante o boot só aparecem na interseção de “Windows que não está em inglês” com o próprio Windows. Em uma instalação do Windows localizada, atualizar para a v0.21.5 deve eliminar muita falha inexplicável.