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: } }