Skip to content

fix(relay): rc.4 relay transport + inbound fixes — dedupe replays, fail pending on drop, fail fast mid-redial, WAN keepalive - #89584

Merged
benbarclay merged 12 commits into
NousResearch:mainfrom
victor-kyriazakos:relay-ws-hardening
Aug 20, 2026
Merged

fix(relay): rc.4 relay transport + inbound fixes — dedupe replays, fail pending on drop, fail fast mid-redial, WAN keepalive#89584
benbarclay merged 12 commits into
NousResearch:mainfrom
victor-kyriazakos:relay-ws-hardening

Conversation

@victor-kyriazakos

@victor-kyriazakos victor-kyriazakos commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Summary

The complete relay-robustness fix set for the rc.4 expedite train, one commit per fix. Driven by a customer incident (2026-08-18, release 2026.8.13): a 1011 keepalive ping timeout close mid-turn produced BOTH wedged sends and an agent that appeared stuck re-running previous turns after /stop//new.

Standalone by design: no live-cards dependencies, cherry-picks cleanly onto release lineages as a hotfix. The inbound dedupe commit was extracted from #85796 (its finding #3) and transplanted here so relay fixes ship without waiting for the task-card/live-cards review; #85796 no longer carries it.

The incident, fully decomposed

  1. WAN latency / event-loop stall tripped the websockets library's default 20s pong deadline → spurious 1011 close. → keepalive commit
  2. In-flight sends blocked the full ~30s outbound timeout on futures only the dead reader could resolve. → pending-fail commit
  3. Sends during the reconnect backoff registered unresolvable futures (_ws still points at the dead socket). → fail-fast commit
  4. On re-handshake the connector replayed its at-least-once inbound buffer; with no gateway-side dedupe, replayed messages re-ran as fresh turns — the 'stuck agent'. → dedupe commit

Commits

Commit Fix
d2975f4219 _read_loop fails all in-flight _pending futures on ANY exit path with the dict shape callers expect
a7946f6502 _request_response fails fast during the redial window (a live supervisor task IS the window; no new state)
534f8d4fb0 Explicit ping_interval=30, ping_timeout=60 at both connect sites; library defaults (20/20) are too aggressive for customer WAN paths
b5f95d0890 Bounded FIFO inbound replay dedupe on (chat_id, message_id); fail-open without message identity. Transplanted from #8579673ce04ae75; tests rewritten standalone (no live-cards fixtures)

Validation

  • tests/gateway/relay/test_ws_transport_hardening.py (3 tests) + tests/gateway/relay/test_relay_inbound_dedupe.py (4 tests: replay dropped, distinct handled, fail-open without id, seen-set bounded)
  • Full relay suite: 184 passed on this branch
  • Mutation checks: all 7 new tests fail against the base revision of their respective files
  • The dedupe fix was validated live on the staging fleet (it has been running on all four staging gateways since 2026-08-16)

Relationship to #85796

Now fully disjoint: #85796 carries only streaming/live-cards work (its branch was rebased to drop the dedupe commit; verified the branch diff is exactly the 105 transplanted lines). Merge order between the two no longer matters.


Review updates (2026-08-20)

Two independent review rounds (plus a follow-up) were run against this PR; 8 commits were added to resolve the findings. Merge order context: #85796 landed on main mid-review, so this branch now carries a merge of main with a composed resolution.

Added commits

Commit Change
22dfdc1c56 [blocker] The dedupe key read event.chat_id — a field MessageEvent does not have (chat identity lives at event.source.chat_id), so the key was None for every production event and the replay dedupe was a complete no-op; the original tests passed only because their SimpleNamespace events carried a top-level chat_id no production path produces. Now reads event.source.chat_id, keyed per platform (Phase 1.5 multiplex-safe).
8772702503 Wire-level regression tests driving _event_from_wire → _on_inbound — the real production path — so an event-shape mismatch can't green-light again. Includes cross-platform non-conflation coverage.
52f9979a6c [blocker] The reader now clears _ws on unexpected exit (identity-guarded, teardown-owned paths excluded). Fixes the ~30s send wedge on the two reader exits that arm NO reconnect supervisor: terminal 4401 revocation (whose own fatal-error notification ate the stall) and reconnect=False transports.
ca3438d395 Sends gate on the socket handle, not supervisor state. The mid-redial guard misread the window from both directions — it missed the supervisor-less wedges above AND rejected sends on an already-live socket while _dial_and_start() was still awaiting hello sends. With _ws cleared by the reader, the existing is None check covers the outage window honestly.
2f77ca1993 A reader scheduled without a socket settles pending waiters through the normal cleanup instead of escaping via assert before it.
c60a525380 Dedupe key platform component is spelling-invariant (enum vs string forms of one platform produce one key; distinct string platforms stay distinct).
bb0a4193d1 A socket write that raises (dead between the liveness guard and the write) returns the result dict callers expect instead of leaking ConnectionClosed into RelayAdapter.send.
141af4febf Merge main (#85796 landed). adapter.py __init__: union of independent sibling caches. _request_response: composes #85796's ambiguous-result contract with the raising-write catch via a frame_sent marker — a raise from the write itself is definite non-delivery (no flag); a failure surfaced after the frame was sent carries ambiguous: True like the timeout.

Validation (corrected)

  • Post-merge relay + adjacent suites: 278 passed, 0 failed via scripts/run_tests.sh (the canonical runner)
  • Every fix commit was mutation-checked: each fails its own regression tests with the fix reverted
  • The composed ambiguity semantics were verified directly: write-raise → ambiguous absent; post-send failure (disconnect mid-flight) → ambiguous: True
  • Correction to the original Validation section: the "validated live on the staging fleet since 2026-08-16" claim predates the review. The staging deployment ran the pre-review dedupe key, which was inert on production events — staging validated stability (no crashes), not dedupe efficacy. The corrected key's efficacy is validated by the wire-level tests. The "mutation checks: all 7 new tests fail against base" claim was also inaccurate as originally stated (2 of 7 passed against base — the fail-open and distinct-message tests are trivially satisfied by a no-op dedupe); the rewritten test suite does fail against the broken key.

Relationship to #82238 (updated)

This PR now covers the pending-future/dead-socket core that overlapped with #82238. That PR's remaining unique value: per-frame exception isolation in the read loop, the non-dict frame guard in _handle_frame, and the go_dormant stale-handle fix.

The reader is the only thing that can resolve a pending outbound_result
future. When the socket dropped unexpectedly (vs a deliberate
disconnect(), which already failed pending), every in-flight
_request_response waiter blocked the full _outbound_timeout_s (~30s) on
a future no reader could resolve.

Coatue incident 2026-08-18: a 1011 keepalive ping timeout close left
final-send and error-notification calls wedged against the dead socket,
holding sessions active while the connector replayed inbound backlog.

Fail every not-done pending future from _read_loop's finally with the
dict shape callers expect ({success: False, error: connection lost}) —
never an exception on the outbound path — then clear the map. list()
snapshot avoids mutation-during-iteration from woken waiters' finally
pops.
Between an unexpected close and the supervisor's successful re-dial,
self._ws still points at the DEAD socket, so the existing None-check
does not cover the backoff window: a send registered a future no reader
could resolve and blocked the caller for _outbound_timeout_s.

A live supervisor task is exactly the redial window (the loop returns
when a dial succeeds and its fresh reader takes over), so no new state
is needed: when self._supervisor is not done, return the error dict
immediately ({success: False, error: reconnecting}). Callers already
handle failed sends; a fast, honest failure beats a 30s wedge
(Coatue 2026-08-18).
…eout=60)

Customer gateways cross WAN paths to the connector; the websockets
library defaults (ping 20s, pong deadline 20s) close the socket with
1011 keepalive ping timeout under transient latency or event-loop
stalls — the trigger of the Coatue 2026-08-18 incident. 60s tolerates
stalls while still detecting a genuinely dead link within ~90s worst
case. Set explicitly at both connect() call sites (with and without
auth headers) so the tuning is visible and pinned by test rather than
inherited from library defaults.
@alt-glitch alt-glitch added type/bug Something isn't working comp/gateway Gateway runner, session dispatch, delivery P2 Medium — degraded but workaround exists sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages labels Aug 19, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

