Files
kandev/apps/backend/internal/agent/runtime/lifecycle/manager_subscription.go
T
2026-09-06 11:32:14 +01:00

434 lines
16 KiB
Go

package lifecycle
import (
"context"
"sync"
"time"
"go.uber.org/zap"
)
// WorkspacePollMode mirrors process.PollMode for the lifecycle layer. Defined
// here as a string to avoid importing the agentctl process package (and the
// surface area that comes with it). Values must stay aligned with
// process.PollMode{Fast,Slow,Paused}.
type WorkspacePollMode string
const (
WorkspacePollModeFast WorkspacePollMode = "fast"
WorkspacePollModeSlow WorkspacePollMode = "slow"
WorkspacePollModePaused WorkspacePollMode = "paused"
)
// rank orders modes from coldest to hottest so we can pick the max across
// sessions sharing a workspace. paused < slow < fast.
func (m WorkspacePollMode) rank() int {
switch m {
case WorkspacePollModeFast:
return 2
case WorkspacePollModeSlow:
return 1
default:
return 0
}
}
// pushPollModeTimeout caps how long a single push to agentctl is allowed to
// block before we move on. Keeps the listener responsive even if a single
// agentctl is unreachable.
const pushPollModeTimeout = 5 * time.Second
// SessionModeQuerier is implemented by the gateway hub. It lets the lifecycle
// manager look up the current effective mode for a session, so when an
// execution becomes ready we can proactively push the right mode even when
// the gateway's subscribe/focus events fired before the execution existed.
type SessionModeQuerier interface {
GetSessionMode(sessionID string) WorkspacePollMode
}
// workspacePollAggregator tracks the per-session mode contributions for each
// workspace and pushes the effective workspace mode to agentctl when it changes.
type workspacePollAggregator struct {
mgr *Manager
hubQry SessionModeQuerier // nil until gateway wires it in
mu sync.Mutex
// sessionModes maps sessionID -> latest mode reported by the gateway.
sessionModes map[string]WorkspacePollMode
// lastPushed maps workspacePath -> last mode we sent to agentctl. Used to
// suppress duplicate pushes when workspace-level mode is unchanged.
lastPushed map[string]WorkspacePollMode
// workspaceSessions is a reverse index: workspacePath -> set of sessionIDs
// known to belong to that workspace. Lets recordAndCompute iterate only
// sessions in the affected workspace instead of scanning all sessionModes.
workspaceSessions map[string]map[string]bool
// runtimeSessions tracks executions independently of browser UI state. An
// active execution contributes the slow polling baseline so task summaries
// continue to receive Git/status updates when no task page is open.
runtimeSessions map[string]bool
// workspaceRuntimeSessions is the runtime-interest reverse index used when
// computing the workspace-level maximum without scanning all executions.
workspaceRuntimeSessions map[string]map[string]bool
// runtimeWorkspaceBySession lets a replacement execution move a session's
// runtime contribution without leaving an entry in its old workspace.
runtimeWorkspaceBySession map[string]string
// Last-write-wins queue: pendingPush + pushInFlight serialize per-workspace HTTP pushes so order matches enqueue.
pendingPush map[string]workspacePushTarget
pushInFlight map[string]bool
// dirtyPush records a desired mode whose last RPC failed. The next
// contribution event retries it even when the effective mode is unchanged.
dirtyPush map[string]bool
}
// workspacePushTarget bundles the queued mode and execution resolved at enqueue time.
// The worker pins that execution's current client only while issuing the RPC.
type workspacePushTarget struct {
mode WorkspacePollMode
execution *AgentExecution
}
// newWorkspacePollAggregator wires an aggregator to the lifecycle manager.
func newWorkspacePollAggregator(mgr *Manager) *workspacePollAggregator {
return &workspacePollAggregator{
mgr: mgr,
sessionModes: make(map[string]WorkspacePollMode),
lastPushed: make(map[string]WorkspacePollMode),
workspaceSessions: make(map[string]map[string]bool),
runtimeSessions: make(map[string]bool),
workspaceRuntimeSessions: make(map[string]map[string]bool),
runtimeWorkspaceBySession: make(map[string]string),
pendingPush: make(map[string]workspacePushTarget),
pushInFlight: make(map[string]bool),
dirtyPush: make(map[string]bool),
}
}
// HandleSessionMode is the entry point called by the gateway hub when a
// session's effective UI mode transitions. Resolves the session to its
// workspace, aggregates with sibling sessions in the same workspace, and pushes
// the workspace-level effective mode to agentctl if it changed.
//
// Best-effort: errors are logged, never returned. The hub should not block on
// this (the call is debounced + computed off the hub critical path already).
//
// Pre-execution focus race: if a session.focus arrives before the lifecycle
// has created its execution, we cache the mode in sessionModes and return. The
// lifecycle manager calls FlushSessionMode once agentctl is ready, which
// resolves the race by pushing the cached mode retroactively.
func (a *workspacePollAggregator) HandleSessionMode(sessionID string, mode WorkspacePollMode) {
execution, exists := a.mgr.GetExecutionBySessionID(sessionID)
if !exists {
// No execution yet — cache the mode so the next event can still
// aggregate correctly. If the mode is paused there's nothing to
// remember, so drop any prior entry to keep the map bounded.
a.mu.Lock()
if mode == WorkspacePollModePaused {
delete(a.sessionModes, sessionID)
} else {
a.sessionModes[sessionID] = mode
}
a.mu.Unlock()
return
}
if execution.WorkspacePath == "" {
return
}
workspacePath, effective, changed := a.recordAndCompute(sessionID, mode, execution.WorkspacePath)
if !changed {
return
}
a.pushAsync(execution, workspacePath, effective)
}
// HandleRuntimeInterest records whether an execution is active for polling
// purposes. Runtime interest is deliberately separate from UI session mode:
// a browser may unsubscribe or lose focus while the execution still needs Git
// and status updates for the task-level summary.
func (a *workspacePollAggregator) HandleRuntimeInterest(sessionID string, active bool) {
execution, exists := a.mgr.GetExecutionBySessionID(sessionID)
if !exists || execution.WorkspacePath == "" {
return
}
workspacePath, effective, changed := a.recordRuntimeAndCompute(sessionID, active, execution.WorkspacePath)
if !changed {
return
}
a.pushAsync(execution, workspacePath, effective)
}
// setSessionModeLocked updates sessionModes and the workspaceSessions reverse
// index for a single session. Caller must hold a.mu.
func (a *workspacePollAggregator) setSessionModeLocked(sessionID string, mode WorkspacePollMode, workspacePath string) {
if mode == WorkspacePollModePaused {
delete(a.sessionModes, sessionID)
a.removeFromWorkspaceIndex(sessionID, workspacePath)
return
}
a.sessionModes[sessionID] = mode
if a.workspaceSessions[workspacePath] == nil {
a.workspaceSessions[workspacePath] = make(map[string]bool)
}
a.workspaceSessions[workspacePath][sessionID] = true
}
// removeFromWorkspaceIndex drops a session from the reverse index, cleaning up
// the workspace key if empty. Caller must hold a.mu.
func (a *workspacePollAggregator) removeFromWorkspaceIndex(sessionID, workspacePath string) {
sessions := a.workspaceSessions[workspacePath]
if sessions == nil {
return
}
delete(sessions, sessionID)
if len(sessions) == 0 {
delete(a.workspaceSessions, workspacePath)
}
}
// removeRuntimeFromWorkspaceIndex drops a runtime contribution from its
// reverse index, cleaning up the workspace key if empty. Caller must hold
// a.mu.
func (a *workspacePollAggregator) removeRuntimeFromWorkspaceIndex(sessionID, workspacePath string) {
sessions := a.workspaceRuntimeSessions[workspacePath]
if sessions == nil {
return
}
delete(sessions, sessionID)
if len(sessions) == 0 {
delete(a.workspaceRuntimeSessions, workspacePath)
}
}
// setRuntimeInterestLocked updates runtime interest and its workspace index.
// Caller must hold a.mu.
func (a *workspacePollAggregator) setRuntimeInterestLocked(sessionID string, active bool, workspacePath string) {
if !active {
delete(a.runtimeSessions, sessionID)
if previousWorkspace := a.runtimeWorkspaceBySession[sessionID]; previousWorkspace != "" {
a.removeRuntimeFromWorkspaceIndex(sessionID, previousWorkspace)
}
delete(a.runtimeWorkspaceBySession, sessionID)
return
}
if previousWorkspace := a.runtimeWorkspaceBySession[sessionID]; previousWorkspace != "" && previousWorkspace != workspacePath {
a.removeRuntimeFromWorkspaceIndex(sessionID, previousWorkspace)
}
a.runtimeSessions[sessionID] = true
a.runtimeWorkspaceBySession[sessionID] = workspacePath
if a.workspaceRuntimeSessions[workspacePath] == nil {
a.workspaceRuntimeSessions[workspacePath] = make(map[string]bool)
}
a.workspaceRuntimeSessions[workspacePath][sessionID] = true
}
// effectiveModeLocked computes the maximum of browser UI contributions and
// runtime-interest contributions for a workspace. Caller must hold a.mu.
func (a *workspacePollAggregator) effectiveModeLocked(workspacePath string) WorkspacePollMode {
effective := WorkspacePollModePaused
for sid := range a.workspaceSessions[workspacePath] {
if mode, ok := a.sessionModes[sid]; ok && mode.rank() > effective.rank() {
effective = mode
}
}
if len(a.workspaceRuntimeSessions[workspacePath]) > 0 && WorkspacePollModeSlow.rank() > effective.rank() {
effective = WorkspacePollModeSlow
}
return effective
}
// applyEffectiveModeLocked records the workspace mode and reports whether a
// push is needed. Caller must hold a.mu. force is used by the ready-time flush
// to retry a mode that may have been sent before agentctl accepted requests.
func (a *workspacePollAggregator) applyEffectiveModeLocked(workspacePath string, effective WorkspacePollMode, force bool) bool {
prev, hadPrev := a.lastPushed[workspacePath]
shouldPush := force || a.dirtyPush[workspacePath] || !hadPrev || prev != effective
if !force && !hadPrev && effective == WorkspacePollModePaused && !a.dirtyPush[workspacePath] {
return false
}
if effective == WorkspacePollModePaused {
delete(a.lastPushed, workspacePath)
// Keep the mode map bounded, but still report a transition from an
// already-managed mode so pushAsync delivers the paused state.
return shouldPush
}
a.lastPushed[workspacePath] = effective
return shouldPush
}
// recordAndCompute updates the per-session mode for the given workspace and
// returns the new workspace-effective mode, plus whether it changed since the
// last push. We compute this under a single lock so concurrent transitions in
// the same workspace can't observe inconsistent intermediate state.
//
// When a session goes to paused we drop its sessionModes entry so the map
// doesn't grow unbounded over a long-running gateway. Same for lastPushed
// when the workspace itself becomes paused.
func (a *workspacePollAggregator) recordAndCompute(sessionID string, mode WorkspacePollMode, workspacePath string) (string, WorkspacePollMode, bool) {
a.mu.Lock()
defer a.mu.Unlock()
a.setSessionModeLocked(sessionID, mode, workspacePath)
// Compute effective mode using only contributions in this workspace (O(k)
// where k = sessions in workspace, not O(N) total sessions).
effective := a.effectiveModeLocked(workspacePath)
return workspacePath, effective, a.applyEffectiveModeLocked(workspacePath, effective, false)
}
// recordRuntimeAndCompute updates runtime interest and returns the resulting
// workspace-effective mode plus whether it changed since the last push.
func (a *workspacePollAggregator) recordRuntimeAndCompute(sessionID string, active bool, workspacePath string) (string, WorkspacePollMode, bool) {
a.mu.Lock()
defer a.mu.Unlock()
a.setRuntimeInterestLocked(sessionID, active, workspacePath)
effective := a.effectiveModeLocked(workspacePath)
return workspacePath, effective, a.applyEffectiveModeLocked(workspacePath, effective, false)
}
// pushAsync queues the latest mode and ensures exactly one pusher goroutine per workspace drains it (last-write-wins).
func (a *workspacePollAggregator) pushAsync(execution *AgentExecution, workspacePath string, mode WorkspacePollMode) {
client, releaseClient := execution.AcquireAgentCtlClient()
if client == nil {
return
}
releaseClient()
a.mu.Lock()
a.pendingPush[workspacePath] = workspacePushTarget{mode: mode, execution: execution}
if a.pushInFlight[workspacePath] {
a.mu.Unlock()
return
}
a.pushInFlight[workspacePath] = true
a.mu.Unlock()
go a.pushLoop(workspacePath)
}
// pushLoop drains pendingPush for a workspace sequentially so HTTP order matches enqueue order.
func (a *workspacePollAggregator) pushLoop(workspacePath string) {
for {
a.mu.Lock()
target, ok := a.pendingPush[workspacePath]
if !ok {
delete(a.pushInFlight, workspacePath)
a.mu.Unlock()
return
}
delete(a.pendingPush, workspacePath)
a.mu.Unlock()
client, releaseClient := target.execution.AcquireAgentCtlClient()
if client == nil {
continue
}
ctx, cancel := context.WithTimeout(context.Background(), pushPollModeTimeout)
err := client.SetWorkspacePollMode(ctx, string(target.mode))
cancel()
releaseClient()
if err != nil {
a.mu.Lock()
a.dirtyPush[workspacePath] = true
a.mu.Unlock()
a.mgr.logger.Warn("failed to push workspace poll mode",
zap.String("workspace", workspacePath),
zap.String("mode", string(target.mode)),
zap.Error(err))
continue
}
a.mu.Lock()
delete(a.dirtyPush, workspacePath)
a.mu.Unlock()
}
}
// FlushSessionMode resolves two races that leave agentctl in the wrong mode:
//
// 1. Pre-execution focus: the gateway sent focus before the execution was
// in executionStore. HandleSessionMode cached the mode but short-circuited
// without pushing.
// 2. Pre-ready push: the gateway sent focus after Add() but before agentctl's
// HTTP server was accepting connections. pushAsync fired the RPC, it
// failed (connection refused), but lastPushed was already updated so
// nothing retries.
//
// This is called from the lifecycle manager's waitForAgentctlReady success
// path — once we know agentctl is accepting RPCs, force-resend whatever
// workspace-effective mode we currently believe is correct. A workspace with
// an active execution is flushed even when no gateway UI event was cached.
func (a *workspacePollAggregator) FlushSessionMode(sessionID string) {
execution, exists := a.mgr.GetExecutionBySessionID(sessionID)
if !exists || execution.WorkspacePath == "" {
return
}
// Authoritative source: query the hub for the session's current mode.
// This closes the race where gateway events fired before the execution
// was registered (nothing cached) or while agentctl was still starting
// up (the RPC fired but connection-refused).
//
// Read hubQry under a.mu so the race detector doesn't flag the
// concurrent write in SetSessionModeQuerier. Copy to a local and
// release a.mu before calling into the hub (which acquires its own
// locks — holding a.mu across the call could invert lock order).
a.mu.Lock()
qry := a.hubQry
a.mu.Unlock()
var (
sessionMode WorkspacePollMode
hasSessionMode bool
)
if qry != nil {
sessionMode = qry.GetSessionMode(sessionID)
hasSessionMode = true
} else {
// Fall back to cached value if no hub wired (e.g., tests).
a.mu.Lock()
cached, hasCached := a.sessionModes[sessionID]
a.mu.Unlock()
if hasCached {
sessionMode = cached
hasSessionMode = true
}
}
a.mu.Lock()
// Merge the queried mode with whatever we had cached when the hub has an
// authoritative answer. Runtime interest is always included, even when no
// UI subscription exists for this session.
wp := execution.WorkspacePath
if hasSessionMode {
a.setSessionModeLocked(sessionID, sessionMode, wp)
}
effective := a.effectiveModeLocked(wp)
shouldPush := a.applyEffectiveModeLocked(wp, effective, true)
a.mu.Unlock()
if shouldPush {
a.pushAsync(execution, execution.WorkspacePath, effective)
}
}
// SetSessionModeQuerier lets the gateway inject itself so the aggregator can
// query the hub's live session mode state.
func (m *Manager) SetSessionModeQuerier(q SessionModeQuerier) {
if m.pollAggregator != nil {
m.pollAggregator.mu.Lock()
m.pollAggregator.hubQry = q
m.pollAggregator.mu.Unlock()
}
}
// setRuntimeInterest keeps workspace polling alive for active executions even
// when no browser is subscribed to the session stream.
func (m *Manager) setRuntimeInterest(sessionID string, active bool) {
if m.pollAggregator != nil {
m.pollAggregator.HandleRuntimeInterest(sessionID, active)
}
}