Files
hermes-agent/apps/shared/src/gateway-events.ts
teknium1 71b61884a4 fix(desktop): deliver a gateway event once even when two sockets to one backend carry it
The backend stamps `seq` once per event before its transport fan-out, so the
same frame arriving on two of the renderer's sockets to one process carries
the same (session_id, seq); `JsonRpcGateway` is per socket and cannot see the
other copy. `JsonRpcGateway.dispatchEvent` now tags each event with the
`replay_epoch` its socket adopted from `gateway.ready` (per process, not per
socket), and both fan-ins in `use-gateway-boot.ts` (primary + registry
secondaries) pass through one `createGatewayEventDedupe()` gate keyed by
(epoch, session_id, seq) before any store runs.

A duplicate is the same key within 30 s. Not "seq not above the highest
seen": the backend restarts a session's counter at 1 when the session leaves
its 64-session replay ring, and a high-water mark would swallow the whole
restart of any short session. Seq-less / session-less events always pass.
Bounded: 2048 seqs per session, 256 sessions LRU.

Live, with the router fix reverted so two sockets still join the chat: the
DOM never doubled a word (0/38 samples vs 4/40 on main) and the doubled
"reply was cut short" card is back to one.

Closes #120007. Part of #120005.
2026-09-23 01:56:12 -07:00

59 lines
2.8 KiB
TypeScript

/**
* Wire types for the `tui_gateway` JSON-RPC surface shared by the Ink TUI, Desktop and the
* web dashboard.
*
* Every shape here is GENERATED from `tui_gateway/contracts` (Python is the single source):
* `./gateway-contract.generated.ts` carries `RpcMethods` (client→server method → params/result),
* `ServerRequestMap` (server→client request → params/result), `GatewayEventMap` (notification
* type → payload) and every value shape. `scripts/gen_gateway_contracts.py` regenerates it and
* `tests/tui_gateway/contracts/test_generated.py` fails when the committed file is stale, so a field the
* backend stops sending fails `tsc` here instead of drifting.
*
* This module adds only what the wire does not carry: the client-local synthetic events the TUI
* transport publishes into the same handler stream, and the `GatewayEvent` envelope.
*/
import type { BackendGatewayEventMap } from './gateway-contract.generated.js'
export * from './gateway-contract.generated.js'
/**
* Client-local synthetic events. Never emitted by `tui_gateway`; the Ink TUI's `gatewayClient`
* publishes them into the same handler stream to report transport state.
*/
export interface ClientLocalGatewayEventMap {
'dashboard.new_session_requested': { reason?: string }
'gateway.protocol_error': { preview?: string }
'gateway.reconnecting': { attempt?: number; delay_ms?: number }
'gateway.start_timeout': { cwd?: string; python?: string; stderr_tail?: string }
'gateway.stderr': { line: string }
}
export interface GatewayEventMap extends BackendGatewayEventMap, ClientLocalGatewayEventMap {}
export type GatewayEventName = keyof GatewayEventMap
/** One `event` notification's `params`. */
export interface GatewayEvent<K extends GatewayEventName = GatewayEventName> {
/** Client-local: recovered/held during reconnect, not fresh user-facing work. */
replayed?: boolean
/** Client-local: the backend process's `replay_epoch` the delivering socket had adopted
* (from `gateway.ready`) when it dispatched this event. Two sockets to one process share
* it, so a renderer can recognise the same frame arriving on both. */
replayEpoch?: string
/** Registry connection whose socket delivered the event (renderer-side tag;
* absent for the local/legacy primary path). */
connectionId?: string
payload?: GatewayEventMap[K]
/** Renderer-side source tag added by the Desktop gateway registry. */
profile?: string
/** Per-session monotonic counter stamped by `tui_gateway/event_replay.py::_stamp_event`;
* absent on session-less broadcasts. */
seq?: number
session_id?: string
type: K
}
/** Backend-emitted notification names (generated `GATEWAY_EVENT_TYPES`), re-exported under the
* name the consumers already use. */
export { GATEWAY_EVENT_TYPES as BACKEND_EVENT_NAMES } from './gateway-contract.generated.js'