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

View File

@@ -1,6 +1,6 @@
import { writable, derived, get, type Writable, type Readable } from 'svelte/store'
import { liveEvents, subscribeEvents } from './events'
import { currentSession, sessions, loadSessions } from './chat'
import { currentSession, sessions, loadSessions, chatFor, streaming } from './chat'
import {
fetchPlan,
fetchQuestions,
@@ -271,10 +271,13 @@ export function startWorkspace(): () => void {
if (!sid) return
// Oldest-first application so ordering (e.g. plan.step.started before
// .finished) is preserved.
let planAffecting = false
for (const e of fresh.slice().reverse()) {
applyEventTo(globalWorkspace, sid, e)
applyHealthChangedTo(globalWorkspace, e)
if (e.correlation_id === sid && STATUS_AFFECTING.has(e.type)) planAffecting = true
}
if (planAffecting) schedulePlanRefetch(globalWorkspace, sid)
})
return () => {
@@ -306,12 +309,62 @@ export function taskFor(sessionId: string): Readable<Session | null> {
return derived(sessions, ($sessions) => $sessions.find((s) => s.id === sessionId) ?? null)
}
// Session statuses where the server is actively running a turn for this task.
// Deliberately EXCLUDES `awaiting_input` (paused for the operator) and the
// terminal states (done/failed/abandoned). This is the reliable "the agent is
// working" truth that survives a dropped SSE stream or an autonomous/background
// turn (which has no chat stream at all) — see plan 2026-08-03 F1.
const ACTIVE_TURN_STATUS = new Set(['planning', 'executing'])
function isWorking($streaming: boolean, $task: Session | null): boolean {
return $streaming || (!!$task && !!$task.status && ACTIVE_TURN_STATUS.has($task.status))
}
// taskWorking(sessionId): true while this session has a live stream OR its
// server-side status says a turn is running. Used by the chat window's
// "working" indicator, trace running state, and the activity spinner so a
// background/long/desynced turn still looks alive (the symptom: "can't tell
// the agent is working").
export function taskWorking(sessionId: string): Readable<boolean> {
const chat = chatFor(sessionId)
return derived([chat.streaming, taskFor(sessionId)], ([$s, $t]) => isWorking($s, $t))
}
// Global "current session" working signal for the main view's panel.
export const currentWorking = derived(
[streaming, currentTask],
([$s, $t]) => isWorking($s, $t)
)
async function hydrateSession(ws: WorkspaceState, sessionId: string) {
const [steps, qs] = await Promise.all([fetchPlan(sessionId), fetchQuestions(sessionId)])
ws.planSteps.set(steps)
ws.questions.set(qs)
}
// Self-heal for the plan panel (plan 2026-08-03 F4): plan steps are otherwise
// driven ONLY by live plan.proposed/plan.step.* events plus a one-time hydrate
// on mount. If an event is missed (window opened mid-turn, a brief events-
// stream gap), the panel freezes on a stale generation. Refetching the plan
// (current generation) on any task-lifecycle event makes it converge back to
// truth. Debounced per session since several of these land in one burst.
const planRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>()
function schedulePlanRefetch(ws: WorkspaceState, sessionId: string) {
const existing = planRefreshTimers.get(sessionId)
if (existing) clearTimeout(existing)
planRefreshTimers.set(
sessionId,
setTimeout(async () => {
planRefreshTimers.delete(sessionId)
try {
ws.planSteps.set(await fetchPlan(sessionId))
} catch {
// network blip — the next lifecycle event retries
}
}, 400)
)
}
export function startSessionWorkspace(sessionId: string): () => void {
const ws = workspaceFor(sessionId)
const unsub = subscribeEvents()
@@ -327,10 +380,13 @@ export function startSessionWorkspace(sessionId: string): () => void {
if (maxId <= lastSeen) return
const fresh = evs.filter((e) => e.id > lastSeen)
lastSeen = maxId
let planAffecting = false
for (const e of fresh.slice().reverse()) {
applyEventTo(ws, sessionId, e)
applyHealthChangedTo(ws, e)
if (e.correlation_id === sessionId && STATUS_AFFECTING.has(e.type)) planAffecting = true
}
if (planAffecting) schedulePlanRefetch(ws, sessionId)
})
return () => {