R-271: the channel recovery after a controller restart is reported
The agent_channel_unauthorized alert's remedy is a re-bootstrap (a restart), and an unseeded->up first observation was silent, so following the instruction guaranteed no recovery event. A down alert now leaves a marker in the data dir; the first UP after a restart sends the recovery and clears it. A restart with no alert outstanding stays silent. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -18,6 +18,7 @@ package channelhealth
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -121,7 +122,8 @@ type Checker struct {
|
||||
mu sync.Mutex
|
||||
state string // "" (unseeded) | stateUnconfirmed (debounce placeholder) | "up" | "down:<reason>"
|
||||
consecutiveDown int
|
||||
alerted bool // have we emitted a down alert for the CURRENT down-spell? (F2: drives
|
||||
markerPath string // R-271: see WithAlertMarker ("" = no persistence)
|
||||
alerted bool // have we emitted a down alert for the CURRENT down-spell? (F2: drives
|
||||
// alerting instead of `prev==""`, so a BORN-down — broken at startup/reseed — alerts too, not
|
||||
// just a live up→down transition; re-armed on recovery / reason-change.)
|
||||
}
|
||||
@@ -131,6 +133,41 @@ func New(probe Probe, sink Sink, logger *log.Logger) *Checker {
|
||||
return &Checker{probe: probe, sink: sink, logger: logger}
|
||||
}
|
||||
|
||||
// WithAlertMarker makes ONE fact survive a controller restart: "a down alert went out and no recovery
|
||||
// has been sent since" (R-271). Without it the operator's trail could never close — the 401 alert's
|
||||
// own remedy is a re-bootstrap, i.e. a restart, which reset the state to unseeded, and an unseeded→up
|
||||
// first observation is silent by design. With the marker, that first UP sends the recovery the
|
||||
// operator is waiting for. The file holds the down state's name only (no secret). "" = disabled.
|
||||
// Pinned by TestR271_RecoveryAfterRestartIsNotified.
|
||||
func (c *Checker) WithAlertMarker(path string) *Checker {
|
||||
c.markerPath = path
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Checker) markAlerted(state string) {
|
||||
if c.markerPath == "" {
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(c.markerPath, []byte(state+"\n"), 0o600); err != nil {
|
||||
c.logger.Printf("[WARN] [channel] could not record the down alert for after a restart (%s): %v — a recovery after a restart will not be reported", c.markerPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
// takeAlertMarker reports (and clears) a down alert recorded before this process started.
|
||||
func (c *Checker) takeAlertMarker() (string, bool) {
|
||||
if c.markerPath == "" {
|
||||
return "", false
|
||||
}
|
||||
b, err := os.ReadFile(c.markerPath)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
if rerr := os.Remove(c.markerPath); rerr != nil && !os.IsNotExist(rerr) {
|
||||
c.logger.Printf("[WARN] [channel] could not clear the down-alert marker %s: %v", c.markerPath, rerr)
|
||||
}
|
||||
return strings.TrimSpace(string(b)), true
|
||||
}
|
||||
|
||||
// Check runs one probe cycle: classify, debounce, reflect on the dashboard, and notify on a real
|
||||
// transition. Best-effort + idempotent — safe to call on a timer. Never returns an error to the
|
||||
// scheduler (a probe failure IS the signal, not a job failure).
|
||||
@@ -149,9 +186,16 @@ func (c *Checker) Check(ctx context.Context) error {
|
||||
prev := c.state
|
||||
c.state = "up"
|
||||
c.alerted = false // re-arm for the next down-spell
|
||||
was, marked := "", false
|
||||
if prev != "up" {
|
||||
was, marked = c.takeAlertMarker() // R-271: a down alert from before a restart
|
||||
}
|
||||
if prev != "" && prev != "up" {
|
||||
c.logger.Printf("[INFO] [channel] agent channel recovered (was %s)", prev)
|
||||
c.sink.NotifyRecovered()
|
||||
} else if marked {
|
||||
c.logger.Printf("[INFO] [channel] agent channel recovered (was %s before the controller restarted — the down alert sent then is now closed)", was)
|
||||
c.sink.NotifyRecovered()
|
||||
}
|
||||
return nil // healthy first-obs / steady-up → no notify
|
||||
}
|
||||
@@ -197,6 +241,7 @@ func (c *Checker) Check(ctx context.Context) error {
|
||||
c.logger.Printf("[WARN] [channel] agent channel DOWN (%s->%s): %v", orUnseeded(prev), newState, perr)
|
||||
c.sink.NotifyDown(cls.reason, cls.eventType, cls.severity, cls.english)
|
||||
c.alerted = true
|
||||
c.markAlerted(newState)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user