fix: state-aware queued message promotion (#24819)

PromoteQueued now branches on chat status: synth tool results before
the user message on requires_action, deferred reorder + Waiting on
running so the worker's persist+auto-promote keeps partial output.
Stale heartbeat falls through to the synchronous path; GetStaleChats
picks up Waiting+queue to recover post-cleanup-crash. Endpoint
returns 202.

Closes CODAGT-119
This commit is contained in:
Mathias Fredriksson
2026-05-06 19:11:56 +03:00
committed by GitHub
parent 2cab1b41ad
commit 6b0518d051
18 changed files with 2431 additions and 101 deletions
+39 -5
View File
@@ -6808,7 +6808,7 @@ func (q *sqlQuerier) GetChatModelConfigsForTelemetry(ctx context.Context) ([]Get
const getChatQueuedMessages = `-- name: GetChatQueuedMessages :many
SELECT id, chat_id, content, created_at, model_config_id FROM chat_queued_messages
WHERE chat_id = $1
ORDER BY id ASC
ORDER BY created_at ASC, id ASC
`
func (q *sqlQuerier) GetChatQueuedMessages(ctx context.Context, chatID uuid.UUID) ([]ChatQueuedMessage, error) {
@@ -7311,12 +7311,21 @@ WHERE
AND heartbeat_at < $1::timestamptz)
OR (status = 'requires_action'::chat_status
AND updated_at < $1::timestamptz)
OR (status = 'waiting'::chat_status
AND updated_at < $1::timestamptz
AND EXISTS (
SELECT 1 FROM chat_queued_messages cqm
WHERE cqm.chat_id = chats.id
))
`
// Find chats that appear stuck and need recovery. This covers:
// Find chats that appear stuck and need recovery:
// 1. Running chats whose heartbeat has expired (worker crash).
// 2. Chats awaiting client action (requires_action) past the
// timeout threshold (client disappeared).
// 2. requires_action chats past the timeout threshold (client
// disappeared).
// 3. Waiting chats with a non-empty queue and stale updated_at
// (deferred-promote stranding when the worker dies before its
// post-cancel cleanup runs).
func (q *sqlQuerier) GetStaleChats(ctx context.Context, staleThreshold time.Time) ([]Chat, error) {
rows, err := q.db.QueryContext(ctx, getStaleChats, staleThreshold)
if err != nil {
@@ -7946,7 +7955,7 @@ DELETE FROM chat_queued_messages
WHERE id = (
SELECT cqm.id FROM chat_queued_messages cqm
WHERE cqm.chat_id = $1
ORDER BY cqm.id ASC
ORDER BY cqm.created_at ASC, cqm.id ASC
LIMIT 1
)
RETURNING id, chat_id, content, created_at, model_config_id
@@ -7965,6 +7974,31 @@ func (q *sqlQuerier) PopNextQueuedMessage(ctx context.Context, chatID uuid.UUID)
return i, err
}
const reorderChatQueuedMessageToFront = `-- name: ReorderChatQueuedMessageToFront :execrows
UPDATE chat_queued_messages AS target
SET created_at = (
SELECT MIN(inner_cqm.created_at) - INTERVAL '1 microsecond'
FROM chat_queued_messages AS inner_cqm
WHERE inner_cqm.chat_id = $1
)
WHERE target.id = $2 AND target.chat_id = $1
`
type ReorderChatQueuedMessageToFrontParams struct {
ChatID uuid.UUID `db:"chat_id" json:"chat_id"`
TargetID int64 `db:"target_id" json:"target_id"`
}
// Mutates only created_at on the target row; ids are unchanged so
// consumers can keep tracking queued messages by id.
func (q *sqlQuerier) ReorderChatQueuedMessageToFront(ctx context.Context, arg ReorderChatQueuedMessageToFrontParams) (int64, error) {
result, err := q.db.ExecContext(ctx, reorderChatQueuedMessageToFront, arg.ChatID, arg.TargetID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const resolveUserChatSpendLimit = `-- name: ResolveUserChatSpendLimit :one
SELECT CASE
WHEN NOT cfg.enabled THEN -1