Related to #82238, which addresses the stale dead-socket result path. This PR also fails pending futures, blocks sends during redial, and tunes keepalives; maintainers should choose or consolidate the overlapping relay fixes.

…age_id)

Live-canary finding NousResearch#3 (Alice, staging): the relay inbound leg is
at-least-once. On WS re-handshake the connector replays its durable
per-instance buffer; a long multi-tool turn (60-100s) straddling a quiet
socket drop got its ORIGINAL inbound replayed after the turn finished,
re-running the entire turn — the user saw the final answer posted 2-5x
(each a separate execution, hence slightly different texts). Receipts:
same msg text at history=0 in back-to-back sessions 121647/121840, no
Slack-side retry on the connector (envelope dedupe never fired).

Consumer-side idempotency: bounded FIFO seen-set (512) keyed by platform
message identity; events without a message_id never dedupe (fail-open —
dropping a real message is worse than rerunning one). No wire change;
contract v1 untouched.

Transplanted-from: victor-fork/feat/relay-slack-live-cards@73ce04ae75 (extracted for the rc.4 relay-fixes train; tests moved to a standalone file with no live-cards dependencies)
@victor-kyriazakos victor-kyriazakos changed the title fix(relay): WS transport hardening — fail pending on drop, fail fast mid-redial, WAN keepalive tuning fix(relay): rc.4 relay transport + inbound fixes — dedupe replays, fail pending on drop, fail fast mid-redial, WAN keepalive Aug 19, 2026
…er platform

The dedupe key read event.chat_id — a field MessageEvent does not have
(chat identity lives on event.source.chat_id; see how every other read
in this adapter resolves it). getattr defaulted to None, so
_inbound_dedupe_key returned None for EVERY production event: the
fail-open branch always taken, the seen-set permanently empty, and a
replayed inbound still re-ran the whole turn. The tests passed because
their SimpleNamespace events carried a top-level chat_id no production
code path produces.

Read the chat id from event.source, and join the underlying platform
into the key: one relay adapter fronts several platforms (Phase 1.5
multiplex), and two platforms' numeric chat/message ids must not
collide into one replay identity.

The test event factory now builds real MessageEvent/SessionSource
objects in the wire decoder's shape, so the replay and bounded-set
tests fail against the broken key instead of green-lighting it.
The dedupe tests validated hand-built events only, so a key that read a
field the production event type doesn't have still went green — and the
'all 7 new tests fail against base' mutation claim didn't hold either
(the fail-open and distinct-message tests pass against base because a
no-op dedupe trivially satisfies both).

Add a wire-level class that decodes a connector frame with
_event_from_wire and dispatches it through _on_inbound — the exact
production path — asserting: a decoded event yields a dedupe key at
all, a re-delivered frame is dropped (fresh decode each time, so
identity must come from the key, not the object), and identical
chat/message ids on two different platforms are NOT conflated
(Phase 1.5 multiplex).

Mutation-checked: with the previous event-shape key reinstated, the
wire tests fail (3 failed); with the fix, all 7 pass.
Two reader-exit paths arm NO reconnect supervisor: a terminal 4401
revocation (deliberately never re-dials) and reconnect=False
transports. On both, _ws kept pointing at the dead socket after the
reader unwound, so the 'is None' liveness guard reported connected and
a send registered a future nothing could ever resolve — the full
_outbound_timeout_s (~30s) wedge this PR exists to eliminate. The
revocation case is the sharpest: the fatal-error notification that
path emits is itself an outbound send, so it ate the stall.

Null the handle in the reader's finally, identity-guarded (only if _ws
still points at the socket THIS reader served) so a supervisor re-dial
that already installed a fresh socket is never clobbered, and gated on
not _closing so disconnect() keeps sole ownership of teardown.

This also makes _ws the single honest liveness signal for the redial
window itself — groundwork for retiring the supervisor-state send
guard, which misreads that window from both directions.

Regression tests cover both uncovered paths, asserting _ws is cleared
and a post-drop send fails fast; both wedge (fail) with the finally
reverted.
The mid-redial fail-fast guard used 'supervisor task not done' as the
definition of the redial window, and that signal is wrong from both
directions:

