Hermes Agent Desktop 启动失败排查实录:后端为何以退出码 0 提前结束

问题背景

在 Windows 上启动 Hermes Agent Desktop 时,桌面窗口无法正常进入应用,日志反复出现类似错误:

Hermes backend exited before it became ready (0).
Desktop boot failed: Hermes backend exited before it became ready (0).

这个现象比较特殊:后端不是因为异常崩溃而退出,而是返回了退出码 0。

在操作系统语义中,退出码 0 通常代表程序正常结束。但 Hermes Desktop 需要后端作为一个长期运行的服务,因此“正常结束”反而
意味着启动失败。

本文记录完整的定位过程、根因分析、临时修复方案和验证结果。

———

一、问题现象

Hermes 的用户数据和运行环境主要位于:

%LOCALAPPDATA%\hermes
%APPDATA%\Hermes

检查日志目录后发现,近期主要日志包括:

%LOCALAPPDATA%\hermes\logs\desktop.log
%LOCALAPPDATA%\hermes\logs\gui.log
%LOCALAPPDATA%\hermes\logs\agent.log
%LOCALAPPDATA%\hermes\logs\bootstrap-installer.log
%LOCALAPPDATA%\hermes\logs\errors.log
%LOCALAPPDATA%\hermes\logs\update.log

其中 desktop.log 不断重复以下启动流程:

[boot] Resolving Hermes backend
[boot] Resolving Hermes runtime
[boot] Hermes runtime is ready
[boot] Starting Hermes backend via Hermes
[boot] Waiting for Hermes backend to launch
Hermes backend exited (0)
[boot] Hermes backend exited before it became ready (0).

从日志可以得出几个初步结论:

  1. Hermes Desktop 本身可以启动。
  2. 本地 Python 虚拟环境能够被识别。
  3. Hermes 后端运行环境被判断为可用。
  4. 后端进程已经创建,但在报告监听端口之前退出。
  5. 退出码是 0,说明更像是某项主动退出逻辑,而不是 Python 异常。

———

二、排除安装损坏和依赖问题

Hermes 后端的核心运行环境位于:

%LOCALAPPDATA%\hermes\hermes-agent

Python 虚拟环境位于:

%LOCALAPPDATA%\hermes\hermes-agent\venv

Hermes Desktop 启动后端时,本质上执行的是类似下面的命令:

env:LOCALAPPDATA\hermes”
env:LOCALAPPDATA\hermes\hermes-agent”

