Fuehre Offline-Schwelle fuer Server-Connectivity ein

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
Jesko Anschütz 2026-03-22 18:25:01 +01:00
parent 2c780d3e60
commit a69135c0b9
4 changed files with 76 additions and 8 deletions

View file

@ -187,7 +187,7 @@ go run ./cmd/agent
1. Backend: einheitliches Fehlerformat und Routing-Grundstruktur anlegen 1. Backend: einheitliches Fehlerformat und Routing-Grundstruktur anlegen
2. Backend: Konfigurations- und App-Lifecycle stabilisieren 2. Backend: Konfigurations- und App-Lifecycle stabilisieren
3. Agent und Backend: den HTTP-Statuspfad als Grundlage fuer Identitaet, Persistenz und spaetere Admin-Vorschau erweitern 3. Agent und Backend: den HTTP-Statuspfad als Grundlage fuer Identitaet, Persistenz und spaetere Admin-Vorschau erweitern
4. Agent: danach einen expliziten `offline`-Zustand und weitere Connectivity-Schwellenlogik aufsetzen 4. Agent: danach MQTT-spezifische Reachability und feinere Connectivity-Schwellenlogik aufsetzen
5. Danach die Netzwerk-, Sync- und Kommandopfade schrittweise produktionsnah ausbauen 5. Danach die Netzwerk-, Sync- und Kommandopfade schrittweise produktionsnah ausbauen
Ergaenzt seit dem ersten Geruest: Ergaenzt seit dem ersten Geruest:
@ -199,7 +199,7 @@ Ergaenzt seit dem ersten Geruest:
- dateibasierte Agent-Konfiguration zusaetzlich zu Env-Overrides - dateibasierte Agent-Konfiguration zusaetzlich zu Env-Overrides
- strukturierte Agent-Logs mit internem Health-Snapshot und signalgesteuertem Shutdown - strukturierte Agent-Logs mit internem Health-Snapshot und signalgesteuertem Shutdown
- erster periodischer HTTP-Status-Reporter im Agent - erster periodischer HTTP-Status-Reporter im Agent
- Server-Connectivity-Zustand im Agent (`unknown`, `online`, `degraded`) auf Basis der Report-Ergebnisse - Server-Connectivity-Zustand im Agent (`unknown`, `online`, `degraded`, `offline`) auf Basis der Report-Ergebnisse
- lokales Compose-Grundgeruest fuer PostgreSQL und Mosquitto - lokales Compose-Grundgeruest fuer PostgreSQL und Mosquitto
## Arbeitsweise ## Arbeitsweise

View file

@ -47,10 +47,17 @@ Getrennt vom Lifecycle fuehrt der Agent fuer die Server-Erreichbarkeit aktuell d
- `unknown` vor dem ersten erfolgreichen oder fehlgeschlagenen Status-Report - `unknown` vor dem ersten erfolgreichen oder fehlgeschlagenen Status-Report
- `online` nach einem erfolgreich bestaetigten HTTP-Status-Report - `online` nach einem erfolgreich bestaetigten HTTP-Status-Report
- `degraded` nach einem fehlgeschlagenen HTTP-Status-Report - `degraded` nach einem fehlgeschlagenen HTTP-Status-Report
- `offline` nach wiederholten fehlgeschlagenen HTTP-Status-Reports
Damit bleibt der Lifecycle sauber von Netz- und Gegenstellenproblemen getrennt. Damit bleibt der Lifecycle sauber von Netz- und Gegenstellenproblemen getrennt.
Ein Report-Fehler stoppt den Agenten nicht, sondern veraendert nur den Connectivity-Zustand. Ein Report-Fehler stoppt den Agenten nicht, sondern veraendert nur den Connectivity-Zustand.
Aktuell gilt fuer diese Schwellenlogik bewusst einfach:
- erster Fehl-Report: `degraded`
- ab dem dritten aufeinanderfolgenden Fehl-Report: `offline`
- naechster erfolgreicher Report: Rueckkehr nach `online`
## Strukturierte Log-Ereignisse ## Strukturierte Log-Ereignisse
Der Agent emittiert in v1 mindestens diese Ereignisse: Der Agent emittiert in v1 mindestens diese Ereignisse:
@ -88,6 +95,6 @@ Nicht Teil dieser Stufe:
- Kommandos oder Sync-Status - Kommandos oder Sync-Status
Die erste Backend-Reachability-Pruefung ist in dieser Stufe bereits ueber den HTTP-Status-Report abgebildet. Die erste Backend-Reachability-Pruefung ist in dieser Stufe bereits ueber den HTTP-Status-Report abgebildet.
Ein expliziter `offline`-Zustand, MQTT-Reachability und weitergehende Schwellenlogik folgen spaeter. MQTT-Reachability und weitergehende Schwellenlogik folgen spaeter.
Diese Punkte folgen erst, wenn echte Netzwerk- und Sync-Funktionalitaet eingebaut wird. Diese Punkte folgen erst, wenn echte Netzwerk- und Sync-Funktionalitaet eingebaut wird.

