diff --git a/AGENTS.md b/AGENTS.md index 103ada8..1f49443 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -541,6 +541,25 @@ first if a remote spawn ever mangles an argument. `pm grant ... POST_NOTIFICATIONS`. And a saved scroll anchor is per session id, so the only way two builds start a scroll from the same place is a *fresh session for each*. +- **A phone that falls behind the stream is answered with `reset`, and + `RUST_LOG=ai_server=debug` says when.** Every SSE subscriber logs the + cursor it arrived with and whether it was continued or reset + (`stream backlog:` in `send_backlog`), which is the only place that + question is answerable: the app sees a window arrive and cannot tell how + far it had fallen, and a reset is the one thing that makes its screen jump + to the newest end. Measured 2026-09-04 against a session streaming at 20 + events a second: reopening one with an anchor 1,800 events back connects + **87-119 events behind**, well under `CATCH_UP_LIMIT`'s 200, because the + restore is two requests -- the opening page, then one span covering the + whole distance to the anchor. So the reset path is not reachable by + reopening a session, and **to exercise it at all you have to lower + `CATCH_UP_LIMIT`** in a throwaway server build; at 5 the app takes the + reset on a live connection, clears, refills and carries on without + reconnecting. Worth knowing alongside it: **the session screen's stream + survives backgrounding here** -- 20 seconds at the launcher while 415 + events were produced brought no reconnect at all -- which is not what the + comment above that loop expects, and is most likely this emulator being + headless rather than the phone's behaviour. - **`ai-server --delay MS` holds every response back.** Over the tunnel a phone's requests take tens to hundreds of milliseconds, and several faults live entirely in what the app does *while* one is outstanding. On diff --git a/TODO.md b/TODO.md index 5155c84..1f9dcde 100644 --- a/TODO.md +++ b/TODO.md @@ -16,19 +16,6 @@ one in place when it turns out to need a decision. would show exactly as "sometimes". Confirming it means driving a real stream-json session and sending it messages in both states. -## App — reconnect - -- [ ] Restarting the app onto a session with a saved anchor, while a long - reply was streaming, left it reconnecting every 1.5s - (`RECONNECT_DELAY_MS`) with the spinner up until the server was - restarted. `events?after=N` more than `CATCH_UP_LIMIT` (200) behind - answers `reset` plus the newest 200 *raw* deltas -- a window starting - mid-message -- and the reset clears `items`, which is the state the - restore loop then pages against. **May already be fixed:** the - restore's one-event-per-request bug was part of what made it so - visible and has since been fixed, so the first thing to find out is - whether this survives that. Found 2026-09-03. - ## Session settings - [ ] Autocompact belongs in session settings; empty disables it, which is the diff --git a/server/src/routes.rs b/server/src/routes.rs index f2b5c51..6cbdf15 100644 --- a/server/src/routes.rs +++ b/server/src/routes.rs @@ -1788,9 +1788,20 @@ async fn stream_session( /// and local; revisit if daily use produces transcripts where this shows /// (phase 6 territory). async fn send_backlog(transcript: &Path, last: &mut u64, tx: &mpsc::Sender) -> bool { + let cursor = *last; let entries = match catch_up(transcript, *last, CATCH_UP_LIMIT) { - Ok(CatchUp::Continue(entries)) => entries, + Ok(CatchUp::Continue(entries)) => { + // The pair of them at debug, because "was this subscriber reset, + // and how far behind was it" is a question about a phone that + // nothing else here can answer -- the app sees a window arrive + // and cannot tell how far it had fallen, and a reset is the one + // thing that makes its screen jump. `RUST_LOG=ai_server=debug`, + // beside the transcript pages. + tracing::debug!(cursor, sent = entries.len(), "stream backlog: continue"); + entries + } Ok(CatchUp::Restart(entries)) => { + tracing::debug!(cursor, sent = entries.len(), "stream backlog: reset"); if tx.send(SseEvent::default().event("reset")).await.is_err() { return false; }