Timothy July 30, 2026 ~11 min read

Timothy Part 19: A Chat That Survives Reloads and Races

Parts 6 and 7 gave sessions an append-only event log and two projections built from it: one for the model, one for the browser. That work made history durable. It did not make the live chat honest. A turn could die mid-stream and leave a silent gap. A page reload during a long answer had no way to find the turn that was still running on the server. Two tabs open on the same session could race each other into the same append-only log. None of that showed up until I started using the assistant daily and hit it by accident.

This post is the fix for all of that: the plumbing that makes a chat turn behave the same way whether you’re watching it live, reloading mid-answer, or opening a second tab.

Why reattach instead of just re-fetching the transcript

A session opened while a turn was still running had nothing better to show than the last persisted event, which read as either stale or (worse) “interrupted” - even though the turn was actively streaming on the server. Re-fetching the transcript on an interval would eventually catch the finished turn, but it can’t reproduce tokens arriving as they’re generated or tool calls appearing as they start.

The fix is a per-session turn broadcaster living in chat.Service. While a turn runs, every event it produces gets appended to a buffer and fanned out to any live subscriber. A new endpoint, GET /v1/sessions/{id}/live, replays that buffer to a freshly attaching client and then follows the channel until the turn’s terminal event - wire-identical to the normal POST /messages stream, so the client reuses the exact same event reducer whether it started the turn itself or reattached to one already in flight.

Why not a separate turn_active flag alongside the broadcaster? Because two sources of truth for the same fact drift. Presence of a session’s entry in the broadcaster registry is “a turn is running”, full stop - GET /v1/sessions/{id} and the live-attach endpoint both read the same map, so they can’t disagree. The entry is only removed once the turn’s terminal event is durably persisted, not when the stream merely ends: if the flag flipped false before the write landed, a client refetching at exactly that moment could see neither the live turn nor the finished one.

Why one turn per session, enforced with a 409

Reattach solves the read side. The write side had its own hole: nothing stopped two simultaneous POSTs on the same session (a genuine double-click, or a retry racing an in-flight send) from both appending to the same event log, interleaving writes into a shape nothing downstream expected.

The fix folds into the same broadcaster registry: registering a turn is now an atomic, exclusive claim. turnBegin returns ErrTurnInFlight if an entry already exists for that session, and the API layer turns that into an HTTP 409 with the code turn_in_flight, before a single byte reaches the log:

// internal/brain/chat/broadcast.go
func (s *Service) turnBegin(sessionID string) (*turnBroadcaster, error) {
    s.turnsMu.Lock()
    defer s.turnsMu.Unlock()
    if _, exists := s.turns[sessionID]; exists {
        return nil, ErrTurnInFlight
    }
    b := newTurnBroadcaster()
    s.turns[sessionID] = b
    return b, nil
}

The exclusivity check has to happen before the user message is appended, not after - a losing caller must never write even its own message, or the log has a message with no turn behind it and a duplicate ahead of it. The web client’s answer to a 409 is to attach to the winning turn’s live stream instead of showing an error, so a double-click just becomes an unremarkable reattach.

Why permission asks are events, not just SSE frames

Some tool calls park mid-turn waiting on the user to approve them. That ask used to ride only the live SSE stream, so anyone who opened the session later, or reloaded while the ask was pending, saw what looked like a turn that had simply hung: no prompt, no explanation, nothing to click.

The fix treats a permission ask and its resolution as first-class session events, appended the moment they cross the relay rather than at turn end, because a parked ask can sit for the full timeout and the whole point is that a session opened during that wait shows the same prompt the live stream shows. Replay reconstructs any unresolved ask from the two new event kinds, and the existing approval modal renders it exactly as it would live. The model-facing projection from part 6 ignores both kinds entirely, so this is a pure UI-replay concern, not a new cost.

Two related fixes rounded this out. Agents already had a per-agent approval_allowlist for missions, but chat never consulted it, so every safe connector read parked on a fresh ask regardless of standing consent configured in Settings. The fix seeds the same session grants missions already use, once per session-and-agent pair, through the identical grant-matching chain permissions already resolve against. It’s safe to widen without a new safety review because it only ever adds a grant row the resolver already knew how to consult; destructive commands still force an ask regardless of any grant, and the allowlist is user-authored configuration, not something a model can expand on its own.

Separately, a step that fires several tool calls of the same kind in parallel used to park every single one on the permission prompt independently, before any had recorded an answer - nine prompts stacked up live for three tool calls that were, from the user’s point of view, one decision. The fix is a small mutex-like gate keyed on (session, tool, subject): the first caller through actually asks, everyone else queues on the same key and, once through, re-resolves and sees the grant the first caller just recorded, skipping the prompt. Execution stays outside the gate, so settled calls still run concurrently.

Why SSE headers get flushed before the provider dial

