Files
coder/agent/agentgit/api.go
Danielle Maywood e981ff4eec fix(agent/agentgit): re-resolve chat paths on refresh (#30675)
A git watch `refresh` only rescanned the repos already being watched. A
path recorded before its repo existed, for example before `git init`, or
a repo that was deleted and recreated, stayed hidden until the client
reconnected, so a page reload showed repos the refresh button didn't.

Refresh now resolves the chat's paths again, as a new connection does.
The first scan of a connection is also sent even when no repos are
watched, so a client can replace state left over from an earlier
connection; later scans with no repos still send nothing.

### Before


https://github.com/user-attachments/assets/33bf9046-0448-4537-9a97-c97f51e1cc39

### After


https://github.com/user-attachments/assets/82b15d03-6460-4fe0-b5e7-b4bafbbd72d1

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

The git panel's refresh button should show everything a page reload
would. Investigation found five ways it didn't, each fixed in its own PR
on the current `changes` protocol:

1. `execute` without a workdir didn't record the directory the process
ran in (`agent/agentproc`).
2. Refresh only rescanned repos already being watched, so a repo created
or recreated after its path was recorded stayed hidden
(`agent/agentgit`). A connection with no repos also got no first
message.
3. After a git watch reconnect, the new full state was merged into the
previous connection's map, so deleted repos stayed visible
(`useGitWatcher`).
4. A chat watch reconnect didn't refetch the open chat, so missed
`diff_status_change` events were lost (`AgentsPageLayout`).
5. Refresh didn't refetch the chat, whose diff statuses back the PR
tabs, and was disabled until git context existed (`GitPanel`,
`AgentChatPageView`).

Considered and deferred: a snapshot protocol with diffs fetched by hash,
which would keep large multi-repo diffs under the 4 MiB proxy read
limit, and making scans faster at scale. Neither is needed to fix
refresh, and together they made a single ~5900-line change that couldn't
be reviewed. The refresh spinner still runs for a fixed time, since
tying it to the response needs a request ID in the protocol.

</details>

---

_Generated by Coder Agents on behalf of @DanielleMaywood._
2026-10-14 17:04:39 +01:00

171 lines
4.7 KiB
Go

package agentgit
import (
"context"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/agent/agentchat"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/wsjson"
"github.com/coder/quartz"
"github.com/coder/websocket"
)
// API exposes the git watch HTTP routes for the agent.
type API struct {
logger slog.Logger
opts []Option
pathStore *PathStore
wsWatcher *httpapi.WSWatcher
}
// NewAPI creates a new git watch API.
func NewAPI(logger slog.Logger, pathStore *PathStore, opts ...Option) *API {
return &API{
logger: logger,
pathStore: pathStore,
opts: opts,
wsWatcher: httpapi.NewWSWatcher(quartz.NewReal(), nil),
}
}
// Routes returns the chi router for mounting at /api/v0/git.
func (a *API) Routes() http.Handler {
r := chi.NewRouter()
r.Get("/watch", a.handleWatch)
return r
}
func (a *API) handleWatch(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var watchChatID uuid.UUID
var hasWatchChatID bool
if chatIDStr := r.URL.Query().Get("chat_id"); chatIDStr != "" {
if parsedChatID, parseErr := uuid.Parse(chatIDStr); parseErr == nil {
watchChatID = parsedChatID
hasWatchChatID = true
// Reuse header-derived ancestors only when the query chat
// matches the header chat. Otherwise the ancestors belong
// to a different chat and would be misleading in logs.
var ancestors []uuid.UUID
if chatContext, ok := agentchat.FromContext(ctx); ok && chatContext.ID == watchChatID {
ancestors = chatContext.AncestorIDs
}
ctx = agentchat.WithContext(ctx, watchChatID, ancestors)
}
}
logger := a.logger.With(agentchat.Fields(ctx)...)
conn, err := websocket.Accept(rw, r, &websocket.AcceptOptions{
CompressionMode: websocket.CompressionNoContextTakeover,
})
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to accept WebSocket.",
Detail: err.Error(),
})
return
}
// 4 MiB read limit — subscribe messages with many paths can exceed the
// default 32 KB limit. Matches the SDK/proxy side.
conn.SetReadLimit(1 << 22)
stream := wsjson.NewStream[
codersdk.WorkspaceAgentGitClientMessage,
codersdk.WorkspaceAgentGitServerMessage,
](conn, websocket.MessageText, websocket.MessageText, logger)
ctx, cancel := context.WithCancel(ctx)
defer cancel()
ctx = a.wsWatcher.Watch(ctx, logger, conn)
handler := NewHandler(logger, a.opts...)
// Scan returns nil once the initial state has been sent and no
// roots are subscribed; otherwise it returns a delta or a
// heartbeat message.
scanAndSend := func() {
msg := handler.Scan(ctx)
if msg == nil {
return
}
if err := stream.Send(*msg); err != nil {
logger.Debug(ctx, "failed to send changes", slog.Error(err))
cancel()
}
}
// If a chat_id query parameter is provided and the PathStore is
// available, subscribe to path updates for this chat.
if hasWatchChatID && a.pathStore != nil {
// Subscribe to future path updates BEFORE reading
// existing paths. This ordering guarantees no
// notification from AddPaths is lost: any call that
// lands before Subscribe is picked up by GetPaths
// below, and any call after Subscribe delivers a
// notification on the channel.
notifyCh, unsubscribe := a.pathStore.Subscribe(watchChatID)
defer unsubscribe()
// Load any paths that are already tracked for this chat.
handler.Subscribe(a.pathStore.GetPaths(watchChatID))
go func() {
for {
select {
case <-ctx.Done():
return
case <-notifyCh:
paths := a.pathStore.GetPaths(watchChatID)
handler.Subscribe(paths)
handler.RequestScan()
}
}
}()
}
// The initial scan is sent even with no repos, so the client
// replaces any state left from an earlier connection.
handler.RequestScan()
go handler.RunLoop(ctx, scanAndSend)
// Read client messages.
updates := stream.Chan()
for {
select {
case <-ctx.Done():
_ = stream.Close(websocket.StatusGoingAway)
return
case msg, ok := <-updates:
if !ok {
return
}
switch msg.Type {
case codersdk.WorkspaceAgentGitClientMessageTypeRefresh:
// Re-resolve the chat's paths, as a new connection
// would, so repos created or recreated after their
// paths were recorded are picked up.
if hasWatchChatID && a.pathStore != nil {
handler.Subscribe(a.pathStore.GetPaths(watchChatID))
}
handler.RequestScan()
default:
if err := stream.Send(codersdk.WorkspaceAgentGitServerMessage{
Type: codersdk.WorkspaceAgentGitServerMessageTypeError,
Message: "unknown message type",
}); err != nil {
return
}
}
}
}
}