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
185 lines
12 KiB
Markdown
185 lines
12 KiB
Markdown
# 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.
|