From 805fec73b1275d1f698bfbd06f465f9d64db61ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B3=E6=99=97?= Date: Tue, 28 Jul 2026 10:48:50 -0700 Subject: [PATCH] =?UTF-8?q?fix(hermes):=20=E5=86=B7=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=E9=A6=96=E6=9D=A1=E6=B6=88=E6=81=AF=E4=B8=8D=E5=86=8D=E7=99=BD?= =?UTF-8?q?=E7=AD=89=2045s=20ready-gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/adapters/cli/hermes.ts | 25 +++++++++++++----- src/worker.ts | 16 ++++++++---- test/cli-adapters.test.ts | 11 ++++++-- test/tmux-backend-env.test.ts | 2 +- test/worker-pipe-initial-screen-order.test.ts | 26 +++++++++++-------- 5 files changed, 55 insertions(+), 25 deletions(-) diff --git a/src/adapters/cli/hermes.ts b/src/adapters/cli/hermes.ts index 1665f2d88..9fb8ab189 100644 --- a/src/adapters/cli/hermes.ts +++ b/src/adapters/cli/hermes.ts @@ -61,12 +61,25 @@ export function createHermesAdapter(pathOverride?: string): CliAdapter { readyPattern: /❯/, completionPattern: undefined, systemHints: BOTMUX_SHELL_HINTS, - // Hermes emits an explicit BOTMUX_READY_COMMAND once prompt_toolkit's real - // input composer has rendered. Arm Botmux's ready-gate so the first queued - // Lark prompt waits for that true-ready signal instead of relying on the - // screen-level ❯ marker alone. Do not opt into type-ahead: before the first - // prompt Hermes can silently drop input typed during TUI initialization. - injectsReadyHook: true, + // Do NOT arm Botmux's ready-gate for Hermes. #353 set injectsReadyHook here + // on the premise that Hermes shell-executes BOTMUX_READY_COMMAND once its + // prompt_toolkit composer renders — a cross-repo contract the shipped Hermes + // never honored: `grep BOTMUX_READY_COMMAND` across hermes-agent 0.18.x is + // empty, and Hermes exposes no composer-ready hook at all (its shell-hooks + // fire only at turn boundaries — on_session_start emits from + // conversation_loop AFTER the first prompt is already submitted, too late to + // gate the first prompt on). So the signal never arrives and the gate always + // falls through its 45s READY_SIGNAL_TIMEOUT_MS, delaying the FIRST cold-start + // message by ~45s even though the real ❯ composer is up in ~3.6s. + // + // Empirically the ❯ readyPattern above IS the earliest reliable readiness + // signal: a PTY probe of the real binary shows the fully-chromed input box + // (border + status bar + "/help for commands") at ~3.6s, and Hermes has NO + // cjadk-style startup selector that would make ❯ a false positive. So we rely + // on the IdleDetector's ❯ match + deferFirstPromptTimeoutUntilReady (queue + // the first message until the real ❯ appears, 90s hard cap). Do NOT opt into + // type-ahead: before the first prompt Hermes can silently drop input typed + // during TUI initialization (see #342). deferFirstPromptTimeoutUntilReady: true, altScreen: false, }; diff --git a/src/worker.ts b/src/worker.ts index 911909e8b..dea3c19fe 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -1301,13 +1301,15 @@ let isSettlingFirstFlush = false; * later markPromptReady call would return early with the first prompt stranded. */ let promptReadyDetectedDuringSettle = false; /** While the ready-gate is holding, the IdleDetector may still fire on a real - * readyPattern (e.g. Hermes's ❯) — proving the input box exists — but + * readyPattern (e.g. grok's ❯) — proving the input box exists — but * markPromptReady() returns early because the gate is armed. Record that the * pattern was seen so the gate's timeout-fallback settle can mark the prompt * ready immediately instead of delivering into a !isPromptReady state that - * flushPending() rejects for non-type-ahead adapters. Without this, a Hermes - * spawn that renders ❯ but never fires BOTMUX_READY_COMMAND waits the full - * hard timeout (and previously never delivered at all). */ + * flushPending() rejects for non-type-ahead adapters. Without this, a ready- + * gated spawn that renders ❯ but never fires its SessionStart signal waits the + * full hard timeout (and previously never delivered at all). (Hermes used to be + * the example here; it no longer arms the gate — see hermes.ts — because the + * shipped binary never emitted BOTMUX_READY_COMMAND.) */ let readyPatternSeenDuringHold = false; /** Claude's SessionStart hooks run in parallel. Its botmux hook proves the * startup selector is behind us, but sibling project hooks may still be @@ -8209,7 +8211,11 @@ async function spawnCli( lastPtyOutputAtMs = Date.now(); const readyHookAvailable = effectiveReadyHookInstall ? hasInstalledSessionReadyHook(effectiveReadyHookInstall) - : true; // Hermes emits BOTMUX_READY_COMMAND directly instead of a config hook. + : true; // No config-file hook to verify → assume a direct ready-command + // integration (env-injected BOTMUX_READY_COMMAND). No current adapter + // takes this branch: claude-code and grok both ship a hookInstall + // config. (Hermes formerly did, on a BOTMUX_READY_COMMAND contract the + // shipped binary never honored — it no longer sets injectsReadyHook.) const isolatedReadyTransportRequired = sandboxRequested || credentialBoundaryActive; const readyPortAvailable = !isolatedReadyTransportRequired || parseDaemonIpcPort(childEnv.BOTMUX_DAEMON_IPC_PORT) !== undefined; diff --git a/test/cli-adapters.test.ts b/test/cli-adapters.test.ts index 00a2ef660..51ca47bed 100644 --- a/test/cli-adapters.test.ts +++ b/test/cli-adapters.test.ts @@ -1060,8 +1060,15 @@ describe('hermes buildArgs', () => { expect(adapter.passesInitialPromptViaArgs).toBeFalsy(); }); - it('uses explicit ready signal gate without type-ahead', () => { - expect(adapter.injectsReadyHook).toBe(true); + it('relies on ❯ readyPattern without ready-gate or type-ahead', () => { + // #353 armed the ready-gate via injectsReadyHook on the premise that Hermes + // shell-executes BOTMUX_READY_COMMAND at composer-ready. The shipped Hermes + // never honored that contract (no composer-ready hook exists), so the gate + // always fell through its 45s timeout, delaying the first cold-start message. + // Hermes must NOT arm the gate; its ❯ readyPattern (input box up in ~3.6s) is + // the earliest reliable readiness signal. + expect(adapter.injectsReadyHook).toBeFalsy(); + expect(adapter.readyPattern?.source).toBe('❯'); expect(adapter.deferFirstPromptTimeoutUntilReady).toBe(true); expect(adapter.supportsTypeAhead).toBeFalsy(); }); diff --git a/test/tmux-backend-env.test.ts b/test/tmux-backend-env.test.ts index bf7a5b6b9..38a806b45 100644 --- a/test/tmux-backend-env.test.ts +++ b/test/tmux-backend-env.test.ts @@ -123,7 +123,7 @@ describe('buildBotmuxEnvAssignments()', () => { expect(out).not.toContain('PATH=/usr/bin'); }); - it('forwards BOTMUX_READY_COMMAND so Hermes can release the first-prompt ready gate', () => { + it('forwards BOTMUX_READY_COMMAND so ready-hook CLIs can release the first-prompt ready gate', () => { const out = buildBotmuxEnvAssignments({ BOTMUX: '1', BOTMUX_READY_COMMAND: '"/usr/local/bin/node" "/opt/botmux/dist/cli.js" session-ready', diff --git a/test/worker-pipe-initial-screen-order.test.ts b/test/worker-pipe-initial-screen-order.test.ts index 4ab2de602..56d3cab00 100644 --- a/test/worker-pipe-initial-screen-order.test.ts +++ b/test/worker-pipe-initial-screen-order.test.ts @@ -174,10 +174,13 @@ describe('worker pipe initial screen ordering', () => { }); it('forces the first prompt for non-type-ahead adapters at the hard timeout', () => { - // THE HERMES FIX (hard-timeout path): previously the hard cap only logged - // "forcing queued message flush" and flushed for type-ahead adapters only; - // non-type-ahead adapters (Hermes) never delivered. The release must now - // route non-type-ahead adapters to markPromptReady() (which then flushes). + // Hard-timeout path (originally "THE HERMES FIX"): previously the hard cap + // only logged "forcing queued message flush" and flushed for type-ahead + // adapters only; non-type-ahead adapters never delivered. The release must + // now route non-type-ahead adapters to markPromptReady() (which then + // flushes). (Hermes drove this originally; it no longer arms the gate — see + // hermes.ts — but the mechanism still guards any non-type-ahead ready-gated + // adapter.) const source = readFileSync(join(process.cwd(), 'src/worker.ts'), 'utf8'); const fallbackStart = source.indexOf('const releaseFirstPromptTimeout ='); const decideIdx = source.indexOf("decideHardTimeoutAction(cliAdapter?.supportsTypeAhead === true)", fallbackStart); @@ -194,13 +197,14 @@ describe('worker pipe initial screen ordering', () => { it('honors a true ready signal that arrives AFTER the timeout fallback (slow cold start)', () => { // ReadyGate.receive() is one-shot: once the 45s fallback fires, a later - // releaseReadyGate from the real signal is skipped entirely. A CLI whose - // cold start exceeds READY_SIGNAL_TIMEOUT_MS (Hermes: 2-3 min) would then - // never take the authoritative markPromptReady path. The session_ready case - // must detect the late arrival (gate armed + already received) and mark - // prompt-ready directly for authoritative non-Claude signals. Claude waits - // for post-hook prompt evidence instead. Both paths are limited to the - // first-prompt phase, so clear/compact SessionStart stays a no-op. + // releaseReadyGate from the real signal is skipped entirely. A ready-gated + // CLI whose cold start exceeds READY_SIGNAL_TIMEOUT_MS would then never take + // the authoritative markPromptReady path. The session_ready case must detect + // the late arrival (gate armed + already received) and mark prompt-ready + // directly for authoritative non-Claude signals. Claude waits for post-hook + // prompt evidence instead. Both paths are limited to the first-prompt phase, + // so clear/compact SessionStart stays a no-op. (Hermes drove this originally + // via its 2-3 min cold start; it no longer arms the gate — see hermes.ts.) const source = readFileSync(join(process.cwd(), 'src/worker.ts'), 'utf8'); const sessionReadyCase = source.indexOf("case 'session_ready'"); const lateCheckIdx = source.indexOf('readyGate.isArmed && readyGate.isReceived', sessionReadyCase);