Skip to content

fix(control-plane): resolve agent node health status flapping - #169

Merged
AbirAbbas merged 3 commits into
mainfrom
fix/node-health-flapping
Feb 3, 2026
Merged

fix(control-plane): resolve agent node health status flapping#169
AbirAbbas merged 3 commits into
mainfrom
fix/node-health-flapping

Conversation

@AbirAbbas

Copy link
Copy Markdown
Contributor

Summary

Fixes #167 — Nodes flickering between online/stale/offline in the UI despite being consistently online.

Root cause: Three independent health systems (HealthMonitor, StatusManager, PresenceManager) independently changing node status and fighting each other.

Changes

Backend (control-plane)

  • health_monitor.go — Require 3 consecutive HTTP failures before marking inactive (was 1). Add configurable recovery debounce (5s, was 30s). Add sync.Once for idempotent Stop().
  • status_manager.go — Remove logic that silently dropped heartbeats for 10s after a health check marked a node inactive. Heartbeats are proof of life and should never be ignored. Increase reconciliation threshold from 30s to 60s.
  • config.go — Add NodeHealthConfig struct with 5 configurable parameters and env var overrides (AGENTFIELD_HEALTH_CHECK_INTERVAL, etc.)
  • nodes.go — Reduce heartbeat DB cache from 8s to 2s to minimize stale timestamp window
  • server.go — Wire config values into HealthMonitor and StatusManager

Frontend (web UI)

  • NodesPage.tsx — Add 30s background refresh to keep last_heartbeat timestamps fresh (SSE events don't carry heartbeat timestamps)

Test Plan

10 new tests added (all passing):

Integration tests (wire all 3 services concurrently, matching production):

  • TestIntegration_NoFlapping_HeartbeatsDuringTransientFailures — THE critical test. Runs heartbeats while introducing intermittent health check failures. Asserts node NEVER goes inactive.
  • TestIntegration_ProperInactiveWhenHeartbeatsStop — Genuine outage correctly detected
  • TestIntegration_RecoveryAfterGenuineOutage — Full lifecycle: healthy → outage → recovery

Unit tests:

  • Consecutive failure tracking (single failure keeps active, 3 failures marks inactive)
  • Success resets failure counter
  • Recovery debounce blocks premature transitions
  • Config values are respected

Status manager tests:

  • Heartbeats are never dropped (directly tests the removed bug)
  • Reconciliation uses configured threshold
# Run new tests
CGO_ENABLED=1 go test -v -run "TestIntegration_|TestHealthMonitor_ConsecutiveFailures|TestHealthMonitor_RecoveryDebounce|TestHealthMonitor_Config_|TestStatusManager_UpdateFromHeartbeat|TestStatusManager_Reconciliation" -timeout 120s -tags "fts5" ./internal/services/

🤖 Generated with Claude Code

Three independent health systems (HealthMonitor, StatusManager, PresenceManager)
were fighting each other, causing nodes to flicker between online/stale/offline.

Root causes fixed:
- Single HTTP failure instantly marked nodes inactive (now requires 3 consecutive failures)
- Heartbeats silently dropped for 10s after health check marked node inactive (removed)
- 30s recovery debounce blocked legitimate recovery (reduced to configurable 5s)
- 8s heartbeat DB cache caused phantom staleness (reduced to 2s)
- 30s reconciliation threshold too aggressive with cache delay (increased to 60s)

Changes:
- health_monitor.go: Add consecutive failure tracking, recovery debounce, sync.Once for Stop()
- status_manager.go: Remove heartbeat-dropping logic, configurable stale threshold
- config.go: Add NodeHealthConfig with env var overrides
- nodes.go: Reduce heartbeat cache from 8s to 2s
- server.go: Wire config into health monitor and status manager
- NodesPage.tsx: Add 30s background refresh for fresh timestamps

Tests: 10 new tests (5 unit + 3 integration + 2 status manager) all passing.
Integration tests wire all 3 services concurrently to validate no-flapping behavior.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
…d stale MCP data

Code review follow-up for #167. Addresses race conditions, missing MCP
health refresh, and test reliability issues found during review.

Key fixes:
- Eliminate stale pointer race: checkAgentHealth now takes nodeID string
  instead of *ActiveAgent, re-fetching canonical state after HTTP call
- Fix MCP health going stale: active agents now refresh MCP data on every
  health check, not only on status transitions
- Initialize LastTransition on registration so debounce has a valid baseline
- Cap consecutive failure counter to prevent unbounded growth
- Add lifecycle guard to NodesPage polling to prevent React state updates
  after unmount
- Fix RecoverFromDatabase tests that raced against async goroutine
- Extract health score magic numbers into named constants
- Document zero-value-means-default semantics on NodeHealthConfig

Tests: 30/30 health monitor + 3/3 integration tests pass

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@AbirAbbas

Copy link
Copy Markdown
Contributor Author

github actions down, waiting to rerun CI

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@AbirAbbas
AbirAbbas merged commit e74ed99 into main Feb 3, 2026
17 checks passed
@AbirAbbas
AbirAbbas deleted the fix/node-health-flapping branch February 3, 2026 00:56
@williamhdean

Copy link
Copy Markdown

thank you. i will try it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Agent nodes flap between active/inactive due to aggressive health check timeout

2 participants