mirror of
https://github.com/hanzoai/agent.git
synced 2026-09-11 21:08:51 +00:00
Consolidate all Hanzo agent infrastructure into one repo: - control-plane/: Go control plane server (was agents-new) - sdk/: Go, Python, TypeScript SDKs - deployments/: Docker, Kubernetes, Helm configs - Dockerfile.sdk: Python SDK image (ghcr.io/hanzoai/agent:latest) - CI builds both SDK + control-plane images No more agentfield — everything is hanzoai/agent.
758 lines
25 KiB
Go
758 lines
25 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/hanzoai/agents/control-plane/internal/core/interfaces"
|
|
"github.com/hanzoai/agents/control-plane/internal/events"
|
|
"github.com/hanzoai/agents/control-plane/internal/logger"
|
|
"github.com/hanzoai/agents/control-plane/internal/storage"
|
|
"github.com/hanzoai/agents/control-plane/pkg/types"
|
|
)
|
|
|
|
// StatusManagerConfig holds configuration for the status manager
|
|
type StatusManagerConfig struct {
|
|
ReconcileInterval time.Duration // How often to reconcile status
|
|
StatusCacheTTL time.Duration // How long to cache status
|
|
MaxTransitionTime time.Duration // Max time for state transitions
|
|
HeartbeatStaleThreshold time.Duration // How old a heartbeat must be before marking inactive (default 60s)
|
|
}
|
|
|
|
// StatusManager provides a single source of truth for agent status
|
|
// It reconciles between different status sources and manages status persistence
|
|
type StatusManager struct {
|
|
storage storage.StorageProvider
|
|
config StatusManagerConfig
|
|
uiService *UIService
|
|
agentClient interfaces.AgentClient
|
|
|
|
// Status cache for fast access (short-term, 5-second TTL)
|
|
statusCache map[string]*cachedAgentStatus
|
|
cacheMutex sync.RWMutex
|
|
|
|
// Transition tracking
|
|
activeTransitions map[string]*types.StateTransition
|
|
transitionMutex sync.RWMutex
|
|
|
|
// Control channels
|
|
stopCh chan struct{}
|
|
|
|
// Event handlers
|
|
eventHandlers []StatusEventHandler
|
|
}
|
|
|
|
// cachedAgentStatus represents a cached status with timestamp
|
|
type cachedAgentStatus struct {
|
|
Status *types.AgentStatus
|
|
Timestamp time.Time
|
|
}
|
|
|
|
func cloneAgentStatus(status *types.AgentStatus) *types.AgentStatus {
|
|
if status == nil {
|
|
return nil
|
|
}
|
|
|
|
clone := *status
|
|
|
|
if status.MCPStatus != nil {
|
|
mcpCopy := *status.MCPStatus
|
|
clone.MCPStatus = &mcpCopy
|
|
}
|
|
|
|
if status.StateTransition != nil {
|
|
transitionCopy := *status.StateTransition
|
|
clone.StateTransition = &transitionCopy
|
|
}
|
|
|
|
if status.LastVerified != nil {
|
|
lastVerifiedCopy := *status.LastVerified
|
|
clone.LastVerified = &lastVerifiedCopy
|
|
}
|
|
|
|
return &clone
|
|
}
|
|
|
|
// StatusEventHandler defines the interface for status event handlers
|
|
type StatusEventHandler interface {
|
|
OnStatusChanged(nodeID string, oldStatus, newStatus *types.AgentStatus)
|
|
}
|
|
|
|
// NewStatusManager creates a new status reconciliation service
|
|
func NewStatusManager(storage storage.StorageProvider, config StatusManagerConfig, uiService *UIService, agentClient interfaces.AgentClient) *StatusManager {
|
|
// Set default values
|
|
if config.ReconcileInterval == 0 {
|
|
config.ReconcileInterval = 30 * time.Second
|
|
}
|
|
if config.StatusCacheTTL == 0 {
|
|
config.StatusCacheTTL = 5 * time.Minute
|
|
}
|
|
if config.MaxTransitionTime == 0 {
|
|
config.MaxTransitionTime = 2 * time.Minute
|
|
}
|
|
if config.HeartbeatStaleThreshold == 0 {
|
|
config.HeartbeatStaleThreshold = 60 * time.Second
|
|
}
|
|
|
|
return &StatusManager{
|
|
storage: storage,
|
|
config: config,
|
|
uiService: uiService,
|
|
agentClient: agentClient,
|
|
statusCache: make(map[string]*cachedAgentStatus),
|
|
activeTransitions: make(map[string]*types.StateTransition),
|
|
stopCh: make(chan struct{}),
|
|
eventHandlers: make([]StatusEventHandler, 0),
|
|
}
|
|
}
|
|
|
|
// Start begins the status manager background processes
|
|
func (sm *StatusManager) Start() {
|
|
logger.Logger.Debug().Msg("🔄 Starting status manager")
|
|
|
|
// Start reconciliation loop
|
|
go sm.reconcileLoop()
|
|
|
|
// Start transition timeout checker
|
|
go sm.transitionTimeoutLoop()
|
|
}
|
|
|
|
// Stop gracefully shuts down the status manager
|
|
func (sm *StatusManager) Stop() {
|
|
logger.Logger.Debug().Msg("🔄 Stopping status manager")
|
|
close(sm.stopCh)
|
|
}
|
|
|
|
// GetAgentStatus retrieves the current unified status for an agent using live health checks
|
|
func (sm *StatusManager) GetAgentStatus(ctx context.Context, nodeID string) (*types.AgentStatus, error) {
|
|
// Check short-term cache with intelligent logic
|
|
sm.cacheMutex.RLock()
|
|
if cached, exists := sm.statusCache[nodeID]; exists {
|
|
cacheAge := time.Since(cached.Timestamp)
|
|
|
|
// For agents marked as inactive/offline, use cache for up to 5 seconds
|
|
if cached.Status.State == types.AgentStateInactive && cacheAge < 5*time.Second {
|
|
sm.cacheMutex.RUnlock()
|
|
// Return cached status with preserved source attribution
|
|
return cached.Status, nil
|
|
}
|
|
|
|
// For agents marked as active, only use very fresh cache (1 second) to ensure responsiveness
|
|
// This prevents serving stale heartbeat data when agents go offline
|
|
if cached.Status.State == types.AgentStateActive && cacheAge < 1*time.Second {
|
|
sm.cacheMutex.RUnlock()
|
|
// Return cached status with preserved source attribution
|
|
return cached.Status, nil
|
|
}
|
|
|
|
// For all other cases or expired cache, proceed with live health check
|
|
}
|
|
sm.cacheMutex.RUnlock()
|
|
|
|
// Perform live health check via HTTP
|
|
var status *types.AgentStatus
|
|
var healthCheckSuccessful bool
|
|
|
|
if sm.agentClient != nil {
|
|
// Create a timeout context for the health check (2-3 seconds)
|
|
healthCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
|
defer cancel()
|
|
|
|
agentStatusResp, err := sm.agentClient.GetAgentStatus(healthCtx, nodeID)
|
|
if err != nil {
|
|
logger.Logger.Debug().Err(err).Str("node_id", nodeID).Msg("🏥 Live health check failed, marking agent as inactive")
|
|
// Health check failed - agent is offline/inactive
|
|
healthCheckSuccessful = false
|
|
|
|
// Invalidate cache when health check fails to ensure fresh checks on subsequent requests
|
|
sm.cacheMutex.Lock()
|
|
delete(sm.statusCache, nodeID)
|
|
sm.cacheMutex.Unlock()
|
|
} else {
|
|
logger.Logger.Debug().Str("node_id", nodeID).Str("status", agentStatusResp.Status).Msg("🏥 Live health check successful")
|
|
healthCheckSuccessful = true
|
|
}
|
|
|
|
// Create status based on health check result
|
|
now := time.Now()
|
|
if healthCheckSuccessful && agentStatusResp.Status == "running" {
|
|
// Agent is active and running
|
|
status = &types.AgentStatus{
|
|
State: types.AgentStateActive,
|
|
HealthScore: 85, // Good health from live verification
|
|
LastSeen: now,
|
|
LifecycleStatus: types.AgentStatusReady,
|
|
HealthStatus: types.HealthStatusActive,
|
|
LastUpdated: now,
|
|
LastVerified: &now, // Set when live health check was performed
|
|
Source: types.StatusSourceHealthCheck,
|
|
}
|
|
} else {
|
|
// Agent is inactive or not responding
|
|
status = &types.AgentStatus{
|
|
State: types.AgentStateInactive,
|
|
HealthScore: 0, // No health
|
|
LastSeen: now,
|
|
LifecycleStatus: types.AgentStatusOffline,
|
|
HealthStatus: types.HealthStatusInactive,
|
|
LastUpdated: now,
|
|
LastVerified: &now, // Set when live health check was performed
|
|
Source: types.StatusSourceHealthCheck,
|
|
}
|
|
}
|
|
} else {
|
|
// Fallback to storage-based status if no agent client available
|
|
agent, err := sm.storage.GetAgent(ctx, nodeID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get agent: %w", err)
|
|
}
|
|
status = types.FromLegacyStatus(agent.HealthStatus, agent.LifecycleStatus, agent.LastHeartbeat)
|
|
}
|
|
|
|
// Update storage with live verification result
|
|
if healthCheckSuccessful {
|
|
if err := sm.storage.UpdateAgentHealth(ctx, nodeID, types.HealthStatusActive); err != nil {
|
|
logger.Logger.Error().Err(err).Str("node_id", nodeID).Msg("❌ Failed to update agent health status in storage")
|
|
}
|
|
} else {
|
|
if err := sm.storage.UpdateAgentHealth(ctx, nodeID, types.HealthStatusInactive); err != nil {
|
|
logger.Logger.Error().Err(err).Str("node_id", nodeID).Msg("❌ Failed to update agent health status in storage")
|
|
}
|
|
}
|
|
|
|
// Cache the status with timestamp
|
|
sm.cacheMutex.Lock()
|
|
sm.statusCache[nodeID] = &cachedAgentStatus{
|
|
Status: status,
|
|
Timestamp: time.Now(),
|
|
}
|
|
sm.cacheMutex.Unlock()
|
|
|
|
// Emit SSE events if status changed
|
|
if sm.uiService != nil {
|
|
// Get the agent for event emission
|
|
if agent, err := sm.storage.GetAgent(ctx, nodeID); err == nil {
|
|
sm.uiService.OnNodeStatusChanged(agent)
|
|
}
|
|
}
|
|
|
|
return status, nil
|
|
}
|
|
|
|
// GetAgentStatusSnapshot returns the best-known status without performing live health checks.
|
|
// This is optimized for UI summaries where fast responses are preferred over live verification.
|
|
func (sm *StatusManager) GetAgentStatusSnapshot(ctx context.Context, nodeID string, cachedNode *types.AgentNode) (*types.AgentStatus, error) {
|
|
// Prefer cached status if available
|
|
sm.cacheMutex.RLock()
|
|
if cached, exists := sm.statusCache[nodeID]; exists && cached.Status != nil {
|
|
statusCopy := cloneAgentStatus(cached.Status)
|
|
sm.cacheMutex.RUnlock()
|
|
return statusCopy, nil
|
|
}
|
|
sm.cacheMutex.RUnlock()
|
|
|
|
// Use provided node data or pull from storage without hitting agent HTTP endpoints
|
|
var agent *types.AgentNode
|
|
var err error
|
|
if cachedNode != nil {
|
|
agent = cachedNode
|
|
} else {
|
|
agent, err = sm.storage.GetAgent(ctx, nodeID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get agent: %w", err)
|
|
}
|
|
}
|
|
|
|
status := types.FromLegacyStatus(agent.HealthStatus, agent.LifecycleStatus, agent.LastHeartbeat)
|
|
status.LastSeen = agent.LastHeartbeat
|
|
status.LastUpdated = agent.LastHeartbeat
|
|
status.HealthStatus = agent.HealthStatus
|
|
status.LifecycleStatus = agent.LifecycleStatus
|
|
status.Source = types.StatusSourceReconcile
|
|
|
|
sm.cacheMutex.Lock()
|
|
sm.statusCache[nodeID] = &cachedAgentStatus{
|
|
Status: status,
|
|
Timestamp: time.Now(),
|
|
}
|
|
sm.cacheMutex.Unlock()
|
|
|
|
return cloneAgentStatus(status), nil
|
|
}
|
|
|
|
// UpdateAgentStatus updates the agent status with reconciliation
|
|
func (sm *StatusManager) UpdateAgentStatus(ctx context.Context, nodeID string, update *types.AgentStatusUpdate) error {
|
|
// Get current status using snapshot (no live health check) to preserve the true "old" state
|
|
// for event broadcasting. Using GetAgentStatus here would perform a live health check,
|
|
// which could return the same state as the update, causing oldStatus == newStatus
|
|
// and preventing status change events from being broadcast.
|
|
currentStatus, err := sm.GetAgentStatusSnapshot(ctx, nodeID, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get current status: %w", err)
|
|
}
|
|
|
|
// Create a copy for the new status
|
|
newStatus := *currentStatus
|
|
oldStatus := *currentStatus
|
|
|
|
// Apply updates
|
|
if update.State != nil {
|
|
if newStatus.State != *update.State {
|
|
// Handle state transition
|
|
if err := sm.handleStateTransition(nodeID, &newStatus, *update.State, update.Reason); err != nil {
|
|
return fmt.Errorf("failed to handle state transition: %w", err)
|
|
}
|
|
|
|
// Auto-sync lifecycle status with state changes to ensure consistency
|
|
// This prevents lifecycle_status from remaining "ready" when the agent goes offline
|
|
switch *update.State {
|
|
case types.AgentStateInactive, types.AgentStateStopping:
|
|
// Agent is going offline - set lifecycle to offline
|
|
if newStatus.LifecycleStatus != types.AgentStatusOffline {
|
|
newStatus.LifecycleStatus = types.AgentStatusOffline
|
|
}
|
|
case types.AgentStateActive:
|
|
// Agent is coming online - set lifecycle to ready if it was offline
|
|
if newStatus.LifecycleStatus == types.AgentStatusOffline || newStatus.LifecycleStatus == "" {
|
|
newStatus.LifecycleStatus = types.AgentStatusReady
|
|
}
|
|
case types.AgentStateStarting:
|
|
// Agent is starting - set lifecycle to starting
|
|
if newStatus.LifecycleStatus == types.AgentStatusOffline || newStatus.LifecycleStatus == "" {
|
|
newStatus.LifecycleStatus = types.AgentStatusStarting
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if update.HealthScore != nil {
|
|
newStatus.HealthScore = *update.HealthScore
|
|
}
|
|
|
|
// Apply explicit lifecycle status update (can override the auto-sync above)
|
|
if update.LifecycleStatus != nil {
|
|
newStatus.LifecycleStatus = *update.LifecycleStatus
|
|
}
|
|
|
|
if update.MCPStatus != nil {
|
|
newStatus.MCPStatus = update.MCPStatus
|
|
}
|
|
|
|
// Update metadata
|
|
newStatus.LastUpdated = time.Now()
|
|
newStatus.Source = update.Source
|
|
|
|
// Update backward compatibility fields
|
|
newStatus.HealthStatus = newStatus.ToLegacyHealthStatus()
|
|
if newStatus.LifecycleStatus == "" {
|
|
newStatus.LifecycleStatus = newStatus.ToLegacyLifecycleStatus()
|
|
}
|
|
|
|
// Persist to storage
|
|
if err := sm.persistStatus(ctx, nodeID, &newStatus); err != nil {
|
|
return fmt.Errorf("failed to persist status: %w", err)
|
|
}
|
|
|
|
// Update cache
|
|
sm.cacheMutex.Lock()
|
|
sm.statusCache[nodeID] = &cachedAgentStatus{
|
|
Status: &newStatus,
|
|
Timestamp: time.Now(),
|
|
}
|
|
sm.cacheMutex.Unlock()
|
|
|
|
// Notify event handlers
|
|
sm.notifyStatusChanged(nodeID, &oldStatus, &newStatus)
|
|
|
|
// Broadcast events
|
|
sm.broadcastStatusEvents(nodeID, &oldStatus, &newStatus)
|
|
|
|
logger.Logger.Debug().
|
|
Str("node_id", nodeID).
|
|
Str("old_state", string(oldStatus.State)).
|
|
Str("new_state", string(newStatus.State)).
|
|
Int("health_score", newStatus.HealthScore).
|
|
Str("source", string(update.Source)).
|
|
Msg("🔄 Agent status updated")
|
|
|
|
return nil
|
|
}
|
|
|
|
// UpdateFromHeartbeat updates status based on heartbeat data
|
|
func (sm *StatusManager) UpdateFromHeartbeat(ctx context.Context, nodeID string, lifecycleStatus *types.AgentLifecycleStatus, mcpStatus *types.MCPStatusInfo) error {
|
|
currentStatus, err := sm.GetAgentStatus(ctx, nodeID)
|
|
if err != nil {
|
|
// If agent doesn't exist, create new status
|
|
currentStatus = types.NewAgentStatus(types.AgentStateStarting, types.StatusSourceHeartbeat)
|
|
}
|
|
|
|
// HEARTBEAT PRIORITY: Heartbeats are the primary signal of agent liveness.
|
|
// If an agent is sending heartbeats, it is alive regardless of what HTTP
|
|
// health checks report. Health checks may fail due to transient network
|
|
// issues, but a heartbeat is direct proof of life from the agent itself.
|
|
// The health monitor requires consecutive failures before marking inactive,
|
|
// so there is no need to suppress heartbeats here.
|
|
|
|
// Update from heartbeat
|
|
currentStatus.UpdateFromHeartbeat(lifecycleStatus, mcpStatus)
|
|
|
|
// Persist changes
|
|
update := &types.AgentStatusUpdate{
|
|
LifecycleStatus: lifecycleStatus,
|
|
MCPStatus: mcpStatus,
|
|
Source: types.StatusSourceHeartbeat,
|
|
Reason: "heartbeat update",
|
|
}
|
|
|
|
return sm.UpdateAgentStatus(ctx, nodeID, update)
|
|
}
|
|
|
|
// RefreshAgentStatus manually refreshes an agent's status
|
|
func (sm *StatusManager) RefreshAgentStatus(ctx context.Context, nodeID string) error {
|
|
// Clear cache to force reload
|
|
sm.cacheMutex.Lock()
|
|
delete(sm.statusCache, nodeID)
|
|
sm.cacheMutex.Unlock()
|
|
|
|
// Reload status
|
|
refreshedStatus, err := sm.GetAgentStatus(ctx, nodeID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to refresh status: %w", err)
|
|
}
|
|
|
|
// Broadcast refresh event
|
|
events.PublishNodeStatusRefreshed(nodeID, refreshedStatus)
|
|
|
|
logger.Logger.Debug().Str("node_id", nodeID).Msg("🔄 Agent status refreshed")
|
|
return nil
|
|
}
|
|
|
|
// AddEventHandler adds a status event handler
|
|
func (sm *StatusManager) AddEventHandler(handler StatusEventHandler) {
|
|
sm.eventHandlers = append(sm.eventHandlers, handler)
|
|
}
|
|
|
|
// handleStateTransition manages state transitions
|
|
func (sm *StatusManager) handleStateTransition(nodeID string, status *types.AgentStatus, newState types.AgentState, reason string) error {
|
|
// Check if transition is valid
|
|
if !sm.isValidTransition(status.State, newState) {
|
|
return fmt.Errorf("invalid state transition from %s to %s", status.State, newState)
|
|
}
|
|
|
|
// Start transition
|
|
status.StartTransition(newState, reason)
|
|
|
|
// Track active transition
|
|
sm.transitionMutex.Lock()
|
|
sm.activeTransitions[nodeID] = status.StateTransition
|
|
sm.transitionMutex.Unlock()
|
|
|
|
// For immediate transitions, complete right away
|
|
if sm.isImmediateTransition(status.State, newState) {
|
|
status.CompleteTransition()
|
|
|
|
// Remove from active transitions
|
|
sm.transitionMutex.Lock()
|
|
delete(sm.activeTransitions, nodeID)
|
|
sm.transitionMutex.Unlock()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// isValidTransition checks if a state transition is valid
|
|
func (sm *StatusManager) isValidTransition(from, to types.AgentState) bool {
|
|
validTransitions := map[types.AgentState][]types.AgentState{
|
|
types.AgentStateInactive: {types.AgentStateStarting, types.AgentStateActive},
|
|
types.AgentStateStarting: {types.AgentStateActive, types.AgentStateInactive},
|
|
types.AgentStateActive: {types.AgentStateInactive, types.AgentStateStopping},
|
|
types.AgentStateStopping: {types.AgentStateInactive},
|
|
}
|
|
|
|
allowed, exists := validTransitions[from]
|
|
if !exists {
|
|
return false
|
|
}
|
|
|
|
for _, allowedState := range allowed {
|
|
if allowedState == to {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// isImmediateTransition checks if a transition should complete immediately
|
|
func (sm *StatusManager) isImmediateTransition(from, to types.AgentState) bool {
|
|
// Most transitions are immediate except starting->active which may take time
|
|
return !(from == types.AgentStateStarting && to == types.AgentStateActive)
|
|
}
|
|
|
|
// persistStatus persists the status to storage
|
|
func (sm *StatusManager) persistStatus(ctx context.Context, nodeID string, status *types.AgentStatus) error {
|
|
// DEFENSIVE: Enforce lifecycle_status consistency with state before persisting.
|
|
// This ensures that even if the auto-sync logic didn't run (e.g., state wasn't changing),
|
|
// the lifecycle_status will be correct in storage. This fixes the bug where offline nodes
|
|
// were incorrectly showing lifecycle_status: "ready" in events and snapshots.
|
|
switch status.State {
|
|
case types.AgentStateInactive, types.AgentStateStopping:
|
|
if status.LifecycleStatus != types.AgentStatusOffline {
|
|
logger.Logger.Debug().
|
|
Str("node_id", nodeID).
|
|
Str("state", string(status.State)).
|
|
Str("old_lifecycle", string(status.LifecycleStatus)).
|
|
Msg("🔧 Enforcing lifecycle_status=offline for inactive/stopping agent")
|
|
status.LifecycleStatus = types.AgentStatusOffline
|
|
}
|
|
case types.AgentStateActive:
|
|
if status.LifecycleStatus == types.AgentStatusOffline {
|
|
logger.Logger.Debug().
|
|
Str("node_id", nodeID).
|
|
Str("state", string(status.State)).
|
|
Str("old_lifecycle", string(status.LifecycleStatus)).
|
|
Msg("🔧 Enforcing lifecycle_status=ready for active agent")
|
|
status.LifecycleStatus = types.AgentStatusReady
|
|
}
|
|
case types.AgentStateStarting:
|
|
if status.LifecycleStatus == types.AgentStatusOffline {
|
|
logger.Logger.Debug().
|
|
Str("node_id", nodeID).
|
|
Str("state", string(status.State)).
|
|
Str("old_lifecycle", string(status.LifecycleStatus)).
|
|
Msg("🔧 Enforcing lifecycle_status=starting for starting agent")
|
|
status.LifecycleStatus = types.AgentStatusStarting
|
|
}
|
|
}
|
|
|
|
// Update health status
|
|
if err := sm.storage.UpdateAgentHealth(ctx, nodeID, status.HealthStatus); err != nil {
|
|
return fmt.Errorf("failed to update health status: %w", err)
|
|
}
|
|
|
|
// Update lifecycle status
|
|
if err := sm.storage.UpdateAgentLifecycleStatus(ctx, nodeID, status.LifecycleStatus); err != nil {
|
|
return fmt.Errorf("failed to update lifecycle status: %w", err)
|
|
}
|
|
|
|
// Update heartbeat timestamp
|
|
if err := sm.storage.UpdateAgentHeartbeat(ctx, nodeID, status.LastSeen); err != nil {
|
|
return fmt.Errorf("failed to update heartbeat: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// notifyStatusChanged notifies all event handlers of status changes
|
|
func (sm *StatusManager) notifyStatusChanged(nodeID string, oldStatus, newStatus *types.AgentStatus) {
|
|
for _, handler := range sm.eventHandlers {
|
|
go func(h StatusEventHandler) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
logger.Logger.Error().
|
|
Interface("panic", r).
|
|
Str("node_id", nodeID).
|
|
Msg("❌ Panic in status event handler")
|
|
}
|
|
}()
|
|
h.OnStatusChanged(nodeID, oldStatus, newStatus)
|
|
}(handler)
|
|
}
|
|
}
|
|
|
|
// broadcastStatusEvents broadcasts status change events using enhanced event system
|
|
func (sm *StatusManager) broadcastStatusEvents(nodeID string, oldStatus, newStatus *types.AgentStatus) {
|
|
// Get updated agent for events
|
|
ctx := context.Background()
|
|
agent, err := sm.storage.GetAgent(ctx, nodeID)
|
|
if err != nil {
|
|
logger.Logger.Error().Err(err).Str("node_id", nodeID).Msg("❌ Failed to get agent for event broadcasting")
|
|
return
|
|
}
|
|
|
|
// FIXED: Only broadcast unified status event when there's a MEANINGFUL change
|
|
// Skip events for minor health score fluctuations - only emit when:
|
|
// - State changed (active/inactive/starting/stopping)
|
|
// - LifecycleStatus changed (ready/not_ready/etc)
|
|
// - HealthStatus changed (active/degraded/unhealthy)
|
|
hasMeaningfulChange := oldStatus.State != newStatus.State ||
|
|
oldStatus.LifecycleStatus != newStatus.LifecycleStatus ||
|
|
oldStatus.HealthStatus != newStatus.HealthStatus
|
|
|
|
if hasMeaningfulChange {
|
|
events.PublishNodeUnifiedStatusChanged(nodeID, oldStatus, newStatus, string(newStatus.Source), "status update")
|
|
}
|
|
|
|
// FIXED: Only broadcast legacy events if specifically needed for backward compatibility
|
|
// and only if state actually changed to prevent duplicate events
|
|
if oldStatus.State != newStatus.State {
|
|
switch newStatus.State {
|
|
case types.AgentStateActive:
|
|
events.PublishNodeOnline(nodeID, agent)
|
|
case types.AgentStateInactive:
|
|
events.PublishNodeOffline(nodeID, agent)
|
|
}
|
|
}
|
|
|
|
// FIXED: Removed duplicate event publishing:
|
|
// - Removed PublishNodeStateTransition (redundant with unified event)
|
|
// - Removed PublishNodeHealthChangedEnhanced (redundant with unified event)
|
|
// - Removed PublishNodeStatusUpdatedEnhanced (was calling PublishNodeUnifiedStatusChanged again!)
|
|
|
|
// Notify UI service for SSE broadcasting (this goes through deduplication)
|
|
if sm.uiService != nil {
|
|
sm.uiService.OnNodeStatusChanged(agent)
|
|
}
|
|
}
|
|
|
|
// reconcileLoop periodically reconciles status across all agents
|
|
func (sm *StatusManager) reconcileLoop() {
|
|
ticker := time.NewTicker(sm.config.ReconcileInterval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
sm.performReconciliation()
|
|
case <-sm.stopCh:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// performReconciliation reconciles status for all agents
|
|
func (sm *StatusManager) performReconciliation() {
|
|
ctx := context.Background()
|
|
|
|
// Get all agents
|
|
agents, err := sm.storage.ListAgents(ctx, types.AgentFilters{})
|
|
if err != nil {
|
|
logger.Logger.Error().Err(err).Msg("❌ Failed to list agents for reconciliation")
|
|
return
|
|
}
|
|
|
|
logger.Logger.Debug().Int("agent_count", len(agents)).Msg("🔄 Starting status reconciliation")
|
|
|
|
for _, agent := range agents {
|
|
// Check if status needs reconciliation
|
|
if sm.needsReconciliation(agent) {
|
|
if err := sm.reconcileAgentStatus(ctx, agent); err != nil {
|
|
logger.Logger.Error().
|
|
Err(err).
|
|
Str("node_id", agent.ID).
|
|
Msg("❌ Failed to reconcile agent status")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// needsReconciliation checks if an agent needs status reconciliation
|
|
func (sm *StatusManager) needsReconciliation(agent *types.AgentNode) bool {
|
|
// Check if last heartbeat is too old (uses configurable threshold, default 60s)
|
|
timeSinceHeartbeat := time.Since(agent.LastHeartbeat)
|
|
if timeSinceHeartbeat > sm.config.HeartbeatStaleThreshold && agent.HealthStatus == types.HealthStatusActive {
|
|
return true
|
|
}
|
|
|
|
// Check for inconsistent status
|
|
if agent.HealthStatus == types.HealthStatusActive && agent.LifecycleStatus == types.AgentStatusOffline {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// reconcileAgentStatus reconciles status for a specific agent
|
|
func (sm *StatusManager) reconcileAgentStatus(ctx context.Context, agent *types.AgentNode) error {
|
|
// Determine correct status based on heartbeat age
|
|
timeSinceHeartbeat := time.Since(agent.LastHeartbeat)
|
|
|
|
var newHealthStatus types.HealthStatus
|
|
var newLifecycleStatus types.AgentLifecycleStatus
|
|
|
|
if timeSinceHeartbeat > sm.config.HeartbeatStaleThreshold {
|
|
newHealthStatus = types.HealthStatusInactive
|
|
newLifecycleStatus = types.AgentStatusOffline
|
|
} else {
|
|
newHealthStatus = types.HealthStatusActive
|
|
if agent.LifecycleStatus == "" || agent.LifecycleStatus == types.AgentStatusOffline {
|
|
newLifecycleStatus = types.AgentStatusReady
|
|
} else {
|
|
newLifecycleStatus = agent.LifecycleStatus
|
|
}
|
|
}
|
|
|
|
// Update if changed
|
|
if agent.HealthStatus != newHealthStatus || agent.LifecycleStatus != newLifecycleStatus {
|
|
update := &types.AgentStatusUpdate{
|
|
Source: types.StatusSourceReconcile,
|
|
Reason: "periodic reconciliation",
|
|
}
|
|
|
|
if agent.HealthStatus != newHealthStatus {
|
|
newState := types.AgentStateInactive
|
|
if newHealthStatus == types.HealthStatusActive {
|
|
newState = types.AgentStateActive
|
|
}
|
|
update.State = &newState
|
|
}
|
|
|
|
if agent.LifecycleStatus != newLifecycleStatus {
|
|
update.LifecycleStatus = &newLifecycleStatus
|
|
}
|
|
|
|
return sm.UpdateAgentStatus(ctx, agent.ID, update)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// transitionTimeoutLoop checks for stuck transitions
|
|
func (sm *StatusManager) transitionTimeoutLoop() {
|
|
ticker := time.NewTicker(30 * time.Second)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
sm.checkTransitionTimeouts()
|
|
case <-sm.stopCh:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// checkTransitionTimeouts checks for and handles stuck transitions
|
|
func (sm *StatusManager) checkTransitionTimeouts() {
|
|
sm.transitionMutex.Lock()
|
|
defer sm.transitionMutex.Unlock()
|
|
|
|
now := time.Now()
|
|
for nodeID, transition := range sm.activeTransitions {
|
|
if now.Sub(transition.StartedAt) > sm.config.MaxTransitionTime {
|
|
logger.Logger.Warn().
|
|
Str("node_id", nodeID).
|
|
Str("from", string(transition.From)).
|
|
Str("to", string(transition.To)).
|
|
Dur("duration", now.Sub(transition.StartedAt)).
|
|
Msg("🔄 Transition timeout, forcing completion")
|
|
|
|
// Force complete the transition
|
|
ctx := context.Background()
|
|
if status, err := sm.GetAgentStatus(ctx, nodeID); err == nil {
|
|
status.CompleteTransition()
|
|
if err := sm.persistStatus(ctx, nodeID, status); err != nil {
|
|
logger.Logger.Warn().
|
|
Err(err).
|
|
Str("node_id", nodeID).
|
|
Msg("failed to persist status during transition timeout")
|
|
}
|
|
}
|
|
|
|
delete(sm.activeTransitions, nodeID)
|
|
}
|
|
}
|
|
}
|