~/bloggap-replaces-progress.md
cchu@nycu:~/blog$ cat gap-replaces-progress.md
2026.11.077 min[agents][event-streams][replay][protocols]

Resetting an Agent Cursor After a Replay Gap

A Switch client replay checks per-source progress, backward gap resets, callback failures, and the difference between cursor advancement and completed effects.

A synthetic gap frame moved a Switch agent client's own-event position from 18 back to 2, moved one room from 43 to 7, and removed another room's position entirely. The next own event advanced the cursor to 3.

Applying the normal progress merge would have preserved the old, larger numbers. That would contradict the reset the server had just announced. In this protocol, ordinary progress and recovery after a gap are different operations: merge the former, replace with the latter.

I examined revision 560af2c7ad9aaaa19b2a7c03a4d7677ca7d3091a and ran the unchanged client modules with Node 24.19.0 on Linux. A TypeScript 5.9.3 transpilation fed synthetic SSE bytes through the actual parser and internal frame handler. All 13 probe scenarios passed. No WebSocket, HTTP connection, heartbeat loop, automatic reconnect loop, server, database, or model was started.

Ordinary progress waits for the event callback and merges source positions by maximum. A gap validates its replacement, waits for onGap, and replaces the position, allowing lower numbers and removal of omitted keys. Callback failure preserves the previous position.

One Cursor Cannot Describe Several Sequences

Switch combines agent orchestration with collaboration bridges and a desktop session runtime. Its server-side event buffer describes two event sources: events addressed to an agent, numbered per agent, and room conversation, numbered separately per room. Addressing decisions are stored when room rows are written rather than recomputed during replay.

That architecture needs a position with more than one coordinate:

{"s":18,"r:room-a":43,"r:room-b":9}

s identifies progress through the agent's own events. Each r: key identifies progress through one room's conversation. A large sequence in one room says nothing about how far the reader has progressed through another source.

The Python position module and the client's positions.ts both define ordinary merging as the larger value per source. The replay executed the TypeScript implementation; the server implementation and its persistence behavior were inspected only.

An ordinary report containing s:17 and r:room-a:45 left the fixture at:

{"s":18,"r:room-a":45,"r:room-b":9}

The older own-event report did not move that coordinate backward, and an omitted room key remained present. Six permutations of three progress reports, each delivered twice, reached the same final position in the probe. This checks order independence and idempotence of position merging, not idempotence of application effects.

A Gap Is a Replacement Boundary

The client's event-stream.ts handles a gap separately from normal event progress. It validates resumed_at, when present, as a nonnegative safe integer. It also validates resumed_position, when present, as a position object.

This is the synthetic SSE gap used for the backward-reset case:

event: gap
data: {"from_sequence":18,"reason":"synthetic reset","resumed_at":2,"resumed_position":{"s":2,"r:room-a":7},"rooms":["room-a"]}

The handler first awaits onGap. During that callback, reading stream.position still returned the old position. After the callback resolved, the new vector replaced it:

CoordinateBefore gapAfter callback and replacement
s182
r:room-a437
r:room-b9Absent

The callback received cursorReset: true because resumed_at was behind the previous own-event cursor. The replacement's omitted room key was removed, not retained as it would be by the ordinary merge.

This boundary also has a failure contract. When the synthetic onGap callback remained pending, the old position remained visible. When it threw, the replacement did not apply. A negative coordinate in resumed_position and an unsafe integer in resumed_at were rejected before invoking the callback.

The compatibility path behaves differently. A gap carrying only legacy resumed_at replaced s while preserving room coordinates. An explicitly empty resumed_position object was a valid whole-vector replacement and cleared all coordinates. An absent replacement and an empty replacement are therefore different inputs.

Handling an Event Precedes Recording Progress

For ordinary event frames, the handler awaits onEvent before calling its progress method, passed(). In the probe, an event at s:19 left the position at 18 while its callback was pending. Completing the callback advanced it to 19.

A callback rejection left the position unchanged. That supports replay after failed processing, but it does not make effects transactional. My synthetic callback incremented an effect counter, then threw. I manually resent the same frame. The second callback succeeded and advanced the cursor, while the effect counter reached two.

This was a callback fixture, not a duplicated real message or a measured reconnect. It demonstrates the scope of the acknowledgment boundary: position advancement waits for a successful callback return; effects performed before a rejection are not rolled back by this client.

There is another deliberate limit. A frame with an unknown event name and no readable event type produced a warning and skipped onEvent, but its valid position still advanced. The default branch logs that unreadable frame, then calls passed(). Cursor progress can therefore mean a frame was explicitly discarded, rather than delivered to the application callback.

Malformed JSON had a different result. The actual sse.ts parser threw before passing a frame to the handler, and the position stayed unchanged. Parser failure, callback failure, and an explicit unknown-frame discard should not be reported as the same outcome.

What This Replay Actually Exercises

The probe verifies SHA-256 digests for seven pinned source files. It transpiles them to temporary CommonJS modules, imports the emitted code, and creates real SwitchEventStream instances with synthetic application callbacks. It does not modify the implementation.

It then divides encoded SSE input into repeating byte chunks of sizes 1,2,7,3,11, passes the chunks to readSse(), and invokes the emitted internal handleFrame() method for each parsed frame. Keepalive comments are included. Calling this TypeScript-private method directly is a probe technique, not a supported public API. The probe never calls start().

The inspected runtime package declares version 0.8.0, Node >=22, a native TypeScript preview compiler, and Vitest. The local scoped tsc attempt with TypeScript 5.9.3 exited 2, reporting a WebSocket-constructor cast error and two narrowing diagnostics. It used --target ES2022 --module commonjs --moduleResolution node --skipLibCheck, event-stream.ts as its entrypoint, and local @types/node version 22.19.15 through --typeRoots. That invocation was not the package's declared typecheck. The probe uses transpileModule, which does not perform semantic typechecking; passing runtime assertions do not turn that failed check into a pass.

The actual cloud command was:

node public/blog/gap-replaces-progress/frame-probe.mjs \
  /tmp/switch-note-nov7 /workspace/cc-portfolio/node_modules/typescript

It exited 0. Selected terminal output:

per_source_progress: old s ignored; room-a advances; room-b retained
pending_event: cursor stays 18 until handler resolves, then 19
rejected_event_retry: cursor retained; synthetic effect executes twice
gap_replacement: {s:18,a:43,b:9} becomes {s:2,a:7}; next s=3 accepted
pending_gap: reset waits for onGap completion
rejected_gap_callback: old position retained
legacy_gap: only own-events cursor replaced; room position retained
unknown_unreadable_frame: warning, no onEvent, position still advances
PASS: 13 synthetic frame/position scenarios; real SSE parser and internal handler; no transport loop, server, database, or model

I also inspected the upstream position tests, client stream tests, and server replay tests. I did not run their Vitest or Postgres suites, negotiate a transport, or verify reconnect timing.

The distinction that survived the replay is small but consequential: ordinary progress accumulates, a gap resets the baseline, and neither operation proves that an application effect happened exactly once. The callback and discard paths explain what each recorded position actually acknowledges.