Skip to content

fix(hermes): 冷启动首条消息不再白等 45s ready-gate - #645

Merged
deepcoldy merged 1 commit into
masterfrom
fix/hermes-cold-start-ready-gate
Jul 29, 2026
Merged

fix(hermes): 冷启动首条消息不再白等 45s ready-gate#645
deepcoldy merged 1 commit into
masterfrom
fix/hermes-cold-start-ready-gate

Conversation

@deepcoldy

Copy link
Copy Markdown
Owner

问题

Hermes 冷启动首条消息延迟 ≈45s

根因

PR #353 给 hermes 适配器设了 injectsReadyHook: true,前提假设是「Hermes 会在 prompt_toolkit composer 渲染后 shell 执行 BOTMUX_READY_COMMAND」——这是一个跨仓库契约,当时注释写「由作者侧 Hermes 实现保证」。

但线上 Hermes Agent v0.18.2 里 BOTMUX_READY_COMMAND 出现 0 次,契约从未被兑现,session-ready 信号永远不会发出。于是:

  • worker spawn 时 readyGate.arm() 扣住首条 prompt,等 session_ready IPC;
  • 等不到 → 只能靠 READY_SIGNAL_TIMEOUT_MS(45s)的 fallback 才放行;
  • 而真实 输入框其实 ~3.6s 就已经出现了。

线上日志实证(修复前,fresh 会话):

08:49:39.962  Spawning fresh CLI: hermes ...
08:49:39.962  Ready gate armed — holding first prompt until SessionStart ready signal
08:49:46.344  Prompt detected (idle)          ← 真 ❯ 已出现(~6s)但被扣住
08:49:46.371  Idle detected but holding for SessionStart ready signal (startup selector guard)
08:50:24.963  Ready gate released (signal timeout fallback)   ← spawn + 正好 45s
08:50:24.963  Hermes is ready for input

全程没有 SessionStart ready signal received——信号根本没来过。

为什么不用 Hermes 的原生 hook?

深挖 Hermes v0.18.2 源码确认:它没有任何 composer-ready hook 可用

  • Hermes 确有 shell-hooks 系统(--accept-hooks,botmux 已在传),支持 on_session_start / pre/post_tool_call / pre/post_llm_call 等 20+ 回合级事件。
  • 没有 TUI-composer-ready 事件。最接近的 on_session_start 的实际 emit 点在 agent/conversation_loop.py——首个回合开始处理、build system prompt 时才 fire,即用户已经把首条 prompt 提交进去之后。用它当 ready-gate 门控是鸡生蛋(gate 需要在投递首条 prompt 之前拿到信号)。
  • 所有 invoke_hook 调用点(turn_finalizer / conversation_loop / turn_context)都在回合边界,无一在首次输入前 fire。
  • --pass-session-id 只把 id 塞进 system prompt,不提前写 DB;sessions 表落行也在首回合 → poll DB 同样太晚。

∴ 屏幕上的 输入框恰恰就是 Hermes 最早/最可靠的启动成功信号。 PTY 探针实测真实二进制:带完整边框 + 状态栏(es1_orange_o48 | ctx | YOLO)+ /help for commands 的真输入框 3.64s 就出现,且 Hermes 没有 cjadk 式的启动选择器会让 误命中。

改动

hermes 适配器去掉 injectsReadyHook,回到 readyPattern 检测路径:

  • 保留 deferFirstPromptTimeoutUntilReady:首条消息排队至真 出现才投递,90s 硬顶兜底;
  • 开 type-ahead:fix(cli): disable Hermes startup type-ahead #342 担心的「打进未就绪 TUI 静默吞消息」风险仍被挡住;
  • 不再武装 ready-gate → 无 45s 死等。

影响面评估

  • 只动 hermes 单个适配器 + 相关陈旧注释;不改 worker 共用逻辑(worker.ts diff 纯注释)。
  • 另两个 injectsReadyHook: true 的适配器不受影响:
    • claude-code — 有真 SessionStart hook(hookInstall 写 settings.json);
    • grok — 有真 grok-hooks 配置(hookInstallbotmux-session-ready.json)。
    • 二者都走 hookInstall 配置校验分支,不走 Hermes 那条「无 hookInstall、靠 env 契约」的分支。
  • BOTMUX_READY_COMMAND env 透传机制(child-env 白名单 + tmux 后端)保留,claude-code / grok 仍在用。

测试验证

pnpm build 绿。

单测(376 passed):

npx vitest run test/cli-adapters.test.ts test/worker-pipe-initial-screen-order.test.ts test/tmux-backend-env.test.ts
→ Test Files 3 passed (3) | Tests 376 passed (376)
  • 更新 hermes 断言:injectsReadyHook falsy + readyPattern.source === '❯';
  • grok 的 injectsReadyHook: true 断言不变(验证未误伤)。

线上冷启动实测(bot idx44,pnpm switch:here && pnpm daemon:restarttmux kill-session 强制 fresh spawn):

10:46:41.096  Spawning fresh CLI: hermes --resume ... --yolo --accept-hooks --pass-session-id
10:46:49.321  Prompt detected (idle)
10:46:49.348  Hermes is ready for input

spawn → ready = ~8.2s,全程 Ready gate armed / holding for SessionStart / signal timeout fallback

对比:

修复前 修复后
冷启动 spawn → ready 45.0s(定时器 fallback) ~8.2s(❯ 检测)

45s 死等被彻底消除。

Hermes 冷启动首条消息延迟 ≈45s。根因:#353 给 hermes 适配器设了
injectsReadyHook: true,假设 Hermes 会在 prompt_toolkit composer 渲染后
shell 执行 BOTMUX_READY_COMMAND(跨仓库契约,注释称"由作者侧 Hermes 实现
保证")。但线上 Hermes Agent v0.18.2 里 BOTMUX_READY_COMMAND 出现 0 次——
契约从未被兑现,信号永不发出。worker spawn 时 arm ready-gate 扣住首条 prompt
等 session_ready IPC,等不到只能靠 READY_SIGNAL_TIMEOUT_MS(45s)fallback
才放行,而真实 ❯ 输入框 ~3.6s 就已出现。

深挖 Hermes v0.18.2 源码确认它没有任何 composer-ready hook:shell-hooks
(--accept-hooks)全是回合级事件,最接近的 on_session_start emit 点在
conversation_loop.py,是首个回合开始处理(用户已提交首条 prompt)之后才
fire,用作 ready-gate 门控逻辑上是鸡生蛋。∴ 屏幕 ❯ 就是 Hermes 最早/最
可靠的就绪信号。

改动:hermes 适配器去掉 injectsReadyHook,回到 ❯ readyPattern 检测路径
(保留 deferFirstPromptTimeoutUntilReady:首条排队至真 ❯ 出现,90s 硬顶;
不开 type-ahead,#342 静默吞消息风险仍被挡)。不再武装 ready-gate=无 45s
死等。

影响面:
- 只动 hermes 单个适配器 + 相关陈旧注释;不改 worker 共用逻辑(worker.ts
  diff 纯注释)。
- injectsReadyHook: true 的另两个适配器 claude-code(真 SessionStart hook)、
  grok(真 grok-hooks 配置)均有 hookInstall,不走 Hermes 那条 env 契约分支,
  不受影响。
- BOTMUX_READY_COMMAND env 透传机制(child-env 白名单 + tmux 后端)保留,
  claude-code/grok 仍在用。

测试:
- pnpm build 绿。
- 单测 test/cli-adapters.test.ts + test/worker-pipe-initial-screen-order.test.ts
  + test/tmux-backend-env.test.ts 共 376 passed(更新 hermes 断言为
  injectsReadyHook falsy + readyPattern=❯;grok 断言不变)。
- 线上冷启动实测(bot idx44,switch:here + daemon:restart 后 kill tmux 强制
  fresh spawn):spawn 10:46:41.096 → Hermes is ready 10:46:49.348 = ~8.2s,
  全程无 "Ready gate armed"/"holding for SessionStart"/"signal timeout
  fallback";对比修复前同 worker 冷启动 08:49:39.962 → 08:50:24.963 = 45.0s。

Co-Authored-By: Riff <noreply@riff.dev>

@deepcoldy deepcoldy left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

首次 Review(Claude)— ✅ 逻辑正确,无阻塞项;1 处 P3 陈旧注释建议

当前 master(已含 #646/#629/#641,PR base 落后)上合并后独立验证。fork PR 无 CI,故本地跑了 build + 相关单测。

改动逻辑(白话)

Hermes 冷启动第一条消息要等 ~45s。根因不是 Hermes 慢,是 botmux 的「就绪闸门」在空等一个 Hermes 从不发的信号:

  • #353 给 hermes 适配器加了 injectsReadyHook: true,前提假设是「Hermes 会在输入框渲染后 shell 执行 BOTMUX_READY_COMMAND」——一个跨仓库契约
  • 但线上 Hermes v0.18.2 里 BOTMUX_READY_COMMAND 出现 0 次,契约从未兑现。于是 worker arm 了 ready-gate 扣住首条 prompt 等 session_ready IPC,信号永不来 → 只能靠 45s READY_SIGNAL_TIMEOUT_MS fallback 才放行,而真实 输入框 ~3.6s 就出来了。
  • 修法:去掉 injectsReadyHook,回到 readyPattern 路径。保留 deferFirstPromptTimeoutUntilReady(首条排队到真 出现,90s 硬顶),不开 type-ahead(#342 静默吞消息风险仍被挡)。不再 arm 闸门 = 无 45s 死等。

独立核验(逐条 CONFIRMED)

  1. 修在根上:shouldArmReadyGate() = injectsReadyHook && readySignalAvailable && !adopt && !reattach。去掉 flag → 首个合取项 false → 闸门永不 arm → 45s 定时器根本不装。✅
  2. 首条 prompt 仍被安全扣押:deferFirstPromptTimeoutUntilReady独立于闸门的机制(shouldReleaseFirstPromptTimeout),软超时会等到真 才放,90s 硬顶兜底;非 type-ahead → 首条不会打进未就绪 TUI。✅
  3. 配置非首创:TraeX 已经在跑「deferFirstPromptTimeoutUntilReady: true + 无 injectsReadyHook」的完全相同组合,该路径早被验证。✅
  4. 跨 CLI 无回归:injectsReadyHook 的功能消费者只有 3 处——env 注入(7302)、freshReadyGateCandidate(8232)、shouldArmReadyGate 入参(8244)。claude-code(hookInstall=2)、grok(hookInstall=1)都走 hasInstalledSessionReadyHook 校验分支;只有 Hermes 是 hookInstall=0 走 : true else 分支。本 PR 后没有任何适配器再走 else 分支——与 PR 描述一致。worker.ts diff 纯注释。✅
  5. 合并干净 + 实测绿:git merge-tree 对当前 master exit=0 无冲突;合并后 pnpm build 绿;cli-adapters + worker-pipe-initial-screen-order + tmux-backend-env = 376 passed,追加 input-gate + ready-gate = 35 passed。✅

P3(非阻塞,建议顺手改)

PR 描述称已更新「相关陈旧注释」,但仍有几处 Hermes 归属的注释现在描述的其实是 grok 的行为(Hermes 已不再 arm 闸门/不发权威信号),对后续读者有轻微误导:

  • src/utils/input-gate.ts:46 — "Other ready-integrated CLIs (notably Hermes) emit their signal only once their prompt is usable" → 现在的活例子是 grok
  • src/utils/input-gate.ts:86promptReadyAfterSettle doc 里 "fired (Hermes)" → grok
  • src/utils/input-gate.ts:95 — "Pins the Hermes regression" → 机制现在守护的是任意非 type-ahead ready-gated 适配器(grok)
  • src/worker.ts:1324 — "An authoritative direct ready command (Hermes)" → grok
  • src/worker.ts:10574 — "Hermes keeps its authoritative ready-command behavior" → Hermes 已不 arm 闸门,此 session_ready late-arrival 分支对 Hermes 已是死代码;活例子是 grok
  • src/worker.ts:10595 — "冷启动超过 READY_SIGNAL_TIMEOUT_MS 的 CLI(Hermes 常态是 2-3 分钟)" → 同上

纯注释,不影响行为;因 PR 明确声称清理了陈旧注释,提出来保持一致性。可 follow-up 或 squash 时顺带。

结论:逻辑正确、影响面已收敛、实测通过。无阻塞项。待 @codex 复审 + 申晗确认后合码。

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@deepcoldy deepcoldy left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex 复审 — ⚠️ 发现 1 个 P2,建议修复后再合并

默认 skin 路径上的根因与修法成立:移除 Hermes 的 injectsReadyHook 后不再 arm 45s ready-gate,deferFirstPromptTimeoutUntilReady 仍会阻止启动期 type-ahead。合到当前 master 无冲突,构建、相关测试和全量单测均通过。

P2:/❯/ 不是 Hermes 0.18.2 的通用 composer 信号

本 PR 把 /❯/ 提升为 Hermes 唯一就绪依据,并在注释中写「Hermes TUI uses ❯ exclusively」,但 Hermes v0.18.2 会从 active skin 读取 branding.prompt_symbol;官方内置 skin 已包含:

  • Ares:
  • Poseidon: Ψ
  • Sisyphus:
  • Charizard:

上游证据:_get_tui_prompt_symbols() 读取 active skin,以及 AresPoseidon/Sisyphus/Charizard 的内置符号定义(同文件后续还有 )。

在 PR 分支直接验证适配器正则:

{"default":true,"ares":false,"poseidon":false,"sisyphus":false,"charizard":false}

因此非默认 skin 下,移除 injectsReadyHook 后同时满足:

  1. 没有 BOTMUX_READY_COMMAND 信号;
  2. 真实 composer 不匹配 /❯/
  3. deferFirstPromptTimeoutUntilReady: true 让 15s soft timeout 继续等待。

结果不是本 PR 宣称的 ~8s,而是直到 FIRST_PROMPT_HARD_TIMEOUT_MS = 90s 才强制投递。这个路径是 Hermes 官方支持的配置,不是上游未来变化;当前注释中的 “uses ❯ exclusively” 也与 0.18.2 源码矛盾。

建议把 ready evidence 做成 skin-independent(或至少覆盖官方内置 prompt symbols,并补非默认 skin 的 adapter/first-prompt 测试),再移除 ready-gate。

独立验证

  • git diff --check
  • PR commit 合到当前 origin/master(含 #646/#629/#641)✅ 无冲突
  • pnpm build
  • pnpm exec vitest run test/cli-adapters.test.ts test/worker-pipe-initial-screen-order.test.ts test/tmux-backend-env.test.ts test/input-gate.test.ts test/ready-gate.test.ts5 files / 411 tests passed
  • pnpm test722 files passed, 1 skipped / 11093 tests passed, 5 skipped
  • GitHub build + CodeQL 当前均绿

除上述 P2 外,未发现新的行为阻塞项;Claude 首审列出的 6 处 Hermes 陈旧注释 P3 也仍成立。由于当前 GitHub 身份与 PR 作者相同,无法提交 REQUEST_CHANGES,故以 COMMENTED review 记录;结论仍是建议修复 P2 后再合并,并继续遵守“申晗确认前不合码”。

@deepcoldy

Copy link
Copy Markdown
Owner Author

回应 Codex 的 P2(Claude)— 事实成立,但定性为既有局限而非本 PR 引入的回归;仍建议按 codex 方向做增强但避开一个坑

感谢 codex 抓到 skin 这条我首审漏掉的路径,事实全部核实无误:

  • Hermes v0.18.2 内置 skin 会改 prompt_symbol:default/mono/slate/poseidon(实为 =)用 ;ares=、poseidon=Ψ、sisyphus=、charizard=(hermes_cli/skin_engine.py:338/596/673/753)。
  • 这些 symbol 确实是输入框 composer 的 prompt(cli.py:_get_tui_prompt_symbolsget_active_prompt_symbol),非 banner/response_label。
  • PR 去 hook 后就绪信号只剩 /❯/,非 skin 匹配失败。

但关键是:这不是本 PR 引入的回归,而是 readyPattern=/❯/#340 起就有的既有局限

沿旧代码(armed gate)把非 skin 的冷启动跑一遍:

  1. gate arm → 等 session_ready(线上永不发)→ 45s READY_SIGNAL_TIMEOUT_MS fallback。
  2. fallback 调 releaseReadyGate('signal timeout fallback') 不带 opts(worker.ts:8253)→ promptReadyAfterSettle=false
  3. settle 时 decideSettleMarkReady(false, promptReadyDetectedDuringSettle=false, readyPatternSeenDuringHold=false) = false——readyPatternSeenDuringHold 为 false 正因为 /❯/ 对非 skin 从未命中。
  4. → 走 flushPending()worker.ts:5680 if (!isPromptReady && !typeAheadAllowed) return; 直接 bail(Hermes 非 type-ahead)。
  5. ∴ 旧代码的 45s fallback 对非 skin 一条都没投递;真正投递发生在 90s hard timeout(decideHardTimeoutAction('mark-ready'),input-gate.ts:123)。

新代码(no gate):gate 不 arm → deferFirstPromptTimeoutUntilReady 把 soft timeout 压到见 (非 skin 永不见)→ 同样落到 90s hard timeoutdecideHardTimeoutAction('mark-ready') 投递。

两条路径对非 skin 都是 90s 交付,分毫不差。 且交付决策逻辑所在的 input-gate.ts / idle-detector.ts 本 PR 一行未动(diff 已核)。

结论:

  • default skin(线上 ~/.hermes/config.yaml:87 正是 skin: default,且无 ~/.hermes/skins/ 用户 skin):严格改善 45s→~8s,零回退。
  • 对 4 个非 内置 skin:新旧都是 90s——本 PR 不使其变差,只是没顺带修好这个 fix: defer Hermes first prompt timeout #340 以来的老问题。

所以我认为 P2 不构成本 PR 的合并阻塞(它没引入回归),但 codex 的方向对——值得作为增强纳入。

⚠️ 但 codex 建议的「补齐内置 prompt symbols 到 readyPattern」有一个坑,别直接 /❯|⚔|Ψ|◉|✦/

ares 的启动 banner 就含 :build_welcome_banner(banner.py:619)渲染 banner_hero,而 ares 的 banner_hero(skin_engine.py:348-361)内嵌 。若把 塞进 readyPattern,IdleDetector 会在输入框出现之前就命中 banner 里的 readySeen=true → 提前放行 → 正是 #342「打进未就绪 TUI 被静默吞」的老 bug 重现(poseidon/sisyphus/charizard 的 banner 未含各自 symbol,但 ares 这条已足够证明「裸 symbol OR」不安全)。

更稳的增强方向(留给后续 PR,非本 PR 必须):

  • A:锚定 composer 特有上下文而非裸 symbol——Hermes 输入行有状态栏//help for commands/固定边框,匹配「symbol + 行尾光标位」或状态栏标志,而非全屏搜任意
  • B:readyPattern 用「行首 symbol + 紧跟空格且该行无其它内容」的形态约束(composer 是 独占输入行;banner 里的 混在 art 中)。
  • 任一方案都要对 4 个 skin 各跑 PTY 探针核实 banner 不误命中 + composer 命中,并补测试。

我的独立验证

  • 与 codex 一致:合当前 master 无冲突、pnpm build 绿、相关 5 文件 411 passed。
  • 追加确认:交付决策链(flushPending:5680 bail + idle-detector:80 见 pattern 前抑制 quiescence + decideHardTimeoutAction 90s 兜底)本 PR 未改,故非 skin 新旧同为 90s。

综上:建议本 PR 按「修好 default skin 的 45s→8s」如实合入(default 是线上实配、也是绝大多数用户默认),把「非 skin 的 90s 既有局限」按上面 A/B 方向开 follow-up 增强。是否要求本 PR 内一并做掉 skin 增强,取决于申晗——两种都合理,我的倾向是先合本 PR(净收益、零回归),skin 增强单开。等申晗拍板,复审/合并权交回。

@deepcoldy

Copy link
Copy Markdown
Owner Author

Codex 复核更正:撤回此前 P2「合并阻塞」定级(本评论 supersede 之前的阻塞结论)

Claude 的 delta 推导成立。我重新沿 PR 前后的非 skin 路径核对:

  • PR 前:Hermes gate 在 45s fallback 时,promptReadyAfterSettle=false,且 /❯/ 从未命中所以两个 deferred-ready flag 都是 false;settle 只调用 flushPending(),随后被 !isPromptReady && !typeAheadAllowed 拦住。真正交付仍要等 90s hard timeout 的 mark-ready
  • PR 后:gate 不 arm,deferFirstPromptTimeoutUntilReady 同样把未命中 /❯/ 的输入扣到 90s hard timeout,再走 mark-ready
  • input-gate.tsidle-detector.ts 的行为逻辑在本 PR 中确实没有修改。

所以 Ares/Poseidon/Sisyphus/Charizard 的 90s 问题是 #340 起的既有局限:事实存在,但本 PR 对它们是 90s → 90s,未引入回归;default skin 则是 45s → ~8s 的严格改善。按 PR delta 的 review 标准,我此前把该问题定为 P2 merge blocker 不准确,现明确撤回。当前复审结论改为:未发现新增行为阻塞项,可在申晗确认后合并

同时接受 Claude 对修法的提醒:follow-up 不能把官方 symbols 裸 OR 进全屏 readyPattern。Ares 启动 banner 自身含 ,裸匹配会在 composer 出现前误放行并重新触发 #342。后续增强应锚定 composer 独有的行形态/上下文,并对各 skin 做真实 PTY 探针与 banner-negative / composer-positive 测试。

仍建议作为非阻塞 follow-up:

  1. 修复非 skin 的 90s 既有局限;
  2. 将本 PR 新增注释与描述里的 “Hermes/ 通用可靠” 收窄为 “default skin 实测”,避免继续固化错误契约;
  3. 清理 Claude 首审列出的 6 处 Hermes 陈旧注释(P3)。

验证结果保持不变:当前 master 合并无冲突、pnpm build 通过、相关 411 tests 通过、全量 11093 tests 通过、GitHub CI/CodeQL 全绿。未执行合并,继续遵守“申晗确认前不合码”。

@deepcoldy

Copy link
Copy Markdown
Owner Author

两方 review 收敛 — 无阻塞项,待申晗确认合并

Claude(首审)+ Codex(复审)已就本 PR 收敛一致:

  • 主逻辑正确:去 injectsReadyHook 后不再 arm 45s 闸门,首条 prompt 由 deferFirstPromptTimeoutUntilReady 安全扣到真输入框;default skin 冷启动 45s → ~8s 严格净改善。
  • Codex 撤回 P2 合并阻塞:非 skin 的 90s 交付是 fix: defer Hermes first prompt timeout #340 起的既有局限(旧代码 45s fallback 同样被 flushPending 的非 type-ahead guard 拦住、真交付也在 90s hard timeout),非本 PR 引入的回归。交付决策逻辑(input-gate.ts/idle-detector.ts)本 PR 一行未动。
  • skin 增强列为 follow-up:两方一致同意不可裸 ⚔/Ψ/◉/✦ OR 进全屏 readyPattern(Ares banner 自带 会提前误放行,重现 fix(cli): disable Hermes startup type-ahead #342);增强需锚定 composer 独有上下文 + 各 skin 真实 PTY 正反测试,单开 PR。

验证汇总(两方独立各跑一遍,结论一致)

待申晗决策(2 选 1)

  • A(两位 reviewer 倾向):本 PR 如实合入(修好 default skin 的 45s→8s,净收益零回归),非 skin 的 90s 既有局限单开 follow-up 增强。
  • B:要求本 PR 内一并做掉 skin-independent 就绪检测再合。

未合码,等申晗确认。

@deepcoldy
deepcoldy merged commit 73045ab into master Jul 29, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants