feat(clients): CLI-15 surface ReplayGap reconnect sentinel as typed signal (4/5 clients)

The gateway emits a ReplayGap sentinel MxEvent at the head of a StreamEvents
stream resumed via after_worker_sequence when the requested cursor predates the
oldest retained event. Clients previously ignored it, silently mis-treating a
lossy resume as continuous. Each client now surfaces the sentinel as a distinct,
typed, non-terminal signal (never synthesized, never swallowed) so a consumer can
detect the gap and re-snapshot; resume contract is
after_worker_sequence = oldest_available_sequence - 1.

- .NET: MxEventStreamItem (IsReplayGap/ReplayGap/Event) via new StreamEventItemsAsync
  + AsStreamItemsAsync extension. Build clean, 87 passed.
- Go: EventResult.ReplayGap field + IsReplayGap(); ReplayGap type alias. build/vet/test clean.
- Rust: EventItem enum (Event/ReplayGap); EventStream now yields Result<EventItem, Error>;
  CLI renders REPLAY_GAP line / replayGap JSON. fmt/check/test/clippy clean.
- Python: ReplayGap dataclass; stream_events yields pb.MxEvent | ReplayGap. 131 passed.
- Shared docs: ClientLibrariesDesign non-goals reframed (reconnect-replay protocol is
  consumable; auto-reconnect stays a non-goal); CrossLanguageSmokeMatrix resume-gap note.

Java client is deferred to the windev batch (no local JRE); CLI-15 stays open until it lands.

Claude-Session: https://claude.ai/code/session_01DMXXvNuPekkkrTEyPNxEkW
This commit is contained in:
Joseph Doherty
2026-07-09 16:22:28 -04:00
parent c823a7b60b
commit 0c6e5b3ace
22 changed files with 972 additions and 37 deletions
@@ -5,6 +5,7 @@ from __future__ import annotations
from collections.abc import AsyncIterator, Sequence
from .errors import ensure_mxaccess_success
from .events import ReplayGap
from .generated import mxaccess_gateway_pb2 as pb
from .values import MxValueInput, to_mx_value
@@ -572,14 +573,57 @@ class Session:
self,
*,
after_worker_sequence: int = 0,
) -> AsyncIterator[pb.MxEvent]:
"""Return an async iterator of `MxEvent` messages for this session."""
return self.client.stream_events_raw(
) -> AsyncIterator[pb.MxEvent | ReplayGap]:
"""Return an async iterator over this session's `MxEvent` stream.
Each yielded item is either a normal :class:`~...MxEvent` or a
:class:`ReplayGap`. Branch on ``isinstance(item, ReplayGap)`` — a gap is
never delivered as an ``MxEvent`` so it cannot be mistaken for a real
MXAccess event.
Pass a non-zero *after_worker_sequence* to resume a previously observed
stream. If that cursor predates the oldest event the gateway still
retains in its replay ring, the stream opens with a single
:class:`ReplayGap` sentinel (events in the gap were dropped and cannot be
replayed), then continues with normal events. On a gap, discard locally
cached state, re-snapshot, and — to resume without another gap —
reconnect with ``after_worker_sequence = gap.resume_after_worker_sequence``.
See :class:`ReplayGap` for the full semantics. The underlying protobuf
stream is available raw via ``GatewayClient.stream_events_raw``.
"""
raw = self.client.stream_events_raw(
pb.StreamEventsRequest(
session_id=self.session_id,
after_worker_sequence=after_worker_sequence,
),
)
return _surface_replay_gaps(raw)
async def _surface_replay_gaps(
raw: AsyncIterator[pb.MxEvent],
) -> AsyncIterator[pb.MxEvent | ReplayGap]:
"""Map the gateway's ``replay_gap`` sentinel event to a typed :class:`ReplayGap`.
Normal events pass through unchanged. The sentinel (``replay_gap`` set,
``family`` unspecified, body unset) is converted to a distinct
:class:`ReplayGap` so a consumer can branch on it without inspecting proto
presence, and is never yielded as an ``MxEvent``. The sentinel is forwarded
faithfully — it is neither dropped nor turned into a normal event.
Closing this generator (``aclose``) propagates to *raw* so the underlying
gRPC call is cancelled, preserving the raw stream's cancel-on-stop contract.
"""
try:
async for event in raw:
if event.HasField("replay_gap"):
yield ReplayGap.from_proto(event.replay_gap)
else:
yield event
finally:
aclose = getattr(raw, "aclose", None)
if aclose is not None:
await aclose()
def _ensure_bulk_size(name: str, count: int) -> None: