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
+41
View File
@@ -137,6 +137,47 @@ redaction. Per-item bulk failures are reported inside each result entry
`invoke_raw` / `client.invoke_raw` escape hatch performs neither check and
returns the unvalidated reply.
## Event Streaming And Reconnect-Replay Gaps
`session.events()` / `session.events_after(after_worker_sequence)` (and the
lower-level `client.stream_events`) return an `EventStream` that yields
`EventItem` values, not bare `MxEvent`s:
```rust
use zb_mom_ww_mxgateway_client::EventItem;
let mut stream = session.events_after(cursor).await?;
while let Some(item) = stream.next().await {
match item? {
EventItem::Event(event) => { /* apply the MXAccess change */ }
EventItem::ReplayGap(gap) => {
// Recent history was evicted — discard local state and re-snapshot,
// then resume without provoking another gap:
let resume = gap.oldest_available_sequence.saturating_sub(1);
stream = session.events_after(resume).await?;
}
}
}
```
Almost every item is a normal `EventItem::Event`. `EventItem::ReplayGap` is a
faithful, typed surfacing of the gateway's reconnect-replay gap sentinel — the
client does not synthesize it. The gateway emits the sentinel at most once, at
the head of a stream **resumed** via `events_after` (`after_worker_sequence`)
when the requested sequence is older than the oldest event still retained in the
session's replay ring: events in the open interval
`(requested_after_sequence, oldest_available_sequence)` were evicted and cannot
be replayed. A `ReplayGap` therefore means "you missed events — discard any
local state and re-snapshot." To resume without a second gap, reconnect with
`events_after(gap.oldest_available_sequence - 1)`, which replays starting at the
first still-retained event. A stream opened from the beginning
(`session.events()` / `events_after(0)`) never produces a `ReplayGap`.
`EventItem` provides `as_event()`, `into_event()`, and `replay_gap()` accessors
for callers that prefer not to `match`. The `mxgw-cli stream-events` subcommand
renders the sentinel as a distinct `REPLAY_GAP …` line (or a `replayGap` JSON
object under `--json` / `--jsonl`).
## Write Semantics And Common Pitfalls
These are MXAccess parity behaviors that surprise new callers. The gateway