2 Commits

Author SHA1 Message Date
39e9227fdb feat(nomos): per-session turn serialization + chat reliability/UX fixes
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
The agent could run two turns for one session at once (a reconnect resumed
while the live turn was still going), and their interleaved tool calls
corrupted the activity panel, fabricated a confusing "parallel/nested"
sequence, and made tasks feel stuck/never-ending. Several UX gaps compounded it.

Turn serialization (F1):
- turnGate: at most one in-flight turn per session. Background resume paths
  (continuation worker, idle sweep, answer-question, /resume, reconnect)
  skip non-blocking when busy; the live chat path waits briefly then bails
  cleanly instead of stacking a second turn.
- resumeSession returns whether it ran; continueSession marks an execution
  "continued" only after a real run (review P0) so a busy-skip can't lose a
  finished-execution result. Idle nudge bumps only after delivery (P1).

Connection state (F2/F3, web):
- humanize/bucket raw errors ("model connection dropped..."); one surface
  per drop; a terminal task.status event clears stuck streaming/disconnected
  state and dismisses the connection toast. Reconnect no longer spawns turns.

Streaming where you look (F4, web):
- live command output in the global activity timeline and in the inline
  tool card (auto-opened, tail-pinned) -- not just the per-window rail.

Other (web): artifact/knowledge deep links (F5); step-first stable
"thinking" headline (F6); stable chat layout, no empty->content reflow (F7);
lazy event sync (P2.2); reconnect skips a terminal session (P2.1).

VERSION: 0.14.2 -> 0.15.0
2026-08-03 15:42:10 +02:00
bb05f215c6 docs(plans): mark plan-drift & dead-activity-panel review done (467589d)
VERSION: 0.14.1 -> 0.14.2
2026-08-03 14:32:24 +02:00
19 changed files with 1490 additions and 153 deletions

View File

@@ -1 +1 @@
0.14.1
0.15.0

View File

@@ -57,6 +57,9 @@ type agent struct {
apiBase string // oikos HTTP API base, derived from NOMOS_MCP_URL, for chat-assent approvals
apiToken string // OIKOS_MCP_BEARER_TOKEN — api's combinedAuth requires it (no dev-open bypass)
httpClient *http.Client
// gate serializes turns per session (at most one in-flight turn per
// sessionID). See turngate.go and plan 2026-08-03 F1.
gate *turnGate
}
func newAgent(ctx context.Context, clients *mcpClientPool, st *store, agentSlug string) (*agent, error) {
@@ -117,6 +120,7 @@ func newAgent(ctx context.Context, clients *mcpClientPool, st *store, agentSlug
apiBase: apiBase,
apiToken: os.Getenv("OIKOS_MCP_BEARER_TOKEN"),
httpClient: &http.Client{Timeout: 15 * time.Second},
gate: newTurnGate(),
}, nil
}

View File

