Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
5d336df
feat: limit concurrent chat agents with pooled admission at acquisition
ibetitsmike Aug 5, 2026
edb9c68
test(coderd): cover FIFO admission, interrupt claim, and concurrent a…
ibetitsmike Aug 5, 2026
ae0122d
refactor(coderd/database): prioritize interrupts and harden candidate…
ibetitsmike Aug 5, 2026
6de8c97
chore(enterprise/coderd/x/chatd): unexport pool caps and use WaitGrou…
ibetitsmike Aug 5, 2026
dee72c0
chore: tighten comments on chatd admission branch
ibetitsmike Aug 5, 2026
0fc32c1
fix(coderd): partition acquisition by capacity pool and add dbauthz t…
ibetitsmike Aug 5, 2026
c822311
fix(coderd/x/chatd): floor acquisition batch size at two
ibetitsmike Aug 5, 2026
161c4bf
fix(coderd/x/chatd): clear persisted queue markers without an admissi…
ibetitsmike Aug 5, 2026
89603b3
docs(coderd/x/chatd): state that runtime-hours usage is not yet popul…
ibetitsmike Aug 5, 2026
1e01395
fix(site): reject stale capacity events and populate db2sdk chat fixture
ibetitsmike Aug 5, 2026
da90da4
fix: count queue entries on marker wins and require newer status events
ibetitsmike Aug 5, 2026
819439a
docs(coderd/x/chatd): correct acquisition ticker default to 1s
ibetitsmike Aug 6, 2026
64d0d69
refactor: derive chat capacity queue state instead of persisting a ma…
ibetitsmike Aug 6, 2026
e52a28d
chore: tighten comments and document capacity gauges
ibetitsmike Aug 6, 2026
b391317
test(coderd/x/chatd): deflake capacity queue event and FIFO tests
ibetitsmike Aug 6, 2026
5d3c88d
fix(coderd/x/chatd): clear queued banner when a different replica admits
ibetitsmike Aug 6, 2026
a36eddb
refactor(coderd): merge chat capacity admission and limits into one seam
ibetitsmike Aug 6, 2026
f54aed7
fix(coderd): clear capacity banner on interrupt and stale capacity ev…
ibetitsmike Aug 6, 2026
07e6306
fix(coderd/x/chatd): publish capacity clear regardless of current cap
ibetitsmike Aug 6, 2026
49beb28
fix(coderd/x/chatd): revalidate capacity queue entry from a fresh sna…
ibetitsmike Aug 6, 2026
e4d3e42
fix(coderd/x/chatd): reconcile unseen capacity queue entries on all-s…
ibetitsmike Aug 6, 2026
ab2a43e
fix(coderd): queue chats arriving behind a full-pool backlog
ibetitsmike Aug 6, 2026
d6c5e60
fix(coderd): close capacity admission bypass and stuck queued banner …
ibetitsmike Aug 6, 2026
6c99ae7
fix(site/src/api/queries): reconcile rejected capacity events via ent…
ibetitsmike Aug 6, 2026
fcefcad
chore: clean up chat capacity comments
ibetitsmike Aug 10, 2026
95806a3
fix: base chat capacity on live ownership
ibetitsmike Aug 10, 2026
e37d268
fix(enterprise/coderd/x/chatd): enforce runtime hard limit
ibetitsmike Aug 11, 2026
8676e86
refactor: simplify chat capacity admission
ibetitsmike Aug 11, 2026
fbb4d32
fix: enforce chat capacity across deployments
ibetitsmike Aug 12, 2026
8bdcf1a
chore: clean up concurrency comments
ibetitsmike Aug 12, 2026
cdadc19
fix(coderd/x/chatd): address review feedback on capacity comments and…
ibetitsmike Aug 12, 2026
84f6666
docs: simplify chatd limiter architecture
ibetitsmike Aug 12, 2026
6d1e03e
test(enterprise/coderd/x/chatd): pin capped unlock for disabled zero-…
jaaydenh Aug 13, 2026
d4e2eee
fix: align pooled chat admission with current main
ibetitsmike Aug 13, 2026
adb7efb
fix(coderd/database): renumber migration to avoid collision with main
ibetitsmike Aug 17, 2026
f56a4e2
fix(site/src/pages/AgentsPage): keep running-chat poll under the bind…
ibetitsmike Aug 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
fix: align pooled chat admission with current main
  • Loading branch information
