From 61a7b800e238e54b3826a58e2893303cce1c59c8 Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Thu, 18 Jun 2026 18:08:26 +0200 Subject: [PATCH] fix(coderd/x/chatd): always commit a workspace context marker (#26520) Closes [CODAGT-629](https://linear.app/codercom/issue/CODAGT-629/agents-can-get-stuck-and-ignore-stop-or-nudge). A stuck chat had these logs associated with it: ``` 1781735334322 2026-06-17T22:28:54.322Z 2026-06-17 22:28:54.322 [debu] coderd.chatd.processor: workspace context build: workspace agent not resolvable chat_id=d4524ebb-4494-47df-b258-d933c0248942 owner_id=d96bf761-3f94-46b3-a1da-6316e2e4735d 1781735334298 2026-06-17T22:28:54.298Z 2026-06-17 22:28:54.298 [debu] coderd.chatd.processor: plan path instruction: agent not reachable chat_id=d4524ebb-4494-47df-b258-d933c0248942 owner_id=d96bf761-3f94-46b3-a1da-6316e2e4735d chat_id=d4524ebb-4494-47df-b258-d933c0248942 ... error= workspace has no running agent: the workspace is likely stopped. Use the start_workspace tool to start it: github.com/coder/coder/v2/coderd/x/chatd.init :1 ``` "workspace agent not resolvable" is printed by [`fetchContextForBuild`](). this causes [`buildWorkspaceContext`]() to exit with a `errWorkspaceContextUnavailable` error. That in turn is interpreted by [`persistWorkspaceContext`]() as an "expected exit" scenario. That's a bug: because the task exits without changing the chat state, the runner never issues another task to process the chat any further. But even if it did, it'd go through the same code path and exit again. We need to ensure that `persistWorkspaceContext` commits a marker file even if it cannot reach the agent. --- coderd/x/chatd/chatd_test.go | 94 +++++++++++++++++++-- coderd/x/chatd/generation.go | 43 ++++++++-- coderd/x/chatd/workspace_context_builder.go | 5 +- 3 files changed, 124 insertions(+), 18 deletions(-) diff --git a/coderd/x/chatd/chatd_test.go b/coderd/x/chatd/chatd_test.go index 03ff68b34a..fabbada49f 100644 --- a/coderd/x/chatd/chatd_test.go +++ b/coderd/x/chatd/chatd_test.go @@ -12693,8 +12693,8 @@ func TestActiveServer_WorkspaceContextAndDynamicToolInjection(t *testing.T) { require.Equal(t, database.ChatStatusWaiting, chatResult.Status) parts := persistedChatParts(ctx, t, db, chat.ID) - require.Len(t, contextFilePartsForAgent(parts, dbAgent.ID), 1) - contextPart := contextFilePartsForAgent(parts, dbAgent.ID)[0] + require.Len(t, allContextFilePartsForAgent(parts, dbAgent.ID), 1) + contextPart := allContextFilePartsForAgent(parts, dbAgent.ID)[0] require.Equal(t, "/home/coder/project/AGENTS.md", contextPart.ContextFilePath) require.Equal(t, contextText, contextPart.ContextFileContent) require.Equal(t, "linux", contextPart.ContextFileOS) @@ -12785,7 +12785,7 @@ func TestActiveServer_WorkspaceContextAndDynamicToolInjection(t *testing.T) { require.Equal(t, database.ChatStatusWaiting, secondResult.Status) parts := persistedChatParts(ctx, t, db, chat.ID) - require.Len(t, contextFilePartsForAgent(parts, dbAgent.ID), 1) + require.Len(t, allContextFilePartsForAgent(parts, dbAgent.ID), 1) require.Equal(t, int32(1), contextConfigCalls.Load()) requestsMu.Lock() @@ -12796,6 +12796,72 @@ func TestActiveServer_WorkspaceContextAndDynamicToolInjection(t *testing.T) { require.True(t, requestHasSystemSubstring(recorded[len(recorded)-1], contextText)) }) + t.Run("commits marker when selected agent is unreachable", func(t *testing.T) { + t.Parallel() + + db, ps := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitLong) + + var ( + requestsMu sync.Mutex + requests []recordedOpenAIRequest + ) + openAIURL := chattest.NewOpenAI(t, func(req *chattest.OpenAIRequest) chattest.OpenAIResponse { + if !req.Stream { + return chattest.OpenAINonStreamingResponse("title") + } + + requestsMu.Lock() + requests = append(requests, recordOpenAIRequest(req)) + requestsMu.Unlock() + + return chattest.OpenAIStreamingResponse( + chattest.OpenAITextChunks("done")..., + ) + }) + + user, org, model := seedChatDependenciesWithProvider(t, db, "openai-compat", openAIURL) + ws, dbAgent := seedWorkspaceWithAgent(t, db, user.ID) + require.NoError(t, db.SoftDeleteWorkspaceAgentsByWorkspaceID(ctx, ws.ID)) + + var agentDialCalls atomic.Int32 + server := newActiveTestServer(t, db, ps, func(cfg *chatd.Config) { + cfg.AgentConn = func(context.Context, uuid.UUID) (workspacesdk.AgentConn, func(), error) { + agentDialCalls.Add(1) + return nil, nil, xerrors.New("unexpected workspace agent dial") + } + }) + + chat, err := server.CreateChat(ctx, chatd.CreateOptions{ + OrganizationID: org.ID, + OwnerID: user.ID, + APIKeyID: testAPIKeyID(t, db, user.ID), + Title: "workspace-context-agent-unreachable", + ModelConfigID: model.ID, + WorkspaceID: uuid.NullUUID{UUID: ws.ID, Valid: true}, + AgentID: uuid.NullUUID{UUID: dbAgent.ID, Valid: true}, + InitialUserContent: []codersdk.ChatMessagePart{ + codersdk.ChatMessageText("Continue without workspace context."), + }, + }) + require.NoError(t, err) + + chatResult := waitForTerminalChat(ctx, t, db, chat.ID) + require.Equal(t, database.ChatStatusWaiting, chatResult.Status) + + parts := persistedChatParts(ctx, t, db, chat.ID) + markers := contextFileMarkersForAgent(parts, dbAgent.ID) + require.Len(t, markers, 1) + require.Empty(t, markers[0].ContextFileContent) + + requestsMu.Lock() + recorded := append([]recordedOpenAIRequest(nil), requests...) + requestsMu.Unlock() + require.Len(t, recorded, 1, "expected model call after marker commit") + require.False(t, requestHasSystemSubstring(recorded[0], "Source: ")) + require.Zero(t, agentDialCalls.Load()) + }) + t.Run("repersists workspace context after agent changes", func(t *testing.T) { t.Parallel() @@ -12892,8 +12958,8 @@ func TestActiveServer_WorkspaceContextAndDynamicToolInjection(t *testing.T) { require.Equal(t, database.ChatStatusWaiting, secondResult.Status) parts := persistedChatParts(ctx, t, db, chat.ID) - require.Len(t, contextFilePartsForAgent(parts, firstAgent.ID), 1) - require.Len(t, contextFilePartsForAgent(parts, secondAgent.ID), 1) + require.Len(t, allContextFilePartsForAgent(parts, firstAgent.ID), 1) + require.Len(t, allContextFilePartsForAgent(parts, secondAgent.ID), 1) requestsMu.Lock() recorded := append([]recordedOpenAIRequest(nil), requests...) @@ -12979,7 +13045,7 @@ func persistedChatMessages( return messages } -func contextFilePartsForAgent( +func allContextFilePartsForAgent( parts []codersdk.ChatMessagePart, agentID uuid.UUID, ) []codersdk.ChatMessagePart { @@ -12996,6 +13062,22 @@ func contextFilePartsForAgent( return matched } +func contextFileMarkersForAgent( + parts []codersdk.ChatMessagePart, + agentID uuid.UUID, +) []codersdk.ChatMessagePart { + var matched []codersdk.ChatMessagePart + for _, part := range parts { + if part.Type != codersdk.ChatMessagePartTypeContextFile || + !part.ContextFileAgentID.Valid || + part.ContextFileAgentID.UUID != agentID { + continue + } + matched = append(matched, part) + } + return matched +} + func requireChatToolPart( t *testing.T, messages []database.ChatMessage, diff --git a/coderd/x/chatd/generation.go b/coderd/x/chatd/generation.go index c3afe31faa..103ddb301b 100644 --- a/coderd/x/chatd/generation.go +++ b/coderd/x/chatd/generation.go @@ -800,9 +800,9 @@ func compactionModel(opts chatloop.GenerateCompactionOptions) string { // workspace context messages (e.g. AGENTS.md, workspace skills) into // chat history. It records a generation attempt, calls the injected // workspace context builder without holding the DB lock, then commits -// the returned messages fenced to the attempt. If the builder returns -// no messages, the action exits as expected and the next worker task -// re-reads the chat. +// the returned messages fenced to the attempt. If context cannot be +// fetched, it commits a marker for the selected agent so the generation +// loop can continue. func (s *taskStarter) persistWorkspaceContext( ctx context.Context, machine *chatstate.ChatMachine, @@ -831,13 +831,16 @@ func (s *taskStarter) persistWorkspaceContext( ActiveAPIKeyID: modelOpts.ActiveAPIKeyID, }) if err != nil { - if errors.Is(err, errWorkspaceContextUnavailable) { - // Builder reported nothing durable to commit (workspace or - // agent missing, unreachable, etc.). Exit the action without - // committing so the next worker task can re-read the chat. - return errTaskExpectedExit + s.opts.Logger.Warn(ctx, "failed to build workspace context, committing marker", + slog.F("chat_id", input.ChatID), + slog.F("worker_id", input.WorkerID), + slogError(err), + ) + marker, err := workspaceContextMarkerMessage(locked, modelOpts.ActiveAPIKeyID) + if err != nil { + return xerrors.Errorf("build workspace context marker: %w", err) } - return err + result.Messages = []chatstate.Message{marker} } return s.commitGenerationStep(ctx, machine, input, attempt, generationActionPersistWorkspaceContext, stepMessagesForCommit{ Messages: result.Messages, @@ -845,6 +848,28 @@ func (s *taskStarter) persistWorkspaceContext( }) } +// workspaceContextMarkerMessage builds an empty context-file sentinel +// for the chat's selected agent. Committing this marker lets the +// generation loop proceed when the agent is unreachable. +func workspaceContextMarkerMessage(chat database.Chat, activeAPIKeyID string) (chatstate.Message, error) { + content, err := chatprompt.MarshalParts([]codersdk.ChatMessagePart{{ + Type: codersdk.ChatMessagePartTypeContextFile, + ContextFileAgentID: chat.AgentID, + }}) + if err != nil { + return chatstate.Message{}, xerrors.Errorf("marshal workspace context marker: %w", err) + } + modelConfigID := chat.LastModelConfigID + return chatstate.Message{ + Role: database.ChatMessageRoleUser, + Content: content, + Visibility: database.ChatMessageVisibilityBoth, + ModelConfigID: uuid.NullUUID{UUID: modelConfigID, Valid: modelConfigID != uuid.Nil}, + ContentVersion: chatprompt.CurrentContentVersion, + APIKeyID: sql.NullString{String: activeAPIKeyID, Valid: activeAPIKeyID != ""}, + }, nil +} + func (s *taskStarter) beginGenerationAttempt( ctx context.Context, machine *chatstate.ChatMachine, diff --git a/coderd/x/chatd/workspace_context_builder.go b/coderd/x/chatd/workspace_context_builder.go index 9f2aac93a5..d27da4d78e 100644 --- a/coderd/x/chatd/workspace_context_builder.go +++ b/coderd/x/chatd/workspace_context_builder.go @@ -19,7 +19,7 @@ import ( // errWorkspaceContextUnavailable is returned by buildWorkspaceContext // when there is nothing safe to persist for the current committed // metadata, e.g. the chat has no bound workspace agent or the agent is -// no longer resolvable. Callers treat it as an expected exit. +// no longer resolvable. var errWorkspaceContextUnavailable = xerrors.New("workspace context unavailable") // buildWorkspaceContext fetches workspace context for the chat's @@ -54,8 +54,7 @@ func (server *Server) buildWorkspaceContext( defer wsCtx.close() parts, expectedAgentID := server.fetchContextForBuild(ctx, chat, &wsCtx, logger) - // If the workspace or agent is gone, fall back to no-op so the - // generation action exits without committing stale context. + // If the workspace or agent is gone, report unavailable. if expectedAgentID == uuid.Nil { return workspaceContextBuildResult{}, errWorkspaceContextUnavailable }