Compare commits
2 Commits
467589d78a
...
39e9227fdb
| Author | SHA1 | Date | |
|---|---|---|---|
| 39e9227fdb | |||
| bb05f215c6 |
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
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)
|
||||
})
|
||||
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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
90
cmd/nomos/turngate.go
Normal 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
114
cmd/nomos/turngate_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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 7–12**.
|
||||
|
||||
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 1–5 (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 7–12 (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 4–5, 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 7–12. Every subsequent
|
||||
`plan.step.started` / `plan.step.finished` carried seq 1–5 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.
|
||||
184
plans/2026-08-03-nomos-chat-changes-review.md
Normal file
184
plans/2026-08-03-nomos-chat-changes-review.md
Normal file
@@ -0,0 +1,184 @@
|
||||
# 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.
|
||||
356
plans/2026-08-03-nomos-chat-reliability-and-ux-audit.md
Normal file
356
plans/2026-08-03-nomos-chat-reliability-and-ux-audit.md
Normal file
@@ -0,0 +1,356 @@
|
||||
# 2026-08-03 — Nomos chat: reliability & predictability audit
|
||||
|
||||
**Status:** Implemented (F1–F7) 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 F1–F7 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.
|
||||
@@ -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) | P0–P2 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 — F1–F7 shipped in v0.15.0; F8 + follow-ups open |
|
||||
|
||||
## Done
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
<span class="shrink-0 text-[9px] tabular-nums text-muted-foreground/50"
|
||||
>{hhmm(tool.timestamp)}</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>
|
||||
<span class="shrink-0 text-[9px] tabular-nums text-muted-foreground/60"
|
||||
>{hhmm(e.timestamp)}</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 }}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
if (activeControllers.get(sessionId)) {
|
||||
activeControllers.get(sessionId)?.abort()
|
||||
}
|
||||
activeControllers.set(sessionId, controller)
|
||||
}
|
||||
|
||||
setTimeout(attemptReconnect, delay)
|
||||
addChatError(
|
||||
'Connection to the agent dropped. The task keeps running — it will catch up here automatically.',
|
||||
'Dismiss',
|
||||
'connection'
|
||||
)
|
||||
}
|
||||
|
||||
// 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)
|
||||
loadSessionMessages(sid)
|
||||
}
|
||||
}
|
||||
)
|
||||
if (activeControllers.get(sid)) {
|
||||
activeControllers.get(sid)?.abort()
|
||||
}
|
||||
activeControllers.set(sid, controller)
|
||||
streaming.set(false)
|
||||
connectionState.set('connected')
|
||||
chatErrors.update((errs) => errs.filter((e) => e.tag !== 'connection'))
|
||||
loadSessionMessages(sid)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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) ----
|
||||
|
||||
Reference in New Issue
Block a user