Move ten completed plans from plans/ to plans/done/ and update the index:
- 2026-07-18 session-review-three-sessions, 2026-07-20 desktop-mascot,
2026-07-20 session-review-ten-sessions, 2026-07-21 chat-full-polish,
2026-07-29 health-check-reality-and-knowledge-graph,
2026-07-30 session-review-plan-drift, and the four 2026-08-03 chat plans
(changes-review, reliability-and-ux-audit, cyberspace-style-adoption,
working-visibility).
- Refresh two stale statuses: cyberspace-style-adoption ("Draft" -> shipped as
full replacement in v0.16.0/757ef2f) and health-check-reality ("ready for
implementation" -> shipped across the v0.14.x-0.16.x check commits).
- .gitignore: ignore local tooling artifacts (.playwright-mcp/, config-screen.png).
No code change. index.md Active/Done tables now match the filesystem (no orphans).
VERSION: 0.17.0 -> 0.17.1
12 KiB
2026-08-03 — Nomos chat: working-visibility, message queue, generation-aware timeline
Status: Implemented (F1–F4) in v0.17.0. See Resolution at the end.
Context (grounded in last-session logs + DB, not just code)
Operator report: "On the chat window I can't tell the agent is working; it's making tool calls but no feedback. Typing returns 'Nomos is still finishing a previous step…'. Activity not up to date. Several plans at once, some don't execute."
Verified against runtime state:
- Last session
23da10dbran ONE live turn for 6m33s (21 iterations, 19:45:36→19:52:03, correlation679566cb). At 19:48:00 the operator typedstatus; at 19:48:05 the turn gate deferred it (turn already active, deferring operator message). The operator could type at all only because the client had already lost the stream (streaming=false) while the server kept running — i.e. the client showed an idle window over a working turn. It endedawaiting_input. - Turn runtimes are long: sessions in the DB run 15-27 min
(e.g.
44df880224:24,4319b9f827:05).handleChat(main.go:170) has no SSE keepalive; inter-iteration gaps reach 20-40s, so a proxy/browser idle close mid-turn resetsstreamingwhile the turn continues oncontext.Background()(pctx). - Re-proposing is real:
44df8802has generation 1 (5 steps, allreplaced) → generation 2 (25 steps, done), with 2propose_plan+ 52update_plan_stepcalls persisted. The activity timeline renders every one of those across both generations.
Root causes
- G1 — "working" ==
streaming. Every working-indication in the chat window (AgentTrace running status, indicator headline, stream cursor, panel spinner,disabled={streaming}input) is gated on the live SSE flag. A background turn (resumeSession/continuation worker) has no stream; a desynced long live turn has a dead stream. In both casesstreaming=falsewhile the server is actively working. The sessionstatus(planning/executing/awaiting_input) is the reliable "server is running a turn" signal and is already live-refreshed (workspace.tstaskFor,STATUS_AFFECTING), but the chat UI never uses it. - G2 — busy-turn message is rejected, not queued. main.go:292-302: the turn gate waits 5s then emits the "still finishing a previous step" error and returns. The user message is persisted (main.go:270) but is inert — the user must manually re-send.
- G3 — activity is poll/event laggy. Tool-level activity derives from
messages, refreshed only by the 3s poller; plan steps are events-only (workspace.tshydrateSession) with no poll, so a missedplan.proposedevent leaves the panel stuck on a stale generation. - G4 — timeline is generation-unaware.
activity.tscomputeActivityLogwalks all messages' tool calls, so a re-proposed task renders N "Proposed plan" entries and attributes tools to steps viacurrentStepSeqinferred fromupdate_plan_stepcalls across every generation — tools land under the wrong (current-gen) step or under steps that werereplaced. This is the "several plans / some steps never run" view.
Fixes (ordered)
F1 — Status-driven working signal (fixes G1)
Add a derived store taskWorking(sessionId) = $streaming OR status ∈ {planning, executing} (explicitly not awaiting_input — that is paused for
input), plus a global currentWorking for the main view backed by currentTask.
Use it wherever streaming currently drives "is it working":
ChatThread.svelte:traceStatuslast-message =working ? 'running' : …;indicatorLabeland the AgentTracestatus/labelprops.TaskContextPanel.svelte:137spinner andUnifiedTimelinestreamingprop →working.- Keep a separate
streamingfor the literal "live text deltas are arriving" cursor;workingis the superset for indicators/input. - Input stays enabled while
working(the user must be able to interject); the send path queues when busy (F2). Show a muted "Nomos is working…" hint in the composer whenworking && !streaming.
F2 — Queue operator messages; auto-run when free (fixes G2)
- Server: in-memory per-session FIFO on the
agentstruct (mirrorsturnGate),{message, reply}entries.handleChat: when the gate is busy, enqueue instead of rejecting, and emit aqueuedSSE event (replaces today's error at main.go:294-302). Persist the user message as today (already done pre-acquire). - Drain: arm a per-session drainer that, on gate release, acquires again and runs
the next queued message as a normal turn (same persist/emit path as
handleChat). Strictly one-at-a-time under the gate — this cannot stack turns (the hazard v0.15.0 F1 removed); backgroundresumeSessionkeeps its non-blocking skip and never touches the queue. - If the session is terminal (
done/failed) orawaiting_inputwhen a queued message runs,reopenSession/answer handling applies as for any follow-up. - Frontend: on the
queuedevent show an inline "Queued — will run when the current step finishes" chip on that user bubble; clear it when the turn's real events begin. Drop the humanized "still finishing" error for the busy case.
F3 — SSE keepalive on handleChat (prevents the G1 desync at the source)
Wrap a.chat(...) in a goroutine + select with a 10-15s ticker that writes
an SSE comment (:keepalive\n\n) and flushes, so 20-40s inter-iteration gaps no
longer trip proxy/browser idle timeouts. Stop the ticker when a.chat returns.
(EventSource ignores comment lines by spec — safe.)
F4 — Generation-aware timeline + self-healing plan panel (fixes G3/G4)
activity.tscomputeActivityLog: find the lastpropose_planin the message stream; ignorepropose_plan/update_plan_stepcalls before it for both rendering andcurrentStepSeqinference. Render at most one "Proposed plan" entry (the current generation). Steps continue to come from$steps(already current-gen viafetchPlanMAX(generation)). Optionally emit a single "Plan revised" entry when >1 generation exists.- Plan-panel resilience: on any
STATUS_AFFECTINGevent (and on reconnect), re-fetch the plan (fetchPlan) in addition to the liveplan.proposedhandler, so a missed event self-heals instead of leaving a stale generation.
Validation
go test ./cmd/nomos/: extendturngate_test.go/newmessagequeue_test.go— queued message runs strictly after release; FIFO order preserved across 3 queued sends; a backgroundresumeSessionbusy-skip does not consume or starve the queue; queued message runs even if session wentawaiting_input.- Web
vitest:activity.test.ts— add a 2-generation fixture (2× propose_plan, interleaved update_plan_step) asserting exactly one "Proposed plan" and correct step attribution to gen-2 steps;chat/store test —workingis true fromstatus==='executing'even withstreaming=false;queuedevent renders the queued chip and clears on first tool_use. - Manual: (a) start a long task, reload the window mid-turn → the working
indicator stays on (status-driven); (b) send a message mid-turn → "Queued" →
runs after the turn; (c) open
44df8802-style 2-gen session → timeline shows one plan, no ghost proposals.
Risks
- F2 must not reintroduce concurrent turns. The queue drains one-at-a-time
under the gate; background resume remains non-blocking and queue-agnostic.
Existing
turngate_test.goconcurrency assertion (max in-flight = 1) must stay green. - Status-driven
workingcould stick on if a terminal event is missed. Mitigated by the existing terminaltask.status→clearTurnStaterecovery plus aloadSessionsrefresh on reconnect (F4). - Keepalive comments must stay SSE comments (
:prefix) so they aren't parsed as events.
Out of scope / follow-ups
- Model efficiency: the 8+ pure-exploration iterations (repeated
list_entities/get_relations) that inflate turn length to 15-27 min — prompt/iteration-budget tuning, separate effort. - F8 from the prior plan (oldest-first timeline toggle; per-tool
tool.*events for background turns). F1's status-drivenworkingmakes background work visible without live per-tool deltas, so this remains lower priority.
Open implementation note
Host the per-session message queue on the agent struct (in-memory map[string] []queuedMsg + per-session drainer goroutine), mirroring turnGate. No DB table
needed — messages are already persisted by handleChat before enqueue; the queue
only schedules when a turn runs, not whether the message is stored.
Resolution (2026-08-03)
Implemented F1–F4 in v0.15.1 → v0.17.0 (the intermediate 0.16.0 was the cyberspace-aesthetic commit, landed via auto-pull during this work).
| Item | What shipped | Where |
|---|---|---|
| F1 | Status-driven working signal (taskWorking(sessionId) / currentWorking) = live stream OR session status ∈ {planning, executing}. Drives the chat trace running state, the "thinking" headline, the activity spinner, and the timeline streaming prop — so a background/long/desynced turn still looks alive (the "can't tell it's working" symptom). The composer stays enabled during background work so the operator can interject. |
web/src/lib/stores/workspace.ts (isWorking, taskWorking, currentWorking), ChatThread.svelte (working prop, traceStatus, indicator), TaskContextPanel.svelte, SessionChatWindow.svelte, NewTaskChat.svelte. |
| 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 drained strictly one-at-a-time under the turn gate (no concurrent-turn reintroduction). A queued SSE event tells the client, which drops the optimistic bubble and shows a "Queued — will run when it finishes the current step" hint (derived from working + last-message shape, so it survives the poller). |
cmd/nomos/messagequeue.go (+messagequeue_test.go), agent.go (queue field), main.go (runChatTurn, drainQueued, handleChat queue path), continue.go (resumeSession drains on release), web/src/lib/types.ts (ChatQueuedEvent), chat.ts (queued handling in sendSessionMessage/startTask). |
| F3 | SSE keepalive: a 12s :keepalive comment ticker during handleChat so 20-40s inter-iteration gaps no longer trip a proxy/browser idle timeout (the desync root cause). All SSE writes (events + keepalive) serialized through one mutex — http.ResponseWriter is not concurrency-safe. |
cmd/nomos/main.go (writeMu/writeEvent, keepalive goroutine). |
| F4 | Generation-aware activity timeline: only the LAST propose_plan renders as "Proposed plan"; superseded ones collapse to a single "Earlier plan revised" marker, and step-attribution only follows the current generation's update_plan_step calls. Plus plan-panel self-heal: the plan is refetched (debounced) on any task-lifecycle event so a missed plan.proposed no longer freezes the panel on a stale generation. |
web/src/lib/stores/activity.ts (computeActivityLog), workspace.ts (schedulePlanRefetch). |
Verification:
go vet ./cmd/nomos/clean;go test ./cmd/nomos/green, incl. newmessagequeue_test.go(FIFO, requeueFront, per-session isolation, concurrency, drainQueued no-op-on-empty, drainQueued requeues-when-busy). Existingturngate_test.go/continue_test.gostill green (single-flight guarantee intact).- Web
vitest72/72 green (added 2 F4 generation-awareness tests toactivity.test.ts: one "Proposed plan" + revised marker + current-gen-only step attribution; plan-less Q&A attributes nothing). vite buildsucceeds.tsc --noEmitshows only the pre-existing baseline errors (ui/*,oidc.ts,windows.ts,workspace.ts:123/201/221) noted in v0.15.0 — no new errors from this change. ESLint: no new errors (the one newsvelte/valid-compileonchatWorkinggot the same disable its siblings have).
Follow-ups (not in this pass):
- Model efficiency: the long (15-27 min) exploration-heavy turns that made the desync so painful — prompt / iteration-budget tuning, separate effort.
- F8 from the prior plan (oldest-first timeline toggle; per-tool
tool.*events for background turns). F1's status-drivenworkingmakes background work visible without live per-tool deltas, so this stays lower priority.