mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-23 16:36:23 +00:00
Review on #20379, finding 1 (High). Two ways an MCP config revision could be silently acknowledged without ever being applied: Client: the poll advanced its accepted mcp_rev BEFORE calling reload.mcp, and quietRpc collapses failures to null — a reload that failed against a temporarily broken server left the revision recorded as applied, and no subsequent poll retried until an unrelated MCP edit. The handshake is now syncMcpReload(): send the observed rev with the request, advance `accepted` only when the server answers status=reloaded (to the server's loaded_rev, falling back to the requested rev on older gateways), and re-compare on EVERY poll tick — decoupled from mtime — so a transient failure heals on the next tick. An in-flight guard stops the 5s poll from stacking requests behind a slow reload. Server: generation-only coalescing let a follower triggered by revision B ack against revision A's registry when the config changed under a slow leader. The leader now re-hashes the MCP-relevant config after discovery and repeats until stable (bounded), records _mcp_reload_loaded_rev, and a follower coalesces only when the revision it was asked to load matches — otherwise it re-runs the full reload itself. Responses carry loaded_rev. Deterministic tests for the exact failure sequences: failed reload → no ack, no generation advance; A-then-B overlap → follower re-runs; matching rev → coalesces; failed leader → follower re-runs; legacy no-rev callers keep generation-only coalescing (thread ordering via an instrumented lock, no sleeps). Client: 6 vitest cases on the ack/retry/in-flight contract.
380 lines
12 KiB
TypeScript
380 lines
12 KiB
TypeScript
import type { MouseTrackingMode } from '@hermes/ink'
|
|
import { useEffect, useRef } from 'react'
|
|
|
|
import { resolveDetailsMode, resolveSections } from '../domain/details.js'
|
|
import type { GatewayClient } from '../gatewayClient.js'
|
|
import type { ConfigFullResponse, ConfigMtimeResponse, ReloadMcpResponse } from '../gatewayTypes.js'
|
|
import { DEFAULT_VOICE_RECORD_KEY, type ParsedVoiceRecordKey, parseVoiceRecordKey } from '../lib/platform.js'
|
|
import { asRpcResult } from '../lib/rpc.js'
|
|
|
|
import { applyConfiguredTuiTheme } from './createGatewayEventHandler.js'
|
|
import {
|
|
type BusyInputMode,
|
|
DEFAULT_INDICATOR_STYLE,
|
|
INDICATOR_STYLES,
|
|
type IndicatorStyle,
|
|
type StatusBarMode
|
|
} from './interfaces.js'
|
|
import { turnController } from './turnController.js'
|
|
import { patchUiState } from './uiStore.js'
|
|
|
|
const STATUSBAR_ALIAS: Record<string, StatusBarMode> = {
|
|
bottom: 'bottom',
|
|
off: 'off',
|
|
on: 'top',
|
|
top: 'top'
|
|
}
|
|
|
|
export const normalizeStatusBar = (raw: unknown): StatusBarMode =>
|
|
raw === false ? 'off' : typeof raw === 'string' ? (STATUSBAR_ALIAS[raw.trim().toLowerCase()] ?? 'top') : 'top'
|
|
|
|
const BUSY_MODES = new Set<BusyInputMode>(['interrupt', 'queue', 'steer'])
|
|
|
|
// TUI defaults to `queue` even though the framework default
|
|
// (`hermes_cli/config.py`) is `interrupt`. Rationale: in a full-screen
|
|
// TUI you're typically authoring the next prompt while the agent is
|
|
// still streaming, and an unintended interrupt loses work. Set
|
|
// `display.busy_input_mode: interrupt` (or `steer`) explicitly to
|
|
// opt out per-config; CLI / messaging adapters keep their `interrupt`
|
|
// default unchanged.
|
|
const TUI_BUSY_DEFAULT: BusyInputMode = 'queue'
|
|
|
|
export const normalizeBusyInputMode = (raw: unknown): BusyInputMode => {
|
|
if (typeof raw !== 'string') {
|
|
return TUI_BUSY_DEFAULT
|
|
}
|
|
|
|
const v = raw.trim().toLowerCase() as BusyInputMode
|
|
|
|
return BUSY_MODES.has(v) ? v : TUI_BUSY_DEFAULT
|
|
}
|
|
|
|
const INDICATOR_STYLE_SET: ReadonlySet<IndicatorStyle> = new Set(INDICATOR_STYLES)
|
|
|
|
export const normalizeIndicatorStyle = (raw: unknown): IndicatorStyle => {
|
|
if (typeof raw !== 'string') {
|
|
return DEFAULT_INDICATOR_STYLE
|
|
}
|
|
|
|
const v = raw.trim().toLowerCase() as IndicatorStyle
|
|
|
|
return INDICATOR_STYLE_SET.has(v) ? v : DEFAULT_INDICATOR_STYLE
|
|
}
|
|
|
|
const FALSEY_MOUSE = new Set(['0', 'false', 'no', 'off'])
|
|
const TRUTHY_MOUSE_ALL = new Set(['1', 'true', 'yes', 'on', 'all', 'full', 'any'])
|
|
const hasOwn = (obj: object, key: PropertyKey) => Object.prototype.hasOwnProperty.call(obj, key)
|
|
|
|
// `display.mouse_tracking` accepts boolean (`true` ⇒ all modes, `false` ⇒ off)
|
|
// for back-compat, plus the string presets `off|wheel|buttons|all` (aliases:
|
|
// `on`/`full`/`any`/`1`/`true`/... → `all`; `0`/`false`/`no`/`off` → `off`).
|
|
// `wheel` enables 1000+1006 — scroll wheel + click only, no drag or hover,
|
|
// which silences tmux's "No image in clipboard" spam over the prompt row.
|
|
// `buttons` adds 1002 so terminal-side text selection drags still register.
|
|
// Legacy `tui_mouse` is honored only if `mouse_tracking` is absent.
|
|
export const normalizeMouseTracking = (display: {
|
|
mouse_tracking?: unknown
|
|
tui_mouse?: unknown
|
|
}): MouseTrackingMode => {
|
|
const raw = hasOwn(display, 'mouse_tracking') ? display.mouse_tracking : display.tui_mouse
|
|
|
|
if (raw === false || raw === 0) {
|
|
return 'off'
|
|
}
|
|
|
|
if (raw === true || raw === undefined || raw === null) {
|
|
return 'all'
|
|
}
|
|
|
|
if (typeof raw === 'number') {
|
|
return 'all'
|
|
}
|
|
|
|
if (typeof raw !== 'string') {
|
|
return 'all'
|
|
}
|
|
|
|
const v = raw.trim().toLowerCase()
|
|
|
|
if (FALSEY_MOUSE.has(v)) {
|
|
return 'off'
|
|
}
|
|
|
|
if (TRUTHY_MOUSE_ALL.has(v)) {
|
|
return 'all'
|
|
}
|
|
|
|
if (v === 'wheel' || v === 'scroll') {
|
|
return 'wheel'
|
|
}
|
|
|
|
if (v === 'buttons' || v === 'button' || v === 'click') {
|
|
return 'buttons'
|
|
}
|
|
|
|
return 'all'
|
|
}
|
|
|
|
const MTIME_POLL_MS = 5000
|
|
|
|
const quietRpc = async <T extends Record<string, any> = Record<string, any>>(
|
|
gw: GatewayClient,
|
|
method: string,
|
|
params: Record<string, unknown> = {}
|
|
): Promise<null | T> => {
|
|
try {
|
|
return asRpcResult<T>(await gw.request<T>(method, params))
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
// ── MCP revision handshake ───────────────────────────────────────────
|
|
//
|
|
// The poll must not ack an MCP config revision until the server confirms it
|
|
// actually LOADED it. Advancing `accepted` before the reload succeeds loses
|
|
// revisions permanently: quietRpc collapses a failed reload to null, the
|
|
// next poll sees the same mcp_rev, and the new config never applies until
|
|
// an unrelated MCP edit. So `accepted` only moves on a confirmed reload —
|
|
// to the server's loaded_rev (what discovery actually read), falling back
|
|
// to the requested rev for older gateways. Retries are decoupled from
|
|
// mtime: every poll re-compares, so a transiently broken server heals on
|
|
// the next tick.
|
|
|
|
export interface McpRevState {
|
|
/** Last revision the server CONFIRMED it loaded (or boot baseline). */
|
|
accepted: string
|
|
/** A reload RPC is outstanding — don't stack another every poll tick. */
|
|
inFlight: boolean
|
|
}
|
|
|
|
export const syncMcpReload = async (
|
|
gw: GatewayClient,
|
|
sid: string,
|
|
nextMcpRev: string,
|
|
state: McpRevState,
|
|
onReloaded?: () => void
|
|
): Promise<void> => {
|
|
if (!nextMcpRev || nextMcpRev === state.accepted || state.inFlight) {
|
|
return
|
|
}
|
|
|
|
state.inFlight = true
|
|
|
|
try {
|
|
const r = await quietRpc<ReloadMcpResponse>(gw, 'reload.mcp', {
|
|
confirm: true,
|
|
rev: nextMcpRev,
|
|
session_id: sid
|
|
})
|
|
|
|
if (r?.status === 'reloaded') {
|
|
state.accepted = String(r.loaded_rev || nextMcpRev)
|
|
onReloaded?.()
|
|
}
|
|
// Failure (null) or confirm_required: leave `accepted` unchanged so the
|
|
// next poll tick retries the same revision.
|
|
} finally {
|
|
state.inFlight = false
|
|
}
|
|
}
|
|
|
|
const _voiceRecordKeyFromConfig = (cfg: ConfigFullResponse | null): ParsedVoiceRecordKey => {
|
|
const raw = cfg?.config?.voice?.record_key
|
|
|
|
return raw ? parseVoiceRecordKey(raw) : DEFAULT_VOICE_RECORD_KEY
|
|
}
|
|
|
|
const _pasteCollapseLinesFromConfig = (cfg: ConfigFullResponse | null): number => {
|
|
if (!cfg?.config) {
|
|
return 5
|
|
}
|
|
|
|
const raw = cfg.config.paste_collapse_threshold
|
|
|
|
if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) {
|
|
return Math.round(raw)
|
|
}
|
|
|
|
if (typeof raw === 'string') {
|
|
const n = parseInt(raw, 10)
|
|
|
|
if (Number.isFinite(n) && n >= 0) {
|
|
return n
|
|
}
|
|
}
|
|
|
|
return 5
|
|
}
|
|
|
|
const _pasteCollapseCharsFromConfig = (cfg: ConfigFullResponse | null): number => {
|
|
if (!cfg?.config) {
|
|
return 2000
|
|
}
|
|
|
|
const raw = cfg.config.paste_collapse_char_threshold
|
|
|
|
if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) {
|
|
return Math.round(raw)
|
|
}
|
|
|
|
if (typeof raw === 'string') {
|
|
const n = parseInt(raw, 10)
|
|
|
|
if (Number.isFinite(n) && n >= 0) {
|
|
return n
|
|
}
|
|
}
|
|
|
|
return 2000
|
|
}
|
|
|
|
/** Fetch ``config.get full`` and fan the result through ``applyDisplay``.
|
|
*
|
|
* Extracted so the mtime-reload path can be exercised by the test
|
|
* suite without a React runtime (Copilot round-12 review on #19835).
|
|
* Both the initial hydration and the mtime poller use this shared
|
|
* helper, so a regression in the fetch/apply plumbing now fails the
|
|
* useConfigSync tests instead of only being visible at runtime. */
|
|
export async function hydrateFullConfig(
|
|
gw: GatewayClient,
|
|
setBell: (v: boolean) => void,
|
|
setVoiceRecordKey?: (v: ParsedVoiceRecordKey) => void
|
|
): Promise<ConfigFullResponse | null> {
|
|
const cfg = await quietRpc<ConfigFullResponse>(gw, 'config.get', { key: 'full' })
|
|
applyDisplay(cfg, setBell, setVoiceRecordKey)
|
|
|
|
return cfg
|
|
}
|
|
|
|
export const applyDisplay = (
|
|
cfg: ConfigFullResponse | null,
|
|
setBell: (v: boolean) => void,
|
|
setVoiceRecordKey?: (v: ParsedVoiceRecordKey) => void
|
|
) => {
|
|
const d = cfg?.config?.display ?? {}
|
|
|
|
setBell(!!d.bell_on_complete)
|
|
|
|
applyConfiguredTuiTheme(d.tui_theme)
|
|
|
|
// Only push the voice record key when the RPC actually returned a
|
|
// config payload. ``quietRpc()`` collapses failures to ``null``; if we
|
|
// reset the cached shortcut on every null we would clobber a custom
|
|
// binding after one transient RPC error until the next config edit
|
|
// (Copilot round-8 review on #19835). The mtime-poll loop advances
|
|
// ``mtimeRef`` before this call, so staying silent on null preserves
|
|
// the last-good state and lets the next successful poll refresh it.
|
|
if (setVoiceRecordKey && cfg) {
|
|
setVoiceRecordKey(_voiceRecordKeyFromConfig(cfg))
|
|
}
|
|
|
|
patchUiState({
|
|
battery: !!d.battery,
|
|
busyInputMode: normalizeBusyInputMode(d.busy_input_mode),
|
|
compact: !!d.tui_compact,
|
|
detailsMode: resolveDetailsMode(d),
|
|
detailsModeCommandOverride: false,
|
|
indicatorStyle: normalizeIndicatorStyle(d.tui_status_indicator),
|
|
inlineDiffs: d.inline_diffs !== false,
|
|
mouseTracking: normalizeMouseTracking(d),
|
|
pasteCollapseLines: _pasteCollapseLinesFromConfig(cfg),
|
|
pasteCollapseChars: _pasteCollapseCharsFromConfig(cfg),
|
|
sections: resolveSections(d.sections),
|
|
showReasoning: !!d.show_reasoning,
|
|
statusBar: normalizeStatusBar(d.tui_statusbar),
|
|
streaming: d.streaming !== false
|
|
})
|
|
}
|
|
|
|
export function useConfigSync({
|
|
gw,
|
|
setBellOnComplete,
|
|
setVoiceEnabled,
|
|
setVoiceRecordKey,
|
|
sid
|
|
}: UseConfigSyncOptions) {
|
|
const mtimeRef = useRef(0)
|
|
const mcpRevRef = useRef<McpRevState>({ accepted: '', inFlight: false })
|
|
|
|
useEffect(() => {
|
|
if (!sid) {
|
|
return
|
|
}
|
|
|
|
// Keep startup cheap: voice.toggle status probes optional audio/STT deps and
|
|
// can run long enough to delay prompt.submit on the single stdio RPC pipe.
|
|
// Environment flags are enough to initialize the UI bit; the heavier status
|
|
// check still runs when the user opens /voice.
|
|
setVoiceEnabled(process.env.HERMES_VOICE === '1')
|
|
quietRpc<ConfigMtimeResponse>(gw, 'config.get', { key: 'mtime' }).then(r => {
|
|
mtimeRef.current = Number(r?.mtime ?? 0)
|
|
// Seed the MCP revision baseline too: after a normal boot mtime is
|
|
// already non-zero, so the poller's baseline branch never runs, and an
|
|
// unset baseline would make the FIRST cosmetic write (mtime bump, same
|
|
// mcp_rev) look like an MCP change and fire a needless reload.mcp.
|
|
mcpRevRef.current.accepted = String(r?.mcp_rev ?? '')
|
|
})
|
|
void hydrateFullConfig(gw, setBellOnComplete, setVoiceRecordKey)
|
|
}, [gw, setBellOnComplete, setVoiceEnabled, setVoiceRecordKey, sid])
|
|
|
|
useEffect(() => {
|
|
if (!sid) {
|
|
return
|
|
}
|
|
|
|
const id = setInterval(() => {
|
|
quietRpc<ConfigMtimeResponse>(gw, 'config.get', { key: 'mtime' }).then(r => {
|
|
const next = Number(r?.mtime ?? 0)
|
|
const nextMcpRev = String(r?.mcp_rev ?? '')
|
|
|
|
if (!mtimeRef.current) {
|
|
if (next) {
|
|
mtimeRef.current = next
|
|
mcpRevRef.current.accepted = nextMcpRev
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// Reload MCP only when the MCP-relevant config actually changed.
|
|
// Cosmetic writes (/skin, /statusbar, /theme) bump mtime constantly;
|
|
// reconnecting every MCP server for those costs seconds and made
|
|
// skin switching feel glacial. The handshake runs on EVERY poll tick
|
|
// (not just mtime changes) so a failed reload retries until the
|
|
// server confirms the revision was loaded.
|
|
if (nextMcpRev) {
|
|
void syncMcpReload(gw, sid, nextMcpRev, mcpRevRef.current, () =>
|
|
turnController.pushActivity('MCP reloaded after config change')
|
|
)
|
|
}
|
|
|
|
if (!next || next === mtimeRef.current) {
|
|
return
|
|
}
|
|
|
|
mtimeRef.current = next
|
|
|
|
// Older gateways don't send mcp_rev — fall back to
|
|
// reload-on-any-change there (no ack tracking possible).
|
|
if (!nextMcpRev) {
|
|
quietRpc<ReloadMcpResponse>(gw, 'reload.mcp', { session_id: sid, confirm: true }).then(
|
|
r => r && turnController.pushActivity('MCP reloaded after config change')
|
|
)
|
|
}
|
|
|
|
void hydrateFullConfig(gw, setBellOnComplete, setVoiceRecordKey)
|
|
})
|
|
}, MTIME_POLL_MS)
|
|
|
|
return () => clearInterval(id)
|
|
}, [gw, setBellOnComplete, setVoiceRecordKey, sid])
|
|
}
|
|
|
|
export interface UseConfigSyncOptions {
|
|
gw: GatewayClient
|
|
setBellOnComplete: (v: boolean) => void
|
|
setVoiceEnabled: (v: boolean) => void
|
|
setVoiceRecordKey?: (v: ParsedVoiceRecordKey) => void
|
|
sid: null | string
|
|
}
|