ibetitsmike committed Aug 18, 2026
commit d4e2eeedfa7a96fa58342d27845823a94386a1b4
133 changes: 14 additions & 119 deletions coderd/database/queries.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 5 additions & 14 deletions coderd/database/queries/chats.sql
Original file line number Diff line number Diff line change
Expand Up @@ -2351,25 +2351,16 @@ candidates AS (
) 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
chats.id,
chats.status,
chats.parent_chat_id
FROM candidates
JOIN chats_expanded ON chats_expanded.id = candidates.id
LEFT JOIN chat_heartbeats
ON chat_heartbeats.chat_id = chats_expanded.id
AND chat_heartbeats.runner_id = chats_expanded.runner_id
JOIN chats ON chats.id = candidates.id
ORDER BY
candidates.status_priority ASC,
candidates.pool_position ASC,
candidates.pool_priority ASC,
chats_expanded.id ASC
chats.id ASC
LIMIT @limit_count::int;

-- name: GetChatsByIDsForRunnerSync :many
Expand Down
4 changes: 2 additions & 2 deletions coderd/x/chatd/agentadmission.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const (
defaultMaxConcurrentSubagents = int64(10)
)

// AgentCapacityLimiter gates chat ownership and reports the dynamic per-pool caps.
// AgentCapacityLimiter controls chat admission and reports the current per-pool limits.
type AgentCapacityLimiter interface {
// Admit runs inside the acquisition transaction so its serialization
// extends through the ownership write. Refused chats remain unowned.
Expand All @@ -25,7 +25,7 @@ type AgentCapacityUnlock interface {
Unlocked() bool
}

// AgentCapacityLimits reports the root and subagent pool capacities.
// AgentCapacityLimits defines concurrent-agent limits for root and subagent pools.
type AgentCapacityLimits struct {
Root int64
Subagent int64
Expand Down
42 changes: 19 additions & 23 deletions coderd/x/chatd/chatd.go
Original file line number Diff line number Diff line change
Expand Up @@ -3316,15 +3316,29 @@ func chatWatchEventSDKChat(chat database.Chat, diffStatus *codersdk.ChatDiffStat
// publishChatPubsubEvent broadcasts a chat lifecycle event via PostgreSQL
// pubsub so that all replicas can push updates to watching clients.
func (p *Server) publishChatPubsubEvent(chat database.Chat, kind codersdk.ChatWatchEventKind, diffStatus *codersdk.ChatDiffStatus) {
p.publishChatWatchEvent(chat, codersdk.ChatWatchEvent{
event := codersdk.ChatWatchEvent{
Kind: kind,
Chat: chatWatchEventSDKChat(chat, diffStatus),
})
}
payload, err := json.Marshal(event)
if err != nil {
p.logger.Error(context.Background(), "failed to marshal chat pubsub event",
slog.F("chat_id", chat.ID),
slog.Error(err),
)
return
}
if err := p.pubsub.Publish(coderdpubsub.ChatWatchEventChannel(chat.OwnerID), payload); err != nil {
p.logger.Error(context.Background(), "failed to publish chat pubsub event",
slog.F("chat_id", chat.ID),
slog.F("kind", kind),
slog.Error(err),
)
}
}

// ChatQueuedForCapacity derives whether the chat is waiting for a
// concurrent-agent capacity slot. It reports false when the deployment is
// currently uncapped.
// ChatQueuedForCapacity reports whether the chat is waiting for a
// concurrent-agent capacity slot. Uncapped deployments always return false.
func (p *Server) ChatQueuedForCapacity(ctx context.Context, chat database.Chat) (bool, error) {
limits, capped := p.agentCapacityLimiter.Limits()
if !capped {
Expand All @@ -3344,24 +3358,6 @@ func (p *Server) ChatQueuedForCapacity(ctx context.Context, chat database.Chat)
})
}

func (p *Server) publishChatWatchEvent(chat database.Chat, event codersdk.ChatWatchEvent) {
payload, err := json.Marshal(event)
if err != nil {
p.logger.Error(context.Background(), "failed to marshal chat pubsub event",
slog.F("chat_id", chat.ID),
slog.Error(err),
)
return
}
if err := p.pubsub.Publish(coderdpubsub.ChatWatchEventChannel(chat.OwnerID), payload); err != nil {
p.logger.Error(context.Background(), "failed to publish chat pubsub event",
slog.F("chat_id", chat.ID),
slog.F("kind", event.Kind),
slog.Error(err),
)
}
}

// PublishDiffStatusChange broadcasts a diff_status_change event for
// the given chat so that watching clients know to re-fetch the diff
// status. This is called from the HTTP layer after the diff status
Expand Down
13 changes: 10 additions & 3 deletions coderd/x/chatd/worker.go
Original file line number Diff line number Diff line change
Expand Up @@ -205,19 +205,26 @@ func (w *chatWorker) acquireOnce(ctx context.Context, workerID uuid.UUID, manage
}

acquired := int32(0)
refusedPools := map[bool]bool{}
rootPoolRefused := false
subagentPoolRefused := false
for _, row := range rows {
if acquired >= w.opts.AcquisitionBatchSize {
return
}
// Interrupting and requires-action chats bypass capacity so their runners
// can finish work or enforce the action deadline.
if row.Status == database.ChatStatusRunning && refusedPools[row.ParentChatID.Valid] {
isSubagent := row.ParentChatID.Valid
if row.Status == database.ChatStatusRunning &&
((isSubagent && subagentPoolRefused) || (!isSubagent && rootPoolRefused)) {
continue
}
candidateAcquired, err := w.acquireCandidateSafely(ctx, workerID, manager, row.ID)
if errors.Is(err, errCapacityRefused) {
refusedPools[row.ParentChatID.Valid] = true
if isSubagent {
subagentPoolRefused = true
} else {
rootPoolRefused = true
}
continue
}
if err != nil {
Expand Down
4 changes: 3 additions & 1 deletion docs/ai-coder/agents/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,9 @@ token volume. Consider:

Community licenses run up to 5 agents at once.
Additional agents queue and start automatically when capacity frees.
Premium licenses with remaining Agent Hours remove the concurrency limit.
Premium licenses with Agent Hours do not impose a concurrency limit unless the Agent Hours hard limit is reached.
If the Agent Hours allocation is exhausted without a configured hard limit, Coder warns about usage but does not impose a concurrency limit.
When the Agent Hours hard limit is reached, additional agents queue under the concurrency limit.
Refer to [Concurrent agents](./platform-controls/index.md#concurrent-agents) for details.

### Pilot with a small group
Expand Down
7 changes: 3 additions & 4 deletions docs/ai-coder/agents/platform-controls/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,10 +124,9 @@ Subtasks delegated by an agent don't count toward this limit.
Those subtasks run in a separate pool of up to 10 concurrent subtasks.

Premium deployments can purchase Agent Hours with their Premium license.
Agent Hours are shared across the deployment, so any number of agents can run concurrently while consuming a shared pool of purchased working hours.
This usage-based model supports enterprise workloads with variable bursts of agent activity.
Large development teams, background automation, and API-triggered tasks can run without a concurrency limit while hours remain.
When purchased hours are exhausted, agents queue under the concurrency limit until more hours are added.
Agent Hours are shared across the deployment, and agents can run concurrently unless the Agent Hours hard limit is reached.
If the Agent Hours allocation is exhausted without a configured hard limit, Coder warns about usage but does not impose a concurrency limit.
When the Agent Hours hard limit is reached, additional agents queue under the concurrency limit.

### Spend management

Expand Down
3 changes: 2 additions & 1 deletion enterprise/coderd/x/chatd/agentadmission.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,14 @@ type agentCapacityUnlock struct {
entitlements *entitlements.Set
}

// Runtime-hour allocation does not enforce caps; the hard limit does.
// The Agent Hours allocation is advisory; the hard limit restores concurrency caps.
func (u *agentCapacityUnlock) Unlocked() bool {
f, ok := u.entitlements.Feature(codersdk.FeatureAgentRuntimeHours)
if !ok || !f.Enabled {
return false
}
if f.HardLimit == nil || f.Actual == nil {
Comment thread
ibetitsmike marked this conversation as resolved.
// Missing hard limits or usage measurements leave concurrency uncapped.
return true
}
return *f.Actual < *f.HardLimit
Expand Down
4 changes: 0 additions & 4 deletions enterprise/coderd/x/chatd/agentadmission_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,10 +98,6 @@ func TestAgentCapacityUnlock(t *testing.T) {
},
},
{
// A zero-allocation license grants the feature disabled with a
// zero limit. Premium licenses without agent_runtime_hours_*
// claims are grandfathered into the same shape, so this case
// pins that both stay capped.
name: "DisabledZeroAllocation",
feature: &codersdk.Feature{
Entitlement: codersdk.EntitlementEntitled,
Expand Down
11 changes: 8 additions & 3 deletions site/src/pages/AgentsPage/AgentChatPageView.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -421,19 +421,24 @@ export const QueuedForCapacityPremiumMember: Story = {
},
};

export const QueuedForCapacityPremiumExhaustedHours: Story = {
export const QueuedForCapacityPremiumHardLimit: Story = {
parameters: {
features: [
"multiple_organizations",
{ name: "agent_runtime_hours", limit: 4000, actual: 4000 },
{
name: "agent_runtime_hours",
limit: 3000,
hard_limit: 4000,
actual: 4000,
},
],
permissions: { viewAllLicenses: true },
},
render: () => <StoryAgentChatPageView queuedForCapacity />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const message = canvas.getByText(
/used all 4000 agent runtime hours included in your license/,
/reached the 4000-hour Agent Hours hard limit/,
);
expect(message).toBeVisible();
expect(message).toHaveTextContent(
Expand Down
Loading