@@ -76,16 +76,20 @@ func (a *agent) processIdleSweep(ctx context.Context) {
s := s
if s.CompletionNudges == 0 {
safego.Go("nomos:idle-nudge:"+s.ID, func() {
if err := a.store.bumpCompletionNudge(ctx, s.ID); err != nil {
slog.Error("nomos: idle nudge bump failed", "session", s.ID, "error", err)
return
}
note := fmt.Sprintf("[System: this task ('%s') has been idle for %s with no complete_task call. "+
"If the goal is done (or can't be completed), call complete_task now with the outcome and a "+
"one-line summary. If you're still genuinely working through the plan, ignore this and continue.]",
s.Goal, idleTaskThreshold)
note = a.store.enrichResumeNote(ctx, s.ID, note)
a.resumeSession(ctx, s.ID, note)
// P1: only count the nudge if it actually delivered. resumeSession
// skips (returns false) when a turn is already active; bumping the
// counter anyway would make the next sweep auto-close a merely-busy
// session as "unanswered."
if a.resumeSession(ctx, s.ID, note) {
if err := a.store.bumpCompletionNudge(ctx, s.ID); err != nil {
slog.Error("nomos: idle nudge bump failed", "session", s.ID, "error", err)
}
}
})
continue
}
@@ -163,7 +167,9 @@ func (a *agent) processContinuations(ctx context.Context) {
continue
}
}
a.store.markContinued(ctx, p.ExecID) // stamp first: a failure here must not cause a re-continue loop
// markContinued now happens inside continueSession, AFTER resumeSession
// actually runs (P0). Pre-marking here consumed the item even when
// resumeSession skipped on a busy session, losing the result.
safego.Go("nomos:continue-session:"+p.SessionID, func() { a.continueSession(ctx, p) })
}
}
@@ -179,7 +185,18 @@ func (a *agent) processContinuations(ctx context.Context) {
// something new to poll for.
func (a *agent) continueSession(ctx context.Context, p pendingContinuation) {
slog.Info("nomos: auto-continuing session", "session", p.SessionID, "execution", p.ExecID, "status", p.Status)
a.resumeSession(ctx, p.SessionID, buildContinuationNote(p))
// P0 (plans/2026-08-03-nomos-chat-changes-review.md): mark the execution
// continued ONLY after the turn actually ran. resumeSession skips (returns
// false) when another turn is already active for this session; marking
// before that — as the old code did — consumed the item (continued_at set,
// never re-queued by pendingContinuations) and silently lost the result.
// On a skip, leave it pending so the next worker tick retries once the
// active turn frees the permit.
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)
}
// resumeSession re-invokes the agent for a session with a system-injected note —
@@ -187,7 +204,26 @@ func (a *agent) continueSession(ctx context.Context, p pendingContinuation) {
// (handleAnswerQuestion) — persisting progress LIVE (a placeholder row updated
// in place as each tool call lands) so the frontend poller sees each step,
// instead of total silence until the whole resume concludes.
func (a *agent) resumeSession(ctx context.Context, sessionID, note string) {
//
// F1 (plan 2026-08-03): this is the single entry point for EVERY background
// turn — the continuation worker, idle sweep, answer-question, /resume, and the
// empty-message reconnect all funnel through here. It acquires the session's
// turn permit non-blocking and SKIPS if a turn is already running. A duplicate
// resume while a turn (live or background) is active is exactly the
// interleaving that corrupted the activity panel and made tasks feel stuck.
//
// Returns whether the turn actually ran. Callers that mutate state before
// resuming (the continuation worker's markContinued, the idle sweep's nudge
// bump) MUST gate that mutation on a true return — otherwise a busy-skip leaves
// the state changed but the work undone (lost continuation / false auto-close).
// See plans/2026-08-03-nomos-chat-changes-review.md P0/P1.
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)
placeholder, _ := json.Marshal(map[string]any{
"role": "assistant",
"text": "",
@@ -242,7 +278,7 @@ func (a *agent) resumeSession(ctx context.Context, sessionID, note string) {
if attempt > 0 {
select {
case <-cctx.Done():
return
return true // a turn ran on an earlier attempt; consume, don't re-loop
case <-time.After(time.Duration(2<<attempt) * time.Second): // 4s, 8s
}
}
@@ -314,9 +350,10 @@ func (a *agent) resumeSession(ctx context.Context, sessionID, note string) {
// No placeholder was inserted (rare), save directly.
a.store.saveMessage(context.Background(), sessionID, "assistant", body)
}
return // do not call persist() again — already persisted above
return true // do not call persist() again — already persisted above
}
persist() // final state — same row, updated one last time with the concluding text
return true
}
// buildContinuationNote frames the finished execution for the model: what

View File

@@ -1,6 +1,11 @@
package main
import "testing"
import (
"context"
"testing"
"github.com/google/uuid"
)
func TestExtractExecutionIDs(t *testing.T) {
// Real tool-result phrasings that should yield an execution id.
@@ -37,3 +42,37 @@ func TestExtractExecutionIDs(t *testing.T) {
t.Errorf("expected de-dup to 1 id, got %v", ids)
}
}
// TestResumeSession_SkipsWhenBusy guards the P0 fix
// (plans/2026-08-03-nomos-chat-changes-review.md): resumeSession must skip —
// return false, body never executed — when a turn is already active for the
// session. continueSession relies on this so it only marks a continuation
// "continued" after a turn really ran (otherwise the result is lost: marked
// continued, never re-queued by pendingContinuations).
//
// A minimal agent with only a gate is enough: if the body ever ran, chatWith
// would dereference the nil provider and panic. Returning false cleanly proves
// the body was skipped.
func TestResumeSession_SkipsWhenBusy(t *testing.T) {
a := &agent{gate: newTurnGate()}
if !a.gate.acquire("sess", 0) {
t.Fatal("precondition: initial acquire should succeed on a free session")
}
ran := a.resumeSession(context.Background(), "sess", "note")
if ran {
t.Fatal("resumeSession must return false (skip) while a turn is active for the session")
}
}
// TestContinueSession_DefersWhenBusy guards the other half of P0: when the
// session is busy, continueSession defers (leaves the execution pending for the
// next worker tick) instead of running or marking it. It must return cleanly
// without reaching resumeSession's body (nil provider → panic) or markContinued.
func TestContinueSession_DefersWhenBusy(t *testing.T) {
a := &agent{gate: newTurnGate()}
if !a.gate.acquire("sess", 0) {
t.Fatal("precondition: initial acquire should succeed on a free session")
}
p := pendingContinuation{ExecID: uuid.New(), SessionID: "sess", Status: "completed"}
a.continueSession(context.Background(), p) // must not panic; must not run/mark
}

View File

@@ -186,12 +186,22 @@ func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) {
return
}
// Empty message with an existing session = reconnect/resume. The
// frontend sends this after a dropped SSE stream to re-establish the
// connection and catch up on any auto-continuation work that happened
// while disconnected. Route into resumeSession so the agent sees a
// system note and reports current state.
// Empty message with an existing session = reconnect/resume. This path is
// defensive now — the frontend (post F2) recovers a dropped SSE via the
// poller + terminal task.status clearing, and no longer POSTs empty
// messages. If a client ever does, route into resumeSession so the agent
// reports current state — but SKIP a terminal session (done/failed/
// abandoned): there's nothing to resume, and running a "report state"
// turn there is just a spare turn the operator never asked for (P2.1).
if req.Message == "" && req.SessionID != "" {
if sess, err := st.getSession(context.Background(), req.SessionID); err == nil {
switch sess.Status {
case "done", "failed", "abandoned":
slog.Info("nomos: reconnect skipped — session already terminal", "session", req.SessionID, "status", sess.Status)
w.WriteHeader(202)
return
}
}
slog.Info("nomos: reconnect", "session", req.SessionID)
safego.Go("nomos:reconnect:"+req.SessionID, func() {
base := "[System: the operator's connection was re-established. The task may have progressed in the background.]"
@@ -200,7 +210,7 @@ func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) {
})
// Return 202 so the frontend doesn't try to consume an SSE stream
// from this POST — resumeSession writes to the DB directly and
// the poller (already running from handleDisconnect) picks it up.
// the poller picks it up.
w.WriteHeader(202)
return
}
@@ -270,6 +280,29 @@ func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) {
sseEvent(w, flusher, agentEvent{Type: "session", Data: sessionID, SessionID: sessionID})
// F1 (plan 2026-08-03): serialize turns per session. The user message is
// already persisted above, so even if we can't run this turn right now it
// isn't lost. Wait briefly for a finishing background turn (continuation /
// resume) so the common case is seamless; if one is still running after
// that, tell the operator to retry rather than spawning a second
// concurrent turn (the interleaving this gate exists to prevent). On
// success the permit is held until this handler returns (stream + post-
// processing done); background resumeSession callers skip while it's held.
const turnWait = 5 * time.Second
if !a.gate.acquire(sessionID, turnWait) {
slog.Info("nomos: turn already active, deferring operator message", "session", sessionID)
sseEvent(w, flusher, agentEvent{
Type: "error",
Data: "Nomos is still finishing a previous step. Your message was saved — give it a moment to finish, then send it again.",
})
sseEvent(w, flusher, agentEvent{Type: "done", Data: map[string]any{
"session_id": sessionID,
"error": true,
}, SessionID: sessionID})
return
}
defer a.gate.release(sessionID)
toolCalls := []map[string]any{}
// P3: accumulate per-iteration reasoning instead of overwriting with
// the final `text` event. The agent loop emits a `text` event for each

90
cmd/nomos/turngate.go Normal file
View File

@@ -0,0 +1,90 @@
package main
import (
"sync"
"time"
)
// turnGate enforces at most one in-flight agent turn per session.
//
// Why this exists (plan 2026-08-03, F1): handleChat runs a turn in the HTTP
// request goroutine, and every "resume" path (the empty-message reconnect,
// the auto-continuation worker, the idle sweep, answer-question, the /resume
// endpoint) launches ANOTHER goroutine running a full turn. Nothing prevented
// two turns for the SAME session at once, so a network blip that triggered a
// reconnect would spawn a duplicate resumeSession while the original turn was
// still alive — their tool calls interleaved on the wire and in the persisted
// transcript, which is the root cause behind the "parallel/nesting/sequence
// is off" and "task didn't end / flaky" reports.
//
// Model: one permit (buffered-1 channel seeded with a single token) per
// session id. Acquiring consumes the token; releasing puts it back.
// - Background/best-effort callers (resumeSession and everything it backs)
// use a non-blocking acquire and SKIP when busy — a duplicate nudge while a
// turn is already running adds nothing, and the continuation/idle tickers
// will retry on their own.
// - The live chat path (an operator message) waits briefly for a finishing
// background turn, then bails with an actionable error if still busy — see
// handleChat.
//
// The permits map grows one entry per session id seen. For this single-agent
// homelab process that set is small and bounded by real sessions; cleanup is
// intentionally omitted (a sweep would race with acquire/release and the
// memory is negligible).
type turnGate struct {
mu sync.Mutex
permits map[string]chan struct{}
}
func newTurnGate() *turnGate {
return &turnGate{permits: make(map[string]chan struct{})}
}
// permit returns the single token-channel for sessionID, creating and seeding
// it on first use. Creation is guarded so two concurrent first-callers for the
// same id share one channel.
func (g *turnGate) permit(sessionID string) chan struct{} {
g.mu.Lock()
defer g.mu.Unlock()
ch, ok := g.permits[sessionID]
if !ok {
ch = make(chan struct{}, 1)
ch <- struct{}{}
g.permits[sessionID] = ch
}
return ch
}
// acquire takes the session's permit. With wait <= 0 it is non-blocking
// (returns false immediately if a turn is active). With wait > 0 it blocks up
// to wait for the permit, returning false on timeout. Every true return MUST
// be paired with exactly one release.
func (g *turnGate) acquire(sessionID string, wait time.Duration) bool {
ch := g.permit(sessionID)
if wait <= 0 {
select {
case <-ch:
return true
default:
return false
}
}
t := time.NewTimer(wait)
defer t.Stop()
select {
case <-ch:
return true
case <-t.C:
return false
}
}
// release returns the session's permit. Idempotent: a release with no matching
// acquire (or a double release) is a no-op rather than a blocking send.
func (g *turnGate) release(sessionID string) {
ch := g.permit(sessionID)
select {
case ch <- struct{}{}:
default:
}
}

114
cmd/nomos/turngate_test.go Normal file
View File

@@ -0,0 +1,114 @@
package main
import (
"sync"
"sync/atomic"
"testing"
"time"
)
func TestTurnGate_NonBlockingSkipsWhenBusy(t *testing.T) {
g := newTurnGate()
if !g.acquire("s1", 0) {
t.Fatal("first non-blocking acquire should succeed on a free session")
}
// A second non-blocking acquire (a background resume) must skip, not queue.
if g.acquire("s1", 0) {
t.Fatal("second non-blocking acquire should fail while a turn is active")
}
// A different session is independent.
if !g.acquire("s2", 0) {
t.Fatal("acquire on a different session should succeed")
}
g.release("s2")
g.release("s1")
// After release, the session is free again.
if !g.acquire("s1", 0) {
t.Fatal("acquire should succeed again after release")
}
g.release("s1")
}
func TestTurnGate_BlockingAcquireWaitsForRelease(t *testing.T) {
g := newTurnGate()
if !g.acquire("s1", 0) {
t.Fatal("first acquire should succeed")
}
got := make(chan bool, 1)
go func() { got <- g.acquire("s1", 2*time.Second) }()
select {
case <-got:
t.Fatal("blocking acquire should wait, not return before release")
case <-time.After(50 * time.Millisecond):
// expected: still waiting
}
g.release("s1")
select {
case ok := <-got:
if !ok {
t.Fatal("blocking acquire should succeed after release")
}
case <-time.After(time.Second):
t.Fatal("blocking acquire did not return after release")
}
g.release("s1")
}
func TestTurnGate_BlockingAcquireTimesOut(t *testing.T) {
g := newTurnGate()
g.acquire("s1", 0) // hold the permit
start := time.Now()
if g.acquire("s1", 60*time.Millisecond) {
t.Fatal("acquire should time out while permit is held")
}
if elapsed := time.Since(start); elapsed < 50*time.Millisecond {
t.Fatalf("acquire returned too fast (%v); expected to wait ~60ms", elapsed)
}
g.release("s1")
}
// TestTurnGate_SingleFlightConcurrent is the core F1 guarantee: many concurrent
// background acquirers on the SAME session, exactly one runs at a time. This is
// the property that prevents two turns interleaving tool calls.
func TestTurnGate_SingleFlightConcurrent(t *testing.T) {
g := newTurnGate()
const n = 50
var inFlight, maxInFlight int64
var runs int64
var wg sync.WaitGroup
wg.Add(n)
start := make(chan struct{})
for i := 0; i < n; i++ {
go func() {
defer wg.Done()
<-start
if !g.acquire("shared", 0) { // background-style: skip if busy
return
}
defer g.release("shared")
cur := atomic.AddInt64(&inFlight, 1)
for {
m := atomic.LoadInt64(&maxInFlight)
if cur <= m || atomic.CompareAndSwapInt64(&maxInFlight, m, cur) {
break
}
}
atomic.AddInt64(&runs, 1)
time.Sleep(2 * time.Millisecond)
atomic.AddInt64(&inFlight, -1)
}()
}
close(start)
wg.Wait()
if maxInFlight != 1 {
t.Fatalf("max in-flight turns = %d, want 1 (turns must not overlap)", maxInFlight)
}
if runs == 0 {
t.Fatal("expected at least one turn to run")
}
}

View File

@@ -0,0 +1,293 @@
# 2026-07-30 — Session review: plan drift & a dead activity panel
**Status:** Done — 2026-08-03. Shipped in `467589d` (v0.14.1), deployed to
production. The two operator-reported complaints are resolved and verified on
the bug-report session itself (`398f5eda`); see [Resolution](#resolution-2026-08-03)
at the end. Items P0.1, P0.2 (fix 1+2), P1.1, P1.2 are complete; P0.2 fix 3,
P2.1, P2.2 are deferred (the reported symptoms no longer reproduce).
**Scope:** The five most-recently-active `agent:nomos` sessions by
`last_active_at`, pulled from the live Postgres on 2026-07-30, plus the
code paths they exercise (`cmd/nomos/store.go`, `cmd/nomos/tasks.go`,
`web/src/lib/stores/{activity,workspace,chat}.ts`,
`web/src/lib/components/UnifiedTimeline.svelte`).
**Trigger:** Operator report — "the plan was off, the activity sidepanel
was not kept up to date and feels off, not live."
Both complaints are real, both reproduce deterministically, and both have
a single-line root cause. They are *not* the same bug, but they compound:
the plan bug produces the exact event stream that the activity panel
silently discards.
---
## Sessions reviewed
| # | sid | goal (short) | outcome | activity rows | plan gens | re-planned? |
|---|---|---|---|---|---|---|
| 1 | `398f5eda` | hubris recurring network outage → EEE mitigation | success | 48 | 2 | **yes** |
| 2 | `0a49ba3d` | triage active signals on host:strong | success | 30 | 1 | no |
| 3 | `9368633d` | sensor temperatures on host:strong | success | 12 | 1 | no |
| 4 | `2065a29a` | temps → pivot to "fun fact about chickens" | success | 18 | 2 | **yes** |
| 5 | `bad26076` | greeting / responsiveness test | success | 6 | 1 | no |
**Score: 5 success / 0 partial / 0 failed.** The agent's *reasoning* was
fine in all five. Every defect below is in the bookkeeping and the
rendering — the parts the operator actually looks at.
**The correlation that matters: both sessions that re-planned (`398f5eda`,
`2065a29a`) recorded a corrupt plan. Neither of the three that didn't
re-plan did.** Re-planning was a 100% failure path (pre-fix).
---
## P0.1 — `update_plan_step` addresses the wrong plan generation
This is "the plan was off," and it was fully deterministic.
`proposePlan` numbered a new generation's steps *continuing* from the old
one (`store.go:937`):
```go
seq := startSeq + i + 1 // startSeq = MAX(seq) of all prior steps
```
So on generation 2 of `398f5eda`, the six new steps landed at **seq 712**.
But the tool result the model got back never mentioned those numbers
(`tasks.go:282`):
```
"Plan set (6 steps). If all steps are read-only, execute now — …"
```
…while `update_plan_step`'s schema told it (`tasks.go:81`):
```go
"seq": "1-based step number from propose_plan."
```
The model had no way to learn the real seq numbers and was explicitly told
to use 1-based ones. It did exactly that.
**What the DB recorded for `398f5eda`:**
```
20:07:40 propose_plan → gen 2 created at seq 7..12
gen 1 (seq 1..6) marked `replaced`
20:08:10 update_plan_step seq=1 running ← hits gen-1 step 1
20:08:18 update_plan_step seq=1 done ← resurrects a `replaced` row
20:08:18 update_plan_step seq=2 running
20:09:57 complete_task
```
Result — the persisted plan was a lie in three separate ways:
- **Steps 15 (the abandoned "force 1Gbps" plan) show `done`** with real
start/finish timestamps. Work that was never performed was recorded as
performed. `updatePlanStep` wrote status by seq with no guard, so it
happily flipped `replaced``running``done`.
- **Steps 712 (the actual EEE work that ran) had `started_at = NULL`**
and were bulk-closed to `done` by `completeTask`'s auto-close sweep
(`store.go:1096`) at 20:09:57 — all six sharing one timestamp.
- **The panel shows 12 steps**, because `getPlanSteps` returned every
generation unfiltered (`store.go:1356`) and the frontend never reads the
`generation` field at all (`grep generation web/src` → zero hits outside
the API type).
`2065a29a` had the identical signature: gen 2 at seq 45, gen-1 steps 1
and 2 flipped to `done`/`skipped` four seconds later.
### Fix (implemented)
1. **Make seq generation-relative.** `proposePlan` resets seq to `1..N` per
generation; `(session_id, generation, seq)` is the addressing key.
`updatePlanStep` resolves against `MAX(generation)`. This matches what
the model naturally does and what every prompt already says.
2. **Return the seq numbers to the model.** The `propose_plan` result now
enumerates them (`1=…; 2=…`).
3. **Refuse writes to superseded rows.** `updatePlanStep` addresses only
the current generation; a stale/out-of-range seq returns
`errPlanStepNotFound` (never resurrects a `replaced` row).
4. **Filter by generation on read.** `getPlanSteps` returns only
`MAX(generation)` by default; `?all=true` for the audit/eval view.
5. **Stamp `started_at` in the auto-close sweep.** `completeTask` closing
a step sets `started_at = COALESCE(started_at, now())`.
A migration (`029`) renumbers existing rows to per-generation `1..N` and
replaces the `(session_id, seq)` index with a unique
`(session_id, generation, seq)`.
---
## P0.2 — The activity panel invents its own timestamps
This is "not live / feels off," and it was worse than a staleness bug: the
times on screen were **fabricated at render time**.
`activity.ts:118` — every tool entry:
```ts
timestamp: now - ($msgs.length - mi) * 1000
```
`now` was `Date.now()` captured at the top of `computeActivityLog`. So a
tool call's displayed time was *"the moment this function last ran, minus
one second per message from the end."* Not when the call happened.
Three consequences, all of which read as "not live":
- **The clock was wrong.** `UnifiedTimeline` rendered these through
`hhmm()` / `hhmmss()`, so opening yesterday's session showed every step
timestamped *right now*, one second apart.
- **It churned every 3 seconds.** The message poller re-set `messages`
unconditionally on every tick, which re-derived `activityLog`, which
re-captured `now`. Every entry's timestamp marched forward 3s at a time,
forever. Motion with no information.
- **Real and fake timestamps sorted together.** Plan steps used the
genuine `started_at`; tool calls used the synthetic value; the final
sort mixed them. Steps with no `started_at` fell back to `now` — **97 of
339 non-pending steps in the DB (29%) had `started_at = NULL`** — so they
landed at the bottom of the timeline regardless of when they ran.
The real data already existed and was already served: `agent_activity`
holds true `ts`, `duration_ms`, `success`, and
`correlation_id = session_id`, exposed at `GET /agent-activity`. The panel
ignored it and reconstructed a worse version from the message blob.
### Fix (implemented — fix 1 + 2)
1. **Carry real timestamps on tool calls.** `computeActivityLog` uses each
tool call's message `created_at` (a true persisted time). The
`now - (len - mi) * 1000` expression is gone entirely.
2. **Only fall back to wall-clock for genuinely-live entries, and freeze
it once assigned** — a `Map<id, timestamp>` outside the derivation, so
re-deriving never moves an existing entry. This is what kills the churn.
Deferred to a later pass: backing the panel with `agent_activity` for
historical sessions (fix 3, unlocks `duration_ms`) — the two reported
symptoms (wrong clock, churn) no longer reproduce without it.
---
## P1.1 — Plan-step events for a superseded generation were silently dropped
The frontend half of P0.1, and the reason the panel *froze* rather than
merely showing wrong steps.
On `plan.proposed` with `appended: false`, the store replaced its step
list wholesale — so after the re-plan it held seq 712. Every subsequent
`plan.step.started` / `plan.step.finished` carried seq 15 and a gen-1
`step_id`, and `applyPlanStepEventTo` bailed on no match:
```ts
if (i === -1) return steps
```
So for the entire second half of `398f5eda` — the half where all the real
work happened — **the panel showed six pending steps and nothing ever
moved.** Then `completeTask` closed them in the DB while emitting only
`task.status`, no per-step events, so they stayed pending on screen even
after the session finished.
### Fix (implemented)
- Fixing P0.1 removed the cause (the events now carry the correct
generation-relative seq + the panel's current steps match). The `i === -1`
branch now `console.warn`s and increments an exported
`droppedPlanStepEvents` counter instead of returning silently, so the
next divergence is visible instead of looking like a dead UI.
- **`completeTask`'s auto-close sweep now emits `plan.step.finished` per
closed step** (scoped to the current generation). General rule enforced:
no plan-step status change without a corresponding event.
---
## P1.2 — Every plan carried a duplicate writeback step
In `398f5eda` gen 2, step 11 was the model's own writeback step and step 12
was the auto-appended one. The detector substring-matched the literal tool
names `update_entity_attributes` / `create_relationship` in the title or
detail; the model wrote a natural-language equivalent, so the match failed
and a redundant step was appended. Same pattern in `0a49ba3d` and
`9368633d`.
### Fix (implemented)
Broadened the detector to a case-insensitive check for `write back` /
`writeback` / `upsert_knowledge` in the title or detail, on top of the
existing tool-name match.
---
## P2.1 — Long unexplained stalls, invisible in the UI *(deferred)*
- `bad26076`: a greeting took **16 minutes** wall-clock with 6 activity rows.
- `2065a29a`: step 1 showed `started_at``finished_at` spanning **16 minutes**
for a `sensors` call that returned in milliseconds.
The work took under a second; the step was *open* for 16 minutes. The panel
has no way to distinguish "working" from "waiting for a nudge." Surfaces a
step's idle time: mark a `running` step *stalled* when it has had no
`agent_activity` row for >60s. Deferred — needs the `agent_activity`-backed
panel (P0.2 fix 3).
## P2.2 — `agent_activity` is a single-type table *(deferred — decision)*
All rows are `activity_type = 'tool_call'`. Either start emitting the other
types the schema anticipates (`reasoning`, `plan`, `error`) or drop the
dimension. Worth a decision, not urgent.
---
## Recommended sequence (executed)
| Order | Item | Status |
|---|---|---|
| 1 | P0.1 fix 3 + 4 (refuse superseded writes, filter on read) | done |
| 2 | P0.2 fix 1 + 2 (real timestamps, frozen fallback) | done |
| 3 | P1.1 (emit events from the auto-close sweep) | done |
| 4 | P0.1 fix 2 (generation-relative seq) + migration | done |
| 5 | P1.2, P2.1 | P1.2 done; P2.1 deferred |
| 6 | P0.2 fix 3 (back the panel with `agent_activity`) | deferred |
| 7 | P2.2 | deferred |
## Regression coverage (added)
- `store_test.go`: `TestUpdatePlanStep_GenerationRelative` — re-plan →
`update_plan_step(seq=1)` must address gen-2 and never resurrect a
superseded gen-1 `replaced` row; out-of-range seq → `errPlanStepNotFound`.
- `store_test.go`: `TestCompleteTask_AutoCloseEmitsEvents` — auto-close
emits one `plan.step.finished` per closed step and stamps `started_at`.
- `store_test.go`: `TestProposePlan_RefuseInFlight` — updated for
generation-relative seq + `?all=true`.
- `web/src/lib/stores/activity.test.ts`: `computeActivityLog` is pure w.r.t.
wall-clock (two calls 50ms apart → identical output), persisted tool calls
use real `created_at`, live entries freeze instead of churning.
---
## Resolution (2026-08-03)
Shipped in commit `467589d` (VERSION `0.14.0``0.14.1`), pushed to
`origin/main`, deployed via the Gitea webhook (`scripts/deploy.sh`):
pg_dump → pull → `docker compose build``up -d` → health check (healthy).
Verification on the bug-report session `398f5eda` post-migration:
```
gen 1: seq 1..6 (the abandoned "force 1Gbps" plan — superseded)
gen 2: seq 1..6 (the real EEE work — was seq 7..12, now normalized to 1..6)
```
- `schema_migrations` v29 applied; old `idx_plan_steps_session` dropped,
unique `idx_plan_steps_session_gen_seq` in place.
- Containers recreated; `healthz` and `/agent/sessions/:id/plan` HTTP 200.
- Full `cmd/nomos` suite (23 tests) + web suite (70 tests) green; `go vet`
clean; ESLint/Prettier clean.
Note: historical `started_at = NULL` on already-completed steps (visible on
`398f5eda` gen 2) is left as-is — backfilling would fabricate times. Going
forward `completeTask` stamps `started_at`, and the frontend freezes
NULL-started steps stably so they no longer churn.

View File

@@ -0,0 +1,184 @@
# 2026-08-03 — Review: nomos chat reliability/UX changes (F1F7)
**Status:** Implemented (P0, P1, P2 all done). See
[Resolution](#resolution) at the end.
A critical self-review of the uncommitted F1F7 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 (F1F7 + 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.

View File

@@ -0,0 +1,356 @@
# 2026-08-03 — Nomos chat: reliability & predictability audit
**Status:** Implemented (F1F7) in v0.15.0; F8 deferred. See
[Resolution](#resolution-2026-08-03) at the end.
**Scope:** The live chat/task UX across one production session, audited through
the code paths behind each operator-reported symptom —
`cmd/nomos/{main.go,agent.go,continue.go,store.go}`,
`web/src/lib/stores/{chat,activity,execstream,events,workspace}.ts`,
`web/src/lib/components/{ChatThread,AgentTrace,ToolCallCard,UnifiedTimeline,TaskContextPanel,SessionChatWindow}.svelte`.
**Trigger:** Operator report — streaming invisible in the tool card; the
activity/plan panel wrong about parallel/nested runs and timestamps with no clear
sequence; no links to artifacts/knowledge referenced in chat; agent "thinking"
flickers/overwrites itself; layout jumps when a chat goes from empty to content;
"Agent connection lost / Error in input stream" messages that aren't actionable
and don't self-resolve; overall flaky/disconnected feel where the task never
cleanly ended.
The prior round (`2026-07-30-session-review-plan-drift-and-dead-activity-panel.md`,
shipped in `467589d`) fixed the plan-seq and fabricated-timestamp rendering bugs.
This round's symptoms are a different layer: **turn orchestration, streaming
wiring, and connection-state UX**. One architectural gap (F1) is the common
cause behind several of them.
---
## The one root cause that compounds everything: F1
### F1 — No per-session turn serialization (concurrent turns corrupt the view)
`handleChat` runs `a.chat(ctx, ...)` directly in the HTTP request goroutine, and
every "resume" path (`resumeSession`, the reconnect empty-message path, the
auto-continuation worker, the idle sweep, answer-question) launches **another
goroutine** (`safego.Go`) running a full turn. There is **no mutex keyed on
`sessionID`** anywhere. The codebase already knows this is a hazard —
`agent.go:316-323` marks approved executions `continued` specifically because
"two concurrent LLM calls for the same session cause empty responses and race
conditions" — but the fix is per-path patching, not a general lock.
What this produces, deterministically:
- A network blip on the browser↔nomos stream fires `handleDisconnect`
(`chat.ts:383`), which POSTs an **empty-message reconnect**
`main.go:194-206` spawns `resumeSession` as a **new goroutine**. If the
original turn is still alive (or finishes its current tool call), **two turns
now run for one session**: interleaved `tool_use`/`text_delta` events, a
re-proposed plan, and "the agent is repeating itself."
- The activity timeline (`activity.ts:119-185`) groups tools under a plan step
by *inferring* `currentStepSeq` from `update_plan_step` calls in the message
stream. Two interleaved turns make that inference wrong → tools land under the
wrong step, steps appear to nest/parallelize that never did, the sequence
reads as garbage. This is the "parallel runs / nesting / no clear sequence"
report.
- Two turns appending to the same session's messages is also the source of the
duplicate-tool-call/empty-response class of bugs the prior plan docs keep
patching individually.
**This is why the experience "felt flaky and disconnected" and "the task didn't
end":** the panel is faithfully rendering a corrupted, interleaved event stream.
### Fix (proposed)
1. **One in-flight turn per session, server-side.** Add a per-`sessionID`
turn mutex (a `sync.Map[string]*singleflight` or a keyed `sync.Mutex`) in
`handleChat`/`resumeSession`/`continue.go`. A second attempt to start a turn
for a session that already has one running must **queue** (preferred — the
operator's message waits its turn) or **return 409 "turn in progress"** (the
frontend then just re-polls; no new goroutine). This single change removes
the interleaving that drives F2/F3/F8.
2. **Make the empty-message reconnect a no-op when a turn is already running.**
Today it *always* spawns `resumeSession`. Gate it on "is any turn active for
this session?" — if yes, return 202 and let the existing turn + the poller do
the work. A blip should never *create* work.
---
## F2 — Reconnect spawns a new turn and surfaces raw, non-actionable errors
`chat.ts:383-426` `handleDisconnect`: on a dropped SSE it sets
`connectionState='disconnected'`, starts the 3s poller, shows
`"Agent connection lost. The task is still running — retrying…"`, then calls
`streamChat('', sid, …)` up to 3× — each of which is the empty-message POST that
triggers F1's new `resumeSession` goroutine. Separately, the LLM stream errors
surface verbatim: `agent.go:388` does `emitError("llm: %v", err)`, so an
OpenRouter transport break reaches the operator as `llm: error in input stream:
…` (the openai-go SDK's SSE-reader text), shown raw in `ChatThread`'s error bar.
Combined with F1, this is the exact "messages not actionable and not
self-resolving" + "task didn't end" experience: a blip both invents a duplicate
turn and paints a scary, unfixable error that lingers.
Secondary defects in the same path:
- `streaming` stays `true` for the entire reconnect window, so the composer is
disabled and the poller's `if (streaming && connected) return` guard
(`chat.ts:177`) suppresses updates except while disconnected — fragile.
- The **per-window** error path (`sendSessionMessage`, `startTask`) does **not**
auto-reconnect at all — it only polls. Its `onReconnect` in
`SessionChatWindow.svelte:110` is `() => loadSessionChat(sessionId)`, which
just *re-fetches the transcript* and never re-attaches to a live stream. And
the global `reconnect()` (`chat.ts:428`) keys off the **global**
`currentSession`, so a floating window's Reconnect button can target the wrong
session. Two different, both-broken reconnect behaviors.
### Fix (proposed)
1. **Stop the empty-message-reconnect from creating turns** (depends on F1.2).
Reconnect should mean "catch up," not "run more."
2. **Humanize + bucket error strings.** Map known transport errors to
operator-readable, actionable copy with a single primary action:
- `llm: …input stream…` / 502/503/timeout → "The model connection dropped.
The task is still running in the background — it'll catch up
automatically." (auto-dismiss when the next event/poll lands)
- `HTTP 401/403` → "Session expired — reconnect." (action: re-auth)
- unknown → show the raw text but behind a "Details" toggle, not as the
headline.
3. **Make errors self-resolving.** Clear the error + connection-lost banner the
moment the poller sees a newer message or any live event for the session
arrives (wire `eventsConnected` / a session-scoped event into the banner's
visibility). Today the banner stays until manual dismiss even after recovery.
4. **Unify reconnect.** One `reconnect(sessionId)` that (a) re-fetches the
transcript, (b) if no turn is active, is a pure no-op refresh; used by both
the main view and windows. Drop the global-`currentSession` coupling.
---
## F3 — The UI can't tell when a turn truly ended (so it never looks "done")
When the SSE stream ends without a `done` event, `streamChat`'s `onDone`
(`chat.ts:355-368`) calls `handleDisconnect`. Even if the backend turn then
finishes and persists its final message, the frontend only learns via the 3s
poller re-setting `messages` — but nothing transitions `streaming``false` or
`connectionState``connected` from that path, so the spinner/indicator and the
"connection lost" banner can persist indefinitely. That is "the task didn't
end / backend connection was lost."
The backend does emit a terminal signal — `task.status` events on
`complete_task`/auto-complete (`workspace.ts:82-88` `STATUS_AFFECTING`) — but
nothing in the chat store reacts to a terminal `task.status` to force
`streaming=false` + clear the banner. The signal exists; the chat ignores it.
### Fix (proposed)
1. **Treat a terminal `task.status` (done/failed) for the viewed session as
authoritative end-of-turn** in `chat.ts`: set `streaming=false`,
`connectionState='connected'`, dismiss any connection-lost error. The poller
already refreshes messages; this just closes the loop on the *state* flags.
2. **Add a `task.completed` / `turn.ended` SSE event** from the backend on every
terminal path (today `done` is a chat-stream-only event; background turns
have no equivalent). The always-on events stream already reaches the panel —
route the same signal to the chat store so background-completed turns clear
the UI without waiting on a poll.
---
## F4 — Command streaming isn't shown where the operator looks
Streaming **exists** (`execstream.ts` `liveExecutionOutputFor`, fed by
`fetchExecutionLogs` via the always-on events stream) and the
`UnifiedTimeline` **does** render `tool.liveOutput` with tail-pinned scroll
(`UnifiedTimeline.svelte:451-457`). But:
- The **global** `activityLog` (`activity.ts:236`) — used by the main Chat page's
panel — never calls `withLiveOutput`. Only the **per-window**
`activityLogFor(sessionId)` (`activity.ts:271`) attaches live output. So the
main chat view's timeline shows no streaming at all.
- The **inline chat tool cards**`ToolCallCard.svelte` (rendered inside
`AgentTrace.svelte`) — show only args/result/error. They never read
`liveOutput`. Expanding a running `run` call in the transcript (the natural
place to "check the tool") shows nothing live; output appears all at once when
the `tool_result` lands.
This is the report: "I expected checking on the tool to let me see the
streaming."
### Fix (proposed)
1. **Wire live output into the global `activityLog`** so the main chat panel
streams too (call `withLiveOutput` in the `activityLog` derivation, same as
`activityLogFor`).
2. **Show streaming in the inline tool card.** Pass the session's live-output
store into `AgentTrace`/`ToolCallCard` (or attach `liveOutput` to the running
`run` tool entry the way the timeline does) and render a tail-pinned `<pre>`
while the call is `tool_use`/running. Reuse the UnifiedTimeline's scroll-pin
pattern. Gated runs (queued-for-approval) should instead show a "queued —
watch in entity detail" affordance (per `execstream.ts` header comment).
---
## F5 — Artifacts and knowledge referenced in chat aren't navigable
When the agent records knowledge, the activity panel shows `Recorded: <title>`
(`activity.ts:188-203`) but it's plain text — no link. The backend already
emits `knowledge.recorded` and links the note to the task
(`store.go:1572 linkKnowledgeToTask`, `agent.go:594`), and the Wiki reader
exists (`web/src/lib/components/knowledge/WikiReader.svelte`). Nothing connects
them. Same for `get_entity`/`run` results: slugs and execution ids appear in
tool output but aren't clickable to open the entity window or execution view.
### Fix (proposed)
1. **Make activity/tool entries link-bearing.** Add an optional
`link?: { kind: 'knowledge'|'entity'|'execution', id: string }` to
`ActivityEntry`. Populate it from `upsert_knowledge` (title→knowledge id from
the result), `get_entity` (slug), and `run` (execution id). Render a
clickable chip that opens the right surface: knowledge → Wiki reader (new tab
/ window), entity → entity detail window, execution → execution log pane
(already fetched by `EntityDetailContent.svelte`).
2. **Render entity/knowledge mentions in assistant markdown as links** when they
resolve to known slugs (lightweight: a post-process pass on rendered text, or
let the model emit explicit `[slug](entity:…)` markers it already has tools to
discover).
---
## F6 — "Thinking" is an unstable single-line headline, not a predictable trace
`ChatThread`'s `indicatorLabel` (`ChatThread.svelte:83-89`) returns the **first**
running activity entry's description; `AgentTrace`'s `headline` mirrors it. As
tools fire sequentially the running entry changes, so the one line rewrites
itself every call — "the thinking overwrites itself." There is no persistent,
additive reasoning surface, and no predictable turn structure (plan → steps →
answer) the operator can learn to read. Claude-Code-style predictability is
absent.
### Fix (proposed)
1. **A stable, additive per-turn reasoning block.** Keep the collapsed trace as
a *summary* ("Step 2 of 4 · running `run`"), but when expanded show an
**append-only** log of (a) the model's intermediate `text` (reasoning before
each tool call — already emitted at `agent.go:458-460` and persisted) and
(b) each tool call as a fixed row, instead of a single mutating headline.
2. **Predictable turn shape.** Enforce/cue a consistent sequence in the UI —
Goal → Plan → Steps (each with its tools nested) → Final answer — and render
each phase as a stable section that fills in rather than a line that
overwrites. The UnifiedTimeline already models most of this; surface the same
model in the inline trace so chat and panel tell one story.
---
## F7 — Layout jumps when a chat goes from empty to content
`SessionChatWindow.svelte:58-63` gates the right rail on `hasContext`: empty
task → `ChatThread` full-width; first activity/touched entity → switches to
`Splitpanes` with the `TaskContextPanel` rail. The swap is instant and
**reflows the chat column width** the moment the first event lands — "switching
from empty to chat with something, the layout was off." Compounded by the
`NewTaskChat` → real `SessionChatWindow` window-swap on first send
(`NewTaskChat.svelte:17-22`).
### Fix (proposed)
1. **Reserve the rail's space from the start** (collapse to a thin sliver / icon
rail when empty) instead of mounting it on demand, so adding content doesn't
change the chat column width. Or animate the rail in.
2. **Avoid the window swap on first send** — let the new-task window *become* the
session window in place once the id is assigned (same component, swap the
store source) rather than close+open.
---
## F8 — Activity/plan ordering & parallelism *(largely a symptom of F1)*
With F1 fixed (no interleaved turns) the heuristic step-grouping in
`activity.ts` becomes reliable again. Remaining standalone items:
- The timeline is **newest-first** with ts-0 goal/pending parked at the bottom
(`UnifiedTimeline.svelte:119-127`); for a long task this can read as
"sequence is off." Consider an explicit **oldest-first / seq-ordered** mode
toggle, and always show the step number prominently so order is unambiguous
regardless of sort.
- Background/auto-continued turns still rely on the 3s poller for their result
to appear; until F3's terminal event lands, the panel can lag. The
always-on events stream already carries `plan.*` and `entity.touched` live —
extend it to carry per-tool `tool.*` deltas for background turns so the panel
is live, not polled, during autonomous work.
---
## Recommended sequence
| Order | Item | Why first |
|---|---|---|
| 1 | **F1** per-session turn mutex + no-op reconnect-when-busy | Removes the interleaving that is the root cause of F2/F3/F8 symptoms; everything else is cosmetics on top of a corrupted stream. |
| 2 | **F3** terminal-event → clear chat state | Once turns can't double, make "the task ended" unambiguous so the UI stops lingering. |
| 3 | **F2** humanized/self-resolving errors + unified reconnect | Turns the scary, sticky "connection lost / input stream" into recoverable, auto-clearing UX. |
| 4 | **F4** streaming in the global log + inline tool card | Highest-visibility "I can't see what it's doing" fix; small, isolated change. |
| 5 | **F6** stable additive reasoning trace | Predictability of the interaction model (the Claude-Code feel). |
| 6 | **F5** artifact/knowledge deep links | Navigation completeness. |
| 7 | **F7** layout stability | Polish. |
| 8 | **F8** ordering mode + live background deltas | Polish, partly free after F1. |
## Verification hooks (when implementing)
- `cmd/nomos`: a test that starts two turns for the same session and asserts the
second queues/is-rejected (no interleaved `tool_use` order in persisted
messages).
- `web/src/lib/stores`: extend `activity.test.ts`/`execstream.test.ts` — global
`activityLog` now carries `liveOutput`; tool-card live output renders while
`tool_use` and clears on `tool_result`.
- A reconnect/integration test: drop the SSE mid-turn, assert (a) no duplicate
`resumeSession` goroutine, (b) banner auto-clears on next event, (c)
`streaming` returns to false on terminal `task.status`.
---
## Note on method
This audit was done against the **code paths** behind the reported symptoms, not
a single session transcript (no MCP/DB access from this session). To tie a
specific finding to a specific past session, pull the session via
`docker exec oikos-postgres-1 psql -U oikos oikos -c "select id,goal,outcome
from agent_sessions order by last_active_at desc limit 5"` and cross-reference
its `agent_activity` rows / persisted messages against the F1 interleaving
signature (two assistant turns' tool ids interleaved in one message shell).
---
## Resolution (2026-08-03)
Implemented F1F7 in v0.15.0 (`VERSION 0.14.2 → 0.15.0`). F8 deferred (its
primary symptom — interleaved/out-of-order entries — is removed by F1; the
ordering toggle and live background tool-delta streaming remain as nice-to-
haves).
| Item | What shipped | Where |
|---|---|---|
| **F1** | Per-session single-flight turn gate (`turnGate`): at most one in-flight turn per session. Background resume paths (`resumeSession` — covers the continuation worker, idle sweep, answer-question, /resume, and the empty-message reconnect) skip non-blocking when busy; the live chat path waits briefly then bails with an actionable error instead of stacking a second turn. | `cmd/nomos/turngate.go` (+`turngate_test.go`), wired in `agent.go` (struct/init), `continue.go` (`resumeSession`), `main.go` (`handleChat`). |
| **F3** | Terminal `task.status` events (done/failed/abandoned/awaiting_input) now clear a stuck chat view's `streaming`/`connectionState` and dismiss the connection-lost toasts — the authoritative "turn ended" signal the UI was ignoring. Poller safety net catches the edge where the event fired during the disconnect window. | `web/src/lib/stores/chat.ts` (`clearTurnState`, liveEvents subscription, `startSessionPolling`). |
| **F2** | Raw errors humanized ("The model connection dropped. The task keeps running…") and bucketed; one connection surface per drop (not banner+toast+raw error); errors self-clear via F3. The turn-spawning reconnect attempt loop is gone (dead global path simplified to a turn-free refresh); window "Reconnect" re-fetches + resets state. | `web/src/lib/stores/chat.ts` (`humanizeChatError`, error handlers, `loadSessionChat`, `handleDisconnect`/`reconnect`), `web/src/lib/components/ChatThread.svelte` (banner copy). |
| **F4** | Command streaming now shows (a) in the **global** activity timeline (live output wired into `activityLog`, was only per-window) and (b) in the **inline chat tool card** — expanding a running `run` shows live output auto-opened and tail-pinned. | `web/src/lib/types.ts` (`liveOutput`), `web/src/lib/stores/activity.ts` (`currentLiveOutput`), `web/src/lib/components/ChatThread.svelte` (`toolsWithLive`), `web/src/lib/components/ToolCallCard.svelte`. |
| **F6** | The "thinking" headline is now step-first (stable across a step's many tool calls) instead of rewriting per command; falls back to the current tool / "thinking…" only when no step is active. | `web/src/lib/components/ChatThread.svelte` (`indicatorLabel`). |
| **F5** | Activity entries now carry a deep link: recorded knowledge docs and `get_entity` lookups get an "open artifact" chip that opens the entity/knowledge window directly. | `web/src/lib/stores/activity.ts` (`link`, `knowledgeLinkFromResult`, `entityLinkFromArgs`), `web/src/lib/components/UnifiedTimeline.svelte`. |
| **F7** | The empty→content layout reflow is gone: `SessionChatWindow` now has one stable `Splitpanes`+`ChatThread` from open (no more destroy/remount of the thread or column reflow when the rail appears). | `web/src/lib/components/SessionChatWindow.svelte`. |
**Verification:**
- `go test ./cmd/nomos/` green (incl. new `turngate_test.go`: non-blocking skip,
blocking-waits-for-release, timeout, and a 50-goroutine single-flight
concurrency test asserting max in-flight = 1). `go vet` clean.
- Web `vitest` 70/70 green (incl. `activity.test.ts`/`execstream.test.ts`); the
`activity.test.ts` chat mock gained `currentSession` for the new
`currentLiveOutput` derivation.
- `vite build` succeeds (all Svelte components compile). Pre-existing `tsc`
strictness errors in unrelated files (`ui/*`, `oidc.ts`, `windows.ts`,
`workspace.ts`) are unchanged; no new errors in any touched file.
**Follow-ups (not in this pass):**
- F8: oldest-first ordering toggle; emit per-tool `tool.*` events on the
always-on stream during background `resumeSession` turns so the panel is live
(not 3s-polled) during autonomous work.
- F5: `run` execution deep-links (open the entity detail's execution pane) —
needs an execution-view opener; knowledge/entity links shipped first as the
explicit complaint.
- F7: the `NewTaskChat → SessionChatWindow` window-swap on first send (a
windows.ts open/close) still causes a brief flash; an in-place handoff
(same window, swap store source) would remove it.

View File

@@ -22,6 +22,7 @@ went sideways, open an investigation.
| 2026-07-20 | [Session review: past 10 sessions](2026-07-20-session-review-ten-sessions.md) | Implemented in v0.7.13 — all P0/P1/P2 items landed |
| 2026-07-20 | [Mascot physics/window-interaction audit](2026-07-20-mascot-physics-audit.md) | P0P2 implemented; P3 ("cool stuff") ideas open |
| 2026-07-21 | [Frontend as OS + Apps — architecture audit & refactor](2026-07-21-frontend-os-apps-architecture.md) | Planned — Phase 1 ready |
| 2026-08-03 | [Nomos chat: reliability & predictability audit](2026-08-03-nomos-chat-reliability-and-ux-audit.md) | In Progress — F1F7 shipped in v0.15.0; F8 + follow-ups open |
## Done

View File

@@ -17,6 +17,7 @@
import { marked } from 'marked'
import DOMPurify from 'dompurify'
import type { ChatMessage } from '$lib/stores/chat'
import type { ToolCallResult } from '$lib/types'
import type { SessionQuestion } from '$lib/api'
let {
@@ -83,8 +84,15 @@
const indicatorLabel = $derived.by(() => {
if (error) return error
if (!streaming && indicatorDone) return 'Done'
const running = $activityLogProp.find((e: ActivityEntry) => e.status === 'running')
if (running) return running.description
// Prefer the running PLAN STEP as the headline — it's stable across the
// step's many tool calls, so the line stops rewriting itself on every
// command (the "thinking overwrites itself" complaint, F6). Falls back to
// the current tool only when there's no active step (a plan-less Q&A or
// between steps), and to a plain "thinking…" otherwise.
const runningStep = $activityLogProp.find((e: ActivityEntry) => e.type === 'step_running')
if (runningStep) return runningStep.description
const runningTool = $activityLogProp.find((e: ActivityEntry) => e.type === 'tool_running')
if (runningTool) return runningTool.description
return 'Agent is thinking…'
})
@@ -195,6 +203,27 @@
if (streaming) return
onSend(q)
}
// Merge live streaming output (from the activity log's `run` entry) onto the
// in-flight turn's tool calls so the inline tool card shows command output as
// it arrives — the place the operator naturally "checks the tool". Only the
// last assistant message can be streaming, so only it gets enriched; history
// is untouched (and has no live output anyway). (F4)
function toolsWithLive(
tools: ToolCallResult[],
entries: ActivityEntry[],
isLiveTurn: boolean
): ToolCallResult[] {
if (!isLiveTurn) return tools
const liveById = new Map<string, string>()
for (const e of entries) {
if (e.liveOutput && e.id) liveById.set(e.id, e.liveOutput)
}
if (liveById.size === 0) return tools
return tools.map((t) =>
t.id && liveById.has(t.id) ? { ...t, liveOutput: liveById.get(t.id) } : t
)
}
</script>
<div class="flex h-full min-h-0 min-w-0 flex-col" bind:clientHeight={threadHeight}>
@@ -274,7 +303,7 @@
the text below it. -->
{#if msg.tools.length > 0 || traceStatus !== 'idle'}
<AgentTrace
tools={msg.tools}
tools={toolsWithLive(msg.tools, $activityLogProp, isLast && traceStatus !== 'idle')}
status={traceStatus}
label={traceStatus === 'idle' ? null : indicatorLabel}
/>
@@ -306,9 +335,10 @@
<div
class="mb-2 flex items-center gap-2 rounded-md border border-warning/50 bg-warning/10 px-3 py-2 text-xs"
>
<RefreshCwIcon class="size-3 shrink-0" aria-hidden="true" />
<RefreshCwIcon class="size-3 shrink-0 animate-spin text-warning" aria-hidden="true" />
<span class="text-warning-foreground flex-1"
>Agent connection lost. The task may still be running.</span
>Connection dropped — the task is still running and will catch up here automatically.
Reconnect to refresh now.</span
>
<Button size="xs" variant="outline" class="h-6 text-[11px]" onclick={onReconnect}
>Reconnect</Button

View File

@@ -37,30 +37,21 @@
const sessionActivityLog = activityLogFor(sessionId)
// Started here (rather than left to TaskContextPanel's own onMount) so the
// workspace is already tracking touched entities/plan/questions before the
// context rail ever mounts — it needs that live even while the rail stays
// hidden (see hasContext below).
// context rail mounts.
// eslint-disable-next-line svelte/valid-compile
const workspace = workspaceFor(sessionId)
// eslint-disable-next-line svelte/valid-compile
const touchedEntities = workspace.touched
// eslint-disable-next-line svelte/valid-compile
const openQuestion = workspace.openQuestion
let loading = $state(true)
// The context rail (Scope/Activity) is only worth its screen space once
// there's something in it — a brand-new task otherwise opens to an empty
// "entities appear here" placeholder next to an equally empty activity
// list. Show it the moment either has real content, and keep it shown
// from then on (no flicker back to hidden if e.g. touched entities later
// expire). An open question does NOT gate this anymore — it renders
// inline in the chat thread itself (see ChatThread's `question` prop
// below), not in this rail.
let hasContext = $state(false)
$effect(() => {
if (!hasContext && ($sessionActivityLog.length > 0 || $touchedEntities.length > 0)) {
hasContext = true
}
})
// F7 (plan 2026-08-03): the rail used to mount on demand (hasContext gate),
// which DESTROYED and remounted the ChatThread — losing the input draft and
// scroll position — and reflowed the chat column the moment the first
// activity/touched entity landed ("layout looks off when a chat goes from
// empty to content"). The layout is now stable from the moment the window
// opens: one Splitpanes, one ChatThread, the rail always present showing
// its own empty state ("Waiting for activity…") until there's something to
// show. A stable-but-initially-quiet rail is a better trade than a jumping
// layout.
// startSessionWorkspace's cleanup is registered via onDestroy below rather
// than returned from this callback — onMount ignores a returned function
@@ -93,7 +84,7 @@
<p class="text-sm text-muted-foreground">Task not found.</p>
<p class="text-xs text-muted-foreground/70">It may have been deleted.</p>
</div>
{:else if hasContext}
{:else}
<Splitpanes theme="oikos-theme" dblClickSplitter={false}>
<Pane>
<ChatThread
@@ -115,20 +106,5 @@
<TaskContextPanel {sessionId} />
</Pane>
</Splitpanes>
{:else}
<ChatThread
messages={$chatMessages}
streaming={$chatStreaming}
connectionState={$chatConnectionState}
error={$chatError}
chatErrors={$chatErrors}
activityLog={sessionActivityLog}
{sessionId}
question={$openQuestion}
onSend={(text) => sendSessionMessage(sessionId, text)}
onCancel={() => cancelSessionStream(sessionId)}
onReconnect={() => loadSessionChat(sessionId)}
onDismissError={dismissError}
/>
{/if}
</div>

View File

@@ -9,6 +9,7 @@
let { tool }: { tool: ToolCallResult } = $props()
let expanded = $state(false)
let liveEl = $state<HTMLPreElement | null>(null)
const status = $derived.by(() => {
if (tool.type === 'tool_use') return 'running'
@@ -16,6 +17,15 @@
return 'done'
})
// Auto-open while a command is streaming its output, so the operator sees it
// without an extra click — mirrors UnifiedTimeline. Once the tool_result
// lands (status flips off running) liveOutput clears and the card respects
// the manual toggle again. (F4)
const open = $derived(expanded || !!tool.liveOutput)
$effect(() => {
if (tool.liveOutput && liveEl) liveEl.scrollTop = liveEl.scrollHeight
})
const label = $derived(toolActivityLabel(tool))
const argsSummary = $derived.by(() => {
@@ -36,8 +46,8 @@
<button
class="flex w-full items-start gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-muted/40 disabled:cursor-default"
onclick={() => (expanded = !expanded)}
aria-expanded={expanded}
disabled={!hasDetail}
aria-expanded={open}
disabled={!hasDetail && !tool.liveOutput}
>
<span class="mt-px shrink-0 {status === 'error' ? 'text-destructive' : 'text-primary'}">
{#if status === 'running'}
@@ -57,17 +67,30 @@
{/if}
</span>
<span class="shrink-0 font-mono text-[10px] text-muted-foreground/50">{tool.name}</span>
{#if hasDetail}
{#if hasDetail || tool.liveOutput}
<ChevronRight
class="mt-px size-3 shrink-0 text-muted-foreground/50 transition-transform {expanded
class="mt-px size-3 shrink-0 text-muted-foreground/50 transition-transform {open
? 'rotate-90'
: ''}"
/>
{/if}
</button>
{#if expanded}
{#if open}
<div class="space-y-2 px-2 pb-2 pl-7">
{#if tool.liveOutput}
<div>
<div
class="mb-1 flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wider text-primary"
>
<Loader2 class="size-2.5 animate-spin" />
Live output
</div>
<pre
bind:this={liveEl}
class="max-h-48 overflow-auto whitespace-pre-wrap break-words rounded-md bg-muted/60 p-2 font-mono text-[11px] text-foreground/90">{tool.liveOutput}</pre>
</div>
{/if}
{#if tool.args}
<div>
<div

View File

@@ -15,6 +15,8 @@
import SparklesIcon from '@lucide/svelte/icons/sparkles'
import HelpCircleIcon from '@lucide/svelte/icons/help-circle'
import FlagIcon from '@lucide/svelte/icons/flag'
import ExternalLinkIcon from '@lucide/svelte/icons/external-link'
import { openEntityWindow } from '$lib/stores/windows'
// Merged plan + activity timeline, designed for the narrow rail:
// - ordered newest-first: what the agent is doing right now is at the top,
@@ -423,10 +425,23 @@
>
{tool.description}
</span>
{#if !tool.link}
<span class="shrink-0 text-[9px] tabular-nums text-muted-foreground/50"
>{hhmm(tool.timestamp)}</span
>
{/if}
</button>
{#if tool.link}
<button
type="button"
class="absolute right-1.5 top-1/2 z-10 flex size-5 -translate-y-1/2 items-center justify-center rounded text-muted-foreground/70 transition-colors hover:bg-muted/60 hover:text-foreground"
title="Open {tool.link.kind === 'knowledge' ? 'knowledge doc' : 'entity'}"
aria-label="Open {tool.link.kind === 'knowledge' ? 'knowledge doc' : 'entity'}"
onclick={() => openEntityWindow(tool.link!.slug)}
>
<ExternalLinkIcon class="size-3" />
</button>
{/if}
{#if tOpen}
<div
transition:slide={{ duration: 120 }}
@@ -516,10 +531,23 @@
>
{e.description}
</span>
{#if !e.link}
<span class="shrink-0 text-[9px] tabular-nums text-muted-foreground/60"
>{hhmm(e.timestamp)}</span
>
{/if}
</button>
{#if e.link}
<button
type="button"
class="absolute right-1.5 top-1/2 z-10 flex size-5 -translate-y-1/2 items-center justify-center rounded text-muted-foreground/70 transition-colors hover:bg-muted/60 hover:text-foreground"
title="Open {e.link.kind === 'knowledge' ? 'knowledge doc' : 'entity'}"
aria-label="Open {e.link.kind === 'knowledge' ? 'knowledge doc' : 'entity'}"
onclick={() => openEntityWindow(e.link!.slug)}
>
<ExternalLinkIcon class="size-3" />
</button>
{/if}
{#if eOpen}
<div
transition:slide={{ duration: 120 }}

View File

@@ -6,6 +6,7 @@ import { writable } from 'svelte/store'
// real store graph (workspace.ts, e.g., starts a top-level setInterval).
vi.mock('./chat', () => ({
messages: writable([]),
currentSession: writable(null),
chatFor: vi.fn(() => ({
messages: writable([]),
streaming: writable(false),

View File

@@ -1,5 +1,5 @@
import { derived, type Readable } from 'svelte/store'
import { messages, chatFor, type ChatMessage, type ToolCallResult } from './chat'
import { messages, chatFor, currentSession, type ChatMessage, type ToolCallResult } from './chat'
import { planSteps, currentTask, workspaceFor, taskFor } from './workspace'
import { liveExecutionOutputFor, type LiveExecutionOutput } from './execstream'
import type { PlanStep, Session } from '$lib/api'
@@ -33,6 +33,11 @@ export interface ActivityEntry {
// Distinct from `detail`, which is only populated once the tool_result
// arrives — for an auto-run that is the moment the command finishes.
liveOutput?: string
// Deep link to an artifact this entry references — a recorded knowledge doc
// or a looked-up entity — so the operator can open it directly instead of
// having to navigate there by hand. Rendered as a clickable chip in the
// timeline (F5). `slug` is an entity slug (e.g. "document:nomos/…").
link?: { kind: 'knowledge' | 'entity'; slug: string }
}
// Detail text is kept full-length (not hard-truncated to a preview snippet)
@@ -55,6 +60,32 @@ function stringifyResult(result: unknown): string {
return s.length > DETAIL_MAX ? `${s.slice(0, DETAIL_MAX)}\n… truncated` : s
}
// A knowledge doc slug as printed in upsert_knowledge's result text — mirrors
// cmd/nomos/store.go's knowledgeSlugRe (e.g. "document:nomos/some-finding").
const KNOWLEDGE_SLUG_RE = /[a-z]+:nomos\/[a-z0-9-]+/
// An entity slug looks like "type:name" (host:strong, lxc:caddy); a bare UUID
// or free text doesn't, so we only deep-link when it does.
const ENTITY_SLUG_RE = /^[a-z][a-z0-9_]*:[^\s]+$/
// entityLinkFromArgs pulls a navigable slug out of a get_entity-style call's
// args so its activity entry can link straight to that entity's window (F5).
function entityLinkFromArgs(args: unknown): ActivityEntry['link'] | undefined {
if (!args || typeof args !== 'object') return undefined
const slug = (args as Record<string, unknown>)?.slug_or_id
if (typeof slug === 'string' && ENTITY_SLUG_RE.test(slug)) {
return { kind: 'entity', slug }
}
return undefined
}
// knowledgeLinkFromResult extracts the created doc's slug from an
// upsert_knowledge result so the "Recorded: …" entry links to the doc (F5).
function knowledgeLinkFromResult(result: unknown): ActivityEntry['link'] | undefined {
const s = typeof result === 'string' ? result : JSON.stringify(result ?? '')
const m = s.match(KNOWLEDGE_SLUG_RE)
return m ? { kind: 'knowledge', slug: m[0] } : undefined
}
// Pure derivation, parameterized so it can back both the global "current
// session" activityLog below and a per-session activityLogFor(sessionId) for
// a floating task window.
@@ -160,6 +191,9 @@ export function computeActivityLog(
running.type = 'tool_done'
running.status = 'done'
running.detail = stringifyResult(t.result)
if (t.name === 'get_entity' || t.name === 'get_entity_knowledge') {
running.link = entityLinkFromArgs(t.args)
}
} else {
// Historical/persisted tool calls arrive as one merged record (args
// + result on the same object, see mergeToolCalls in chat.ts) rather
@@ -177,7 +211,11 @@ export function computeActivityLog(
toolName: t.name,
stepSeq: stepTag,
indent: stepTag != null,
status: t.error ? 'failed' : 'done'
status: t.error ? 'failed' : 'done',
link:
t.name === 'get_entity' || t.name === 'get_entity_knowledge'
? entityLinkFromArgs(t.args)
: undefined
})
}
}
@@ -196,7 +234,8 @@ export function computeActivityLog(
type: 'knowledge',
description: title ? `Recorded: ${title.slice(0, 60)}` : 'Recorded knowledge',
timestamp: freeze(kid, msgTs),
status: 'done'
status: 'done',
link: knowledgeLinkFromResult(t.result)
})
}
}
@@ -233,8 +272,28 @@ export function computeActivityLog(
// re-derivation can't march it forward. Owned here, outside the derivation,
// so it survives re-runs. The per-session path has its own Map keyed by id.
const frozenTimestamps = new Map<string, number>()
export const activityLog = derived([messages, planSteps, currentTask], ([$msgs, $steps, $task]) =>
computeActivityLog($msgs, $steps, $task, frozenTimestamps)
// Live execution output for whichever session the global "current session"
// view is on — used to attach streaming `run` output to the global activityLog
// (the per-window activityLogFor has its own). Follows currentSession via a
// derived setup function so the subscription moves to the right session's
// store when the operator switches tasks.
const currentLiveOutput = derived(
currentSession,
($sid, set) => {
if (!$sid) {
set(null)
return
}
return liveExecutionOutputFor($sid).subscribe(set)
},
null as LiveExecutionOutput | null
)
export const activityLog = derived(
[messages, planSteps, currentTask, currentLiveOutput],
([$msgs, $steps, $task, $live]) =>
withLiveOutput(computeActivityLog($msgs, $steps, $task, frozenTimestamps), $live)
)
// Attach streaming output to the `run` entry that is currently executing.

View File

@@ -8,6 +8,7 @@ import {
} from '$lib/api'
import type { ChatEvent, Session, Message } from '$lib/api'
import type { ToolCallResult } from '$lib/types'
import { liveEvents, subscribeEvents } from './events'
export type { ToolCallResult }
@@ -78,14 +79,90 @@ export const currentSession = writable<string | null>(null)
export const sessions = writable<Session[]>([])
export const sessionMessages = writable<Message[]>([])
export const error = writable<string | null>(null)
export const chatErrors = writable<{ id: string; message: string; action?: string }[]>([])
export const chatErrors = writable<{ id: string; message: string; action?: string; tag?: string }[]>([])
export function dismissError(id: string) {
chatErrors.update((e) => e.filter((x) => x.id !== id))
}
export function addChatError(message: string, action?: string) {
chatErrors.update((e) => [...e, { id: crypto.randomUUID(), message, action }])
export function addChatError(message: string, action?: string, tag?: string) {
chatErrors.update((e) => [...e, { id: crypto.randomUUID(), message, action, tag }])
}
// Session statuses where no turn is running — the agent reached a terminal
// state (done/failed/abandoned) or paused for operator input (awaiting_input).
// A task.status event landing in one of these is an authoritative "the turn
// ended" signal, used by clearTurnState (F3) to unstick a chat view that lost
// its SSE stream mid-turn.
const TURN_ENDED_STATUS = new Set(['done', 'failed', 'abandoned', 'awaiting_input'])
// humanizeChatError turns raw transport/SDK error strings into operator-
// readable, non-alarming copy. The raw forms ("llm: error in input stream:
// …", "Failed to fetch", "HTTP 502") read as catastrophic and unactionable;
// most are transient model-connection drops where the task itself is fine.
// Used for both the inline error box (LLM error events) and the connection
// toast (F2).
function humanizeChatError(raw: string): string {
const s = raw.toLowerCase()
if (
s.includes('input stream') ||
s.includes('llm:') ||
s.includes('failed to fetch') ||
s.includes('network') ||
s.includes('econnreset') ||
s.includes('timeout') ||
/http 5\d\d/.test(s)
) {
return 'The model connection dropped. The task keeps running in the background — it will catch up here automatically.'
}
if (/http 401|http 403|unauthor|forbidden/.test(s)) {
return 'Your session expired. Reconnect to continue.'
}
return raw
}
// clearTurnState resets a session's chat view to a clean "connected, idle"
// state — the recovery action when a dropped SSE left it stuck showing
// streaming/disconnected after the turn had already ended. Clears the window
// bundle, the global bundle (if that's the viewed session), and any
// connection-lost toasts tagged 'connection' (F2/F3).
function clearTurnState(sessionId: string) {
const win = sessionChats.get(sessionId)
if (win) {
win.streaming.set(false)
win.connectionState.set('connected')
}
if (get(currentSession) === sessionId) {
streaming.set(false)
connectionState.set('connected')
}
chatErrors.update((errs) => errs.filter((e) => e.tag !== 'connection'))
}
// One app-lifetime subscription to the always-on event stream: a terminal
// task.status for a session we have open is the authoritative end-of-turn
// signal, and recovers a chat view whose SSE dropped without a 'done' event
// (the "task never ended" symptom). Ref-counted by subscribeEvents, so this
// shares the single connection the rest of the app already keeps open.
//
// Lazily armed from chatFor() (P2.2) rather than at module import, so
// importing this module — e.g. in a test — doesn't open an SSE connection as
// an import side-effect.
let chatEventSyncArmed = false
function ensureChatEventSync() {
if (chatEventSyncArmed) return
chatEventSyncArmed = true
subscribeEvents()
liveEvents.subscribe((events) => {
const ev = events[0]
if (!ev || ev.type !== 'task.status') return
const sid = ev.correlation_id
if (!sid) return
const status = (ev.data as { status?: string } | null)?.status
if (typeof status === 'string' && TURN_ENDED_STATUS.has(status)) {
clearTurnState(sid)
}
})
}
// Per-session controller tracking. Multiple tasks can stream concurrently
@@ -378,80 +455,35 @@ export function sendMessage(text: string) {
}
}
// handleDisconnect is called when the SSE stream drops mid-turn without
// receiving a 'done' event. Falls back to polling and attempts reconnection.
// handleDisconnect is called when the global SSE stream drops mid-turn
// without a 'done' event. NOTE: the global single-session chat action path
// (sendMessage → this) is not currently wired to any UI — only the
// per-session window path (sendSessionMessage/startTask) is live, which has
// its own inline equivalent. This is kept safe and turn-free in case the
// global path is re-wired: it falls back to polling and surfaces one
// connection toast; recovery is driven by the poller + the terminal
// task.status subscription (clearTurnState), NEVER by POSTing an empty
// message that would spawn a duplicate background turn (F1/F2).
function handleDisconnect(sessionId: string) {
const MAX_RECONNECT = 3
connectionState.set('disconnected')
startPolling(sessionId)
addChatError('Agent connection lost. The task is still running — retrying…', 'Dismiss')
let attempts = 0
let delay = 1000
const attemptReconnect = () => {
if (get(currentSession) !== sessionId || attempts >= MAX_RECONNECT) {
connectionState.set('disconnected')
streaming.set(false)
return
}
if (attempts > 0) {
connectionState.set('reconnecting')
addChatError(`Reconnecting to agent (attempt ${attempts + 1}/${MAX_RECONNECT})…`, 'Dismiss')
}
attempts++
const controller = streamChat(
'',
sessionId,
(_ev: ChatEvent) => {},
(_err: string) => {
delay = Math.min(delay * 2, 8000)
setTimeout(attemptReconnect, delay)
},
() => {
if (get(currentSession) === sessionId) {
connectionState.set('connected')
streaming.set(false)
loadSessionMessages(sessionId)
}
}
addChatError(
'Connection to the agent dropped. The task keeps running — it will catch up here automatically.',
'Dismiss',
'connection'
)
if (activeControllers.get(sessionId)) {
activeControllers.get(sessionId)?.abort()
}
activeControllers.set(sessionId, controller)
}
setTimeout(attemptReconnect, delay)
}
// Manual reconnect for the global view (currently unused — windows use
// loadSessionChat via their onReconnect). Re-fetches the transcript and
// resets state; does NOT start a new turn.
export function reconnect() {
const sid = get(currentSession)
if (!sid) return
connectionState.set('reconnecting')
const controller = streamChat(
'',
sid,
(_ev: ChatEvent) => {},
(_err: string) => {
connectionState.set('disconnected')
addChatError(
'Reconnect failed. The task may still be running — try sending a message to wake the agent.',
'Dismiss'
)
},
() => {
if (get(currentSession) === sid) {
connectionState.set('connected')
streaming.set(false)
connectionState.set('connected')
chatErrors.update((errs) => errs.filter((e) => e.tag !== 'connection'))
loadSessionMessages(sid)
}
}
)
if (activeControllers.get(sid)) {
activeControllers.get(sid)?.abort()
}
activeControllers.set(sid, controller)
}
export function newChat() {
@@ -524,6 +556,7 @@ const sessionPollers = new Map<string, ReturnType<typeof setInterval>>()
// Lazily creates (and memoizes) the store bundle for a session — call this to
// get the stores to subscribe to; it does not fetch anything.
export function chatFor(sessionId: string): SessionChatState {
ensureChatEventSync() // arm the terminal task.status → clearTurnState recovery (P2.2)
let c = sessionChats.get(sessionId)
if (!c) {
c = {
@@ -549,6 +582,16 @@ function startSessionPolling(sessionId: string) {
const msgs = await fetchMessages(sessionId)
if (get(chat.streaming)) return // re-check: the fetch itself takes time
chat.messages.set(toChatMessages(msgs))
// F3 safety net: if we're recovering from a dropped SSE but the
// session's task has already reached a turn-ended status, clear the
// stuck disconnected/streaming flags. Catches the edge where the
// terminal task.status event fired during the brief disconnect window.
if (get(chat.connectionState) !== 'connected') {
const s = get(sessions).find((x) => x.id === sessionId)
if (s?.status && TURN_ENDED_STATUS.has(s.status)) {
clearTurnState(sessionId)
}
}
}, 3000)
)
}
@@ -566,7 +609,15 @@ export function stopSessionPolling(sessionId: string) {
// equivalent of loadSessionMessages, for a window rather than the main view.
export async function loadSessionChat(sessionId: string): Promise<void> {
const chat = chatFor(sessionId)
// A fresh (re)load is a clean view: not streaming, connected, no stale
// error. This also serves the window's manual "Reconnect" button —
// re-fetching the transcript and resetting state, never spawning a new
// turn (the old reconnect path POSTed an empty message that started a
// duplicate background turn; F1/F2 removed that).
chat.streaming.set(false)
chat.connectionState.set('connected')
chat.error.set(null)
chatErrors.update((errs) => errs.filter((e) => e.tag !== 'connection'))
const msgs = await fetchMessagesOrNotFound(sessionId)
if (msgs === null) {
chat.notFound.set(true)
@@ -668,7 +719,7 @@ export function sendSessionMessage(sessionId: string, text: string) {
})
startSessionPolling(sessionId)
} else if (ev.type === 'error') {
chat.error.set(ev.data)
chat.error.set(humanizeChatError(ev.data))
}
},
(err: string) => {
@@ -676,12 +727,21 @@ export function sendSessionMessage(sessionId: string, text: string) {
chat.streaming.set(false)
return
}
chat.error.set(err)
// Network drop (no 'done' received): show ONE connection-lost surface
// and recover via the poller + terminal task.status event (F2/F3).
// Don't also set chat.error — the banner+toast convey it, and a raw
// "Failed to fetch" alongside would just be noise.
if (!receivedDone) {
chat.connectionState.set('disconnected')
startSessionPolling(sessionId)
addChatError('Agent connection lost. The task is still running — retrying…', 'Dismiss')
addChatError(
'Connection to the agent dropped. The task keeps running — it will catch up here automatically.',
'Dismiss',
'connection'
)
} else {
// Stream ended cleanly but fetch reported an error tail — surface it.
chat.error.set(humanizeChatError(err))
chat.streaming.set(false)
}
},
@@ -793,7 +853,7 @@ export function startTask(text: string, onSession: (sessionId: string) => void):
})
startSessionPolling(sessionId)
} else if (ev.type === 'error') {
c.error.set(ev.data)
c.error.set(humanizeChatError(ev.data))
}
}
@@ -823,12 +883,16 @@ export function startTask(text: string, onSession: (sessionId: string) => void):
chat.streaming.set(false)
return
}
chat.error.set(err)
if (!receivedDone && sessionId) {
chat.connectionState.set('disconnected')
startSessionPolling(sessionId)
addChatError('Agent connection lost. The task is still running — retrying…', 'Dismiss')
addChatError(
'Connection to the agent dropped. The task keeps running — it will catch up here automatically.',
'Dismiss',
'connection'
)
} else {
chat.error.set(humanizeChatError(err))
chat.streaming.set(false)
}
},

View File

@@ -57,6 +57,11 @@ export interface ToolCallResult {
args?: Record<string, unknown>
result?: unknown
error?: string
// Streaming command output for an in-flight `run` call — attached live from
// the execution.output event stream (execstream.ts) while the call is still
// running, so the tool card can show output as it arrives instead of all at
// once when the tool_result lands. Not present on persisted/historical calls.
liveOutput?: string
}
// ---- Message content (persisted messages from /agent/sessions/:id) ----