View file

@ -24,8 +24,11 @@ const (
ConnectivityUnknown Connectivity = "unknown" ConnectivityUnknown Connectivity = "unknown"
ConnectivityOnline Connectivity = "online" ConnectivityOnline Connectivity = "online"
ConnectivityDegraded Connectivity = "degraded" ConnectivityDegraded Connectivity = "degraded"
ConnectivityOffline Connectivity = "offline"
) )
const offlineFailureThreshold = 3
type HealthSnapshot struct { type HealthSnapshot struct {
Status Status Status Status
ServerConnectivity Connectivity ServerConnectivity Connectivity
@ -43,11 +46,12 @@ type App struct {
now func() time.Time now func() time.Time
reporter statusSender reporter statusSender
mu sync.RWMutex mu sync.RWMutex
status Status status Status
serverConnectivity Connectivity serverConnectivity Connectivity
startedAt time.Time consecutiveReportFailures int
lastHeartbeatAt time.Time startedAt time.Time
lastHeartbeatAt time.Time
} }
type statusSender interface { type statusSender interface {
@ -181,13 +185,18 @@ func (a *App) reportStatus(ctx context.Context) {
}) })
if err != nil { if err != nil {
a.mu.Lock() a.mu.Lock()
a.consecutiveReportFailures++
a.serverConnectivity = ConnectivityDegraded a.serverConnectivity = ConnectivityDegraded
if a.consecutiveReportFailures >= offlineFailureThreshold {
a.serverConnectivity = ConnectivityOffline
}
a.mu.Unlock() a.mu.Unlock()
a.logger.Printf("event=status_report_failed screen_id=%s error=%v", a.Config.ScreenID, err) a.logger.Printf("event=status_report_failed screen_id=%s error=%v", a.Config.ScreenID, err)
return return
} }
a.mu.Lock() a.mu.Lock()
a.consecutiveReportFailures = 0
a.serverConnectivity = ConnectivityOnline a.serverConnectivity = ConnectivityOnline
a.mu.Unlock() a.mu.Unlock()
a.logger.Printf("event=status_report_sent screen_id=%s", a.Config.ScreenID) a.logger.Printf("event=status_report_sent screen_id=%s", a.Config.ScreenID)

View file

@ -15,10 +15,16 @@ import (
type recordingReporter struct { type recordingReporter struct {
callCount int callCount int
err error err error
errs []error
} }
func (r *recordingReporter) Send(_ context.Context, _ statusreporter.Snapshot) error { func (r *recordingReporter) Send(_ context.Context, _ statusreporter.Snapshot) error {
r.callCount++ r.callCount++
if len(r.errs) > 0 {
err := r.errs[0]
r.errs = r.errs[1:]
return err
}
return r.err return r.err
} }
@ -236,3 +242,49 @@ func TestAppRunMarksServerConnectivityOnlineAfterSuccessfulReport(t *testing.T)
cancel() cancel()
<-errCh <-errCh
} }
func TestReportStatusMarksServerConnectivityOfflineAfterRepeatedFailures(t *testing.T) {
reporter := &recordingReporter{err: context.DeadlineExceeded}
application := newApp(config.Config{
ScreenID: "screen-offline",
ServerBaseURL: "http://127.0.0.1:8080",
MQTTBroker: "tcp://127.0.0.1:1883",
HeartbeatEvery: 30,
StatusReportEvery: 30,
}, log.New(&bytes.Buffer{}, "", 0), time.Now, reporter)
application.reportStatus(context.Background())
if got, want := application.Snapshot().ServerConnectivity, ConnectivityDegraded; got != want {
t.Fatalf("after first failure ServerConnectivity = %q, want %q", got, want)
}
application.reportStatus(context.Background())
application.reportStatus(context.Background())
if got, want := application.Snapshot().ServerConnectivity, ConnectivityOffline; got != want {
t.Fatalf("after repeated failures ServerConnectivity = %q, want %q", got, want)
}
}
func TestReportStatusRecoversFromOfflineToOnline(t *testing.T) {
reporter := &recordingReporter{errs: []error{context.DeadlineExceeded, context.DeadlineExceeded, context.DeadlineExceeded, nil}}
application := newApp(config.Config{
ScreenID: "screen-recover",
ServerBaseURL: "http://127.0.0.1:8080",
MQTTBroker: "tcp://127.0.0.1:1883",
HeartbeatEvery: 30,
StatusReportEvery: 30,
}, log.New(&bytes.Buffer{}, "", 0), time.Now, reporter)
application.reportStatus(context.Background())
application.reportStatus(context.Background())
application.reportStatus(context.Background())
if got, want := application.Snapshot().ServerConnectivity, ConnectivityOffline; got != want {
t.Fatalf("offline state = %q, want %q", got, want)
}
application.reportStatus(context.Background())
if got, want := application.Snapshot().ServerConnectivity, ConnectivityOnline; got != want {
t.Fatalf("recovered state = %q, want %q", got, want)
}
}