Files
kandev/apps/backend/internal/agentctl/server/process/status_tracker.go
T
2026-01-25 17:49:53 +00:00

257 lines
7.1 KiB
Go

// Package process provides background process execution and output streaming for agentctl.
//
// StatusTracker uses a virtual terminal emulator (vt10x) to parse agent TUI output
// and detect agent states (working, waiting for approval, waiting for input).
package process
import (
"sync"
"time"
"github.com/kandev/kandev/internal/common/logger"
"github.com/tuzig/vt10x"
"go.uber.org/zap"
)
// AgentState represents the detected state of an agent CLI.
type AgentState string
const (
StateUnknown AgentState = "unknown"
StateWorking AgentState = "working"
StateWaitingApproval AgentState = "waiting_approval"
StateWaitingInput AgentState = "waiting_input"
)
// StatusDetector is implemented by agent-specific detectors to detect state from terminal content.
type StatusDetector interface {
// DetectState examines the visible terminal content and returns the detected state.
// lines contains the visible text lines, glyphs contains the full glyph data with attributes.
DetectState(lines []string, glyphs [][]vt10x.Glyph) AgentState
// ShouldAcceptStateChange determines if a state transition should be accepted.
// This allows detectors to implement stability windows for debouncing.
ShouldAcceptStateChange(currentState, newState AgentState) bool
}
// StateChangeCallback is called when the detected state changes.
type StateChangeCallback func(sessionID string, state AgentState)
// StatusTrackerConfig contains configuration for the status tracker.
type StatusTrackerConfig struct {
Rows int // Terminal rows (default 24)
Cols int // Terminal columns (default 80)
CheckInterval time.Duration // How often to check state (default 100ms)
StabilityWindow time.Duration // Time state must be stable before reporting (default 0)
}
// DefaultStatusTrackerConfig returns the default configuration.
func DefaultStatusTrackerConfig() StatusTrackerConfig {
return StatusTrackerConfig{
Rows: 24,
Cols: 80,
CheckInterval: 100 * time.Millisecond,
StabilityWindow: 0,
}
}
// StatusTracker uses a virtual terminal emulator to track agent state.
type StatusTracker struct {
logger *logger.Logger
sessionID string
detector StatusDetector
callback StateChangeCallback
config StatusTrackerConfig
// vt10x terminal emulator
term vt10x.Terminal
// State tracking
mu sync.Mutex
lastState AgentState
lastStateChange time.Time
lastCheck time.Time
pendingState AgentState // State waiting for stability window
pendingStateTime time.Time // When pending state was first detected
}
// NewStatusTracker creates a new status tracker with the given detector.
func NewStatusTracker(
sessionID string,
detector StatusDetector,
callback StateChangeCallback,
config StatusTrackerConfig,
log *logger.Logger,
) *StatusTracker {
if config.Rows <= 0 {
config.Rows = 24
}
if config.Cols <= 0 {
config.Cols = 80
}
if config.CheckInterval <= 0 {
config.CheckInterval = 100 * time.Millisecond
}
// Create vt10x terminal emulator
term := vt10x.New(vt10x.WithSize(config.Cols, config.Rows))
return &StatusTracker{
logger: log.WithFields(zap.String("component", "status-tracker"), zap.String("session_id", sessionID)),
sessionID: sessionID,
detector: detector,
callback: callback,
config: config,
term: term,
lastState: StateUnknown,
}
}
// Write feeds data to the virtual terminal emulator.
// This should be called with all PTY output data.
func (t *StatusTracker) Write(data []byte) {
t.mu.Lock()
defer t.mu.Unlock()
// Feed data to the virtual terminal
_, _ = t.term.Write(data)
}
// Resize updates the virtual terminal size.
func (t *StatusTracker) Resize(cols, rows int) {
t.mu.Lock()
defer t.mu.Unlock()
t.term.Resize(cols, rows)
t.config.Cols = cols
t.config.Rows = rows
}
// ShouldCheck returns true if it's time to check the terminal state.
func (t *StatusTracker) ShouldCheck() bool {
t.mu.Lock()
defer t.mu.Unlock()
return time.Since(t.lastCheck) >= t.config.CheckInterval
}
// CheckAndUpdate checks the terminal state and emits a callback if the state changed.
// Returns the current state.
func (t *StatusTracker) CheckAndUpdate() AgentState {
t.mu.Lock()
defer t.mu.Unlock()
t.lastCheck = time.Now()
// Extract visible lines and glyphs from the terminal
lines, glyphs := t.extractTerminalContent()
// Detect state using the agent-specific detector
detectedState := t.detector.DetectState(lines, glyphs)
// Handle stability window if configured
if t.config.StabilityWindow > 0 {
return t.handleStabilityWindow(detectedState)
}
// No stability window - check if detector accepts the transition
if detectedState != t.lastState {
if t.detector.ShouldAcceptStateChange(t.lastState, detectedState) {
t.emitStateChange(detectedState)
}
}
return t.lastState
}
// CurrentState returns the current detected state.
func (t *StatusTracker) CurrentState() AgentState {
t.mu.Lock()
defer t.mu.Unlock()
return t.lastState
}
// extractTerminalContent extracts the visible lines and glyphs from the vt10x terminal.
func (t *StatusTracker) extractTerminalContent() ([]string, [][]vt10x.Glyph) {
rows := t.config.Rows
cols := t.config.Cols
lines := make([]string, rows)
glyphs := make([][]vt10x.Glyph, rows)
for row := 0; row < rows; row++ {
rowGlyphs := make([]vt10x.Glyph, cols)
var rowChars []rune
for col := 0; col < cols; col++ {
g := t.term.Cell(col, row)
rowGlyphs[col] = g
if g.Char == 0 {
rowChars = append(rowChars, ' ')
} else {
rowChars = append(rowChars, g.Char)
}
}
lines[row] = string(rowChars)
glyphs[row] = rowGlyphs
}
return lines, glyphs
}
// handleStabilityWindow implements debouncing for state changes.
func (t *StatusTracker) handleStabilityWindow(detectedState AgentState) AgentState {
now := time.Now()
if detectedState != t.pendingState {
// New state detected, start stability timer
t.pendingState = detectedState
t.pendingStateTime = now
return t.lastState
}
// Same state as pending - check if stable long enough
if now.Sub(t.pendingStateTime) >= t.config.StabilityWindow {
if t.pendingState != t.lastState {
if t.detector.ShouldAcceptStateChange(t.lastState, t.pendingState) {
t.emitStateChange(t.pendingState)
}
}
}
return t.lastState
}
// emitStateChange updates the state and calls the callback.
// Must be called with mutex held.
func (t *StatusTracker) emitStateChange(newState AgentState) {
oldState := t.lastState
t.lastState = newState
t.lastStateChange = time.Now()
t.logger.Debug("agent state changed",
zap.String("old_state", string(oldState)),
zap.String("new_state", string(newState)))
if t.callback != nil {
// Release lock before callback to prevent deadlocks
t.mu.Unlock()
t.callback(t.sessionID, newState)
t.mu.Lock()
}
}
// NoOpDetector is a detector that always returns StateUnknown.
// Used when no detector is configured.
type NoOpDetector struct{}
func (d *NoOpDetector) DetectState(lines []string, glyphs [][]vt10x.Glyph) AgentState {
return StateUnknown
}
func (d *NoOpDetector) ShouldAcceptStateChange(currentState, newState AgentState) bool {
return true
}