129 lines
4.8 KiB
C#
129 lines
4.8 KiB
C#
using ClawdDotNet.Core.Accounting;
|
||
using ClawdDotNet.Core.Config;
|
||
|
||
namespace ClawdDotNet.Core.Deploymentcenter.Watchdog;
|
||
|
||
/// <summary>
|
||
/// Leitet den Instanz-Zustand für den Heartbeat ab.
|
||
///
|
||
/// <para>Ein Heartbeat allein beweist nur, dass ein Faden läuft. Deshalb geht der
|
||
/// selbst ermittelte Gesundheitszustand als <c>checks</c> mit — der Server stuft einen
|
||
/// als <c>ok</c> gemeldeten Beat herab, sobald eine Prüfung fehlschlägt, und nennt in
|
||
/// der Antwort die betroffene. Der klassische Fall, den das abfängt: Der Takt meldet
|
||
/// brav <c>ok</c>, während der Aufgaben-Scanner seit einer Stunde tot ist.</para>
|
||
///
|
||
/// <list type="bullet">
|
||
/// <item><c>error</c> — kein OpenRouter-Key konfiguriert (Agenten deaktiviert).</item>
|
||
/// <item><c>warning</c> — Tagesbudget der Instanz erschöpft oder Scanner steht.</item>
|
||
/// <item><c>ok</c> — sonst.</item>
|
||
/// </list>
|
||
///
|
||
/// <para>Die Metriken sind bewusst schlank und ausschließlich numerisch: keine
|
||
/// sensiblen Nutzdaten, und nur Zahlen landen im Verlauf.</para>
|
||
/// </summary>
|
||
public sealed class InstanceHealthProvider : IInstanceHealthProvider
|
||
{
|
||
private readonly string _instanceName;
|
||
private readonly bool _agentsEnabled;
|
||
private readonly InstanceBudget _budget;
|
||
private readonly IUsageRepository? _usage;
|
||
private readonly Func<int> _agentCount;
|
||
private readonly Func<int> _runningChats;
|
||
private readonly Func<bool>? _schedulerRunning;
|
||
private readonly Func<DateTime> _now;
|
||
|
||
public InstanceHealthProvider(
|
||
string instanceName,
|
||
bool agentsEnabled,
|
||
InstanceBudget budget,
|
||
IUsageRepository? usage,
|
||
Func<int> agentCount,
|
||
Func<int> runningChats,
|
||
Func<bool>? schedulerRunning = null,
|
||
Func<DateTime>? now = null)
|
||
{
|
||
_instanceName = instanceName;
|
||
_agentsEnabled = agentsEnabled;
|
||
_budget = budget;
|
||
_usage = usage;
|
||
_agentCount = agentCount;
|
||
_runningChats = runningChats;
|
||
_schedulerRunning = schedulerRunning;
|
||
_now = now ?? (() => DateTime.Now);
|
||
}
|
||
|
||
public async Task<InstanceHealth> GetAsync(CancellationToken ct)
|
||
{
|
||
var metrics = new Dictionary<string, double>
|
||
{
|
||
["agentCount"] = _agentCount(),
|
||
["runningChats"] = _runningChats()
|
||
};
|
||
|
||
var checks = new Dictionary<string, HealthCheck>
|
||
{
|
||
["agents"] = new(_agentsEnabled,
|
||
_agentsEnabled ? null : "Kein OpenRouter-API-Key konfiguriert.")
|
||
};
|
||
|
||
if (_schedulerRunning is not null)
|
||
{
|
||
var running = _schedulerRunning();
|
||
checks["scheduler"] = new(running,
|
||
running ? null : "Aufgaben-Scanner läuft nicht.");
|
||
}
|
||
|
||
string? budgetProblem = null;
|
||
|
||
if (_usage is not null)
|
||
{
|
||
var today = DateOnly.FromDateTime(_now());
|
||
var used = await _usage.GetDailyAsync(today, agentId: "", ct).ConfigureAwait(false);
|
||
|
||
metrics["todayCostUsd"] = (double)decimal.Round(used.CostUsd, 4);
|
||
metrics["todayTokens"] = used.TotalTokens;
|
||
|
||
if (Exceeds(_budget.DailyCostUsd, used.CostUsd))
|
||
{
|
||
budgetProblem =
|
||
$"Tagesbudget erschöpft: {used.CostUsd:F2} von {_budget.DailyCostUsd:F2} USD.";
|
||
}
|
||
else if (Exceeds(_budget.DailyTokens, used.TotalTokens))
|
||
{
|
||
budgetProblem =
|
||
$"Token-Tageslimit erschöpft: {used.TotalTokens:N0} von {_budget.DailyTokens:N0}.";
|
||
}
|
||
|
||
checks["budget"] = new(budgetProblem is null, budgetProblem);
|
||
}
|
||
|
||
// Der Instanzname steht in der Meldung, nicht in den Metriken: Metriken sind
|
||
// Zahlen, alles andere würde der Server beim Verdichten ohnehin verwerfen.
|
||
if (!_agentsEnabled)
|
||
{
|
||
return new InstanceHealth(
|
||
WatchdogStatus.Error,
|
||
$"{_instanceName}: Kein OpenRouter-API-Key konfiguriert – Agenten deaktiviert.",
|
||
metrics, checks);
|
||
}
|
||
|
||
if (budgetProblem is not null)
|
||
return new InstanceHealth(WatchdogStatus.Warning, $"{_instanceName}: {budgetProblem}", metrics, checks);
|
||
|
||
if (_schedulerRunning is not null && !_schedulerRunning())
|
||
{
|
||
return new InstanceHealth(
|
||
WatchdogStatus.Warning,
|
||
$"{_instanceName}: Aufgaben-Scanner läuft nicht.",
|
||
metrics, checks);
|
||
}
|
||
|
||
return new InstanceHealth(WatchdogStatus.Ok, $"{_instanceName}: Betrieb normal.", metrics, checks);
|
||
}
|
||
|
||
/// <summary>0 oder kleiner bedeutet: keine Grenze gesetzt. Deckungsgleich mit BudgetGuard.</summary>
|
||
private static bool Exceeds(decimal limit, decimal used) => limit > 0 && used >= limit;
|
||
|
||
private static bool Exceeds(long limit, long used) => limit > 0 && used >= limit;
|
||
}
|