Files
ClawdDotNet/src/ClawdDotNet.Core/Deploymentcenter/Watchdog/WatchdogHeartbeatService.cs
T
RichardandClaude Opus 5 33d95a6c3f UI-Entwurf umgesetzt, Rocket.Chat-Tool, Deploymentcenter 2.4
Sicherungspunkt vor dem Aufraeumen. Buendelt die Arbeit, die seit dem
Abschluss der Avalonia-Portierung im Arbeitsverzeichnis lag.

Oberflaeche
- Entwurf aus Mockup/ umgesetzt: Theme.axaml (Farben je Thema, Barlow als
  mitgelieferte Schrift), Icons.axaml (Symbolgeometrien), Shell.axaml
  (eigene ControlThemes statt Fluent umzufaerben).
- Neue Steuerelemente StrokeIcon und BlueprintFrame, Seiten fuer
  Token-Verbrauch und Agenten-Chats, Werkzeug-Einstellungen als Seite
  statt eigenem Fenster, Texteditor-Fenster.
- ThemeManager mit hellem und dunklem Thema; die beiden Pinsel-Konverter
  entfallen, weil ein fester Farbwert den Themenwechsel nicht ueberlebt.

Rocket.Chat
- Neues Tool-Projekt (Client, Konfiguration, Workspace-Dateien) nach der
  Bauform des Telegram-Tools: rocketchat_poll als Tool-Job, geweckt wird
  nur, wenn wirklich etwas anliegt.
- send_file ist freigabepflichtig, send_message bewusst nicht: Der Raum
  ist Arbeitsraum, der Schutz sitzt an der Raum-Allowlist.
- Konzept-Doc um die Messung gegen die echte Instanz 8.7 ergaenzt; drei
  Annahmen waren falsch und sind korrigiert.

Deploymentcenter
- DC6 (Update anwenden) und DC7 (Erstinstallation ueber setup.json)
  erledigt, DC3 fuer win-x64/dev; deploy/publish.py als Release-Strecke.
- AppHost.DisposeAsync gegen doppeltes Herunterfahren gesperrt - sonst
  ueberschreibt eine zweite Abmeldung den Wartungszustand am Watchdog.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 12:16:05 +02:00

