What happened?
An SSE client that reconnects to GET /api/runs/:id/events with a Last-Event-ID / ?after=<n> cursor older than the in-memory event ring buffer silently loses every event the buffer already evicted β with no gap signal.
run.events is a bounded ring buffer (createChatRunService maxEvents = 2000). stream() replays only the surviving records whose id is greater than the cursor. On a run that has emitted more than 2000 events, the earliest events have been spliced out of memory, so a client reconnecting from a stale cursor jumps straight to the oldest surviving id and never receives the events in between. The full history is still in the on-disk events.jsonl; only the in-memory replay is lossy.
Steps to reproduce
Reproduced end-to-end against a real daemon over the production HTTP API, with a fake claude CLI that streams past the ring buffer:
- Point the daemon at a fake
claude binary that emits ~2500 text_delta stream events, then exits 0.
- Start a run (
POST /api/runs) and wait for it to finish. The on-disk events.jsonl has ~2506 events; run.events in memory has been truncated to the last 2000.
- Reattach:
GET /api/runs/:id/events?after=5 (with Last-Event-ID: 5) β i.e. "give me everything after event 5".
Observed on main:
- First replayed event id is 507, not 6.
- Events 6β506 (501 events) are absent from the stream.
- No
events_truncated / gap signal is emitted β the client sees a contiguous-looking stream and cannot tell anything was dropped.
Expected behavior
The reattach resumes at cursor+1 and is gapless: the client receives every event after its cursor, in order, exactly as if it had never disconnected. Since the evicted events are still on disk, the daemon can serve them.
Open Design version
0.14.1
Platform
macOS (Apple Silicon)
Additional context
What happened?
An SSE client that reconnects to
GET /api/runs/:id/eventswith aLast-Event-ID/?after=<n>cursor older than the in-memory event ring buffer silently loses every event the buffer already evicted β with no gap signal.run.eventsis a bounded ring buffer (createChatRunServicemaxEvents = 2000).stream()replays only the surviving records whose id is greater than the cursor. On a run that has emitted more than 2000 events, the earliest events have been spliced out of memory, so a client reconnecting from a stale cursor jumps straight to the oldest surviving id and never receives the events in between. The full history is still in the on-diskevents.jsonl; only the in-memory replay is lossy.Steps to reproduce
Reproduced end-to-end against a real daemon over the production HTTP API, with a fake
claudeCLI that streams past the ring buffer:claudebinary that emits ~2500text_deltastream events, then exits 0.POST /api/runs) and wait for it to finish. The on-diskevents.jsonlhas ~2506 events;run.eventsin memory has been truncated to the last 2000.GET /api/runs/:id/events?after=5(withLast-Event-ID: 5) β i.e. "give me everything after event 5".Observed on
main:events_truncated/ gap signal is emitted β the client sees a contiguous-looking stream and cannot tell anything was dropped.Expected behavior
The reattach resumes at cursor+1 and is gapless: the client receives every event after its cursor, in order, exactly as if it had never disconnected. Since the evicted events are still on disk, the daemon can serve them.
Open Design version
0.14.1
Platform
macOS (Apple Silicon)
Additional context
stream()cannot replay events it has already evicted from the ring buffer. [codex] Fix buffered run event checkpointsΒ #4399 does not touchruns.ts/stream().events.jsonlbefore replaying the in-memory tail) and will open a PR thatFixesthis issue.