feat(nomos): chat working-visibility, message queue, generation-aware timeline
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled

Make background/long/desynced turns visible and queueable, fixing the four
symptoms that survived the v0.15.0 chat reliability pass.

F1 - status-driven working signal (workspace.ts taskWorking/currentWorking =
streaming OR status in {planning,executing}). Drives the chat trace, indicator,
and activity spinner so a turn with no live stream (background resume, a dropped
SSE, an idle-close mid long turn) still looks alive.

F2 - operator messages sent during an in-flight turn are now QUEUED and
auto-run when the gate frees, replacing the "still finishing a previous step...
send it again" rejection. Per-session in-memory FIFO (messagequeue.go, capped at
20) drained one-at-a-time under the turn gate; a `queued` SSE event drives a
"Queued" hint. drainQueued releases via a per-iteration deferred closure so a
runChatTurn panic can't deadlock the session's gate.

F3 - SSE keepalive (12s `:keepalive` comment) in handleChat so 20-40s
inter-iteration gaps no longer trip a proxy/browser idle close (the desync root
cause). All SSE writes serialized through one mutex.

F4 - generation-aware activity timeline (only the last propose_plan renders;
superseded ones collapse to one "Earlier plan revised" marker; step-attribution
follows only the current generation) + debounced plan refetch on lifecycle
events so a missed plan.proposed self-heals.

Verified against the last session (23da10db: 6m33s turn, operator "status"
deferred at 19:48:05). go test ./cmd/nomos/ green (new messagequeue tests);
web vitest 72/72 (new F4 generation tests); vite build clean.

VERSION: 0.16.0 -> 0.17.0
This commit is contained in:
2026-08-03 22:34:14 +02:00
parent 757ef2f34b
commit 5b68bdc16c
17 changed files with 877 additions and 136 deletions

82
cmd/nomos/messagequeue.go Normal file
View File

@@ -0,0 +1,82 @@
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])
}