Files
thotam 3f90057c24 fix(pipeline): stop aborting runs on heuristic context budget estimates (#1587)
Runs on models without a registered tokenizer (e.g. 9router brand models)
ended with the generic "Agent couldn't generate a response" fallback even
though the real request used about 55% of the context window.

PruneStage counted history with TokenCounter, which falls back to a
chars/2 heuristic for unregistered models and overcounted about 1.8x.
Once over budget it ran memory flush (~35s, invisible in traces), then
mid-loop compaction, which cannot summarize a history made only of tool
call/result pairs. The callback reported the untouched history as
compacted, PruneStage still saw it over budget and returned AbortRun
before any LLM call, and FinalizeStage replaced the empty reply with the
fallback.

- PruneStage and ContextStage overhead count with the request guard's
  BudgetCounter. PruneStage no longer controls loop flow; the final
  request guard in ThinkStage decides.
- CompactMessages returns ErrNotCompacted when history is unchanged.
  Callers stop counting it as a compaction and do not retry it in the
  same run, while post-run summarization still sees the pressure.
- When the guard exhausts every reduction step, ThinkStage stops the run
  with a localized chat.context_budget_exceeded notice instead of an
  error, so the run's tool results are still persisted. The stop reason
  marks the trace and agent span as error; team tasks, cron and
  heartbeat treat it as a failure via RunOutcome.Failure().
- Memory flush and mid-loop compaction emit event spans.
- Web and desktop UIs treat an unset context_pruning as enabled (the
  backend default since 7639a8c0), keep it unset when untouched, and can
  re-enable pruning after it was turned off.
2026-09-29 18:04:08 +07:00

485 lines
13 KiB
Go

package scheduler
import (
"context"
"errors"
"fmt"
"log/slog"
"sync"
"time"
"github.com/nextlevelbuilder/goclaw/internal/agent"
)
// QueueMode determines how incoming messages are handled when an agent
// is already processing a message for the same session.
type QueueMode string
const (
// QueueModeQueue is simple FIFO: new messages wait until current finishes.
QueueModeQueue QueueMode = "queue"
// QueueModeFollowup queues as a follow-up after the current run completes.
QueueModeFollowup QueueMode = "followup"
// QueueModeInterrupt cancels the current run and starts the new message.
QueueModeInterrupt QueueMode = "interrupt"
)
// DropPolicy determines which messages to drop when the queue is full.
type DropPolicy string
const (
DropOld DropPolicy = "old" // drop oldest message
DropNew DropPolicy = "new" // reject incoming message
)
// QueueConfig configures per-session message queuing.
type QueueConfig struct {
Mode QueueMode `json:"mode"`
Cap int `json:"cap"`
Drop DropPolicy `json:"drop"`
DebounceMs int `json:"debounce_ms"`
MaxConcurrent int `json:"max_concurrent"` // 0 or 1 = serial (default)
}
// DefaultQueueConfig returns sensible defaults.
func DefaultQueueConfig() QueueConfig {
return QueueConfig{
Mode: QueueModeQueue,
Cap: 10,
Drop: DropOld,
DebounceMs: 800,
MaxConcurrent: 1,
}
}
// RunFunc is the callback that executes an agent run.
// The scheduler calls this when it's the request's turn.
type RunFunc func(ctx context.Context, req agent.RunRequest) (*agent.RunResult, error)
// TokenEstimateFunc returns token estimate and context window for a session.
// Used by adaptive throttle to reduce concurrency near the summary threshold.
type TokenEstimateFunc func(sessionKey string) (tokens int, contextWindow int)
// PendingRequest is a queued agent run awaiting execution.
type PendingRequest struct {
Req agent.RunRequest
ResultCh chan RunOutcome
EnqueuedAt time.Time // timestamp when enqueued, used for stale message detection
}
// RunOutcome is the result of a scheduled agent run.
type RunOutcome struct {
Result *agent.RunResult
Err error
}
// Failure returns Err, or the reason the pipeline stopped the run itself (the
// run still carries a user-facing notice). Automated consumers treat both as failed.
func (o RunOutcome) Failure() error {
if o.Err != nil {
return o.Err
}
if o.Result != nil && o.Result.StopReason != "" {
return errors.New(o.Result.StopReason)
}
return nil
}
// activeRunEntry tracks a running agent execution with its generation.
type activeRunEntry struct {
cancel context.CancelFunc
generation uint64
}
// SessionQueue manages agent runs for a single session key.
// Supports configurable concurrency: 1 (serial) or N (concurrent).
type SessionQueue struct {
key string
config QueueConfig
runFn RunFunc
laneMgr *LaneManager
lane string
mu sync.Mutex
queue []*PendingRequest
activeRuns map[string]activeRunEntry // runID → entry (with generation)
activeOrder []string // FIFO order of active runIDs
maxConcurrent int // effective limit (from config or per-session override)
timer *time.Timer // debounce timer
parentCtx context.Context // stored from first Enqueue call
abortCutoffTime time.Time // messages enqueued before this are stale
generation uint64 // bumped on Reset() to ignore stale completions
tokenEstimateFn TokenEstimateFunc // optional: for adaptive throttle
}
// NewSessionQueue creates a queue for a specific session.
func NewSessionQueue(key, lane string, cfg QueueConfig, laneMgr *LaneManager, runFn RunFunc) *SessionQueue {
maxC := cfg.MaxConcurrent
if maxC <= 0 {
maxC = 1
}
return &SessionQueue{
key: key,
config: cfg,
runFn: runFn,
laneMgr: laneMgr,
lane: lane,
activeRuns: make(map[string]activeRunEntry),
maxConcurrent: maxC,
}
}
// SetMaxConcurrent overrides the per-session max concurrent runs.
// Typically called from the consumer when it knows the peer kind (group vs DM).
func (sq *SessionQueue) SetMaxConcurrent(n int) {
sq.mu.Lock()
defer sq.mu.Unlock()
if n <= 0 {
n = 1
}
sq.maxConcurrent = n
}
// effectiveMaxConcurrent returns the current concurrency limit,
// reduced to 1 when near the summary threshold (adaptive throttle).
// Must be called with sq.mu held.
func (sq *SessionQueue) effectiveMaxConcurrent() int {
max := sq.maxConcurrent
if max <= 0 {
max = 1
}
if sq.tokenEstimateFn == nil {
return max
}
tokens, contextWindow := sq.tokenEstimateFn(sq.key)
if contextWindow > 0 && float64(tokens)/float64(contextWindow) >= 0.6 {
return 1 // near summary threshold → serialize
}
return max
}
// hasCapacity returns whether a new run can start.
// Must be called with sq.mu held.
func (sq *SessionQueue) hasCapacity() bool {
return len(sq.activeRuns) < sq.effectiveMaxConcurrent()
}
// Enqueue adds a request to the session queue.
// If capacity is available, it starts immediately (after debounce).
// Returns a channel that receives the result when the run completes.
func (sq *SessionQueue) Enqueue(ctx context.Context, req agent.RunRequest) <-chan RunOutcome {
outcome := make(chan RunOutcome, 1)
pending := &PendingRequest{Req: req, ResultCh: outcome, EnqueuedAt: time.Now()}
sq.mu.Lock()
defer sq.mu.Unlock()
// Store parent context for spawning future runs
if sq.parentCtx == nil {
sq.parentCtx = ctx
}
switch sq.config.Mode {
case QueueModeInterrupt:
// Cancel all active runs
for runID, entry := range sq.activeRuns {
entry.cancel()
delete(sq.activeRuns, runID)
}
sq.activeOrder = nil
// Clear existing queue and enqueue this one
sq.drainQueue(RunOutcome{Err: context.Canceled})
sq.queue = append(sq.queue, pending)
if sq.hasCapacity() {
sq.scheduleNext(ctx)
}
default: // queue, followup
if len(sq.queue) >= sq.config.Cap {
sq.applyDropPolicy(pending)
} else {
sq.queue = append(sq.queue, pending)
}
if sq.hasCapacity() {
sq.scheduleNext(ctx)
}
}
return outcome
}
// scheduleNext starts the next queued request(s), applying debounce.
// Must be called with sq.mu held.
func (sq *SessionQueue) scheduleNext(ctx context.Context) {
if len(sq.queue) == 0 {
return
}
debounce := time.Duration(sq.config.DebounceMs) * time.Millisecond
if debounce <= 0 {
sq.startAvailable(ctx)
return
}
// Reset debounce timer: collapses rapid messages
if sq.timer != nil {
sq.timer.Stop()
}
sq.timer = time.AfterFunc(debounce, func() {
sq.mu.Lock()
defer sq.mu.Unlock()
if sq.hasCapacity() && len(sq.queue) > 0 {
sq.startAvailable(ctx)
}
})
}
// startAvailable starts as many queued requests as capacity allows.
// Must be called with sq.mu held.
func (sq *SessionQueue) startAvailable(ctx context.Context) {
for sq.hasCapacity() && len(sq.queue) > 0 {
sq.startOne(ctx)
}
}
// startOne picks the first queued request and runs it in the lane.
// Skips stale messages that were enqueued before the last abort cutoff.
// Must be called with sq.mu held.
func (sq *SessionQueue) startOne(ctx context.Context) {
// Skip stale messages enqueued before the last /stopall abort cutoff.
for len(sq.queue) > 0 {
head := sq.queue[0]
if !sq.abortCutoffTime.IsZero() && head.EnqueuedAt.Before(sq.abortCutoffTime) {
sq.queue = sq.queue[1:]
head.ResultCh <- RunOutcome{Err: ErrMessageStale}
close(head.ResultCh)
slog.Debug("scheduler: skipped stale message",
"session", sq.key,
"enqueued", head.EnqueuedAt,
"cutoff", sq.abortCutoffTime,
)
continue
}
// Clear cutoff once a non-stale message is found
sq.abortCutoffTime = time.Time{}
break
}
if len(sq.queue) == 0 {
return
}
pending := sq.queue[0]
sq.queue = sq.queue[1:]
runID := pending.Req.RunID
runCtx, cancel := context.WithCancel(ctx)
sq.activeRuns[runID] = activeRunEntry{cancel: cancel, generation: sq.generation}
sq.activeOrder = append(sq.activeOrder, runID)
lane := sq.laneMgr.Get(sq.lane)
if lane == nil {
lane = sq.laneMgr.Get(LaneMain)
}
gen := sq.generation // capture generation under lock
if lane == nil {
// No lane available — run directly
go sq.executeRun(runCtx, runID, gen, pending)
return
}
err := lane.Submit(ctx, func() {
sq.executeRun(runCtx, runID, gen, pending)
})
if err != nil {
pending.ResultCh <- RunOutcome{Err: err}
close(pending.ResultCh)
// caller already holds sq.mu — clean up
delete(sq.activeRuns, runID)
sq.removeFromOrder(runID)
}
}
// executeRun runs the agent and then starts the next queued message(s) if capacity allows.
func (sq *SessionQueue) executeRun(ctx context.Context, runID string, runGeneration uint64, pending *PendingRequest) {
// Defense-in-depth: if runFn panics despite agent-level recovery,
// ensure cleanup still runs so the session queue doesn't orphan this run.
defer func() {
if r := recover(); r != nil {
slog.Error("scheduler: executeRun panicked", "run_id", runID, "panic", fmt.Sprint(r))
pending.ResultCh <- RunOutcome{Err: fmt.Errorf("run panic: %v", r)}
close(pending.ResultCh)
sq.mu.Lock()
delete(sq.activeRuns, runID)
sq.removeFromOrder(runID)
if sq.hasCapacity() && len(sq.queue) > 0 {
sq.scheduleNext(sq.parentCtx)
}
sq.mu.Unlock()
}
}()
result, err := sq.runFn(ctx, pending.Req)
pending.ResultCh <- RunOutcome{Result: result, Err: err}
close(pending.ResultCh)
sq.mu.Lock()
// Check generation: ignore stale completions from a previous generation.
if entry, ok := sq.activeRuns[runID]; ok && entry.generation == sq.generation {
delete(sq.activeRuns, runID)
sq.removeFromOrder(runID)
} else if runGeneration != sq.generation {
// Stale completion from old generation — skip cleanup.
sq.mu.Unlock()
return
}
if sq.hasCapacity() && len(sq.queue) > 0 {
// Use parentCtx (not the per-run ctx which may be cancelled)
sq.scheduleNext(sq.parentCtx)
}
sq.mu.Unlock()
}
// removeFromOrder removes a runID from the activeOrder slice.
// Must be called with sq.mu held.
func (sq *SessionQueue) removeFromOrder(runID string) {
for i, id := range sq.activeOrder {
if id == runID {
sq.activeOrder = append(sq.activeOrder[:i], sq.activeOrder[i+1:]...)
return
}
}
}
// applyDropPolicy handles a full queue.
// Must be called with sq.mu held.
func (sq *SessionQueue) applyDropPolicy(incoming *PendingRequest) {
switch sq.config.Drop {
case DropOld:
// Drop the oldest queued message
if len(sq.queue) > 0 {
old := sq.queue[0]
old.ResultCh <- RunOutcome{Err: ErrQueueDropped}
close(old.ResultCh)
sq.queue = sq.queue[1:]
}
sq.queue = append(sq.queue, incoming)
case DropNew:
// Reject the incoming message
incoming.ResultCh <- RunOutcome{Err: ErrQueueFull}
close(incoming.ResultCh)
default:
// Default to drop old
if len(sq.queue) > 0 {
old := sq.queue[0]
old.ResultCh <- RunOutcome{Err: ErrQueueDropped}
close(old.ResultCh)
sq.queue = sq.queue[1:]
}
sq.queue = append(sq.queue, incoming)
}
}
// drainQueue cancels all pending requests with the given outcome.
// Must be called with sq.mu held.
func (sq *SessionQueue) drainQueue(outcome RunOutcome) {
for _, p := range sq.queue {
p.ResultCh <- outcome
close(p.ResultCh)
}
sq.queue = nil
}
// CancelOne stops the oldest active run (FIFO).
// Does NOT drain the pending queue or set abort cutoff. Used by /stop command.
// Returns true if an active run was actually cancelled.
func (sq *SessionQueue) CancelOne() bool {
sq.mu.Lock()
defer sq.mu.Unlock()
if len(sq.activeOrder) == 0 {
return false
}
// Cancel the oldest active run
runID := sq.activeOrder[0]
if entry, ok := sq.activeRuns[runID]; ok {
entry.cancel()
delete(sq.activeRuns, runID)
sq.activeOrder = sq.activeOrder[1:]
return true
}
return false
}
// CancelAll stops all active runs and drains all pending requests.
// Sets abort cutoff so stale queued messages are skipped on next schedule.
// Used by /stopall command.
// Returns true if any active run was actually cancelled.
func (sq *SessionQueue) CancelAll() bool {
sq.mu.Lock()
defer sq.mu.Unlock()
sq.abortCutoffTime = time.Now() // mark cutoff for stale message skipping
cancelled := false
for runID, entry := range sq.activeRuns {
entry.cancel()
delete(sq.activeRuns, runID)
cancelled = true
}
sq.activeOrder = nil
sq.drainQueue(RunOutcome{Err: context.Canceled})
return cancelled
}
// Cancel is an alias for CancelAll (backward compat with /stop command).
func (sq *SessionQueue) Cancel() bool {
return sq.CancelAll()
}
// IsActive returns whether any run is currently executing.
func (sq *SessionQueue) IsActive() bool {
sq.mu.Lock()
defer sq.mu.Unlock()
return len(sq.activeRuns) > 0
}
// ActiveCount returns the number of currently executing runs.
func (sq *SessionQueue) ActiveCount() int {
sq.mu.Lock()
defer sq.mu.Unlock()
return len(sq.activeRuns)
}
// QueueLen returns the number of pending messages.
func (sq *SessionQueue) QueueLen() int {
sq.mu.Lock()
defer sq.mu.Unlock()
return len(sq.queue)
}
// Reset bumps the generation counter, cancels all active runs, and drains
// the pending queue. Stale completions from the old generation are ignored.
// Used during in-process restart (e.g. SIGUSR1).
func (sq *SessionQueue) Reset() {
sq.mu.Lock()
defer sq.mu.Unlock()
sq.generation++
for _, entry := range sq.activeRuns {
entry.cancel()
}
sq.activeRuns = make(map[string]activeRunEntry)
sq.activeOrder = nil
sq.drainQueue(RunOutcome{Err: ErrLaneCleared})
}