219 lines
8.1 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Core.Deploymentcenter.Watchdog;
/// <summary>
/// Sendet im festen Takt Heartbeats an das Watchdog-Modul und meldet Start und Ende.
/// Ein nicht erreichbares Deploymentcenter darf ClawdDotNet nie beeinträchtigen —
/// alle Sendefehler werden geloggt und verschluckt.
///
/// <para><b>Sauberes Beenden.</b> Beim Herunterfahren geht ein Heartbeat mit
/// <c>status: "stopped"</c> raus. Der Evaluator lässt einen so gemeldeten Monitor in
/// Ruhe; ohne das erzeugte jedes geplante Beenden wenige Minuten später einen
/// Fehlalarm. Das reine Ereignis genügt dafür nicht — der Evaluator sieht nur den
/// Monitor-Zustand.</para>
/// </summary>
public sealed class WatchdogHeartbeatService : IAsyncDisposable
{
private readonly IWatchdogClient _client;
private readonly bool _ownsClient;
private readonly IInstanceHealthProvider _health;
private readonly int _intervalSeconds;
private readonly ILogger _logger;
private CancellationTokenSource? _cts;
private Task? _loop;
private string _lastState = "unknown";
private string _shutdownStatus = WatchdogStatus.Stopped;
private string _shutdownMessage = "Instanz planmäßig beendet.";
private string _shutdownEventKind = WatchdogEventKind.StoppedGraceful;
public WatchdogHeartbeatService(
IWatchdogClient client,
IInstanceHealthProvider health,
int intervalSeconds,
ILogger logger,
bool ownsClient = false)
{
_client = client;
_health = health;
_intervalSeconds = Math.Clamp(intervalSeconds, 10, 86400);
_logger = logger;
_ownsClient = ownsClient;
}
/// <summary>
/// Baut Client und Dienst in einem Zug. Wirft nur bei grob falscher Konfiguration
/// (fehlende oder nicht-HTTPS-URL).
/// </summary>
public static WatchdogHeartbeatService Create(
string baseUrl, string token, string source, string instance, string group, string os,
string version, int intervalSeconds, IInstanceHealthProvider health, ILogger logger)
{
var client = WatchdogClient.Create(baseUrl, token, source, instance, group, os, version);
return new WatchdogHeartbeatService(
client, health, intervalSeconds, logger, ownsClient: true);
}
public bool IsRunning => _loop is { IsCompleted: false };
/// <summary>Der zuletzt vom Server gemeldete Monitor-Zustand — für die Anzeige.</summary>
public string LastState => _lastState;
/// <summary>
/// Meldet das nächste Herunterfahren als <b>Wartung</b> statt als planmäßiges Ende.
///
/// <para>Gedacht für das Einspielen eines Updates: Die Instanz ist gleich weg,
/// kommt aber wieder. <c>stopped</c> wäre die falsche Auskunft — es heißt „bewusst
/// beendet" und lässt den Monitor liegen, bis jemand ihn wieder anfasst.
/// <c>maintenance</c> sagt dasselbe über den Alarm aus, trägt aber die Absicht
/// mit: Im Dashboard ist zu sehen, dass hier gerade aktualisiert wird, statt dass
/// eine Instanz ohne Grund verschwindet.</para>
/// </summary>
public void AnnounceMaintenance(string message)
{
_shutdownStatus = WatchdogStatus.Maintenance;
_shutdownMessage = message;
_shutdownEventKind = WatchdogEventKind.MaintenanceStart;
}
public void Start()
{
if (IsRunning)
return;
_cts = new CancellationTokenSource();
_loop = RunAsync(_cts.Token);
_logger.LogInformation("Watchdog-Heartbeat gestartet (alle {Interval}s).", _intervalSeconds);
}
private async Task RunAsync(CancellationToken ct)
{
await TrySendAsync(
() => _client.SendEventAsync(
WatchdogEventKind.Started, "info", "Instanz gestartet.", null, ct),
"Start-Ereignis").ConfigureAwait(false);
// Erster Beat sofort, damit ein neuer Monitor nicht erst nach einem vollen
// Intervall im Dashboard auftaucht.
await BeatAsync(ct).ConfigureAwait(false);
try
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(_intervalSeconds));
while (await timer.WaitForNextTickAsync(ct).ConfigureAwait(false))
await BeatAsync(ct).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// Regulärer Stopp.
}
}
private async Task BeatAsync(CancellationToken ct)
{
InstanceHealth health;
try
{
health = await _health.GetAsync(ct).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
// Selbst wenn die Zustandsermittlung scheitert, soll ein Lebenszeichen
// rausgehen — sonst sieht ein Fehler in unserem Code aus wie ein Ausfall.
_logger.LogWarning(ex, "Watchdog: Zustandsermittlung fehlgeschlagen melde warning.");
health = new InstanceHealth(
WatchdogStatus.Warning, "Zustand konnte nicht ermittelt werden.",
new Dictionary<string, double>(), new Dictionary<string, HealthCheck>());
}
await TrySendAsync(async () =>
{
var result = await _client.SendHeartbeatAsync(health, _intervalSeconds, ct)
.ConfigureAwait(false);
if (result.State != _lastState)
{
_logger.LogInformation("Watchdog: Monitor-Zustand {Previous} → {State}{Failing}",
_lastState, result.State,
result.FailingChecks.Count > 0
? $" (fehlgeschlagen: {string.Join(", ", result.FailingChecks)})"
: "");
_lastState = result.State;
}
}, "Heartbeat").ConfigureAwait(false);
}
private async Task TrySendAsync(Func<Task> send, string what)
{
try
{
await send().ConfigureAwait(false);
}
catch (OperationCanceledException)
{
throw;
}
catch (DeploymentcenterException ex) when (ex.IsAuthorizationProblem)
{
// Ein abgelehntes Token ist kein Rauschen: Ohne Eingriff bleibt der Monitor
// für immer stumm, und niemand merkt es, weil ja nichts abstürzt.
_logger.LogWarning(
"Watchdog: {What} abgelehnt ({Code}) Token prüfen (Recht watchdog:ping).",
what, ex.Code);
}
catch (Exception ex)
{
// Ausfall des Monitorings darf den Betrieb nie stören.
_logger.LogDebug(ex, "Watchdog: {What} konnte nicht gesendet werden (ignoriert).", what);
}
}
public async ValueTask DisposeAsync()
{
if (_cts is null)
return;
await _cts.CancelAsync().ConfigureAwait(false);
if (_loop is not null)
{
try { await _loop.ConfigureAwait(false); }
catch (OperationCanceledException) { /* erwartet */ }
catch (Exception ex) { _logger.LogDebug(ex, "Watchdog: Heartbeat-Schleife endete mit Fehler."); }
}
// Angekündigtes Ende, mit kurzer Frist. Hier wird alles geschluckt (auch ein
// Zeitüberlauf), damit das Herunterfahren nie hängt oder wirft.
try
{
using var stopCts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
await _client.SendHeartbeatAsync(
new InstanceHealth(
_shutdownStatus, _shutdownMessage,
new Dictionary<string, double>(), new Dictionary<string, HealthCheck>()),
_intervalSeconds, stopCts.Token).ConfigureAwait(false);
await _client.SendEventAsync(
_shutdownEventKind, "info", _shutdownMessage, null, stopCts.Token)
.ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Watchdog: Ende konnte nicht gemeldet werden (ignoriert).");
}
_cts.Dispose();
if (_ownsClient && _client is IDisposable disposable)
disposable.Dispose();
}
}