The agent could run two turns for one session at once (a reconnect resumed
while the live turn was still going), and their interleaved tool calls
corrupted the activity panel, fabricated a confusing "parallel/nested"
sequence, and made tasks feel stuck/never-ending. Several UX gaps compounded it.
Turn serialization (F1):
- turnGate: at most one in-flight turn per session. Background resume paths
(continuation worker, idle sweep, answer-question, /resume, reconnect)
skip non-blocking when busy; the live chat path waits briefly then bails
cleanly instead of stacking a second turn.
- resumeSession returns whether it ran; continueSession marks an execution
"continued" only after a real run (review P0) so a busy-skip can't lose a
finished-execution result. Idle nudge bumps only after delivery (P1).
Connection state (F2/F3, web):
- humanize/bucket raw errors ("model connection dropped..."); one surface
per drop; a terminal task.status event clears stuck streaming/disconnected
state and dismisses the connection toast. Reconnect no longer spawns turns.
Streaming where you look (F4, web):
- live command output in the global activity timeline and in the inline
tool card (auto-opened, tail-pinned) -- not just the per-window rail.
Other (web): artifact/knowledge deep links (F5); step-first stable
"thinking" headline (F6); stable chat layout, no empty->content reflow (F7);
lazy event sync (P2.2); reconnect skips a terminal session (P2.1).
VERSION: 0.14.2 -> 0.15.0
91 lines
3.0 KiB
Go
91 lines
3.0 KiB
Go
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:
|
|
}
|
|
}
|