package main import ( "log/slog" "sync" ) // maxQueuedPerSession caps a session's queue. A held turn plus unbounded // enqueues would grow memory without limit; an operator nudging a long // autonomous turn realistically queues only a handful, so a generous cap is // pure insurance. Overflow drops the newest enqueue and logs (the message is // already persisted in the DB by handleChat before enqueue, so it isn't lost // from the transcript — it just won't auto-run). const maxQueuedPerSession = 20 // messageQueue holds operator messages that arrived while a turn was already // running for a session. Plan 2026-08-03 (F2): instead of rejecting the // operator's message with "Nomos is still finishing a previous step… send it // again", the message is queued and auto-run when the in-flight turn releases // the session's turn-gate permit. // // The queue only schedules WHEN a turn runs, not WHETHER the message is stored // — handleChat persists the user message before acquiring the gate, so a queued // message is already in the transcript; this just makes sure a turn eventually // acts on it. // // Draining is strictly one-at-a-time under the turn gate (see drainQueued in // main.go), so this cannot stack concurrent turns — the exact hazard the gate // itself exists to prevent. Background resumeSession callers never touch this // queue; they keep their non-blocking skip. type messageQueue struct { mu sync.Mutex queue map[string][]string } func newMessageQueue() *messageQueue { return &messageQueue{queue: map[string][]string{}} } // enqueue appends a message to the back of the session's FIFO. Returns false // (and logs) if the session is already at maxQueuedPerSession — the caller's // message is already persisted in the DB, so this only skips auto-running it. func (q *messageQueue) enqueue(sessionID, msg string) bool { q.mu.Lock() defer q.mu.Unlock() if len(q.queue[sessionID]) >= maxQueuedPerSession { slog.Warn("nomos: message queue full; dropping auto-run for operator message", "session", sessionID, "cap", maxQueuedPerSession) return false } q.queue[sessionID] = append(q.queue[sessionID], msg) return true } // dequeue pops the next message from the front of the session's FIFO. Returns // ok=false when empty. func (q *messageQueue) dequeue(sessionID string) (string, bool) { q.mu.Lock() defer q.mu.Unlock() xs := q.queue[sessionID] if len(xs) == 0 { return "", false } m := xs[0] q.queue[sessionID] = xs[1:] return m, true } // requeueFront pushes a message back to the front — used when a drainer popped // a message but lost the race for the gate to a live turn; that turn's own // release will drain it again. func (q *messageQueue) requeueFront(sessionID, msg string) { q.mu.Lock() defer q.mu.Unlock() q.queue[sessionID] = append([]string{msg}, q.queue[sessionID]...) } // peek reports the queued depth for a session (test/diagnostic helper). func (q *messageQueue) peek(sessionID string) int { q.mu.Lock() defer q.mu.Unlock() return len(q.queue[sessionID]) }