# 2026-08-03 — Review: nomos chat reliability/UX changes (F1–F7) **Status:** Implemented (P0, P1, P2 all done). See [Resolution](#resolution) at the end. A critical self-review of the uncommitted F1–F7 changeset (`plans/2026-08-03-nomos-chat-reliability-and-ux-audit.md` Resolution). The change set is mostly sound and builds/tests green, but **F1 introduced one real lost-work regression** by changing the contract of `resumeSession` (it can now skip) without updating two callers that mutate state *before* calling it. That must be fixed before this ships. ## What was changed (for orientation) - F1 `cmd/nomos/turngate.go` (+test): per-session single-flight; `resumeSession` acquires non-blocking and **skips** if a turn is active; `handleChat` live path acquires with a 5s wait. - F2/F3 `web/src/lib/stores/chat.ts`: humanized errors, `clearTurnState` on terminal `task.status`, turn-free reconnect. - F4 streaming in global `activityLog` + inline `ToolCallCard`. - F5 artifact/knowledge deep links; F6 step-first headline; F7 stable layout. --- ## P0 — F1 loses finished-execution continuations (must fix before shipping) **Bug.** `processContinuations` (`cmd/nomos/continue.go:166-167`) calls `a.store.markContinued(ctx, p.ExecID)` **before** dispatching `continueSession → resumeSession`. `markContinued` sets `continued_at`, and `pendingContinuations` (`store.go:1763`) filters `WHERE continued_at IS NULL` — so a marked execution is **never re-queued**. Before F1, `resumeSession` always ran, so marking-first was safe. F1 made `resumeSession` skip when a turn is already active for the session. Now: - **Two executions for one session finish near-simultaneously** (the common multi-step case): the loop marks BOTH, spawns two goroutines; goroutine 1 acquires and runs, goroutine 2's `resumeSession` **skips** → execution 2 is marked continued but its result is **never fed back to the agent. Lost.** - **A live turn is streaming when an async execution finishes**: continuation marks + dispatches; `resumeSession` skips (live turn holds the permit) → result lost. This silently drops auto-continuation — worse than the interleaving F1 set out to fix. **Fix.** Make `resumeSession` report whether it actually ran, and mark-continued only after a successful run; on a busy-skip, leave the execution pending for the next worker tick. 1. `cmd/nomos/continue.go` — change `resumeSession` to return `bool`: ```go func (a *agent) resumeSession(ctx context.Context, sessionID, note string) bool { if !a.gate.acquire(sessionID, 0) { slog.Info("nomos: turn already active, skipping background resume", "session", sessionID) return false } defer a.gate.release(sessionID) …existing body… return true } ``` 2. `continueSession` — mark only after a real run; on skip, leave pending: ```go func (a *agent) continueSession(ctx context.Context, p pendingContinuation) { slog.Info("nomos: auto-continuing session", "session", p.SessionID, "execution", p.ExecID, "status", p.Status) if !a.resumeSession(ctx, p.SessionID, buildContinuationNote(p)) { slog.Info("nomos: continuation deferred — a turn is active; will retry next tick", "session", p.SessionID, "execution", p.ExecID) return } a.store.markContinued(ctx, p.ExecID) } ``` 3. `processContinuations` — **delete** the `a.store.markContinued(ctx, p.ExecID)` line at `continue.go:166` (the dispatch `safego.Go(... continueSession ...)` stays). The `markContinued` at `:162` (the no-assent-window branch, which saves a note and does **not** call resumeSession) stays as-is — that path intentionally consumes the item. 4. Update every other `resumeSession` caller to ignore the new return value (`/resume`, `handleAnswerQuestion`, the empty-message reconnect in `handleChat`) — they don't need the bool; a bare call discards it. No behavior change for them (their skip semantics are already correct/desired). **Why this preserves the original "no re-continue loop" guarantee:** a `resumeSession` that *runs* always returns `true` (even on its internal LLM failure path — it has already persisted a failure note), so it gets marked and won't loop. Only a *busy-skip* returns `false` and stays pending, which is correct (retry once the turn frees). Crash-safety also improves: a crash between acquire and mark leaves the item un-marked → re-queued on restart. **Validation:** - New test: two `pendingContinuation`s for one session dispatched concurrently; assert both are eventually processed (both `continued_at` set) and at no point do two `resumeSession` bodies overlap (reuse the `turnGate` single-flight pattern, or assert via a shared counter in a stubbed `chatWith`). - Existing `cmd/nomos` suite stays green; `go vet` clean. --- ## P1 — F1 can false-auto-close a merely-busy session (low risk, fix for robustness) **Bug.** `processIdleSweep` (`continue.go:78-89`) bumps `completion_nudges` **before** calling `resumeSession`. If `resumeSession` skips (busy), the nudge is counted as unanswered; the next sweep sees `CompletionNudges >= 1` and **auto-closes** a session that was just busy. **Likelihood is low** because `staleGoalSessions` (`store.go:1336`) filters `last_active_at < now() - threshold` and an active turn keeps updating `last_active_at` — so a busy session shouldn't appear stale. But the coupling is the same shape as P0 and worth closing. **Fix.** Gate the bump on the run, mirroring P0: ```go safego.Go("nomos:idle-nudge:"+s.ID, func() { note := … if a.resumeSession(ctx, s.ID, note) { if err := a.store.bumpCompletionNudge(ctx, s.ID); err != nil { … } } }) ``` (If skipped, leave `completion_nudges` at 0 so a genuinely-stale sweep nudges again later.) --- ## P2 — Minor / hygiene (optional, can ship without) - **Redundant catch-up turn on reconnect.** When the live turn *already ended* before a dropped-SSE reconnect fires, the empty-message path still runs a "report your state" `resumeSession` turn the operator didn't ask for. F1 makes it non-concurrent (good) but it's still a spare turn. Consider: in `handleChat`'s empty-message branch, skip the `resumeSession` if the session is already terminal (`done`/`failed`/`abandoned`) or had activity within the last few seconds — just return 202 and let the poller catch up. - **Top-level side-effect on import.** `chat.ts` now calls `subscribeEvents()` + `liveEvents.subscribe(...)` at module top level. It works (and `vitest` stays green because tests mock `./chat`), but a hidden SSE-connect-on-import is fragile for future tests. Prefer a lazy `ensureChatEventSync()` called from the window mount path, matching how `workspace.ts` subscribes inside `startWorkspace` rather than at import. - **F7 follow-up (already documented):** the `NewTaskChat → SessionChatWindow` window-swap on first send still flashes; an in-place handoff would remove it. - **Pre-existing, not introduced:** `a.chat` retries the LLM stream on `ctx`-cancellation (client disconnect) up to 3×, holding the turn permit a few extra seconds. Out of scope here. --- ## Out of scope - F8 (ordering toggle + live background tool-delta streaming) — deferred in the original plan; its main symptom is removed by F1. - `run` execution deep-links (need an execution-view opener). ## Recommended order 1. **P0** (lost continuations) — blocks shipping F1. 2. **P1** (idle-sweep nudge gate) — small, same pattern. 3. P2 items as time allows. 4. Re-run `go test ./cmd/nomos/`, `go vet`, web `vitest`, `vite build`; keep `VERSION` at `0.15.0` (these are correctness fixes to the same changeset, not a new bump) — or bump patch to `0.15.1` if shipped as a follow-up commit. --- ## Resolution All review items implemented. The whole batch (F1–F7 + these review fixes) remains one uncommitted changeset at `VERSION 0.15.0`. | Item | Fix | Where | |---|---|---| | **P0** | `resumeSession` returns `bool` (false on busy-skip). `continueSession` marks an execution `continued` **only after** the turn ran; on a skip it defers and the next worker tick retries (item stays pending). Removed the pre-dispatch `markContinued` in `processContinuations`. Other callers (`/resume`, answer-question, reconnect) ignore the return. | `cmd/nomos/continue.go` | | **P0 test** | `TestResumeSession_SkipsWhenBusy`, `TestContinueSession_DefersWhenBusy` — DB-free contract tests proving the skip path returns false without running the body (nil provider would panic otherwise). | `cmd/nomos/continue_test.go` | | **P1** | Idle sweep bumps `completion_nudges` only after `resumeSession` actually runs, so a busy-skip can't be counted as an unanswered nudge → no false auto-close. | `cmd/nomos/continue.go` (`processIdleSweep`) | | **P2.1** | Empty-message reconnect (now defensive — the frontend no longer POSTs empty messages post-F2) skips a terminal session instead of spawning a spare "report state" turn. | `cmd/nomos/main.go` (`handleChat`) | | **P2.2** | Event subscription armed lazily from `chatFor()` (`ensureChatEventSync`) instead of at module import — no SSE-connect-on-import side-effect. | `web/src/lib/stores/chat.ts` | **Verification:** `go test -count=1 ./cmd/nomos/` green (incl. the two new contract tests); `go vet` clean. Web `vitest` 70/70; `vite build` succeeds; no new `tsc`/eslint errors in any touched file. **Note on the P0 end-to-end test:** the full "two continuations both processed, no overlap" scenario needs a live LLM provider (chatWith isn't stubbable without a refactor) and was therefore covered at the contract level (the skip returns false without running the body) plus the existing `turnGate` single-flight test for serialization, rather than as a DB integration test.