- Too narrow: the wedge it fixed also occurs on reader exits that arm
  NO supervisor (terminal 4401 revocation, reconnect=False) — those
  stayed wedged.
- Too broad: _reconnect_loop -> _dial_and_start installs the fresh
  socket and starts its reader, THEN awaits one hello send per fronted
  identity before the supervisor unwinds. Through those awaits the
  transport is fully live, yet the guard rejected every send as
  'reconnecting' — refusing real traffic on a healthy socket.

With the previous commit the reader clears _ws on unexpected exit, so
the existing 'is None' check now covers the entire outage window
honestly: _ws is the single liveness signal. Drop the supervisor-state
guard.

The redial-window test now drives the real sequence (reader exit arms
the supervisor and clears _ws) instead of hand-crafting a stale-_ws
state the transport can no longer reach, and a new test pins the
post-dial window: a send issued while the supervisor is still
unwinding past a live socket must reach that socket (fails with the
guard reinstated).
…f asserting

_read_loop opened with 'assert self._ws is not None' — an exit that
escaped BEFORE the finally that fails pending futures, contradicting
the 'fails pending on ANY exit path' invariant the hardening commit
established. Production currently assigns _ws before scheduling the
reader, so this was latent, but any future lifecycle change hitting it
would strand every in-flight waiter for the full outbound timeout with
only an AssertionError in the logs.

Turn it into a guarded early-return INSIDE the try: the reader logs
the lifecycle bug and unwinds through the same finally as every other
exit, settling all waiters. Regression test drives _read_loop with
_ws=None against a registered pending future.
The key derived its platform component with getattr(platform, 'value',
''), which handles the Platform enum the wire decoder always produces
but collapses a plain-string platform — or a missing one — to the same
empty string. Two DIFFERENT string platforms would then share one key
component (cross-platform id collisions conflate), and enum vs string
spellings of the SAME platform would produce two keys (a replay decoded
differently would not dedupe).

Inert on today's wire path (the decoder canonicalizes unknowns to
Platform.RELAY), but alternate event constructors carry strings, so
normalize at the key: enum value when present, the string itself
otherwise, empty only when there is genuinely no platform. Missing
platform intentionally still yields a key — fail-open on identity is
reserved for missing message/chat ids.

Tests pin all three properties; the spelling-invariance pair fails
against the previous expression.
…ception

The socket can die BETWEEN _request_response's 'is None' liveness guard
and the actual write: the reader's finally hasn't cleared _ws yet, so
_send raises ConnectionClosed straight into callers whose contract is a
result dict (RelayAdapter.send consumes it with no try — only the
cosmetic typing lanes wrap the call). No liveness check can close this
window; it has to be caught at the write.

Convert the raise to {'success': False, 'error': ...} like every other
failed send, log the traceback at debug (the returned string alone
can't distinguish an ordinary dead socket from a defect in the frame
building above), and rely on the existing finally to drop the pending
entry. CancelledError is a BaseException, so cancellation still
propagates.

Same disposition as the equivalent guard in PR NousResearch#82238; regression test
drives a socket whose write raises while the reader is still parked,
and fails without the except clause.
NousResearch#85796 (live-cards gateway half) landed on main and touches the same
two files. Resolutions:

- adapter.py __init__: union — the dedupe seen-set and the live-cards
  draft/seal caches are independent sibling attributes.
- ws_transport.py _request_response: keep main's ambiguous-timeout
  contract AND this branch's raising-write catch, composed: a raise
  from the WRITE itself means the frame never reached the wire
  (definite non-delivery, no flag), while a failure surfaced after the
  frame was sent carries ambiguous=True like the timeout — tracked via
  a frame_sent marker.
@benbarclay
benbarclay merged commit 5a17b1f into NousResearch:main Aug 20, 2026
47 checks passed
prmartinow pushed a commit to prmartinow/hermes-agent that referenced this pull request Aug 26, 2026
…-hardening

fix(relay): rc.4 relay transport + inbound fixes — dedupe replays, fail pending on drop, fail fast mid-redial, WAN keepalive
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/gateway Gateway runner, session dispatch, delivery P2 Medium — degraded but workaround exists sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants