mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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 <autogenerated>:1 ``` "workspace agent not resolvable" is printed by [`fetchContextForBuild`](<https://github.com/coder/coder/blob/fecc991ac9f9d673d7a772b0042ca821d19b6296/coderd/x/chatd/workspace_context_builder.go#L145>). this causes [`buildWorkspaceContext`](<https://github.com/coder/coder/blob/fecc991ac9f9d673d7a772b0042ca821d19b6296/coderd/x/chatd/workspace_context_builder.go#L60>) to exit with a `errWorkspaceContextUnavailable` error. That in turn is interpreted by [`persistWorkspaceContext`](<https://github.com/coder/coder/blob/fecc991ac9f9d673d7a772b0042ca821d19b6296/coderd/x/chatd/generation.go#L838>) 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.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user