Files
kandev/apps/backend/internal/gateway/websocket/hub_session_mode.go
T

307 lines
11 KiB
Go

package websocket
import (
"sync"
"time"
"go.uber.org/zap"
)
// SessionMode is the desired polling intensity for a session derived from UI state.
// Mirrors the agentctl-side process.PollMode, but defined here so the gateway
// doesn't import agentctl/process types.
type SessionMode string
const (
// SessionModePaused: no clients subscribed to this session.
SessionModePaused SessionMode = "paused"
// SessionModeSlow: clients subscribed (e.g. sidebar diff badge) but none focused.
SessionModeSlow SessionMode = "slow"
// SessionModeFast: at least one client has the session focused (task details page, panel).
SessionModeFast SessionMode = "fast"
)
// SessionModeListener is invoked when a session's effective mode transitions.
// Up-transitions (towards fast) fire immediately; down-transitions fire after
// a debounce window so quick tab churn doesn't tear down + restart polling.
type SessionModeListener func(sessionID string, mode SessionMode)
// downTransitionDebounce is how long the hub waits before notifying listeners
// of a down-transition (fast → slow, slow → paused). Tunable; 5s catches the
// common "open / close / reopen" pattern without leaving CPU on for too long.
const downTransitionDebounce = 5 * time.Second
// sessionModeTracker holds the focus map, debounce timers, and listener list.
//
// Locking: focusByClient is protected by Hub.mu (for consistency with
// sessionSubscribers). All other fields are protected by sessionModeTracker.mu.
type sessionModeTracker struct {
// One client set per session that has currently focused it. Separate from
// sessionSubscribers so a client can be "subscribed but not focused" (the
// sidebar case) — the sets evolve independently.
// Protected by Hub.mu, NOT by sessionModeTracker.mu.
focusByClient map[string]map[*Client]bool
// Last known mode per session, used to suppress redundant listener calls.
lastMode map[string]SessionMode
// Pending debounced down-transitions (sessionID → timer that will fire
// the listener if not cancelled by a re-up).
pendingDownTransitions map[string]*time.Timer
// Listeners to invoke on transition. Multiple listeners are supported but
// in practice there's one (lifecycle manager).
listeners []SessionModeListener
// debounce is the delay for down-transitions (fast→slow, slow→paused).
// Defaults to downTransitionDebounce (5s). Tests can shorten it via
// setDebounceForTest to avoid 5+ seconds of wall-clock sleep.
debounce time.Duration
mu sync.Mutex
}
func newSessionModeTracker() *sessionModeTracker {
return &sessionModeTracker{
focusByClient: make(map[string]map[*Client]bool),
lastMode: make(map[string]SessionMode),
pendingDownTransitions: make(map[string]*time.Timer),
debounce: downTransitionDebounce,
}
}
// setDebounceForTest allows tests to use a short debounce duration instead of
// waiting 5+ real seconds per test.
func (h *Hub) setDebounceForTest(d time.Duration) {
h.sessionMode.mu.Lock()
h.sessionMode.debounce = d
h.sessionMode.mu.Unlock()
}
// AddSessionModeListener registers a callback for session mode transitions.
// Listeners are called from arbitrary goroutines; they should be fast.
func (h *Hub) AddSessionModeListener(l SessionModeListener) {
h.sessionMode.mu.Lock()
h.sessionMode.listeners = append(h.sessionMode.listeners, l)
h.sessionMode.mu.Unlock()
}
// FocusSession marks a session as focused by the given client. Causes the
// session mode to transition to fast (immediately).
func (h *Hub) FocusSession(client *Client, sessionID string) {
if sessionID == "" {
return
}
h.mu.Lock()
if _, ok := h.sessionMode.focusByClient[sessionID]; !ok {
h.sessionMode.focusByClient[sessionID] = make(map[*Client]bool)
}
h.sessionMode.focusByClient[sessionID][client] = true
client.sessionFocus[sessionID] = true
h.mu.Unlock()
h.logger.Debug("client focused session",
zap.String("client_id", client.ID),
zap.String("session_id", sessionID))
h.recomputeSessionMode(sessionID)
}
// UnfocusSession removes the focus mark for the given client.
func (h *Hub) UnfocusSession(client *Client, sessionID string) {
if sessionID == "" {
return
}
h.mu.Lock()
if clients, ok := h.sessionMode.focusByClient[sessionID]; ok {
delete(clients, client)
if len(clients) == 0 {
delete(h.sessionMode.focusByClient, sessionID)
}
}
delete(client.sessionFocus, sessionID)
h.mu.Unlock()
h.logger.Debug("client unfocused session",
zap.String("client_id", client.ID),
zap.String("session_id", sessionID))
h.recomputeSessionMode(sessionID)
}
// computeSessionModeLocked returns the effective mode for a session given the
// current subscribers and focus sets. Caller must hold h.mu (read or write).
func (h *Hub) computeSessionModeLocked(sessionID string) SessionMode {
if len(h.sessionMode.focusByClient[sessionID]) > 0 {
return SessionModeFast
}
if len(h.sessionSubscribers[sessionID]) > 0 {
return SessionModeSlow
}
return SessionModePaused
}
// GetSessionMode returns the current effective mode for a session (fast if any
// client focused, slow if any subscribed, paused otherwise). Used by the
// lifecycle manager when an execution becomes ready — it queries the hub's
// live state and pushes to agentctl, closing the race where the hub's mode
// transitions fired before the execution was registered.
func (h *Hub) GetSessionMode(sessionID string) SessionMode {
h.mu.RLock()
defer h.mu.RUnlock()
return h.computeSessionModeLocked(sessionID)
}
// recomputeSessionMode is called after any state change that could affect a
// session's mode (subscribe, unsubscribe, focus, unfocus, client disconnect).
// Up-transitions notify immediately; down-transitions are debounced.
//
// Lock order: sessionMode.mu before h.mu (RLock). This is also the order used
// by the timer callback in scheduleDownTransition, which lets the callback
// re-read the latest mode atomically with respect to other recomputeSessionMode
// calls (otherwise a TOCTOU window between "read latest" and "compare to
// lastMode" can let a concurrent up-transition be silently overwritten).
func (h *Hub) recomputeSessionMode(sessionID string) {
h.sessionMode.mu.Lock()
h.mu.RLock()
current := h.computeSessionModeLocked(sessionID)
h.mu.RUnlock()
prev, hadPrev := h.sessionMode.lastMode[sessionID]
h.logger.Debug("recomputeSessionMode",
zap.String("session_id", sessionID),
zap.String("current", string(current)),
zap.String("prev", string(prev)),
zap.Bool("had_prev", hadPrev),
zap.Int("listener_count", len(h.sessionMode.listeners)))
// Always cancel any pending debounced down-transition — either we're
// transitioning to a new mode (it's stale), or we're back at the same mode
// it was scheduled to drop us to (it's redundant). If we kept the old
// timer alive, a slow→fast→slow→fast within the debounce window would
// leave the original slow timer pending and fire after the user had
// already re-focused.
//
// When we cancel a pending timer, also clear lastMode so the cancelled
// transition doesn't leave stale state. Without this, a page refresh
// (disconnect → paused debounce → reconnect within 5s) would see
// prev=fast, current=fast → "no change", and never broadcast the mode
// to the new client.
if t, ok := h.sessionMode.pendingDownTransitions[sessionID]; ok {
t.Stop()
delete(h.sessionMode.pendingDownTransitions, sessionID)
delete(h.sessionMode.lastMode, sessionID)
prev = ""
hadPrev = false
}
if hadPrev && prev == current {
h.logger.Debug("recomputeSessionMode: no change, skipping",
zap.String("session_id", sessionID))
h.sessionMode.mu.Unlock()
return
}
if isUpTransition(prev, current) {
// Up-transitions fire immediately so the user sees fresh data right away.
h.sessionMode.lastMode[sessionID] = current
listeners := h.snapshotListenersLocked()
h.logger.Debug("recomputeSessionMode: up-transition, firing listeners",
zap.String("session_id", sessionID),
zap.String("from", string(prev)),
zap.String("to", string(current)),
zap.Int("listener_count", len(listeners)))
h.sessionMode.mu.Unlock()
h.fireListeners(listeners, sessionID, current)
return
}
// Down-transition: debounce so quick tab churn doesn't tear down + restart.
h.logger.Debug("recomputeSessionMode: down-transition, scheduling debounce",
zap.String("session_id", sessionID),
zap.String("from", string(prev)),
zap.String("to", string(current)))
h.sessionMode.pendingDownTransitions[sessionID] = time.AfterFunc(h.sessionMode.debounce, func() {
h.fireDebouncedDownTransition(sessionID)
})
h.sessionMode.mu.Unlock()
}
// fireDebouncedDownTransition is called by the debounce timer. Acquires
// sessionMode.mu first (matching recomputeSessionMode's lock order) so that
// reading latest and comparing/updating lastMode happens atomically — no
// concurrent recomputeSessionMode can sneak an up-transition between the two
// reads.
func (h *Hub) fireDebouncedDownTransition(sessionID string) {
h.sessionMode.mu.Lock()
// Drop the timer pointer so a future scheduling can replace it cleanly.
delete(h.sessionMode.pendingDownTransitions, sessionID)
h.mu.RLock()
latest := h.computeSessionModeLocked(sessionID)
h.mu.RUnlock()
prevAtFire, hadPrev := h.sessionMode.lastMode[sessionID]
if hadPrev && prevAtFire == latest {
h.sessionMode.mu.Unlock()
return
}
if latest == SessionModePaused {
// Session is fully idle — drop the entry so the maps don't grow
// unbounded over a long-running gateway. Next event re-adds.
delete(h.sessionMode.lastMode, sessionID)
} else {
h.sessionMode.lastMode[sessionID] = latest
}
listeners := h.snapshotListenersLocked()
h.sessionMode.mu.Unlock()
h.fireListeners(listeners, sessionID, latest)
}
// stopAllPendingTransitions cancels every pending debounce timer and clears
// the tracking maps. Called from closeAllClients during shutdown so timers
// don't outlive the hub and fire stale events into listeners.
func (h *Hub) stopAllPendingTransitions() {
h.sessionMode.mu.Lock()
defer h.sessionMode.mu.Unlock()
for id, t := range h.sessionMode.pendingDownTransitions {
t.Stop()
delete(h.sessionMode.pendingDownTransitions, id)
}
h.sessionMode.lastMode = make(map[string]SessionMode)
}
// snapshotListenersLocked returns a copy of the listener slice. Caller holds h.sessionMode.mu.
func (h *Hub) snapshotListenersLocked() []SessionModeListener {
out := make([]SessionModeListener, len(h.sessionMode.listeners))
copy(out, h.sessionMode.listeners)
return out
}
func (h *Hub) fireListeners(listeners []SessionModeListener, sessionID string, mode SessionMode) {
for _, l := range listeners {
l(sessionID, mode)
}
}
// isUpTransition returns true if newMode represents a higher polling intensity
// than oldMode. Order: paused < slow < fast.
func isUpTransition(oldMode, newMode SessionMode) bool {
return modeRank(newMode) > modeRank(oldMode)
}
func modeRank(m SessionMode) int {
switch m {
case SessionModeFast:
return 2
case SessionModeSlow:
return 1
case SessionModePaused:
return 0
default:
return 0
}
}