feat(chatd): persist last_error on chats table (#22436)

Adds a nullable `last_error` column to the `chats` table so error
reasons survive page reloads.

**Backend:**
- Migration adds `last_error TEXT` (nullable) to chats
- `UpdateChatStatus` writes the error reason when status transitions to
`error`, clears it (NULL) on recovery
- `convertChat` maps `sql.NullString` to `*string` in the SDK

**Frontend:**
- Sidebar falls back to `chat.last_error` when no stream error reason is
cached
- Chat detail page does the same for `persistedErrorReason`
- Fixtures updated for new required field
This commit is contained in:
Kyle Carberry
2026-02-28 12:27:26 -05:00
committed by GitHub
parent d412972cd5
commit 0ad2f9ecd7
16 changed files with 134 additions and 38 deletions
+12 -3
View File
@@ -478,6 +478,7 @@ func (p *Server) EditMessage(
WorkerID: uuid.NullUUID{},
StartedAt: sql.NullTime{},
HeartbeatAt: sql.NullTime{},
LastError: sql.NullString{},
})
if err != nil {
return xerrors.Errorf("set chat pending: %w", err)
@@ -739,6 +740,7 @@ func setChatPendingWithStore(
WorkerID: uuid.NullUUID{},
StartedAt: sql.NullTime{},
HeartbeatAt: sql.NullTime{},
LastError: sql.NullString{},
})
if err != nil {
return database.Chat{}, xerrors.Errorf("set chat pending: %w", err)
@@ -753,6 +755,7 @@ func (p *Server) setChatWaiting(ctx context.Context, chatID uuid.UUID) (database
WorkerID: uuid.NullUUID{},
StartedAt: sql.NullTime{},
HeartbeatAt: sql.NullTime{},
LastError: sql.NullString{},
})
if err != nil {
return database.Chat{}, err
@@ -810,6 +813,7 @@ func insertUserMessageAndSetPending(
WorkerID: uuid.NullUUID{},
StartedAt: sql.NullTime{},
HeartbeatAt: sql.NullTime{},
LastError: sql.NullString{},
})
if err != nil {
return database.ChatMessage{}, database.Chat{}, xerrors.Errorf("set chat pending: %w", err)
@@ -1622,8 +1626,9 @@ func (p *Server) processChat(ctx context.Context, chat database.Chat) {
Valid: true,
})
// Determine the final status to set when we're done.
// Determine the final status and last error to set when we're done.
status := database.ChatStatusWaiting
lastError := ""
remainingQueuedMessages := []database.ChatQueuedMessage{}
shouldPublishQueueUpdate := false
@@ -1636,7 +1641,8 @@ func (p *Server) processChat(ctx context.Context, chat database.Chat) {
// Handle panics gracefully.
if r := recover(); r != nil {
logger.Error(cleanupCtx, "panic during chat processing", slog.F("panic", r))
p.publishError(chat.ID, panicFailureReason(r))
lastError = panicFailureReason(r)
p.publishError(chat.ID, lastError)
status = database.ChatStatusError
}
@@ -1707,6 +1713,7 @@ func (p *Server) processChat(ctx context.Context, chat database.Chat) {
WorkerID: uuid.NullUUID{},
StartedAt: sql.NullTime{},
HeartbeatAt: sql.NullTime{},
LastError: sql.NullString{String: lastError, Valid: lastError != ""},
})
return updateErr
}, nil)
@@ -1746,7 +1753,8 @@ func (p *Server) processChat(ctx context.Context, chat database.Chat) {
}
logger.Error(ctx, "failed to process chat", slog.Error(err))
if reason, ok := processingFailureReason(err); ok {
p.publishError(chat.ID, reason)
lastError = reason
p.publishError(chat.ID, lastError)
}
status = database.ChatStatusError
return
@@ -2458,6 +2466,7 @@ func (p *Server) recoverStaleChats(ctx context.Context) {
WorkerID: uuid.NullUUID{},
StartedAt: sql.NullTime{},
HeartbeatAt: sql.NullTime{},
LastError: sql.NullString{},
})
if err != nil {
p.logger.Error(ctx, "failed to recover stale chat",
+55
View File
@@ -577,6 +577,61 @@ func TestWaitingChatsAreNotRecoveredAsStale(t *testing.T) {
"waiting chat should not be modified by stale recovery")
}
func TestUpdateChatStatusPersistsLastError(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
_ = newTestServer(t, db, ps, uuid.New())
ctx := testutil.Context(t, testutil.WaitLong)
user, model := seedChatDependencies(ctx, t, db)
chat, err := db.InsertChat(ctx, database.InsertChatParams{
OwnerID: user.ID,
Title: "error-persisted",
LastModelConfigID: model.ID,
})
require.NoError(t, err)
// Simulate a chat that failed with an error.
errorMessage := "stream response: status 500: internal server error"
chat, err = db.UpdateChatStatus(ctx, database.UpdateChatStatusParams{
ID: chat.ID,
Status: database.ChatStatusError,
WorkerID: uuid.NullUUID{},
StartedAt: sql.NullTime{},
HeartbeatAt: sql.NullTime{},
LastError: sql.NullString{String: errorMessage, Valid: true},
})
require.NoError(t, err)
require.Equal(t, database.ChatStatusError, chat.Status)
require.Equal(t, sql.NullString{String: errorMessage, Valid: true}, chat.LastError)
// Verify the error is persisted when re-read from the database.
fromDB, err := db.GetChatByID(ctx, chat.ID)
require.NoError(t, err)
require.Equal(t, database.ChatStatusError, fromDB.Status)
require.Equal(t, sql.NullString{String: errorMessage, Valid: true}, fromDB.LastError)
// Verify the error is cleared when the chat transitions to a
// non-error status (e.g. pending after a retry).
chat, err = db.UpdateChatStatus(ctx, database.UpdateChatStatusParams{
ID: chat.ID,
Status: database.ChatStatusPending,
WorkerID: uuid.NullUUID{},
StartedAt: sql.NullTime{},
HeartbeatAt: sql.NullTime{},
LastError: sql.NullString{},
})
require.NoError(t, err)
require.Equal(t, database.ChatStatusPending, chat.Status)
require.False(t, chat.LastError.Valid)
fromDB, err = db.GetChatByID(ctx, chat.ID)
require.NoError(t, err)
require.False(t, fromDB.LastError.Valid)
}
func newTestServer(
t *testing.T,
db database.Store,
+4
View File
@@ -795,6 +795,7 @@ func (api *API) interruptChat(rw http.ResponseWriter, r *http.Request) {
WorkerID: uuid.NullUUID{},
StartedAt: sql.NullTime{},
HeartbeatAt: sql.NullTime{},
LastError: sql.NullString{},
})
if updateErr != nil {
api.Logger.Error(ctx, "failed to mark chat as waiting",
@@ -2059,6 +2060,9 @@ func convertChat(c database.Chat, diffStatus *database.ChatDiffStatus) codersdk.
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
}
if c.LastError.Valid {
chat.LastError = &c.LastError.String
}
if c.ParentChatID.Valid {
parentChatID := c.ParentChatID.UUID
chat.ParentChatID = &parentChatID
+2 -1
View File
@@ -1274,7 +1274,8 @@ CREATE TABLE chats (
parent_chat_id uuid,
root_chat_id uuid,
last_model_config_id uuid NOT NULL,
archived boolean DEFAULT false NOT NULL
archived boolean DEFAULT false NOT NULL,
last_error text
);
CREATE TABLE connection_logs (
@@ -0,0 +1 @@
ALTER TABLE chats DROP COLUMN last_error;
@@ -0,0 +1 @@
ALTER TABLE chats ADD COLUMN last_error TEXT;
+16 -15
View File
@@ -3886,21 +3886,22 @@ type BoundaryUsageStat struct {
}
type Chat struct {
ID uuid.UUID `db:"id" json:"id"`
OwnerID uuid.UUID `db:"owner_id" json:"owner_id"`
WorkspaceID uuid.NullUUID `db:"workspace_id" json:"workspace_id"`
WorkspaceAgentID uuid.NullUUID `db:"workspace_agent_id" json:"workspace_agent_id"`
Title string `db:"title" json:"title"`
Status ChatStatus `db:"status" json:"status"`
WorkerID uuid.NullUUID `db:"worker_id" json:"worker_id"`
StartedAt sql.NullTime `db:"started_at" json:"started_at"`
HeartbeatAt sql.NullTime `db:"heartbeat_at" json:"heartbeat_at"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
ParentChatID uuid.NullUUID `db:"parent_chat_id" json:"parent_chat_id"`
RootChatID uuid.NullUUID `db:"root_chat_id" json:"root_chat_id"`
LastModelConfigID uuid.UUID `db:"last_model_config_id" json:"last_model_config_id"`
Archived bool `db:"archived" json:"archived"`
ID uuid.UUID `db:"id" json:"id"`
OwnerID uuid.UUID `db:"owner_id" json:"owner_id"`
WorkspaceID uuid.NullUUID `db:"workspace_id" json:"workspace_id"`
WorkspaceAgentID uuid.NullUUID `db:"workspace_agent_id" json:"workspace_agent_id"`
Title string `db:"title" json:"title"`
Status ChatStatus `db:"status" json:"status"`
WorkerID uuid.NullUUID `db:"worker_id" json:"worker_id"`
StartedAt sql.NullTime `db:"started_at" json:"started_at"`
HeartbeatAt sql.NullTime `db:"heartbeat_at" json:"heartbeat_at"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
ParentChatID uuid.NullUUID `db:"parent_chat_id" json:"parent_chat_id"`
RootChatID uuid.NullUUID `db:"root_chat_id" json:"root_chat_id"`
LastModelConfigID uuid.UUID `db:"last_model_config_id" json:"last_model_config_id"`
Archived bool `db:"archived" json:"archived"`
LastError sql.NullString `db:"last_error" json:"last_error"`
}
type ChatDiffStatus struct {
+31 -17
View File
@@ -2842,7 +2842,7 @@ WHERE
1
)
RETURNING
id, owner_id, workspace_id, workspace_agent_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived
id, owner_id, workspace_id, workspace_agent_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error
`
type AcquireChatParams struct {
@@ -2871,6 +2871,7 @@ func (q *sqlQuerier) AcquireChat(ctx context.Context, arg AcquireChatParams) (Ch
&i.RootChatID,
&i.LastModelConfigID,
&i.Archived,
&i.LastError,
)
return i, err
}
@@ -2939,7 +2940,7 @@ func (q *sqlQuerier) DeleteChatQueuedMessage(ctx context.Context, arg DeleteChat
const getChatByID = `-- name: GetChatByID :one
SELECT
id, owner_id, workspace_id, workspace_agent_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived
id, owner_id, workspace_id, workspace_agent_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error
FROM
chats
WHERE
@@ -2965,12 +2966,13 @@ func (q *sqlQuerier) GetChatByID(ctx context.Context, id uuid.UUID) (Chat, error
&i.RootChatID,
&i.LastModelConfigID,
&i.Archived,
&i.LastError,
)
return i, err
}
const getChatByIDForUpdate = `-- name: GetChatByIDForUpdate :one
SELECT id, owner_id, workspace_id, workspace_agent_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived FROM chats WHERE id = $1::uuid FOR UPDATE
SELECT id, owner_id, workspace_id, workspace_agent_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error FROM chats WHERE id = $1::uuid FOR UPDATE
`
func (q *sqlQuerier) GetChatByIDForUpdate(ctx context.Context, id uuid.UUID) (Chat, error) {
@@ -2992,6 +2994,7 @@ func (q *sqlQuerier) GetChatByIDForUpdate(ctx context.Context, id uuid.UUID) (Ch
&i.RootChatID,
&i.LastModelConfigID,
&i.Archived,
&i.LastError,
)
return i, err
}
@@ -3288,7 +3291,7 @@ func (q *sqlQuerier) GetChatQueuedMessages(ctx context.Context, chatID uuid.UUID
const getChatsByOwnerID = `-- name: GetChatsByOwnerID :many
SELECT
id, owner_id, workspace_id, workspace_agent_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived
id, owner_id, workspace_id, workspace_agent_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error
FROM
chats
WHERE
@@ -3323,6 +3326,7 @@ func (q *sqlQuerier) GetChatsByOwnerID(ctx context.Context, ownerID uuid.UUID) (
&i.RootChatID,
&i.LastModelConfigID,
&i.Archived,
&i.LastError,
); err != nil {
return nil, err
}
@@ -3339,7 +3343,7 @@ func (q *sqlQuerier) GetChatsByOwnerID(ctx context.Context, ownerID uuid.UUID) (
const getStaleChats = `-- name: GetStaleChats :many
SELECT
id, owner_id, workspace_id, workspace_agent_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived
id, owner_id, workspace_id, workspace_agent_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error
FROM
chats
WHERE
@@ -3374,6 +3378,7 @@ func (q *sqlQuerier) GetStaleChats(ctx context.Context, staleThreshold time.Time
&i.RootChatID,
&i.LastModelConfigID,
&i.Archived,
&i.LastError,
); err != nil {
return nil, err
}
@@ -3407,7 +3412,7 @@ INSERT INTO chats (
$7::text
)
RETURNING
id, owner_id, workspace_id, workspace_agent_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived
id, owner_id, workspace_id, workspace_agent_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error
`
type InsertChatParams struct {
@@ -3447,6 +3452,7 @@ func (q *sqlQuerier) InsertChat(ctx context.Context, arg InsertChatParams) (Chat
&i.RootChatID,
&i.LastModelConfigID,
&i.Archived,
&i.LastError,
)
return i, err
}
@@ -3572,7 +3578,7 @@ func (q *sqlQuerier) InsertChatQueuedMessage(ctx context.Context, arg InsertChat
const listChatsByRootID = `-- name: ListChatsByRootID :many
SELECT
id, owner_id, workspace_id, workspace_agent_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived
id, owner_id, workspace_id, workspace_agent_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error
FROM
chats
WHERE
@@ -3606,6 +3612,7 @@ func (q *sqlQuerier) ListChatsByRootID(ctx context.Context, rootChatID uuid.UUID
&i.RootChatID,
&i.LastModelConfigID,
&i.Archived,
&i.LastError,
); err != nil {
return nil, err
}
@@ -3622,7 +3629,7 @@ func (q *sqlQuerier) ListChatsByRootID(ctx context.Context, rootChatID uuid.UUID
const listChildChatsByParentID = `-- name: ListChildChatsByParentID :many
SELECT
id, owner_id, workspace_id, workspace_agent_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived
id, owner_id, workspace_id, workspace_agent_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error
FROM
chats
WHERE
@@ -3656,6 +3663,7 @@ func (q *sqlQuerier) ListChildChatsByParentID(ctx context.Context, parentChatID
&i.RootChatID,
&i.LastModelConfigID,
&i.Archived,
&i.LastError,
); err != nil {
return nil, err
}
@@ -3711,7 +3719,7 @@ SET
WHERE
id = $2::uuid
RETURNING
id, owner_id, workspace_id, workspace_agent_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived
id, owner_id, workspace_id, workspace_agent_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error
`
type UpdateChatByIDParams struct {
@@ -3738,6 +3746,7 @@ func (q *sqlQuerier) UpdateChatByID(ctx context.Context, arg UpdateChatByIDParam
&i.RootChatID,
&i.LastModelConfigID,
&i.Archived,
&i.LastError,
)
return i, err
}
@@ -3817,19 +3826,21 @@ SET
worker_id = $2::uuid,
started_at = $3::timestamptz,
heartbeat_at = $4::timestamptz,
last_error = $5::text,
updated_at = NOW()
WHERE
id = $5::uuid
id = $6::uuid
RETURNING
id, owner_id, workspace_id, workspace_agent_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived
id, owner_id, workspace_id, workspace_agent_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error
`
type UpdateChatStatusParams struct {
Status ChatStatus `db:"status" json:"status"`
WorkerID uuid.NullUUID `db:"worker_id" json:"worker_id"`
StartedAt sql.NullTime `db:"started_at" json:"started_at"`
HeartbeatAt sql.NullTime `db:"heartbeat_at" json:"heartbeat_at"`
ID uuid.UUID `db:"id" json:"id"`
Status ChatStatus `db:"status" json:"status"`
WorkerID uuid.NullUUID `db:"worker_id" json:"worker_id"`
StartedAt sql.NullTime `db:"started_at" json:"started_at"`
HeartbeatAt sql.NullTime `db:"heartbeat_at" json:"heartbeat_at"`
LastError sql.NullString `db:"last_error" json:"last_error"`
ID uuid.UUID `db:"id" json:"id"`
}
func (q *sqlQuerier) UpdateChatStatus(ctx context.Context, arg UpdateChatStatusParams) (Chat, error) {
@@ -3838,6 +3849,7 @@ func (q *sqlQuerier) UpdateChatStatus(ctx context.Context, arg UpdateChatStatusP
arg.WorkerID,
arg.StartedAt,
arg.HeartbeatAt,
arg.LastError,
arg.ID,
)
var i Chat
@@ -3857,6 +3869,7 @@ func (q *sqlQuerier) UpdateChatStatus(ctx context.Context, arg UpdateChatStatusP
&i.RootChatID,
&i.LastModelConfigID,
&i.Archived,
&i.LastError,
)
return i, err
}
@@ -3871,7 +3884,7 @@ SET
WHERE
id = $3::uuid
RETURNING
id, owner_id, workspace_id, workspace_agent_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived
id, owner_id, workspace_id, workspace_agent_id, title, status, worker_id, started_at, heartbeat_at, created_at, updated_at, parent_chat_id, root_chat_id, last_model_config_id, archived, last_error
`
type UpdateChatWorkspaceParams struct {
@@ -3899,6 +3912,7 @@ func (q *sqlQuerier) UpdateChatWorkspace(ctx context.Context, arg UpdateChatWork
&i.RootChatID,
&i.LastModelConfigID,
&i.Archived,
&i.LastError,
)
return i, err
}
+1
View File
@@ -266,6 +266,7 @@ SET
worker_id = sqlc.narg('worker_id')::uuid,
started_at = sqlc.narg('started_at')::timestamptz,
heartbeat_at = sqlc.narg('heartbeat_at')::timestamptz,
last_error = sqlc.narg('last_error')::text,
updated_at = NOW()
WHERE
id = @id::uuid
+1
View File
@@ -38,6 +38,7 @@ type Chat struct {
LastModelConfigID uuid.UUID `json:"last_model_config_id" format:"uuid"`
Title string `json:"title"`
Status ChatStatus `json:"status"`
LastError *string `json:"last_error"`
DiffStatus *ChatDiffStatus `json:"diff_status,omitempty"`
CreatedAt time.Time `json:"created_at" format:"date-time"`
UpdatedAt time.Time `json:"updated_at" format:"date-time"`
+1
View File
@@ -1060,6 +1060,7 @@ export interface Chat {
readonly last_model_config_id: string;
readonly title: string;
readonly status: ChatStatus;
readonly last_error: string | null;
readonly diff_status?: ChatDiffStatus;
readonly created_at: string;
readonly updated_at: string;
@@ -122,6 +122,7 @@ const baseChatFields = {
created_at: "2026-02-18T00:00:00.000Z",
updated_at: "2026-02-18T00:00:00.000Z",
archived: false,
last_error: null,
} as const;
// ---------------------------------------------------------------------------
+3 -1
View File
@@ -974,7 +974,9 @@ const AgentDetail: FC = () => {
<AgentDetailConversation
store={store}
chatID={agentId}
persistedErrorReason={chatErrorReasons[agentId]}
persistedErrorReason={
chatErrorReasons[agentId] || chatRecord?.last_error || undefined
}
compressionThreshold={compressionThreshold}
onDeleteQueuedMessage={handleDeleteQueuedMessage}
onPromoteQueuedMessage={handlePromoteQueuedMessage}
@@ -113,6 +113,7 @@ const makeChat = (chatID: string): TypesGen.Chat => ({
created_at: "2025-01-01T00:00:00.000Z",
updated_at: "2025-01-01T00:00:00.000Z",
archived: false,
last_error: null,
});
const makeMessage = (
@@ -39,6 +39,7 @@ const buildChat = (overrides: Partial<Chat> = {}): Chat => ({
created_at: "2026-02-18T00:00:00.000Z",
updated_at: "2026-02-18T00:00:00.000Z",
archived: false,
last_error: null,
...overrides,
});
+3 -1
View File
@@ -312,7 +312,9 @@ const ChatTreeNode = memo<ChatTreeNodeProps>(({ chat, isChildNode }) => {
modelOptions,
);
const errorReason =
chat.status === "error" ? chatErrorReasons[chat.id] : undefined;
chat.status === "error"
? chatErrorReasons[chat.id] || chat.last_error || undefined
: undefined;
const subtitle = errorReason || modelName;
const diffStatus = getChatDiffStatus(chat);
const hasLinkedDiffStatus = Boolean(diffStatus?.url);