& “$env:LOCALAPPDATA\hermes\hermes-agent\venv\Scripts\python.exe” -m hermes_cli.main serve
–host 127.0.0.1 `
–port 0

手动执行后,后端可以正常输出:

HERMES_BACKEND_READY port=51746

并且持续运行。

这说明:

  • Python 解释器正常;
  • 虚拟环境没有损坏;
  • hermes_cli 可以正常导入;
  • serve 子命令可用;
  • 后端能够成功绑定本地端口;
  • 问题并不在安装文件、依赖或端口冲突。

因此,没有必要直接重装 Hermes,也不应该贸然删除配置和数据库。

———

三、通过环境变量缩小范围

Hermes Desktop 启动后端时,不只执行 hermes serve,还会注入一组环境变量,例如:

HERMES_HOME
HERMES_DESKTOP=1
HERMES_PARENT_PID
HERMES_DASHBOARD_SESSION_TOKEN
TERMINAL_CWD
HERMES_WEB_DIST

随后使用与桌面端接近的环境重新测试。

加入下面两个变量后,问题被稳定复现:

env:HERMES_PARENT_PID = “<桌面端进程 PID>”

此时后端没有输出 Python 异常,也没有报告端口,而是在短时间内直接返回:

EXIT=0

这与桌面端日志中的现象完全一致。

由此可以判断,问题和桌面模式下的父进程监视机制有关。

———

四、根因分析

Hermes 后端包含一个父进程存活监视器。

它的设计目的是:如果 Hermes Desktop 异常退出,后端服务也应该随之结束,避免遗留孤儿进程以及相关的 MCP 子进程。

相关逻辑位于:

hermes_cli/web_server.py

原始判断方式可概括为:

def _is_serve_orphaned(original_ppid: int, getppid=os.getppid) -> bool:
return getppid() != original_ppid

桌面端启动后端时,将自己的 PID 写入:

HERMES_PARENT_PID

监视线程每隔一段时间比较:

os.getppid() != int(os.environ[“HERMES_PARENT_PID”])

一旦二者不同,就认为桌面父进程已经死亡:

os._exit(0)

这恰好解释了为什么日志中看到的是退出码 0。

Windows 上的问题

在当前机器的 Windows 进程环境中,Python 返回的 os.getppid() 并不一定等于 Electron 传入的 PID。

实际测试可以观察到类似结果:

HERMES_PARENT_PID=5288
os.getppid()=10472

这并不代表 PID 为 5288 的 Electron 进程已经死亡,而是说明 Python 看到的直接父进程可能是进程链中的中间层。

相关进程边界可能来自:

  • Electron;
  • Node.js 的 child_process.spawn();
  • Windows 进程创建机制;
  • 启动包装器;
  • 沙箱或进程代理层。

因此,仅通过比较父 PID 是否相等来判断 Electron 是否存活,在 Windows 上并不可靠。

最终形成了下面的错误链路:

Hermes Desktop 启动

Electron 创建 Python 后端

桌面端传入 HERMES_PARENT_PID

Python 的 os.getppid() 返回另一个中间进程 PID

监视器误判桌面父进程已死亡

调用 os._exit(0)

后端尚未报告端口便结束

桌面端显示启动失败

———

五、修复方案

修复思路是:

  • 非 Windows 平台继续使用原来的父 PID 比较;
  • Windows 平台不再依赖 os.getppid();
  • 改为通过 Windows API 直接检查 HERMES_PARENT_PID 对应的进程是否仍然存活。

修改后的核心代码如下:

def _is_serve_orphaned(original_ppid: int, getppid=os.getppid) -> bool:
“””True when this process lost its original spawning parent.”””

  # Windows 下 os.getppid() 可能返回进程链中的中间进程,
  # 因而不能可靠判断 Electron 是否仍在运行。
  if sys.platform == "win32" and getppid is os.getppid:
      try:
          import ctypes

          SYNCHRONIZE = 0x00100000
          WAIT_TIMEOUT = 0x00000102

          kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)

          handle = kernel32.OpenProcess(
              SYNCHRONIZE,
              False,
              original_ppid,
          )

          if not handle:
              return True

          try:
              return (
                  kernel32.WaitForSingleObject(handle, 0)
                  != WAIT_TIMEOUT
              )
          finally:
              kernel32.CloseHandle(handle)

      except Exception:
          # 原生检查不可用时,保留原来的兼容行为。
          pass

  return getppid() != original_ppid

代码含义

OpenProcess() 尝试打开指定 PID 的进程句柄:

handle = kernel32.OpenProcess(
SYNCHRONIZE,
False,
original_ppid,
)

如果无法打开句柄:

if not handle:
return True

则将其视为父进程已经结束。

如果成功打开句柄,再使用:

kernel32.WaitForSingleObject(handle, 0)

进行一次非阻塞状态检查。

当返回值为:

WAIT_TIMEOUT

表示进程仍在运行;否则表示进程已经结束。

最终无论结果如何,都关闭句柄:

kernel32.CloseHandle(handle)

这样判断的是“指定 PID 对应的进程是否还活着”,而不是“它是否恰好是 Python 当前报告的直接父进程”。

———

六、修复验证

1. 直接验证后端

使用与桌面端一致的环境变量启动:

env:LOCALAPPDATA\hermes”
env:LOCALAPPDATA\hermes\hermes-agent”
env:HERMES_PARENT_PID = “<一个仍存活的进程 PID>”
$env:HERMES_DASHBOARD_SESSION_TOKEN = “diagnostic-token”

& “$env:LOCALAPPDATA\hermes\hermes-agent\venv\Scripts\python.exe” -m hermes_cli.main serve
–host 127.0.0.1 `
–port 0

修复前:

EXIT=0

后端约两秒后自动退出。

修复后:

HERMES_BACKEND_READY port=51896

进程持续运行,直到诊断命令主动超时结束。

这证明父进程监视器不再误杀后端。

