fix: resolve chat title update race conditions and improve resilience (#22450)

## Problem

Chat titles sometimes don't update in the UI. The generated AI title
gets stuck as the fallback (first 6 words of the message) even though
the backend successfully generates a proper title.

## Root Causes

### 1. Cancelable context used during cleanup DB read (P0)
In `processChat`, the deferred cleanup re-reads the chat from the DB to
pick up the AI-generated title for the `status_change` pubsub event. But
it used the cancelable `ctx` instead of `cleanupCtx`:

```go
// Before — ctx may already be canceled here
if freshChat, readErr := p.db.GetChatByID(ctx, chat.ID); readErr == nil {
```

When the context is canceled, the DB read fails silently and the
`status_change` event carries the stale fallback title.

### 2. Title goroutine not tracked by inflight WaitGroup (P2)
The `maybeGenerateChatTitle` goroutine was fire-and-forget — not tracked
by `p.inflight`. During graceful shutdown, the server could exit before
the goroutine completes its DB write or pubsub publish.

### 3. No recovery when watchChats() WebSocket misses events
The frontend relies entirely on the `watchChats()` SSE connection for
title updates. If the connection drops or misses events, titles never
recover — the only fix was a full page reload.

## Fixes

1. **Use `cleanupCtx`** for the `GetChatByID` call and logger in the
deferred cleanup block.
2. **Track the title goroutine** with `p.inflight.Add(1)` / `defer
p.inflight.Done()` so shutdown waits for it.
3. **Invalidate chats query** on WebSocket open/close/error events so
missed updates are recovered via refetch. Also enable
`refetchOnWindowFocus` for the chats query.

Co-authored-by: Coder <coder@users.noreply.github.com>
This commit is contained in:
Kyle Carberry
2026-02-28 21:38:16 -05:00
committed by GitHub
co-authored by Coder
parent 1c71fd69f6
commit 533b90a3a4
3 changed files with 17 additions and 3 deletions
+7 -3
View File
@@ -1741,10 +1741,10 @@ func (p *Server) processChat(ctx context.Context, chat database.Chat) {
// changes made during processing (e.g. AI-generated titles
// from maybeGenerateChatTitle). The local `chat` variable
// is a value copy and won't reflect updates made in runChat.
if freshChat, readErr := p.db.GetChatByID(ctx, chat.ID); readErr == nil {
if freshChat, readErr := p.db.GetChatByID(cleanupCtx, chat.ID); readErr == nil {
chat = freshChat
} else {
logger.Warn(ctx, "failed to re-read chat for status event",
logger.Warn(cleanupCtx, "failed to re-read chat for status event",
slog.F("chat_id", chat.ID), slog.Error(readErr))
}
chat.Status = status
@@ -1816,7 +1816,11 @@ func (p *Server) runChat(
// Fire title generation asynchronously so it doesn't block the
// chat response. It uses a detached context so it can finish
// even after the chat processing context is canceled.
go p.maybeGenerateChatTitle(context.WithoutCancel(ctx), chat, messages, model, logger)
p.inflight.Add(1)
go func() {
defer p.inflight.Done()
p.maybeGenerateChatTitle(context.WithoutCancel(ctx), chat, messages, model, logger)
}()
prompt, err := chatprompt.ConvertMessages(messages)
if err != nil {