hermes update 把网关停了却再没叫醒它:一个 git 读不了的 .git 引发的连环故障


一台 Windows 机器上,hermes update 跑完,Telegram 和 WhatsApp 再也不回消息了。日志里只有三行:先说你那个叫 hermes-2 的网关被“暂停”了,然后是 git 的一句 fatal: not a git repository,最后是一句安抚——“Paused gateway(s) left stopped: the checkout HEAD is unreadable”,意思是下次跑任意 hermes 命令就会把网关拉起来。

你跑了。网关没起来。再跑一次 hermes update,同样的三行,一个字都没变。

PR #142609(2026 年 10 月 21 日合并)修的就是这个死循环。它值得讲,因为它不是“某个功能不工作”,而是错误处理本身把恢复路径堵死了——这类 bug 平时不出现,一出现就让人以为是自己弄坏了什么。

根因:一个 git 自己都不认的 .git

先把前提说清楚:这次的 .git 不是“不存在”,而是“存在但 git 拒绝读”。典型两种形态:

  • .git/HEAD 是空的,或者整个 HEAD 文件没了;
  • .git 是一个文件(不是目录),里面写着 gitdir: <某个路径>,而那个路径已经不存在——常见于 worktree 或迁移残留。

Hermes 的更新器逻辑是“.git 存在才去 fetch“,于是它会照着这个 .git 去 fetch,git 回一句 not a git repository,更新失败。到这步还算正常。

真正的问题是暂停记录。更新前会先把正在跑的网关“暂停”(避免一边改代码一边有进程在读),并记下当时的仓库树状态。这个记录有个校验函数 tree_is_whole:只要发现 HEAD 读不出来,它就永久保留这份暂停集合。于是:

  1. 第一次 update:暂停网关 → fetch 失败 → 因为 HEAD 读不出来,暂停集合被保留 → 网关停着。
  2. 第二次 update:还是那个读不出来的 HEAD → 集合继续被保留 → 网关还停着。
  3. 无限循环。

也就是说,一个已经坏掉的前提(HEAD 不可读)被当成了“还不能恢复”的理由,而它永远不会自己变好。

修复一:暂停记录会自己解开

新的判断是:如果集合里每一次暂停在记录时 HEAD 就已经读不出来,并且之后再没有任何步骤绑定过仓库树移动,那么这份暂停集合就直接放行、恢复网关。

理由很朴素:网关当时服务的正是那棵树,而且没有任何东西写过它——既然没有“半写状态”的风险,就没有理由继续扣着不放。

有两种情况仍然保留暂停集合,这点很重要,别以为它变成了无条件放行:

  • 记录里存在一次已绑定的树移动(_checkout_move 会在 git 真正写之前记下目标和路径);
  • 暂停时 HEAD 是可读的,现在却读不出来了——这说明期间发生了别的破坏,需要人看。

修复二:错误信息终于说了人话

第二个改动在 fetch 的诊断里。以前无论什么原因失败,都只报一句 Failed to fetch updates from origin.,你只能靠猜。

现在,当 git 自己输出 fatal: not a git repository、并且当前 checkout 本身也 rev-parse --show-toplevel 失败时,Hermes 会打印:

This Hermes checkout's git data is damaged ...

后面跟一条指向这个 checkout 的安装命令——注意是带路径的:

curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash -s -- --dir <path>

PowerShell 侧是 -InstallDir <path>。这意味着自定义目录安装的人也能就地修复,而不是被迫重装到默认路径。

为什么要额外检查 rev-parse?因为远端仓库的 .git 悬空时,git 也会输出同一句 fatal: not a git repository。只看那行字会误判成本地损坏,所以判定必须落在“当前 checkout 自己能不能被解析”上。--check(只检查不更新)也会给出同样的诊断。

修复三:安装脚本对悬空的 .git 文件也生效

两个安装脚本本来就有“HEAD 读不出来就把整个 checkout 移到 .broken-<时间戳>,然后重新 clone”的能力。但 install.sh 只对 .git 目录做了这个处理,遇到 .git 是个悬空文件时直接拒绝,报“not a Hermes git checkout”。

现在它用 -e 判断,和 install.ps1 的 Test-Path 对齐了。两边的警告文案也补上了“damaged .git”,不再只说“interrupted clone”——因为这两种情况以前看起来一模一样。

为什么 ZIP 兜底还是不救

更新器里有一条 ZIP 兜底路径:git 彻底不可用时,直接下压缩包覆盖。但在这个场景里它仍然拒绝。

原因在文档里写得很清楚:没有 git status,它无法区分“用户自己的本地改动”和“损坏”,覆盖就等于把别人的工作删了。这个 fail-closed 的规则(#87304)保持不变——宁可让你手动处理,也不替你猜。

如果你现在就卡在这一步

假设你正盯着一台停了的网关,可以按这个顺序试(都基于这套机制的行为,不涉及未发布的私有命令):

# 1. 先看网关到底什么状态
hermes gateway list
hermes gateway status

# 2. 如果只是被暂停、没有被别的流程占着,直接拉起来
hermes gateway restart

# 3. 如果 .git 确实坏了,用安装脚本就地修(把 <path> 换成你的 checkout 路径)
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash -s -- --dir <path>

升级到带这个修复的版本之后,第 3 步不需要你自己判断——hermes update 会直接把命令打出来给你。这也顺便解决了另一个常见误区:“下次 hermes 命令会重启网关”这句提示,在旧版本里并不总是真的,它依赖暂停记录能被解开,而当时的代码恰恰不会解开。

版本状态与影响面

PR 在 2026 年 10 月 21 日深夜合并,晚于当天的 v0.22.4 发布(18:49 UTC),所以它不在 v0.22.4 里,也不在任何 tag 上,目前只在 main。

影响面主要是 Windows:现场报告就是从 Windows 上来的(.git/HEAD 被删、工作树还在)。但根因与平台无关——任何让 HEAD 变得不可读的操作(手工清理 .git、中断的 clone、迁移时只拷了工作树)在 Linux 和 macOS 上同样会触发。区别只在于 Windows 上更容易被文件锁和权限问题制造出这种状态。

一句话总结这次的教训:当恢复逻辑依赖“某个前提会自己变好”,而那个前提正是坏掉的东西时,系统会安静地卡死在原地。现在它要么恢复,要么明确告诉你去哪修。