Windows 上只有用本地化系统的你才会撞到的四个崩溃


你的系统是德语版 Windows。装好 Hermes,跑起来,然后每开一个新会话它就退出一次,日志里只有一行看不懂的东西:UnicodeEncodeError: 'utf-8' codec can't encode character '\udce9'。你把系统语言切回英文,一切正常——于是你以为是自己运气不好。另一个人用的是中文 Windows,桌面端日志里刷满 UnicodeDecodeError: 'gbk' codec can't decode;还有人在 Windows 上发现 agent 写出的 C:\...\report.html 既点不开也预览不了。

这四个问题在代码上是完全独立的,但它们有同一个共同点:只在本地化(非英文)的 Windows 上出现,因此在英文环境里几乎不会被复现和报告。2026 年 9 月 24 日发布的 v0.21.5(tag v2026.9.24)里,它们一起被修掉了。下面逐个讲清楚原因。

一、时区名把系统提示的构建搞崩了

报错长这样:

agent/system_prompt.py -> _zone_bits() -> now.strftime("%Z")
UnicodeEncodeError: 'utf-8' codec can't encode character '\udce9' ...: surrogates not allowed

根因:Windows 给出的时区名是 ANSI 代码页编码的字节,不是 Unicode。Paris, Madrid (heure d'été) 这种名字在法语版 Windows 上会以 cp1252 字节返回。当进程的 LC_CTYPE 是 UTF-8 时(Python 的 UTF-8 模式,或者 Piper/espeak 这类组件从另一个线程改了进程级 locale),这些字节会以 surrogateescape 解码,于是 tzname() 里留下的是孤立的代理字符。

真正的麻烦在后面:datetime.strftime 会把 tzname() 以 UTF-8 插入到格式串里,所以 %Z 会在 strftime 内部抛异常——在任何字符串存在之前,代码里已有的 _sanitize_surrogates 清理根本没机会运行。

而且系统提示在每次会话开始和每次上下文压缩时都会重建,所以这个故障不是偶发一次,是你每开一个会话被拒一次。

修法(PR #120748):新增一个很小的辅助函数 hermes_time.safe_strftime(value, fmt),所有会把 locale 名塞进字符串的 strftime 调用点改用它。它的行为有两档:

  • locale 健康时,输出与 value.strftime(fmt) 逐字节一致。这一点是硬要求——系统提示必须保持稳定,否则前缀缓存(prompt caching)就废了。测试里对朴素时间、IANA 时区名、以及合法但非 ASCII 的时区名都做了逐字节比对。
  • 带代理字符时,如果这段文本只由 ASCII 加代理转义字节构成,就按 ANSI 代码页(locale.getencoding())把字节还原回来,于是 heure d'été 又变回正常文字;无法解码的部分回落到 U+FFFD,结果永远是可以安全 JSON 序列化的。

报告这个问题的 locale 有 fr-FR、es-AR 和 de-DE 三个。

二、中文 Windows 上的子进程输出解码

日志长这样(不断刷):

[gateway-crash] thread Thread-NNN (_readerthread) raised
UnicodeDecodeError: 'gbk' codec can't decode byte 0x85 in position 2: illegal multibyte sequence

根因:_readerthread 是 subprocess.communicate() 在 Windows 上启动的读取线程。当捕获只传了 text=True 而没有传 encoding= 时,subprocess 会用 subprocess._text_encoding() 解码,也就是 locale.getencoding()——中文 Windows 上是 cp936/GBK。而被读的子进程写的是 UTF-8,任何在 GBK 里非法的字节都会在那个线程里抛 UnicodeDecodeError,接着被 serve 进程的 threading.excepthook 打成一行 [gateway-crash]。

具体踩雷的是 hermes_cli/web_routers/files.py 里的 _fs_git_branch:它为了 /api/fs/default-cwd 跑 git branch --show-current,而桌面端会不停轮询这个端点。git 写的是 UTF-8(包括分支名和它自己的本地化报错,比如“不是 git 仓库”的中文提示),所以在 GBK 主机上每次轮询都在读线程里炸一次,端点返回空分支。

值得一提的是它为什么一直没被抓到:这个调用的 kwargs 放在字典字面量里("text": True),而项目里那条“按行匹配 text=True“的检查规则永远匹配不到它。

修法(PR #120749):把这些捕获显式固定为 UTF-8(encoding="utf-8",配 errors="replace")。tui_gateway/ 下的各个 spawn(slash worker、compute host、cli.exec、pdftoppm)本来就钉住了 UTF-8,这次是补齐进程内捕获这一类。

顺带说一句:v0.21.0 引入的隐藏控制台 gateway 启动器让这个症状在报告者机器上消失了,但读取线程本身没修——表面好了不等于根因没了,这次才是修根因。

三、C:\ 开头的路径既不能标记,也不能预览

症状:Windows 上的 agent 写出 C:\Users\me\report.html 或者 UNC 路径 \\server\share\...,桌面端不出现产物标签;即使标签出现了,预览的标题会变成 \Users\me\report.html 这种奇怪的样子;渲染端回退还会把路径拼到工作目录后面,产生 /repo/C:\...。

根因:fallback-model/targets.ts 里的 looksLikePath 和 isPreviewableTarget 只接受 file://、/、./、~/ 四种形式,所以 Windows 绝对路径进不了“这是不是路径”的判断,toolPreviewTarget 返回空,标签自然不出现。渲染端的 localPreviewTarget 把任何不以 / 开头的路径都当相对路径拼到 cwd 上。而 previewName 用 new URL('C:\\...') 解析时,c: 被当成 URL scheme,于是标签只剩下后半段。

修法(PR #121074):在 lib/path-compare.ts 里新增 isWindowsAbsolutePath,覆盖盘符(正斜杠和反斜杠都算)以及 UNC 的 \\ 前缀;looksLikePath 接受这类路径;isPreviewableTarget 对任何 looksLikePath 接受的路径都跑它那套 HTML 判断,所以 C:\x.html 可预览、C:\x.txt 不可预览,和 POSIX 路径的行为一致;localPreviewTarget 不再把 Windows 绝对路径拼到 cwd 上;previewName 改用文件名作为标签。附件上传检查里那份私有的同款正则也改成了调用同一个函数(单独的提交,行为不变)。

四、冷启动被 10 秒探针判成“启动失败”

症状:Windows 上冷启动桌面端,后端已经能应答 HTTP 了,然后界面报启动失败,子进程被拆掉,日志里出现 “Ignoring stale Hermes backend exit (1)” 的级联。

根因:Windows 冷启动时,本地后端的事件循环会在持有 GIL 导入 gateway 平台模块期间停住 12–28 秒。启动时那个 WebSocket 探针的固定预算只有 10 秒(DEFAULT_CONNECT_TIMEOUT_MS),于是把一次正常的冷启动锁死成失败,接着触发拆进程和上面那串级联。

修法(PR #120750):由本应用拉起的后端,只要子进程还活着,探针就会越过 10 秒的基础预算继续等,上限 90 秒——和端口公告步骤对冷启动给出的预算是同一个数。判断依据是进程存活而不是输出新鲜度,因为这段停顿是无声的:进程在事件循环恢复之前什么都不打印(web_server.py 的循环心跳只在事后记录 event loop stalled)。

两种失败形状仍然区分得很清楚:一个活着的回环子进程接受了 TCP 连接但还没回升级请求,那叫忙,探针继续等;连接被拒绝或者鉴权被拒仍然立刻以 error/close 返回;子进程退出会在下一次 1 秒检查时失败;一直不回答的活后端会在上限处失败,并弹出既有的启动失败遮罩(它的 Retry 会重载窗口)。远端 gateway、“Test remote”按钮和附着 host 后端的情况仍然保持固定 10 秒预算。

这四个修复的发布状态

四条都在 v0.21.5(tag v2026.9.24,2026 年 9 月 24 日) 里,也就是说升级到这一版就能拿到——不需要跟进 main。这一点值得明确,因为同日还有一批修复是在这个 tag 发布之后才合入的,那些还不在这里面。

你可以做的自查

如果你曾经遇到过上面任何一个症状,升级后可以这样确认:

hermes --version       # 期望看到 v0.21.5 (2026.9.24)
  • 时区崩溃:在本地化 Windows 上开一个新会话,不该再出现 surrogates not allowed;同时系统提示应该保持稳定(前缀缓存命中率不该因此变化)。
  • 子进程解码:中文 Windows 上打开桌面端,日志里不该再持续出现 [gateway-crash] ... UnicodeDecodeError。
  • Windows 路径:让 agent 写一个 C:\...\report.html,产物标签应该出现并且能预览;UNC 路径同理。
  • 冷启动:断掉 gateway 状态后重新冷启动桌面端,不该再出现启动失败遮罩加 “Ignoring stale Hermes backend exit”。

这四条都属于那个“没有发布说明”的补丁窗口,完整清单可以看站内的 v0.21.5 发版页 和 v0.21.5 窗口自查指南。同一天合入、但还不在这版里的那批流式投递修复,在流式投递修复合集里。

小结

这四个问题的共同点,也是本地化用户最难受的地方:它们在英文环境里几乎不会复现。时区名、子进程编码、盘符路径、导入导致的启动停顿,全都是在“非英文系统 + Windows”这个交叉点上才现形。如果你用的是本地化 Windows,升级到 v0.21.5 之后应该能明显感觉到少了很多莫名其妙的失败。