Hermes gateway 鲁棒性永久修复方案

1. 临时止血:外层 watchdog

当前先不改 Hermes 源码,用 PowerShell watchdog 把 gateway 进程守住:

powershell -ExecutionPolicy Bypass -File "$env:LOCALAPPDATA\hermes\run-gateway-watchdog.ps1"

脚本位置:

C:\Users\yunlu\AppData\Local\hermes\run-gateway-watchdog.ps1

日志位置:

C:\Users\yunlu\AppData\Local\hermes\logs\gateway-watchdog.log

行为:

  • 循环运行 hermes gateway。
  • gateway 退出后记录退出码和运行时长。
  • 按 10s -> 20s -> 40s -> ... -> 300s 退避重启。
  • 如果 gateway 稳定运行超过 300 秒后才退出,下一轮退避恢复为 10 秒。
  • 不注册开机自启,不改 Hermes 源码,不改 git 仓库。

这个方案只能解决“进程死了自动拉起”,不能解决 Discord zombie websocket、状态不可信、DeepSeek turn 失败补偿等根因。

2. 永久目标

把 Hermes gateway 改成常驻 supervisor:

gateway 主进程常驻
├─ Discord adapter 断线:标记 discord=retrying,后台指数退避重连
├─ DeepSeek/provider 断流:当前 turn failed/retryable,记录状态,不杀 gateway
├─ cron/background review 报错:记录 job failed,不影响主消息通道
└─ health/status 真实反映:running / degraded / retrying / failed

核心原则:

  • 平台连接失败不等于 gateway 失败。
  • 模型调用失败不等于 gateway 失败。
  • 子任务失败不等于主进程失败。

3. 需要修改的核心文件

gateway/platforms/discord.py

目标:Discord 网络错误可恢复,重连前必须关闭旧 client。

要点:

  • 在 connect() 创建新的 commands.Bot 前,如果 self._client 存在且未关闭,先 await self._client.close()。
  • 同步清理 self._bot_task 和 self._ready_event,避免旧 websocket 残留和重复响应。
  • 启动 bot 时使用 self._client.start(..., reconnect=False),让 gateway runner 统一管理重连策略。
  • READY 等待超时应标记 retryable failure,而不是悬挂。
  • 网络类错误标记为 retryable:
    • aiohttp.ClientConnectorError
    • aiohttp.ServerDisconnectedError
    • aiohttp.ClientOSError
    • asyncio.TimeoutError
    • ConnectionResetError
    • OSError
    • discord.ConnectionClosed
  • 配置/权限类错误标记为 failed:
    • token 缺失
    • discord.LoginFailure
    • discord.PrivilegedIntentsRequired
    • 明确的权限/配置错误

gateway/run.py

目标:retryable 平台错误不让 gateway 退出。

要点:

  • GatewayRunner.start() 中,如果所有启用消息平台启动时都只是 retryable 网络失败:
    • 返回 True。
    • 保持 gateway 进程运行。
    • 写 gateway_state="degraded"。
    • 把失败平台放进 _failed_platforms 等后台重连。
  • _handle_adapter_fatal_error() 中,如果 runtime retryable 断线:
    • 从 self.adapters 移除该 adapter。
    • 写 platforms.discord.state="retrying"。
    • 放入 _failed_platforms。
    • 即使最后一个消息平台断开,也不要 await self.stop()。
  • non-retryable 错误才标记 failed,并按现有逻辑停止该平台或请求干净退出。
  • _platform_reconnect_watcher() 退避策略改为:
    • 初始 5 秒。
    • 指数退避。
    • 最大 300 秒。
    • 加 jitter。
    • retryable 网络错误不因为固定 20 次失败就永久放弃。

gateway/status.py

目标:gateway_state.json 能真实表达 degraded/retrying/provider 状态。

建议结构:

