feat: limit concurrent chat agents with pooled admission (#27902)

Limits concurrent chat generation on capped deployments to 5 root chats
and 10 delegated subagent chats. The pools are deployment-wide and
independent, so delegated work can continue while root capacity is full.

The default caps live in AGPL code. Enterprise contributes only a
licensing unlock, so unlicensed deployments stay capped and cannot fail
open. Licensed deployments are uncapped while Agent Hours usage stays
below an explicit hard limit. Deployments without a hard limit remain
uncapped, and reaching the Agent Hours allocation only triggers
warnings.

Admission happens before a worker takes chat ownership. Capped
deployments serialize admission across replicas with a
transaction-scoped advisory lock and derive active and queued state from
current ownership plus fresh runner heartbeats, rather than persisted
queue markers or per-replica state. The acquisition query returns a
bounded, pool-interleaved candidate set instead of ranking the whole
backlog; a migration replaces the acquisition index with a pool-aware
one. Refused chats stay running but unowned, and interrupt requests
bypass admission so users can stop queued or over-cap chats.

The single-chat API derives `queued_for_capacity` from live pool state;
list endpoints do not report it. The UI polls that value every 5 seconds
while a chat is running and shows a callout when the chat is waiting for
capacity.

Updates the administrator documentation and deployment-wide Prometheus
gauges for active and queued agents. Replica-level values must be
aggregated with `max`, not `sum`.

> Mux updated this PR on Mike's behalf.
This commit is contained in:
Michael Suchacz
2026-08-18 16:55:43 +02:00
committed by GitHub
parent cb0a9ebbbf
commit 119f2b1dd9
51 changed files with 2187 additions and 277 deletions
+2 -5
View File
@@ -757,11 +757,8 @@ func TestChat_AllFieldsPopulated(t *testing.T) {
v := reflect.ValueOf(got)
typ := v.Type()
// HasUnread is populated by ChatRowsWithChildren (which joins the
// read-cursor query), not by Chat. Warnings is a transient
// field populated by handlers, not the converter. Both are
// expected to remain zero here.
skip := map[string]bool{"HasUnread": true, "Warnings": true}
// These fields are set outside db2sdk.Chat and intentionally remain zero.
skip := map[string]bool{"HasUnread": true, "Warnings": true, "QueuedForCapacity": true}
for i := range typ.NumField() {
field := typ.Field(i)
if skip[field.Name] {
+23
View File
@@ -1964,6 +1964,20 @@ func (q *querier) CountAuditLogs(ctx context.Context, arg database.CountAuditLog
return q.db.CountAuthorizedAuditLogs(ctx, arg, prep)
}
func (q *querier) CountChatCapacityActiveByPool(ctx context.Context, arg database.CountChatCapacityActiveByPoolParams) (database.CountChatCapacityActiveByPoolRow, error) {
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceChat); err != nil {
return database.CountChatCapacityActiveByPoolRow{}, err
}
return q.db.CountChatCapacityActiveByPool(ctx, arg)
}
func (q *querier) CountChatCapacityQueuedByPool(ctx context.Context, staleSeconds int32) (database.CountChatCapacityQueuedByPoolRow, error) {
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceChat); err != nil {
return database.CountChatCapacityQueuedByPoolRow{}, err
}
return q.db.CountChatCapacityQueuedByPool(ctx, staleSeconds)
}
func (q *querier) CountChatQueuedMessages(ctx context.Context, chatID uuid.UUID) (int64, error) {
_, err := q.GetChatByID(ctx, chatID)
if err != nil {
@@ -3483,6 +3497,15 @@ func (q *querier) GetChatPlanModeInstructions(ctx context.Context) (string, erro
return q.db.GetChatPlanModeInstructions(ctx)
}
func (q *querier) GetChatQueuedForCapacity(ctx context.Context, arg database.GetChatQueuedForCapacityParams) (bool, error) {
// The pool-fullness derivation counts other users' chats, so require
// deployment-wide chat read rather than per-chat authorization.
if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceChat); err != nil {
return false, err
}
return q.db.GetChatQueuedForCapacity(ctx, arg)
}
func (q *querier) GetChatQueuedMessageByID(ctx context.Context, arg database.GetChatQueuedMessageByIDParams) (database.ChatQueuedMessage, error) {
_, err := q.GetChatByID(ctx, arg.ChatID)
if err != nil {
+17
View File
@@ -608,6 +608,23 @@ func (s *MethodTestSuite) TestChats() {
dbm.EXPECT().GetChatWorkerAcquisitionCandidates(gomock.Any(), arg).Return([]database.GetChatWorkerAcquisitionCandidatesRow{row}, nil).AnyTimes()
check.Args(arg).Asserts(rbac.ResourceChat, policy.ActionUpdate).Returns([]database.GetChatWorkerAcquisitionCandidatesRow{row})
}))
s.Run("CountChatCapacityActiveByPool", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
arg := database.CountChatCapacityActiveByPoolParams{ExcludeChatID: uuid.New(), StaleSeconds: 30}
row := database.CountChatCapacityActiveByPoolRow{ActiveRootCount: 1, ActiveSubagentCount: 2}
dbm.EXPECT().CountChatCapacityActiveByPool(gomock.Any(), arg).Return(row, nil).AnyTimes()
check.Args(arg).Asserts(rbac.ResourceChat, policy.ActionRead).Returns(row)
}))
s.Run("CountChatCapacityQueuedByPool", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
const staleSeconds = int32(30)
row := database.CountChatCapacityQueuedByPoolRow{QueuedRootCount: 3, QueuedSubagentCount: 4}
dbm.EXPECT().CountChatCapacityQueuedByPool(gomock.Any(), staleSeconds).Return(row, nil).AnyTimes()
check.Args(staleSeconds).Asserts(rbac.ResourceChat, policy.ActionRead).Returns(row)
}))
s.Run("GetChatQueuedForCapacity", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
arg := database.GetChatQueuedForCapacityParams{ChatID: uuid.New(), StaleSeconds: 30, RootCapacity: 5, SubagentCapacity: 10}
dbm.EXPECT().GetChatQueuedForCapacity(gomock.Any(), arg).Return(true, nil).AnyTimes()
check.Args(arg).Asserts(rbac.ResourceChat, policy.ActionRead).Returns(true)
}))
s.Run("GetChatsByIDsForRunnerSync", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
ids := []uuid.UUID{uuid.New(), uuid.New()}
chat := testutil.Fake(s.T(), faker, database.Chat{ID: ids[0]})
+24
View File
@@ -321,6 +321,22 @@ func (m queryMetricsStore) CountAuditLogs(ctx context.Context, arg database.Coun
return r0, r1
}
func (m queryMetricsStore) CountChatCapacityActiveByPool(ctx context.Context, arg database.CountChatCapacityActiveByPoolParams) (database.CountChatCapacityActiveByPoolRow, error) {
start := time.Now()
r0, r1 := m.s.CountChatCapacityActiveByPool(ctx, arg)
m.queryLatencies.WithLabelValues("CountChatCapacityActiveByPool").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "CountChatCapacityActiveByPool").Inc()
return r0, r1
}
func (m queryMetricsStore) CountChatCapacityQueuedByPool(ctx context.Context, staleSeconds int32) (database.CountChatCapacityQueuedByPoolRow, error) {
start := time.Now()
r0, r1 := m.s.CountChatCapacityQueuedByPool(ctx, staleSeconds)
m.queryLatencies.WithLabelValues("CountChatCapacityQueuedByPool").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "CountChatCapacityQueuedByPool").Inc()
return r0, r1
}
func (m queryMetricsStore) CountChatQueuedMessages(ctx context.Context, chatID uuid.UUID) (int64, error) {
start := time.Now()
r0, r1 := m.s.CountChatQueuedMessages(ctx, chatID)
@@ -1705,6 +1721,14 @@ func (m queryMetricsStore) GetChatPlanModeInstructions(ctx context.Context) (str
return r0, r1
}
func (m queryMetricsStore) GetChatQueuedForCapacity(ctx context.Context, arg database.GetChatQueuedForCapacityParams) (bool, error) {
start := time.Now()
r0, r1 := m.s.GetChatQueuedForCapacity(ctx, arg)
m.queryLatencies.WithLabelValues("GetChatQueuedForCapacity").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "GetChatQueuedForCapacity").Inc()
return r0, r1
}
func (m queryMetricsStore) GetChatQueuedMessageByID(ctx context.Context, arg database.GetChatQueuedMessageByIDParams) (database.ChatQueuedMessage, error) {
start := time.Now()
r0, r1 := m.s.GetChatQueuedMessageByID(ctx, arg)
+45
View File
@@ -483,6 +483,36 @@ func (mr *MockStoreMockRecorder) CountAuthorizedConnectionLogs(ctx, arg, prepare
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountAuthorizedConnectionLogs", reflect.TypeOf((*MockStore)(nil).CountAuthorizedConnectionLogs), ctx, arg, prepared)
}
// CountChatCapacityActiveByPool mocks base method.
func (m *MockStore) CountChatCapacityActiveByPool(ctx context.Context, arg database.CountChatCapacityActiveByPoolParams) (database.CountChatCapacityActiveByPoolRow, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "CountChatCapacityActiveByPool", ctx, arg)
ret0, _ := ret[0].(database.CountChatCapacityActiveByPoolRow)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// CountChatCapacityActiveByPool indicates an expected call of CountChatCapacityActiveByPool.
func (mr *MockStoreMockRecorder) CountChatCapacityActiveByPool(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountChatCapacityActiveByPool", reflect.TypeOf((*MockStore)(nil).CountChatCapacityActiveByPool), ctx, arg)
}
// CountChatCapacityQueuedByPool mocks base method.
func (m *MockStore) CountChatCapacityQueuedByPool(ctx context.Context, staleSeconds int32) (database.CountChatCapacityQueuedByPoolRow, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "CountChatCapacityQueuedByPool", ctx, staleSeconds)
ret0, _ := ret[0].(database.CountChatCapacityQueuedByPoolRow)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// CountChatCapacityQueuedByPool indicates an expected call of CountChatCapacityQueuedByPool.
func (mr *MockStoreMockRecorder) CountChatCapacityQueuedByPool(ctx, staleSeconds any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CountChatCapacityQueuedByPool", reflect.TypeOf((*MockStore)(nil).CountChatCapacityQueuedByPool), ctx, staleSeconds)
}
// CountChatQueuedMessages mocks base method.
func (m *MockStore) CountChatQueuedMessages(ctx context.Context, chatID uuid.UUID) (int64, error) {
m.ctrl.T.Helper()
@@ -3165,6 +3195,21 @@ func (mr *MockStoreMockRecorder) GetChatPlanModeInstructions(ctx any) *gomock.Ca
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatPlanModeInstructions", reflect.TypeOf((*MockStore)(nil).GetChatPlanModeInstructions), ctx)
}
// GetChatQueuedForCapacity mocks base method.
func (m *MockStore) GetChatQueuedForCapacity(ctx context.Context, arg database.GetChatQueuedForCapacityParams) (bool, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetChatQueuedForCapacity", ctx, arg)
ret0, _ := ret[0].(bool)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetChatQueuedForCapacity indicates an expected call of GetChatQueuedForCapacity.
func (mr *MockStoreMockRecorder) GetChatQueuedForCapacity(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChatQueuedForCapacity", reflect.TypeOf((*MockStore)(nil).GetChatQueuedForCapacity), ctx, arg)
}
// GetChatQueuedMessageByID mocks base method.
func (m *MockStore) GetChatQueuedMessageByID(ctx context.Context, arg database.GetChatQueuedMessageByIDParams) (database.ChatQueuedMessage, error) {
m.ctrl.T.Helper()
+1 -1
View File
@@ -4842,7 +4842,7 @@ CREATE INDEX idx_chats_title_fts ON chats USING gin (to_tsvector('simple'::regco
COMMENT ON INDEX idx_chats_title_fts IS 'Used for full text search. Defined over all rows of the chats table.';
CREATE INDEX idx_chats_worker_acquisition_candidates ON chats USING btree (status, updated_at, id) WHERE (archived = false);
CREATE INDEX idx_chats_worker_acquisition_candidates ON chats USING btree (((parent_chat_id IS NULL)), status, updated_at, id) WHERE (archived = false);
CREATE INDEX idx_chats_workspace ON chats USING btree (workspace_id);
+1
View File
@@ -17,6 +17,7 @@ const (
LockIDBoundaryUsageStats
LockIDAIProvidersEnvSeed
LockIDChatModelConfigWrites
LockIDChatCapacityAdmission
)
// GenLockID generates a unique and consistent lock ID from a given string.
@@ -0,0 +1,4 @@
DROP INDEX idx_chats_worker_acquisition_candidates;
CREATE INDEX idx_chats_worker_acquisition_candidates ON chats
(status, updated_at, id)
WHERE archived = false;
@@ -0,0 +1,4 @@
DROP INDEX idx_chats_worker_acquisition_candidates;
CREATE INDEX idx_chats_worker_acquisition_candidates ON chats
((parent_chat_id IS NULL), status, updated_at, id)
WHERE archived = false;
+8 -11
View File
@@ -93,6 +93,9 @@ type sqlcQuerier interface {
CleanupDeletedMCPServerIDsFromChats(ctx context.Context) error
CountAIBridgeSessions(ctx context.Context, arg CountAIBridgeSessionsParams) (int64, error)
CountAuditLogs(ctx context.Context, arg CountAuditLogsParams) (int64, error)
// Excluding the candidate keeps ownership takeover capacity-neutral.
CountChatCapacityActiveByPool(ctx context.Context, arg CountChatCapacityActiveByPoolParams) (CountChatCapacityActiveByPoolRow, error)
CountChatCapacityQueuedByPool(ctx context.Context, staleSeconds int32) (CountChatCapacityQueuedByPoolRow, error)
// Cheap queue-length check used by ChatMachine.Update when deciding
// whether the chat is in a "1" sub-state.
CountChatQueuedMessages(ctx context.Context, chatID uuid.UUID) (int64, error)
@@ -477,6 +480,8 @@ type sqlcQuerier interface {
// personal chat model overrides. It defaults to false when unset.
GetChatPersonalModelOverridesEnabled(ctx context.Context) (bool, error)
GetChatPlanModeInstructions(ctx context.Context) (string, error)
// Pool fullness distinguishes capacity waits from worker pickup delays.
GetChatQueuedForCapacity(ctx context.Context, arg GetChatQueuedForCapacityParams) (bool, error)
GetChatQueuedMessageByID(ctx context.Context, arg GetChatQueuedMessageByIDParams) (ChatQueuedMessage, error)
// Returns the queue head (lowest position, then lowest id).
GetChatQueuedMessageHead(ctx context.Context, chatID uuid.UUID) (ChatQueuedMessage, error)
@@ -507,17 +512,9 @@ type sqlcQuerier interface {
// jsonb_array_elements never raises "cannot extract elements from a
// scalar". Backed by idx_chat_messages_user_prompts.
GetChatUserPromptsByChatID(ctx context.Context, arg GetChatUserPromptsByChatIDParams) ([]GetChatUserPromptsByChatIDRow, error)
// Returns chats that workers may try to acquire. Candidates must be:
// - in a worker-runnable execution status;
// - unarchived; and
// - missing ownership, carrying inconsistent ownership, or lacking a
// fresh heartbeat for the assigned runner.
//
// Missing ownership is worker_id IS NULL. Inconsistent ownership is
// runner_id IS NULL while worker_id is set. Stale ownership is no
// heartbeat row for (chat_id, runner_id), or one older than
// @stale_seconds by database time. Candidates are ordered by oldest
// updated_at first so workers drain stale runnable chats predictably.
// Returns a bounded, pool-interleaved set of chats that workers may acquire.
// Interrupting chats finish active work first. Requires-action chats follow so
// their runner can enforce the action deadline before new generations start.
GetChatWorkerAcquisitionCandidates(ctx context.Context, arg GetChatWorkerAcquisitionCandidatesParams) ([]GetChatWorkerAcquisitionCandidatesRow, error)
// Returns the global TTL for chat workspaces as a Go duration string.
// Returns "0s" (disabled) when no value has been configured.
+183 -142
View File
@@ -7048,6 +7048,69 @@ func (q *sqlQuerier) BatchUpsertChatHeartbeats(ctx context.Context, arg BatchUps
return err
}
const countChatCapacityActiveByPool = `-- name: CountChatCapacityActiveByPool :one
SELECT
COUNT(*) FILTER (WHERE c.parent_chat_id IS NULL)::bigint AS active_root_count,
COUNT(*) FILTER (WHERE c.parent_chat_id IS NOT NULL)::bigint AS active_subagent_count
FROM chat_heartbeats hb
JOIN chats c
ON c.id = hb.chat_id
AND c.runner_id = hb.runner_id
WHERE c.worker_id IS NOT NULL
AND c.id != $1::uuid
AND hb.heartbeat_at > NOW() - (INTERVAL '1 second' * $2::int)
`
type CountChatCapacityActiveByPoolParams struct {
ExcludeChatID uuid.UUID `db:"exclude_chat_id" json:"exclude_chat_id"`
StaleSeconds int32 `db:"stale_seconds" json:"stale_seconds"`
}
type CountChatCapacityActiveByPoolRow struct {
ActiveRootCount int64 `db:"active_root_count" json:"active_root_count"`
ActiveSubagentCount int64 `db:"active_subagent_count" json:"active_subagent_count"`
}
// Excluding the candidate keeps ownership takeover capacity-neutral.
func (q *sqlQuerier) CountChatCapacityActiveByPool(ctx context.Context, arg CountChatCapacityActiveByPoolParams) (CountChatCapacityActiveByPoolRow, error) {
row := q.db.QueryRowContext(ctx, countChatCapacityActiveByPool, arg.ExcludeChatID, arg.StaleSeconds)
var i CountChatCapacityActiveByPoolRow
err := row.Scan(&i.ActiveRootCount, &i.ActiveSubagentCount)
return i, err
}
const countChatCapacityQueuedByPool = `-- name: CountChatCapacityQueuedByPool :one
SELECT
COUNT(*) FILTER (WHERE c.parent_chat_id IS NULL)::bigint AS queued_root_count,
COUNT(*) FILTER (WHERE c.parent_chat_id IS NOT NULL)::bigint AS queued_subagent_count
FROM chats c
WHERE c.status = 'running'::chat_status
AND c.archived = false
AND (
c.worker_id IS NULL
OR c.runner_id IS NULL
OR NOT EXISTS (
SELECT 1
FROM chat_heartbeats hb
WHERE hb.chat_id = c.id
AND hb.runner_id = c.runner_id
AND hb.heartbeat_at > NOW() - (INTERVAL '1 second' * $1::int)
)
)
`
type CountChatCapacityQueuedByPoolRow struct {
QueuedRootCount int64 `db:"queued_root_count" json:"queued_root_count"`
QueuedSubagentCount int64 `db:"queued_subagent_count" json:"queued_subagent_count"`
}
func (q *sqlQuerier) CountChatCapacityQueuedByPool(ctx context.Context, staleSeconds int32) (CountChatCapacityQueuedByPoolRow, error) {
row := q.db.QueryRowContext(ctx, countChatCapacityQueuedByPool, staleSeconds)
var i CountChatCapacityQueuedByPoolRow
err := row.Scan(&i.QueuedRootCount, &i.QueuedSubagentCount)
return i, err
}
const countChatQueuedMessages = `-- name: CountChatQueuedMessages :one
SELECT COUNT(*)::bigint AS count
FROM chat_queued_messages
@@ -8507,6 +8570,62 @@ func (q *sqlQuerier) GetChatModelConfigsForTelemetry(ctx context.Context) ([]Get
return items, nil
}
const getChatQueuedForCapacity = `-- name: GetChatQueuedForCapacity :one
WITH active AS (
SELECT
COUNT(*) FILTER (WHERE a.parent_chat_id IS NULL)::bigint AS root_count,
COUNT(*) FILTER (WHERE a.parent_chat_id IS NOT NULL)::bigint AS subagent_count
FROM chat_heartbeats hb
JOIN chats a
ON a.id = hb.chat_id
AND a.runner_id = hb.runner_id
WHERE a.worker_id IS NOT NULL
AND hb.heartbeat_at > NOW() - (INTERVAL '1 second' * $1::int)
)
SELECT (
c.status = 'running'::chat_status
AND c.archived = false
AND (
c.worker_id IS NULL
OR c.runner_id IS NULL
OR NOT EXISTS (
SELECT 1
FROM chat_heartbeats hb
WHERE hb.chat_id = c.id
AND hb.runner_id = c.runner_id
AND hb.heartbeat_at > NOW() - (INTERVAL '1 second' * $1::int)
)
)
AND CASE
WHEN c.parent_chat_id IS NULL THEN active.root_count >= $2::bigint
ELSE active.subagent_count >= $3::bigint
END
)::boolean AS queued_for_capacity
FROM chats c
CROSS JOIN active
WHERE c.id = $4::uuid
`
type GetChatQueuedForCapacityParams struct {
StaleSeconds int32 `db:"stale_seconds" json:"stale_seconds"`
RootCapacity int64 `db:"root_capacity" json:"root_capacity"`
SubagentCapacity int64 `db:"subagent_capacity" json:"subagent_capacity"`
ChatID uuid.UUID `db:"chat_id" json:"chat_id"`
}
// Pool fullness distinguishes capacity waits from worker pickup delays.
func (q *sqlQuerier) GetChatQueuedForCapacity(ctx context.Context, arg GetChatQueuedForCapacityParams) (bool, error) {
row := q.db.QueryRowContext(ctx, getChatQueuedForCapacity,
arg.StaleSeconds,
arg.RootCapacity,
arg.SubagentCapacity,
arg.ChatID,
)
var queued_for_capacity bool
err := row.Scan(&queued_for_capacity)
return queued_for_capacity, err
}
const getChatQueuedMessageByID = `-- name: GetChatQueuedMessageByID :one
SELECT id, chat_id, content, created_at, model_config_id, position, created_by, reasoning_effort FROM chat_queued_messages
WHERE id = $1::bigint AND chat_id = $2::uuid
@@ -8759,108 +8878,80 @@ func (q *sqlQuerier) GetChatUserPromptsByChatID(ctx context.Context, arg GetChat
}
const getChatWorkerAcquisitionCandidates = `-- name: GetChatWorkerAcquisitionCandidates :many
WITH candidate_partitions AS (
SELECT true AS is_root, 'interrupting'::chat_status AS status, 0 AS status_priority, 0 AS pool_priority
UNION ALL
SELECT false, 'interrupting'::chat_status, 0, 1
UNION ALL
SELECT true, 'requires_action'::chat_status, 1, 0
UNION ALL
SELECT false, 'requires_action'::chat_status, 1, 1
UNION ALL
SELECT true, 'running'::chat_status, 2, 0
UNION ALL
SELECT false, 'running'::chat_status, 2, 1
),
candidates AS (
SELECT
candidate.id,
candidate_partitions.status_priority,
candidate_partitions.pool_priority,
ROW_NUMBER() OVER (
PARTITION BY candidate_partitions.status_priority, candidate_partitions.is_root
ORDER BY candidate.updated_at ASC, candidate.id ASC
) AS pool_position
FROM candidate_partitions
CROSS JOIN LATERAL (
SELECT chats.id, chats.updated_at
FROM chats
WHERE (chats.parent_chat_id IS NULL) = candidate_partitions.is_root
AND chats.status = candidate_partitions.status
AND chats.archived = false
AND (
chats.worker_id IS NULL
OR chats.runner_id IS NULL
OR NOT EXISTS (
SELECT 1
FROM chat_heartbeats current_lease
WHERE current_lease.chat_id = chats.id
AND current_lease.runner_id = chats.runner_id
AND current_lease.heartbeat_at > NOW() - (INTERVAL '1 second' * $2::int)
)
)
ORDER BY chats.updated_at ASC, chats.id ASC
LIMIT $1::int
) candidate
)
SELECT
chats_expanded.id, chats_expanded.owner_id, chats_expanded.workspace_id, chats_expanded.title, chats_expanded.status, chats_expanded.worker_id, chats_expanded.started_at, chats_expanded.heartbeat_at, chats_expanded.created_at, chats_expanded.updated_at, chats_expanded.parent_chat_id, chats_expanded.root_chat_id, chats_expanded.last_model_config_id, chats_expanded.last_reasoning_effort, chats_expanded.archived, chats_expanded.last_error, chats_expanded.mode, chats_expanded.mcp_server_ids, chats_expanded.labels, chats_expanded.build_id, chats_expanded.agent_id, chats_expanded.pin_order, chats_expanded.last_read_message_id, chats_expanded.dynamic_tools, chats_expanded.organization_id, chats_expanded.plan_mode, chats_expanded.client_type, chats_expanded.last_turn_summary, chats_expanded.summary, chats_expanded.summary_generated_at, chats_expanded.snapshot_version, chats_expanded.history_version, chats_expanded.queue_version, chats_expanded.generation_attempt, chats_expanded.retry_state, chats_expanded.retry_state_version, chats_expanded.runner_id, chats_expanded.requires_action_deadline_at, chats_expanded.user_acl, chats_expanded.group_acl, chats_expanded.owner_username, chats_expanded.owner_name, chats_expanded.context_aggregate_hash, chats_expanded.context_dirty_since, chats_expanded.context_dirty_resources, chats_expanded.context_error, chats_expanded.compaction_requested_at,
chat_heartbeats.heartbeat_at AS current_heartbeat_at,
NOT EXISTS (
SELECT 1
FROM chat_heartbeats current_lease
WHERE current_lease.chat_id = chats_expanded.id
AND current_lease.runner_id = chats_expanded.runner_id
AND current_lease.heartbeat_at > NOW() - (INTERVAL '1 second' * $1::int)
) AS heartbeat_stale
FROM chats_expanded
LEFT JOIN chat_heartbeats
ON chat_heartbeats.chat_id = chats_expanded.id
AND chat_heartbeats.runner_id = chats_expanded.runner_id
WHERE
chats_expanded.status IN ('running'::chat_status, 'interrupting'::chat_status, 'requires_action'::chat_status)
AND chats_expanded.archived = false
AND (
chats_expanded.worker_id IS NULL
OR chats_expanded.runner_id IS NULL
OR NOT EXISTS (
SELECT 1
FROM chat_heartbeats current_lease
WHERE current_lease.chat_id = chats_expanded.id
AND current_lease.runner_id = chats_expanded.runner_id
AND current_lease.heartbeat_at > NOW() - (INTERVAL '1 second' * $1::int)
)
)
ORDER BY chats_expanded.updated_at ASC, chats_expanded.id ASC
LIMIT $2::int
chats.id,
chats.status,
chats.parent_chat_id
FROM candidates
JOIN chats ON chats.id = candidates.id
ORDER BY
candidates.status_priority ASC,
candidates.pool_position ASC,
candidates.pool_priority ASC,
chats.id ASC
LIMIT $1::int
`
type GetChatWorkerAcquisitionCandidatesParams struct {
StaleSeconds int32 `db:"stale_seconds" json:"stale_seconds"`
LimitCount int32 `db:"limit_count" json:"limit_count"`
StaleSeconds int32 `db:"stale_seconds" json:"stale_seconds"`
}
type GetChatWorkerAcquisitionCandidatesRow 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"`
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"`
LastReasoningEffort NullChatReasoningEffort `db:"last_reasoning_effort" json:"last_reasoning_effort"`
Archived bool `db:"archived" json:"archived"`
LastError pqtype.NullRawMessage `db:"last_error" json:"last_error"`
Mode NullChatMode `db:"mode" json:"mode"`
MCPServerIDs []uuid.UUID `db:"mcp_server_ids" json:"mcp_server_ids"`
Labels StringMap `db:"labels" json:"labels"`
BuildID uuid.NullUUID `db:"build_id" json:"build_id"`
AgentID uuid.NullUUID `db:"agent_id" json:"agent_id"`
PinOrder int32 `db:"pin_order" json:"pin_order"`
LastReadMessageID sql.NullInt64 `db:"last_read_message_id" json:"last_read_message_id"`
DynamicTools pqtype.NullRawMessage `db:"dynamic_tools" json:"dynamic_tools"`
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
PlanMode NullChatPlanMode `db:"plan_mode" json:"plan_mode"`
ClientType ChatClientType `db:"client_type" json:"client_type"`
LastTurnSummary sql.NullString `db:"last_turn_summary" json:"last_turn_summary"`
Summary sql.NullString `db:"summary" json:"summary"`
SummaryGeneratedAt sql.NullTime `db:"summary_generated_at" json:"summary_generated_at"`
SnapshotVersion int64 `db:"snapshot_version" json:"snapshot_version"`
HistoryVersion int64 `db:"history_version" json:"history_version"`
QueueVersion int64 `db:"queue_version" json:"queue_version"`
GenerationAttempt int64 `db:"generation_attempt" json:"generation_attempt"`
RetryState pqtype.NullRawMessage `db:"retry_state" json:"retry_state"`
RetryStateVersion int64 `db:"retry_state_version" json:"retry_state_version"`
RunnerID uuid.NullUUID `db:"runner_id" json:"runner_id"`
RequiresActionDeadlineAt sql.NullTime `db:"requires_action_deadline_at" json:"requires_action_deadline_at"`
UserACL ChatACL `db:"user_acl" json:"user_acl"`
GroupACL ChatACL `db:"group_acl" json:"group_acl"`
OwnerUsername string `db:"owner_username" json:"owner_username"`
OwnerName string `db:"owner_name" json:"owner_name"`
ContextAggregateHash []byte `db:"context_aggregate_hash" json:"context_aggregate_hash"`
ContextDirtySince sql.NullTime `db:"context_dirty_since" json:"context_dirty_since"`
ContextDirtyResources pqtype.NullRawMessage `db:"context_dirty_resources" json:"context_dirty_resources"`
ContextError string `db:"context_error" json:"context_error"`
CompactionRequestedAt sql.NullTime `db:"compaction_requested_at" json:"compaction_requested_at"`
CurrentHeartbeatAt sql.NullTime `db:"current_heartbeat_at" json:"current_heartbeat_at"`
HeartbeatStale bool `db:"heartbeat_stale" json:"heartbeat_stale"`
ID uuid.UUID `db:"id" json:"id"`
Status ChatStatus `db:"status" json:"status"`
ParentChatID uuid.NullUUID `db:"parent_chat_id" json:"parent_chat_id"`
}
// Returns chats that workers may try to acquire. Candidates must be:
// - in a worker-runnable execution status;
// - unarchived; and
// - missing ownership, carrying inconsistent ownership, or lacking a
// fresh heartbeat for the assigned runner.
//
// Missing ownership is worker_id IS NULL. Inconsistent ownership is
// runner_id IS NULL while worker_id is set. Stale ownership is no
// heartbeat row for (chat_id, runner_id), or one older than
// @stale_seconds by database time. Candidates are ordered by oldest
// updated_at first so workers drain stale runnable chats predictably.
// Returns a bounded, pool-interleaved set of chats that workers may acquire.
// Interrupting chats finish active work first. Requires-action chats follow so
// their runner can enforce the action deadline before new generations start.
func (q *sqlQuerier) GetChatWorkerAcquisitionCandidates(ctx context.Context, arg GetChatWorkerAcquisitionCandidatesParams) ([]GetChatWorkerAcquisitionCandidatesRow, error) {
rows, err := q.db.QueryContext(ctx, getChatWorkerAcquisitionCandidates, arg.StaleSeconds, arg.LimitCount)
rows, err := q.db.QueryContext(ctx, getChatWorkerAcquisitionCandidates, arg.LimitCount, arg.StaleSeconds)
if err != nil {
return nil, err
}
@@ -8868,57 +8959,7 @@ func (q *sqlQuerier) GetChatWorkerAcquisitionCandidates(ctx context.Context, arg
var items []GetChatWorkerAcquisitionCandidatesRow
for rows.Next() {
var i GetChatWorkerAcquisitionCandidatesRow
if err := rows.Scan(
&i.ID,
&i.OwnerID,
&i.WorkspaceID,
&i.Title,
&i.Status,
&i.WorkerID,
&i.StartedAt,
&i.HeartbeatAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.ParentChatID,
&i.RootChatID,
&i.LastModelConfigID,
&i.LastReasoningEffort,
&i.Archived,
&i.LastError,
&i.Mode,
pq.Array(&i.MCPServerIDs),
&i.Labels,
&i.BuildID,
&i.AgentID,
&i.PinOrder,
&i.LastReadMessageID,
&i.DynamicTools,
&i.OrganizationID,
&i.PlanMode,
&i.ClientType,
&i.LastTurnSummary,
&i.Summary,
&i.SummaryGeneratedAt,
&i.SnapshotVersion,
&i.HistoryVersion,
&i.QueueVersion,
&i.GenerationAttempt,
&i.RetryState,
&i.RetryStateVersion,
&i.RunnerID,
&i.RequiresActionDeadlineAt,
&i.UserACL,
&i.GroupACL,
&i.OwnerUsername,
&i.OwnerName,
&i.ContextAggregateHash,
&i.ContextDirtySince,
&i.ContextDirtyResources,
&i.ContextError,
&i.CompactionRequestedAt,
&i.CurrentHeartbeatAt,
&i.HeartbeatStale,
); err != nil {
if err := rows.Scan(&i.ID, &i.Status, &i.ParentChatID); err != nil {
return nil, err
}
items = append(items, i)
+125 -39
View File
@@ -2303,46 +2303,64 @@ WHERE chat_id = @chat_id::uuid
AND content::jsonb @> '[{"type": "context-file"}]';
-- name: GetChatWorkerAcquisitionCandidates :many
-- Returns chats that workers may try to acquire. Candidates must be:
-- - in a worker-runnable execution status;
-- - unarchived; and
-- - missing ownership, carrying inconsistent ownership, or lacking a
-- fresh heartbeat for the assigned runner.
--
-- Missing ownership is worker_id IS NULL. Inconsistent ownership is
-- runner_id IS NULL while worker_id is set. Stale ownership is no
-- heartbeat row for (chat_id, runner_id), or one older than
-- @stale_seconds by database time. Candidates are ordered by oldest
-- updated_at first so workers drain stale runnable chats predictably.
-- Returns a bounded, pool-interleaved set of chats that workers may acquire.
-- Interrupting chats finish active work first. Requires-action chats follow so
-- their runner can enforce the action deadline before new generations start.
WITH candidate_partitions AS (
SELECT true AS is_root, 'interrupting'::chat_status AS status, 0 AS status_priority, 0 AS pool_priority
UNION ALL
SELECT false, 'interrupting'::chat_status, 0, 1
UNION ALL
SELECT true, 'requires_action'::chat_status, 1, 0
UNION ALL
SELECT false, 'requires_action'::chat_status, 1, 1
UNION ALL
SELECT true, 'running'::chat_status, 2, 0
UNION ALL
SELECT false, 'running'::chat_status, 2, 1
),
candidates AS (
SELECT
candidate.id,
candidate_partitions.status_priority,
candidate_partitions.pool_priority,
ROW_NUMBER() OVER (
PARTITION BY candidate_partitions.status_priority, candidate_partitions.is_root
ORDER BY candidate.updated_at ASC, candidate.id ASC
) AS pool_position
FROM candidate_partitions
CROSS JOIN LATERAL (
SELECT chats.id, chats.updated_at
FROM chats
WHERE (chats.parent_chat_id IS NULL) = candidate_partitions.is_root
AND chats.status = candidate_partitions.status
AND chats.archived = false
AND (
chats.worker_id IS NULL
OR chats.runner_id IS NULL
OR NOT EXISTS (
SELECT 1
FROM chat_heartbeats current_lease
WHERE current_lease.chat_id = chats.id
AND current_lease.runner_id = chats.runner_id
AND current_lease.heartbeat_at > NOW() - (INTERVAL '1 second' * @stale_seconds::int)
)
)
ORDER BY chats.updated_at ASC, chats.id ASC
LIMIT @limit_count::int
) candidate
)
SELECT
chats_expanded.*,
chat_heartbeats.heartbeat_at AS current_heartbeat_at,
NOT EXISTS (
SELECT 1
FROM chat_heartbeats current_lease
WHERE current_lease.chat_id = chats_expanded.id
AND current_lease.runner_id = chats_expanded.runner_id
AND current_lease.heartbeat_at > NOW() - (INTERVAL '1 second' * @stale_seconds::int)
) AS heartbeat_stale
FROM chats_expanded
LEFT JOIN chat_heartbeats
ON chat_heartbeats.chat_id = chats_expanded.id
AND chat_heartbeats.runner_id = chats_expanded.runner_id
WHERE
chats_expanded.status IN ('running'::chat_status, 'interrupting'::chat_status, 'requires_action'::chat_status)
AND chats_expanded.archived = false
AND (
chats_expanded.worker_id IS NULL
OR chats_expanded.runner_id IS NULL
OR NOT EXISTS (
SELECT 1
FROM chat_heartbeats current_lease
WHERE current_lease.chat_id = chats_expanded.id
AND current_lease.runner_id = chats_expanded.runner_id
AND current_lease.heartbeat_at > NOW() - (INTERVAL '1 second' * @stale_seconds::int)
)
)
ORDER BY chats_expanded.updated_at ASC, chats_expanded.id ASC
chats.id,
chats.status,
chats.parent_chat_id
FROM candidates
JOIN chats ON chats.id = candidates.id
ORDER BY
candidates.status_priority ASC,
candidates.pool_position ASC,
candidates.pool_priority ASC,
chats.id ASC
LIMIT @limit_count::int;
-- name: GetChatsByIDsForRunnerSync :many
@@ -2800,3 +2818,71 @@ LEFT JOIN to_archive t ON t.id = a.id
-- created_at ASC flows through to dbpurge's digest truncation; see
-- buildDigestData in dbpurge.go for the tradeoff rationale.
ORDER BY (a.root_chat_id IS NULL) DESC, a.owner_id ASC, a.created_at ASC, a.id ASC;
-- name: CountChatCapacityActiveByPool :one
-- Excluding the candidate keeps ownership takeover capacity-neutral.
SELECT
COUNT(*) FILTER (WHERE c.parent_chat_id IS NULL)::bigint AS active_root_count,
COUNT(*) FILTER (WHERE c.parent_chat_id IS NOT NULL)::bigint AS active_subagent_count
FROM chat_heartbeats hb
JOIN chats c
ON c.id = hb.chat_id
AND c.runner_id = hb.runner_id
WHERE c.worker_id IS NOT NULL
AND c.id != @exclude_chat_id::uuid
AND hb.heartbeat_at > NOW() - (INTERVAL '1 second' * @stale_seconds::int);
-- name: CountChatCapacityQueuedByPool :one
SELECT
COUNT(*) FILTER (WHERE c.parent_chat_id IS NULL)::bigint AS queued_root_count,
COUNT(*) FILTER (WHERE c.parent_chat_id IS NOT NULL)::bigint AS queued_subagent_count
FROM chats c
WHERE c.status = 'running'::chat_status
AND c.archived = false
AND (
c.worker_id IS NULL
OR c.runner_id IS NULL
OR NOT EXISTS (
SELECT 1
FROM chat_heartbeats hb
WHERE hb.chat_id = c.id
AND hb.runner_id = c.runner_id
AND hb.heartbeat_at > NOW() - (INTERVAL '1 second' * @stale_seconds::int)
)
);
-- name: GetChatQueuedForCapacity :one
-- Pool fullness distinguishes capacity waits from worker pickup delays.
WITH active AS (
SELECT
COUNT(*) FILTER (WHERE a.parent_chat_id IS NULL)::bigint AS root_count,
COUNT(*) FILTER (WHERE a.parent_chat_id IS NOT NULL)::bigint AS subagent_count
FROM chat_heartbeats hb
JOIN chats a
ON a.id = hb.chat_id
AND a.runner_id = hb.runner_id
WHERE a.worker_id IS NOT NULL
AND hb.heartbeat_at > NOW() - (INTERVAL '1 second' * @stale_seconds::int)
)
SELECT (
c.status = 'running'::chat_status
AND c.archived = false
AND (
c.worker_id IS NULL
OR c.runner_id IS NULL
OR NOT EXISTS (
SELECT 1
FROM chat_heartbeats hb
WHERE hb.chat_id = c.id
AND hb.runner_id = c.runner_id
AND hb.heartbeat_at > NOW() - (INTERVAL '1 second' * @stale_seconds::int)
)
)
AND CASE
WHEN c.parent_chat_id IS NULL THEN active.root_count >= @root_capacity::bigint
ELSE active.subagent_count >= @subagent_capacity::bigint
END
)::boolean AS queued_for_capacity
FROM chats c
CROSS JOIN active
WHERE c.id = @chat_id::uuid;