ADR-028: The answer reaches the reader while it is written
Status: Accepted, amends ADR-007
Date: 2026-11-01
Context
The chat ran a turn in a background process and polled every two seconds. A reader saw nothing of an answer until the turn had ended, and then, on average, another second later. On the demo the median turn took 11 s. nr-llm ADR-240 makes the text of a tool round available while the model writes it, in the process that runs the turn. It has to reach the browser from there.
Decision
The conversation's row carries the text. The process writes the whole text
so far to live_text, at most every 100 ms (LiveTextRecorder), and empties
the column once the turn's messages are stored. Every reader starts from the
same state, so a second tab, a reload in the middle of an answer and a reconnect
need no code of their own. Only the transcript is a record.
A request of its own delivers it. GET /ai-chat/conversations/stream
(ConversationStream) reads the row every 100 ms and sends what changed as
Server-Sent Events, with the same headers and padding as nr-llm's playground and
the Cowriter dialog. Through the demo's chain (Caddy, nginx, PHP-FPM) the Cowriter
route delivered its events as they were sent, measured on 2026-11-01.
ADR-007 is amended, for its two reasons. The connection limit of FPM is met by
maxLiveStreams and a lease; the message bus between the processes is the row,
which the stream reads. Polling stays the contract.
Polling stays. The stream carries the text, the activity and the number of
messages, nothing else. A message that arrives inside a turn that goes on makes
the chat poll at once, as the end of the turn does.
Its done event makes the chat poll once, at once, and that poll brings the
messages, the approval card, the question and the guided state exactly as
before. While a stream is open the poll runs every 10 s to notice a stream that
went quiet. When a stream is refused or breaks without having delivered, the chat
goes on polling every 2 s, and the poll shows live_text too.
A stream is bounded, because it holds a PHP-FPM worker. It opens only for a
running turn and ends with it. It ends after 100 s in any case, and the chat
opens a new one. maxLiveStreams conversations stream at the same time, 3 by
default. A lease on the row counts them, so a stream that died stops counting
after 15 s. A conversation over the limit is refused and polls. Two readers can
pass the count at the same moment and go one over, and two tabs on one
conversation count once: the limit is a margin below pm.max_children, not an
exact number. The nginx comment of the demo records what a pinned pool costs.
No announcement per token. The running text is aria-hidden. The finished
message is added to the live region as before and is announced once.
What a guardrail holds does not appear early. nr-llm decides: with a guardrail that holds a stream back (nr-llm ADR-238) the round is not streamed, and the text appears with the message. Otherwise a guardrail that refuses the finished text fails the turn after the reader saw it; the running text then goes and the error stands in its place.
Consequences
- The reader sees the first text about when the model has written the first 128 bytes, the part nr-llm's secret redaction holds back. An answer shorter than that appears when it is complete.
- A model round that only calls a tool shows no text; the activity list and the typing indicator say the turn is working.
- A provider that cannot stream a tool round behaves as before, through the poll.
- Every running turn with a reader causes ten small reads per second and one write per 100 ms of model output.
- Two more columns on the conversation. Neither is part of the full-row write, so a turn's final write cannot put back an old value.