2. 启动 Hermes Desktop

重新启动 Hermes Desktop 后,日志出现:

[boot] Starting Hermes backend via Hermes
[boot] Waiting for Hermes backend to launch
HERMES_BACKEND_READY port=51933
[boot] Waiting for Hermes backend to become ready
[boot] Hermes backend is ready. Finalizing desktop startup

同时可以观察到:

  • 多个 Hermes/Electron 进程正常运行;
  • Hermes 虚拟环境中的 Python 后端持续运行;
  • 后端成功监听本地随机端口;
  • 不再出现“启动两秒后退出”的问题。

至此,启动故障修复完成。

———

七、排查过程中的几个关键经验

1. 退出码 0 也可能代表启动失败

对于命令行工具,退出码 0 通常表示成功。

但对于需要长期运行的服务程序,如果它在报告就绪状态前返回,即便退出码是 0,对上层调用方而言仍然是启动失败。

因此看到:

exited before it became ready (0)

时,应优先检查:

  • 主动退出逻辑;
  • 父进程监视器;
  • 单实例检查;
  • 停止标记;
  • 守护进程状态判断;
  • 更新交接逻辑。

不应只围绕 Python 异常、依赖缺失或端口冲突排查。

2. 先手动运行后端,再考虑重装

桌面应用经常只是后端服务的启动器。

将桌面端实际执行的命令提取出来手动运行,可以快速判断问题属于哪一层:

桌面 UI

Electron 主进程

后端启动器

Python 环境

业务服务

本次排查中,手动执行 hermes serve 成功,迅速排除了虚拟环境损坏、Python 包缺失和端口绑定问题。

3. 环境变量是复现桌面行为的关键

如果简单手动运行成功,但桌面端启动失败,差异通常来自:

  • 工作目录;
  • 启动参数;
  • 环境变量;
  • 标准输入输出模式;
  • 父进程关系;
  • 隐藏窗口参数;
  • 用户配置目录。

逐项加入桌面端设置的环境变量,能够高效定位触发条件。

4. Windows 上不要把 PPID 相等当作唯一存活依据

如果真正需要判断某个已知 PID 是否存活,Windows 下更可靠的方式是:

  • 使用 Win32 API 打开进程句柄;
  • 查询进程退出码;
  • 对进程句柄执行非阻塞等待;
  • 或使用成熟的跨平台进程管理库。

os.getppid() 更适合描述当前 Python 进程所观察到的直接父进程,不适合在复杂启动链中替代“指定进程是否仍然存在”的检查。

———

八、后续注意事项

本次修改直接作用于本机 Hermes Agent 的运行源码:

%LOCALAPPDATA%\hermes\hermes-agent\hermes_cli\web_server.py

Hermes 后续自动更新时,安装程序可能重新部署该文件,从而覆盖本地修复。

如果更新后问题再次出现,可以检查 desktop.log 是否重新出现:

Hermes backend exited before it became ready (0).

并再次验证:

HERMES_PARENT_PID

和:

os.getppid()

是否不一致。

从长期维护角度看,更理想的方案是由 Hermes 官方在上游代码中采用 Windows 原生进程存活检查,并补充以下测试:

  1. Electron 直接启动 Python 后端;
  2. 存在中间启动进程时的父进程监视;
  3. Electron 正常退出后,后端自动结束;
  4. Electron 仍存活时,后端不得误退出;
  5. 无权限打开进程句柄时的降级行为。

———

总结

本次 Hermes Agent Desktop 启动失败并不是安装损坏,也不是 Python 依赖、模型接口或端口冲突导致的。

真正原因是:

Hermes 后端在 Windows 上使用 os.getppid() 与 Electron PID 进行严格比较,错误地把正常的进程链差异识别为父进程死亡,随
后主动调用 os._exit(0) 结束后端。

通过改用 Windows 原生 API 检查指定 PID 是否仍然存活,后端可以正常报告监听端口并持续运行,Hermes Desktop 也成功完成启
动。

这个案例再次说明:面对桌面应用的后端启动故障,最有效的路径往往不是立即重装,而是沿着“启动命令—环境变量—进程生命周期—就
绪协议”逐层缩小范围。