feat(coderd/x/chatd): parallelize ConvertMessagesWithFiles with g2 errgroup (#24034)

## Summary

Move `ConvertMessagesWithFiles` into the `g2` errgroup so prompt
conversion runs concurrently with instruction persistence, user prompt
resolution, MCP server connections, and workspace MCP tool discovery.

## Problem

In `runChat`, the setup before the first LLM `Stream()` call is
sequential across two errgroups:

```
g.Wait()                          // model + messages + MCP configs
ConvertMessagesWithFiles()        // sequential — blocked on g2 starting
g2.Wait()                         // instructions + user prompt + MCP connect + workspace MCP
```

`ConvertMessagesWithFiles` can take non-trivial time on conversations
with file attachments (batch DB resolution), and it was blocking g2 from
starting.

## Fix

`ConvertMessagesWithFiles` only reads the `messages` slice (available
after `g.Wait()`) and resolves file references via the database. No g2
task reads or writes the `prompt` variable. This makes it safe to
overlap with g2:

```
g.Wait()
g2.Wait()   // now includes ConvertMessagesWithFiles in parallel
```

The `InsertSystem` call for parent chats and the `promptErr` check are
deferred to after `g2.Wait()`, preserving correctness.

<details><summary>Decision log</summary>

- `ConvertMessagesWithFiles` is read-only on `messages` — no mutation,
safe for concurrent access
- `prompt` and `promptErr` are written only by the conversion goroutine,
read only after `g2.Wait()` — no data race
- Error from prompt conversion is checked immediately after `g2.Wait()`,
before any code that uses `prompt`
- `chatloop.Run` now uses `:=` instead of `=` since the prior `err`
declaration from `prompt, err :=` was removed

</details>

> Generated by Coder Agents
This commit is contained in:
Kyle Carberry
2026-04-05 11:42:07 +00:00
committed by GitHub
parent e18094825a
commit 8625543413
+20 -11
View File
@@ -3934,14 +3934,6 @@ func (p *Server) runChat(
)
}()
prompt, err := chatprompt.ConvertMessagesWithFiles(ctx, messages, p.chatFileResolver(), logger)
if err != nil {
return result, xerrors.Errorf("build chat prompt: %w", err)
}
if chat.ParentChatID.Valid {
prompt = chatprompt.InsertSystem(prompt, defaultSubagentInstruction)
}
// Detect computer-use subagent via the mode column.
isComputerUse := chat.Mode.Valid && chat.Mode.ChatMode == database.ChatModeComputerUse
@@ -3998,7 +3990,20 @@ func (p *Server) runChat(
needsInstructionPersist = true
}
}
// Convert messages to prompt format in parallel with g2 work.
// ConvertMessagesWithFiles only reads `messages` (available
// after g.Wait()) and resolves file references via the DB.
// No g2 task reads or writes `prompt`, so this is safe.
var prompt []fantasy.Message
var g2 errgroup.Group
g2.Go(func() error {
var err error
prompt, err = chatprompt.ConvertMessagesWithFiles(ctx, messages, p.chatFileResolver(), logger)
if err != nil {
return xerrors.Errorf("build chat prompt: %w", err)
}
return nil
})
if needsInstructionPersist {
g2.Go(func() error {
var persistErr error
@@ -4112,8 +4117,12 @@ func (p *Server) runChat(
return nil
})
}
// All g2 goroutines return nil; error is discarded.
_ = g2.Wait()
if err := g2.Wait(); err != nil {
return result, err
}
if chat.ParentChatID.Valid {
prompt = chatprompt.InsertSystem(prompt, defaultSubagentInstruction)
}
if mcpCleanup != nil {
defer mcpCleanup()
}
@@ -4571,7 +4580,7 @@ func (p *Server) runChat(
prompt = filterPromptForChainMode(prompt, chainInfo.trailingUserCount)
}
err = chatloop.Run(ctx, chatloop.RunOptions{
err := chatloop.Run(ctx, chatloop.RunOptions{
Model: model,
Messages: prompt,
Tools: tools, MaxSteps: maxChatSteps,