From 533b90a3a4ebc39dadd70b90819ee76d1b142f76 Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Sat, 28 Feb 2026 21:38:16 -0500 Subject: [PATCH] fix: resolve chat title update race conditions and improve resilience (#22450) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- coderd/chatd/chatd.go | 10 +++++++--- site/src/api/queries/chats.ts | 1 + site/src/pages/AgentsPage/AgentsPage.tsx | 9 +++++++++ 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/coderd/chatd/chatd.go b/coderd/chatd/chatd.go index 3476e68bb5..06fa5b7f8d 100644 --- a/coderd/chatd/chatd.go +++ b/coderd/chatd/chatd.go @@ -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 { diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index 1065204723..43f6ade2db 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -8,6 +8,7 @@ export const chatKey = (chatId: string) => ["chats", chatId] as const; export const chats = () => ({ queryKey: chatsKey, queryFn: () => API.getChats(), + refetchOnWindowFocus: true as const, }); export const chat = (chatId: string) => ({ diff --git a/site/src/pages/AgentsPage/AgentsPage.tsx b/site/src/pages/AgentsPage/AgentsPage.tsx index 4d27543e59..efe3fcd764 100644 --- a/site/src/pages/AgentsPage/AgentsPage.tsx +++ b/site/src/pages/AgentsPage/AgentsPage.tsx @@ -267,6 +267,15 @@ const AgentsPage: FC = () => { useEffect(() => { const ws = watchChats(); + ws.addEventListener("open", () => { + void queryClient.invalidateQueries({ queryKey: chatsKey }); + }); + ws.addEventListener("close", () => { + void queryClient.invalidateQueries({ queryKey: chatsKey }); + }); + ws.addEventListener("error", () => { + void queryClient.invalidateQueries({ queryKey: chatsKey }); + }); ws.addEventListener("message", (event) => { const sse = event.parsedMessage; if (sse?.type !== "data" || !sse.data) {