mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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.
351 lines
9.0 KiB
Go
351 lines
9.0 KiB
Go
package chatd
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"golang.org/x/xerrors"
|
|
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
coderdpubsub "github.com/coder/coder/v2/coderd/pubsub"
|
|
"github.com/coder/coder/v2/coderd/x/chatd/chatstate"
|
|
)
|
|
|
|
// chatWorker owns chat acquisition and runner lifecycle for one process.
|
|
type chatWorker struct {
|
|
server *Server
|
|
opts chatWorkerOptions
|
|
|
|
mu sync.Mutex
|
|
started bool
|
|
ctx context.Context
|
|
cancel context.CancelFunc
|
|
manager *runnerManager
|
|
unsubscribe func()
|
|
wakeCh chan struct{}
|
|
wg sync.WaitGroup
|
|
}
|
|
|
|
// newChatWorker constructs a chat worker. The worker is idle until Start is
|
|
// called.
|
|
func newChatWorker(server *Server, opts chatWorkerOptions) (*chatWorker, error) {
|
|
if server == nil {
|
|
return nil, xerrors.New("chatworker: server is required")
|
|
}
|
|
withDefaults, err := opts.withDefaults()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &chatWorker{server: server, opts: withDefaults}, nil
|
|
}
|
|
|
|
// chatWorkerID returns this worker's configured worker ID.
|
|
func (w *chatWorker) chatWorkerID() uuid.UUID {
|
|
return w.opts.WorkerID
|
|
}
|
|
|
|
// Start starts the acquisition and runner manager loops.
|
|
func (w *chatWorker) Start(ctx context.Context) error {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
if w.started {
|
|
return xerrors.New("chatworker: worker already started")
|
|
}
|
|
workerID := w.opts.WorkerID
|
|
workerCtx, cancel := context.WithCancel(ctx)
|
|
manager := newRunnerManager(workerCtx, w.server, w.opts)
|
|
if manager.opts.TaskStarter == nil {
|
|
starter, err := newTaskStarter(manager.server, manager.opts, manager.RouteStateHint, manager.requestCleanup)
|
|
if err != nil {
|
|
cancel()
|
|
return err
|
|
}
|
|
manager.opts.TaskStarter = starter
|
|
}
|
|
wakeCh := make(chan struct{}, w.opts.AcquisitionWakeChannelSize)
|
|
|
|
unsubscribe, err := w.opts.Pubsub.SubscribeWithErr(
|
|
coderdpubsub.ChatStateOwnershipChannel,
|
|
coderdpubsub.HandleChatStateOwnership(func(ctx context.Context, _ coderdpubsub.ChatStateOwnershipMessage, err error) {
|
|
if err != nil {
|
|
w.opts.Logger.Warn(ctx, "chatworker ownership hint decode failed", slogError(err))
|
|
return
|
|
}
|
|
wake(wakeCh)
|
|
}),
|
|
)
|
|
if err != nil {
|
|
cancel()
|
|
return xerrors.Errorf("subscribe ownership hints: %w", err)
|
|
}
|
|
|
|
w.started = true
|
|
w.ctx = workerCtx
|
|
w.cancel = cancel
|
|
w.manager = manager
|
|
w.unsubscribe = unsubscribe
|
|
w.wakeCh = wakeCh
|
|
|
|
manager.start()
|
|
w.wg.Go(func() {
|
|
w.acquisitionLoop(workerCtx, workerID, manager, wakeCh)
|
|
})
|
|
w.wg.Go(func() {
|
|
w.archiveLoop(workerCtx)
|
|
})
|
|
if w.opts.CapacityMetrics != nil {
|
|
w.wg.Go(func() {
|
|
w.capacityMetricsLoop(workerCtx)
|
|
})
|
|
}
|
|
wake(wakeCh)
|
|
return nil
|
|
}
|
|
|
|
// Wake requests an immediate acquisition pass.
|
|
func (w *chatWorker) Wake() {
|
|
w.mu.Lock()
|
|
wakeCh := w.wakeCh
|
|
w.mu.Unlock()
|
|
if wakeCh != nil {
|
|
wake(wakeCh)
|
|
}
|
|
}
|
|
|
|
// WaitIdle waits until the worker has no active or cleaning runners.
|
|
func (w *chatWorker) WaitIdle(ctx context.Context) error {
|
|
for {
|
|
w.mu.Lock()
|
|
manager := w.manager
|
|
w.mu.Unlock()
|
|
if manager == nil || manager.idle() {
|
|
return nil
|
|
}
|
|
timer := w.opts.Clock.NewTimer(10*time.Millisecond, "chatworker", "wait-idle")
|
|
select {
|
|
case <-timer.C:
|
|
case <-ctx.Done():
|
|
timer.Stop()
|
|
return ctx.Err()
|
|
}
|
|
timer.Stop()
|
|
}
|
|
}
|
|
|
|
// Close stops the worker and waits for its loops to exit.
|
|
func (w *chatWorker) Close() error {
|
|
w.mu.Lock()
|
|
if !w.started {
|
|
w.mu.Unlock()
|
|
return nil
|
|
}
|
|
cancel := w.cancel
|
|
unsubscribe := w.unsubscribe
|
|
manager := w.manager
|
|
w.started = false
|
|
w.cancel = nil
|
|
w.unsubscribe = nil
|
|
w.manager = nil
|
|
w.wakeCh = nil
|
|
w.mu.Unlock()
|
|
|
|
if unsubscribe != nil {
|
|
unsubscribe()
|
|
}
|
|
cancel()
|
|
w.wg.Wait()
|
|
if manager != nil {
|
|
manager.wait()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func wake(ch chan<- struct{}) {
|
|
select {
|
|
case ch <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
|
|
func (w *chatWorker) acquisitionLoop(
|
|
ctx context.Context,
|
|
workerID uuid.UUID,
|
|
manager *runnerManager,
|
|
wakeCh <-chan struct{},
|
|
) {
|
|
ticker := w.opts.Clock.NewTicker(w.opts.AcquisitionInterval, "chatworker", "acquisition")
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-wakeCh:
|
|
w.acquireOnce(ctx, workerID, manager)
|
|
case <-ticker.C:
|
|
w.acquireOnce(ctx, workerID, manager)
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (w *chatWorker) acquireOnce(ctx context.Context, workerID uuid.UUID, manager *runnerManager) {
|
|
// Fetch twice the budget so one full pool cannot hide candidates in the other.
|
|
rows, err := w.opts.Store.GetChatWorkerAcquisitionCandidates(ctx, database.GetChatWorkerAcquisitionCandidatesParams{
|
|
StaleSeconds: w.opts.HeartbeatStaleSeconds,
|
|
LimitCount: w.opts.AcquisitionBatchSize * 2,
|
|
})
|
|
if err != nil {
|
|
if ctx.Err() == nil {
|
|
w.opts.Logger.Warn(ctx, "chatworker acquisition query failed", slogError(err))
|
|
}
|
|
return
|
|
}
|
|
|
|
acquired := int32(0)
|
|
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.
|
|
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) {
|
|
if isSubagent {
|
|
subagentPoolRefused = true
|
|
} else {
|
|
rootPoolRefused = true
|
|
}
|
|
continue
|
|
}
|
|
if err != nil {
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
w.opts.Logger.Warn(ctx, "chatworker acquisition candidate failed", slogError(err))
|
|
continue
|
|
}
|
|
if candidateAcquired {
|
|
acquired++
|
|
}
|
|
}
|
|
}
|
|
|
|
var (
|
|
errSkipAcquire = xerrors.New("skip acquire")
|
|
errCapacityRefused = xerrors.New("capacity refused")
|
|
)
|
|
|
|
func (w *chatWorker) acquireCandidateSafely(
|
|
ctx context.Context,
|
|
workerID uuid.UUID,
|
|
manager *runnerManager,
|
|
chatID uuid.UUID,
|
|
) (acquired bool, err error) {
|
|
defer func() {
|
|
if recovered := recover(); recovered != nil {
|
|
err = xerrors.Errorf("chatworker acquisition panic: %v", recovered)
|
|
}
|
|
}()
|
|
return w.acquireCandidate(ctx, workerID, manager, chatID)
|
|
}
|
|
|
|
func (w *chatWorker) acquireCandidate(
|
|
ctx context.Context,
|
|
workerID uuid.UUID,
|
|
manager *runnerManager,
|
|
chatID uuid.UUID,
|
|
) (bool, error) {
|
|
runnerID := uuid.New()
|
|
machine := chatstate.NewChatMachine(w.opts.Store, w.opts.Pubsub, chatID)
|
|
err := machine.Update(ctx, func(tx *chatstate.Tx, store database.Store) error {
|
|
chat, err := store.GetChatByID(ctx, chatID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return errSkipAcquire
|
|
}
|
|
if err != nil {
|
|
return xerrors.Errorf("load chat: %w", err)
|
|
}
|
|
queueCount, err := store.CountChatQueuedMessages(ctx, chatID)
|
|
if err != nil {
|
|
return xerrors.Errorf("count queue: %w", err)
|
|
}
|
|
if !chatstate.ClassifyExecutionState(chat, queueCount > 0, true).IsRunnable() || chat.Archived {
|
|
return errSkipAcquire
|
|
}
|
|
if chat.WorkerID.Valid && chat.RunnerID.Valid {
|
|
stale, err := store.IsChatHeartbeatStale(ctx, database.IsChatHeartbeatStaleParams{
|
|
ChatID: chat.ID,
|
|
RunnerID: chat.RunnerID.UUID,
|
|
StaleSeconds: w.opts.HeartbeatStaleSeconds,
|
|
})
|
|
if err != nil {
|
|
return xerrors.Errorf("check heartbeat stale: %w", err)
|
|
}
|
|
if !stale {
|
|
return errSkipAcquire
|
|
}
|
|
}
|
|
admitted, err := w.opts.AgentCapacityLimiter.Admit(ctx, store, chat)
|
|
if err != nil {
|
|
return xerrors.Errorf("agent admission: %w", err)
|
|
}
|
|
if !admitted {
|
|
// Roll back to suppress the ownership hint, which would wake every
|
|
// worker into an immediate retry of this unowned chat.
|
|
return errCapacityRefused
|
|
}
|
|
_, err = tx.Acquire(chatstate.AcquireInput{WorkerID: workerID, RunnerID: runnerID})
|
|
return err
|
|
})
|
|
if errors.Is(err, errCapacityRefused) {
|
|
return false, errCapacityRefused
|
|
}
|
|
if errors.Is(err, errSkipAcquire) || errors.Is(err, chatstate.ErrChatNotFound) {
|
|
return false, nil
|
|
}
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if err := manager.Spawn(ctx, spawnRunnerRequest{ChatID: chatID, WorkerID: workerID, RunnerID: runnerID}); err != nil {
|
|
if errAbandon := w.abandonAcquiredChat(ctx, workerID, runnerID, chatID); errAbandon != nil {
|
|
return false, errors.Join(err, errAbandon)
|
|
}
|
|
return false, err
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
func (w *chatWorker) abandonAcquiredChat(ctx context.Context, workerID uuid.UUID, runnerID uuid.UUID, chatID uuid.UUID) error {
|
|
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), shutdownCleanupTimeout)
|
|
defer cancel()
|
|
machine := chatstate.NewChatMachine(w.opts.Store, w.opts.Pubsub, chatID)
|
|
err := machine.Update(cleanupCtx, func(tx *chatstate.Tx, store database.Store) error {
|
|
chat, err := store.GetChatByID(cleanupCtx, chatID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return errSkipAcquire
|
|
}
|
|
if err != nil {
|
|
return xerrors.Errorf("load chat: %w", err)
|
|
}
|
|
if !chat.WorkerID.Valid || chat.WorkerID.UUID != workerID || !chat.RunnerID.Valid || chat.RunnerID.UUID != runnerID {
|
|
return errSkipAcquire
|
|
}
|
|
_, err = tx.Abandon(chatstate.AbandonInput{})
|
|
return err
|
|
})
|
|
if errors.Is(err, errSkipAcquire) || errors.Is(err, chatstate.ErrChatNotFound) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|