# 2026-08-03 — Nomos chat: working-visibility, message queue, generation-aware timeline

**Status:** Implemented (F1–F4) in v0.17.0. See
[Resolution](#resolution-2026-08-03) 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 `23da10db`** ran ONE live turn for **6m33s** (21 iterations,
  19:45:36→19:52:03, correlation `679566cb`). At 19:48:00 the operator typed
  `status`; 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
  ended `awaiting_input`.
- **Turn runtimes are long**: sessions in the DB run 15-27 min
  (e.g. `44df8802` 24:24, `4319b9f8` 27:05). `handleChat` (main.go:170) has **no
  SSE keepalive**; inter-iteration gaps reach 20-40s, so a proxy/browser idle
  close mid-turn resets `streaming` while the turn continues on
  `context.Background()` (pctx).
- **Re-proposing is real**: `44df8802` has **generation 1 (5 steps, all
  `replaced`) → generation 2 (25 steps, done)**, with **2 `propose_plan` + 52
  `update_plan_step`** calls 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 cases `streaming=false` while the server is actively
  working. The **session `status`** (`planning`/`executing`/`awaiting_input`) is
  the reliable "server is running a turn" signal and is already live-refreshed
  (`workspace.ts` `taskFor`, `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.ts` `hydrateSession`) with no poll, so a missed `plan.proposed`
  event leaves the panel stuck on a stale generation.
- **G4 — timeline is generation-unaware.** `activity.ts` `computeActivityLog`
  walks **all** messages' tool calls, so a re-proposed task renders N
  "Proposed plan" entries and attributes tools to steps via `currentStepSeq`
  inferred from `update_plan_step` calls across **every** generation — tools land
  under the wrong (current-gen) step or under steps that were `replaced`. 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`: `traceStatus` last-message = `working ? 'running' : …`;
  `indicatorLabel` and the AgentTrace `status`/`label` props.
- `TaskContextPanel.svelte:137` spinner and `UnifiedTimeline` `streaming` prop →
  `working`.
- Keep a separate `streaming` for the literal "live text deltas are arriving"
  cursor; `working` is 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 when `working && !streaming`.

### F2 — Queue operator messages; auto-run when free (fixes G2)
- Server: in-memory per-session FIFO on the `agent` struct (mirrors `turnGate`),
  `{message, reply}` entries. `handleChat`: when the gate is busy, **enqueue**
  instead of rejecting, and emit a `queued` SSE 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); background `resumeSession` keeps its
  non-blocking skip and never touches the queue.
- If the session is terminal (`done`/`failed`) or `awaiting_input` when a queued
  message runs, `reopenSession`/answer handling applies as for any follow-up.
- Frontend: on the `queued` event 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.ts` `computeActivityLog`: find the **last** `propose_plan` in the
  message stream; ignore `propose_plan`/`update_plan_step` calls **before** it
  for both rendering and `currentStepSeq` inference. Render at most one
  "Proposed plan" entry (the current generation). Steps continue to come from
  `$steps` (already current-gen via `fetchPlan` MAX(generation)). Optionally emit
  a single "Plan revised" entry when >1 generation exists.
- Plan-panel resilience: on any `STATUS_AFFECTING` event (and on reconnect),
  re-fetch the plan (`fetchPlan`) in addition to the live `plan.proposed` handler,
  so a missed event self-heals instead of leaving a stale generation.

## Validation

- `go test ./cmd/nomos/`: extend `turngate_test.go`/new `messagequeue_test.go` —
  queued message runs strictly after release; FIFO order preserved across 3
  queued sends; a background `resumeSession` busy-skip does **not** consume or
  starve the queue; queued message runs even if session went `awaiting_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 — `working` is true from
  `status==='executing'` even with `streaming=false`; `queued` event 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.go` concurrency assertion (max in-flight = 1) must stay
  green.
- **Status-driven `working` could stick on** if a terminal event is missed.
  Mitigated by the existing terminal `task.status` → `clearTurnState` recovery
  plus a `loadSessions` refresh 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-driven `working` makes 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. new
  `messagequeue_test.go` (FIFO, requeueFront, per-session isolation, concurrency,
  drainQueued no-op-on-empty, drainQueued requeues-when-busy). Existing
  `turngate_test.go`/`continue_test.go` still green (single-flight guarantee
  intact).
- Web `vitest` 72/72 green (added 2 F4 generation-awareness tests to
  `activity.test.ts`: one "Proposed plan" + revised marker + current-gen-only
  step attribution; plan-less Q&A attributes nothing).
- `vite build` succeeds. `tsc --noEmit` shows 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 new
  `svelte/valid-compile` on `chatWorking` got 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-driven `working` makes background work
  visible without live per-tool deltas, so this stays lower priority.