A separate, smaller bug hit the same honesty theme from the other direction: the gateway’s stream handler set its SSE headers but let the first send() write them implicitly. A local model that cold-loads or chews through a long prefill could leave the connection completely silent past the brain’s response-header timeout, which read as a dead request and triggered a retry that hit the exact same wall on the next attempt. The fix is one WriteHeader and Flush before the first provider dial starts. Nothing downstream relied on a non-200 status past this point anyway, since chain exhaustion already streams as a normal SSE error event, so committing to 200 immediately costs nothing and buys a slow first token the time it needs without looking dead.

Why a turn that dies before any reply still needs a retry button

A failed turn leaves a dangling user message with nothing after it. After a reload, the transcript had no assistant item at all, which meant the old retry affordance - an error badge painted on the assistant message - simply never rendered, because there was no assistant message to paint it on. retryLast itself was written to reset the last assistant item in place, a silent no-op in exactly this state. The fix moves the retry affordance onto the trailing user message when the transcript ends there, and teaches retryLast to append a fresh assistant item to stream into rather than assuming one already exists.

A follow-up fix widened the backend side of the same problem: lastUserMessage originally required the session’s literal last event to be the user message for a turn to count as retryable, so a crash that left tool-execution or permission events trailing after the user message made the turn permanently unretryable (a 409 on every attempt). The rule is now that a turn is retryable whenever no completed assistant_turn follows the last user message - trailing tool and permission events don’t block it, and a trailing checkpoint resumes as an interrupted answer rather than blocking retry outright.

Why zero-output and cut-off turns needed a name for silence

The deepest bug in this batch was also the quietest. A provider stream that ended cleanly with zero content deltas got booked in the cost ledger as status “ok” - the client had no idea anything had gone wrong, and that false “ok” could even pin a session’s provider-stickiness onto a model that had just returned nothing. Separately, the loop’s terminal-event handling was last-write-wins: the gateway sends an incomplete event followed by a done event for a cut-off or empty stream, and done silently clobbered incomplete, so the abnormal-end path never fired. Together, a turn could complete with no text, no reasoning, and no tool calls, and persist as a blank assistant_turn - invisible in the log, invisible in the UI, no error anywhere.

The fix has two halves. The gateway now treats a drained stream with no content event as incomplete rather than ok, sending the client an incomplete event before the terminal done, the same shape a genuinely cut-off stream already used. And the loop keeps a sticky reference to that incomplete event so a later done can’t erase it:

// internal/brain/loop/agent.go
case stream.EventIncomplete:
    terminal = ev
    evCopy := ev
    incompleteEv = &evCopy

On the persistence side, a new event kind, turn_failed, gets appended whenever a turn ends with a captured error and nothing worth keeping, or completes with literally nothing to show. Both session projections render it, and the web transcript shows a matching error notice, so a failed turn is visible on reload instead of quietly missing from the conversation. It’s the same species of bug the series keeps running into: a code path that fails cleanly reads as success unless something explicitly checks for the absence of output, not just the absence of an error.

The recurring lesson: token caps have to budget for thinking

One more fix here is worth calling out because it’s the third time this exact mistake has shown up in the series. Turn distillation - the small structured summary appended after every turn, from part 6 - was hardcoded to a local route with a 500-token cap and a 30-second timeout. That route now serves a local reasoning model, which spends its completion budget thinking before it ever emits the strict-JSON answer the distiller expects, so every chat turn was burning thirty to sixty seconds of GPU time and producing an incomplete row because the cap ran out mid-reasoning. Same species of bug as the 30-token auto-title from part 6: a cap sized for an answer, applied to a model that spends most of its budget on reasoning it never gets to finish. The fix is two-pronged rather than just raising the number: distillation now defaults to the cheap cloud summarize route for ordinary turns, and only pins to the local reasoning route for sensitive turns that must stay local, with the timeout raised to 90 seconds to give that model room to actually finish thinking.

Two smaller honesty fixes

A tool-heavy turn used to render a dozen small cards before any answer text showed up, which made a turn that called several tools unreadable while it was still running. Assistant turns now collapse into one compact activity line (running state, total duration, deduped tool names) that opens a detail panel with the full args, result digest, and status per call on demand. Replay had to converge on the same shape live turns use: the transcript-rebuilding logic nests tool events into their assistant turn during replay instead of emitting them as flat items, so a reloaded session looks identical to one that was watched live.

Separately, turns already reported tool counts and token usage, but nothing tied either to wall-clock time. The brain now stamps a duration onto the terminal meta event it already sends and persists, so both a live turn and a replayed one render the identical stats line without the client guessing elapsed time from timestamps.

What still doesn’t exist

Reattach only covers a turn already in flight when a client connects - there’s no notion of resuming a turn that failed partway through tool execution and re-running just the unfinished calls; a retry re-runs the whole turn from the last user message. The permission-ask replay renders an unresolved ask faithfully, but there’s still no way to approve or deny it from a device other than the one that opened the modal first.

Next part: the assistant grows ears and eyes, voice input and image attachments.

// end of article — process exited with code 0

// share:Twitter / XLinkedIn

// comments

loading...