{
  "gateway_state": "degraded",
  "platforms": {
    "discord": {
      "state": "retrying",
      "error_code": "discord_client_error",
      "error_message": "Cannot connect to host gateway-us-east1-d.discord.gg",
      "next_retry_at": "2026-05-15T18:31:45+00:00",
      "updated_at": "2026-05-15T18:31:15+00:00"
    }
  },
  "providers": {
    "deepseek": {
      "status": "retrying",
      "model": "deepseek-v4-flash",
      "base_url": "https://api.deepseek.com/v1",
      "session_id": "20260515_095752_5e4d8d3b",
      "message_id": "discord:1496306840106045451",
      "attempts": 3,
      "error": "peer closed connection without sending complete message body (incomplete chunked read)",
      "retryable": true,
      "next_retry_at": "2026-05-15T19:24:03+00:00",
      "updated_at": "2026-05-15T19:19:03+00:00"
    }
  }
}

状态枚举:

  • Gateway: starting / running / degraded / draining / stopped / startup_failed
  • Discord: starting / connected / retrying / disconnected / failed
  • DeepSeek/provider: ok / degraded / retrying / failed

Provider turn/status 相关路径

候选位置:

  • run_agent.py
  • gateway/run.py 的 GatewayRunner._run_agent(...)
  • background review / auxiliary agent 调用处

目标:

  • DeepSeek RemoteProtocolError、incomplete chunked read、APIConnectionError、timeout 等网络错误重试耗尽后,只让当前 turn 失败。
  • 返回结果中带上:
    • failed=True
    • retryable=True
    • provider
    • model
    • base_url
    • session_id
    • message_id
    • attempts
    • error
    • next_retry_at
  • 写入 gateway_state.json.providers.deepseek。
  • 如果消息 adapter 仍可用,给用户发送简短提示:
模型连接中断,本次任务已记录,可稍后重试。
  • 不要继续向上 raise 到 gateway 主循环。

4. 测试用例

建议新增或更新这些测试:

tests/gateway/test_discord_connect.py
tests/gateway/test_runner_startup_failures.py
tests/gateway/test_platform_reconnect.py
tests/gateway/test_runner_fatal_adapter.py
tests/gateway/test_status.py

必须覆盖:

  • Discord connect 抛 aiohttp.ClientConnectorError:
    • gateway 返回启动成功。
    • gateway_state="degraded"。
    • platforms.discord.state="retrying"。
    • 进入后台重连队列。
  • Discord runtime bot task 因网络错误结束:
    • 不调用 runner.stop()。
    • 不写 fatal exit。
    • 进入后台重连队列。
  • Discord token/auth 错误:
    • 标记 failed。
    • 不无限 retry。
  • Discord 重连成功:
    • platforms.discord.state="connected"。
    • 如果没有其他 degraded 项,gateway_state="running"。
  • DeepSeek stream retry 耗尽:
    • 当前 turn failed=True。
    • provider 状态写为 retrying 或 degraded。
    • gateway 继续运行。
  • Provider 后续成功:
    • providers.deepseek.status="ok"。

建议验收命令:

python -m pytest tests/gateway/test_discord_connect.py -q -n0
python -m pytest tests/gateway/test_runner_startup_failures.py tests/gateway/test_platform_reconnect.py tests/gateway/test_runner_fatal_adapter.py -q -n0
python -m pytest tests/gateway/test_status.py -q -n0

5. 验收标准

  • Discord 代理/网络断线时,日志不再出现把网络错误当成 gateway fatal exit 的行为。
  • gateway_state.json 能看到 Discord retrying,整体 gateway_state 为 degraded。
  • Discord 恢复后自动重连,状态回到 connected/running。
  • DeepSeek 断流重试耗尽后,用户收到当前任务失败提示,gateway 仍继续接受后续消息。
  • background review / cron 报错只记录失败,不影响主消息通道。
  • 不新增第三方依赖,不大改架构。

6. 备注

当前本机日志已经观察到相关症状:

Discord client stopped: Cannot connect to host gateway-us-east1-d.discord.gg
Fatal discord adapter error (discord_client_error)
RemoteProtocolError: peer closed connection without sending complete message body (incomplete chunked read)
Stream exhausted on attempt 3/3

这些错误都应被视为可恢复条件,而不是 gateway 主进程失败条件。

2026-10-09 Codex 审阅说明

这是 2026-05-15 的 Windows 修复方案,不构成“永久修复”或当前 CachyOS 服务验收。pytest -n 0 的 -n 属于 pytest-xdist 插件;没有该依赖时用普通 pytest,不能把参数报错判作测试失败。