Files
ClawdDotNet/src/ClawdDotNet.Manager/SupervisionReporter.cs
T
RichardandClaude Opus 5 72b0764d1a Instanzmanager: Register, Einstellungen und stellvertretende Meldung (#528)
Der zweite Teil von Stufe 3. Die Aufsicht ueber eine einzelne Instanz stand
schon; jetzt gibt es die Klammer darum und den Weg nach draussen.

InstanceRegistry haelt je Instanz eine Aufsicht. Zwei Zusagen, beide aus
E-M6: Beim Start wird UEBERNOMMEN, nicht gestartet - ein Manager, der beim
Hochkommen alles hochfaehrt, was er findet, startet nach einem Neustart der
Maschine auch das, was jemand mit Absicht angehalten hat. Und beim Beenden
wird nichts beendet: Wer den Manager schliesst, will den Manager
schliessen. Einzige Ausnahme ist StopAllAsync fuer einen Grund, der alle
betrifft - und der heisst Lizenzwiderruf.

SupervisionReporter macht aus einem Befund eine Bugtracker-Meldung,
stellvertretend: mit Prozesskennung, Abschiedscode und dem Ausschnitt aus
dem Instanzprotokoll, der noch auf der Platte liegt. Der Idempotenzschluessel
ist stabil je Instanz und Art - stuerzt dieselbe Instanz wiederholt ab,
zaehlt der Server hoch, statt den Bugtracker mit Zwillingen zu fuellen.

Ein leerer Protokollauszug ist dabei die aussagekraeftigste Zeile der
Meldung und wird ausdruecklich als solche geschrieben. Genau das war der
Zustand am 2026-09-08: Der Prozess wurde erschlagen, bevor sein
Schreibpuffer geleert war, und hinterliess nichts.

manager.json traegt die Felder aus Abschnitt 6 des Konzepts und sonst
nichts. Lizenzschluessel, Master-Token und Web-Token liegen darin
verschluesselt und zur Laufzeit im Klartext - dieselbe Bauart wie die
Anwendungseinstellungen, mit Absicht. Unsinnige Aufsichtswerte werden
eingefangen, weil die Datei von Hand bearbeitbar ist.

InstanceLayout ist nach Core gezogen (ResolveLogDirectory,
ResolveBackupDirectory). Der Manager braucht das Protokollverzeichnis einer
Instanz fuer den Auszug in der Meldung und darf ClawdDotNet.App nicht
kennen; dieselbe Rechnung an zwei Stellen zu fuehren waere die schlechtere
Antwort. AppHost leitet nur noch weiter.

EIN DEFEKT, den erst die eigenen Tests gezeigt haben: Eine angehaltene
Instanz erschien im Register als "Instance-Alpha" statt "Alpha". Die
Aufsicht kennt den Namen erst, wenn die Instanz einmal geantwortet hat, und
eine angehaltene tut das nie - waehrend genau diese Ansicht der Manager
anzeigt. Das Register fuehrt den Namen jetzt aus InstanceSettings.json mit.

Und ein Testdouble, das zu viel behauptet hat: Der falsche Socket
antwortete fuer jedes Verzeichnis dieselbe Instanz. Das laesst Tests gruen
werden, die im Betrieb zwei Instanzen verwechseln wuerden; er antwortet
jetzt je Verzeichnis.

29 neue Tests. Build ohne Warnungen, 766 Tests gruen, 8 uebersprungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 22:45:22 +02:00

130 lines
5.8 KiB
C#

using System.Text;
using ClawdDotNet.Core.Deploymentcenter;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Manager;
/// <summary>
/// Meldet ans Deploymentcenter, was die Instanz selbst nicht mehr melden kann.
///
/// <para><b>Das ist der Grund, warum es den Manager gibt.</b> Am 2026-09-08 hat sich eine
/// Instanz aufgehängt und wurde von Windows beendet: keine Protokollzeile, keine
/// Fehlermeldung, kein Bugtracker-Eintrag, kein Watchdog-Alarm. Bauartbedingt — Meldeweg
/// und Watchdog lebten in dem Prozess, über den sie berichten sollten. Fällt er hart,
/// fällt der Meldeweg mit, und zwar genau im Fall, der gemeldet werden müsste.</para>
///
/// <para><b>Zwei Wege, mit Absicht.</b> Die Instanz meldet, solange sie lebt — eigener
/// Fehler-Stream, eigener Heartbeat, eigenes Sub-Token. Der Manager meldet, was sie nicht
/// mehr melden kann. Jeder Weg deckt den Ausfall des anderen ab; ein Manager, der jede
/// Meldung durchreichte, wäre der neue Einzelpunkt.</para>
///
/// <para><b>Und er meldet wenig.</b> Nur Absturz, Verstummen und ein gescheiterter Start —
/// das, was aus seiner Sicht relevant ist. Ein fehlgeschlagener Werkzeugaufruf, ein
/// abgebrochener Agentenlauf, eine überschrittene Budgetgrenze bleiben in der Instanz.
/// Der Manager sieht davon nichts und soll davon nichts sehen (Konzept, Abschnitt 4).</para>
/// </summary>
public sealed class SupervisionReporter(
BugtrackerClient bugtracker, ILogger logger, Func<string, IReadOnlyList<string>>? logExcerpt = null)
{
private readonly Func<string, IReadOnlyList<string>> _logExcerpt =
logExcerpt ?? (dir => InstanceLogExcerpt.Read(dir));
/// <summary>
/// Meldet ein Vorkommnis, wenn es meldenswert ist — sonst nicht.
///
/// <para><b>Wirft nie.</b> Ein Meldeweg, der selbst wirft, wäre die schlechteste aller
/// Welten: Der ursprüngliche Befund ginge dabei verloren, und die Aufsicht, die ihn
/// gefunden hat, stürbe an ihrem eigenen Bericht.</para>
/// </summary>
/// <returns><c>true</c>, wenn eine Meldung rausgegangen ist.</returns>
public async Task<bool> ReportAsync(SupervisionEvent ereignis, CancellationToken ct = default)
{
if (!ereignis.WorthReporting) return false;
try
{
await bugtracker.ReportAsync(
type: BugtrackerItemType.Bug,
title: Title(ereignis),
description: Description(ereignis),
severity: ereignis.Kind == InstanceEventKind.StartFailed ? "medium" : "high",
// Stabil je Instanz und Art: Stuerzt dieselbe Instanz wiederholt ab, zaehlt
// der Server hoch, statt den Bugtracker mit Zwillingen zu fuellen. Genau
// dafuer gibt es occurrence_count - und wiederholte Abstuerze sind ein
// eigener Befund, kein Dutzend gleicher.
clientRef: $"manager-{ereignis.Kind}-{Path.GetFileName(ereignis.InstanceDirectory)}"
.ToLowerInvariant(),
context: new Dictionary<string, object?>
{
["quelle"] = "instanzmanager",
["instanz"] = ereignis.InstanceName,
["instanzverzeichnis"] = ereignis.InstanceDirectory,
["prozesskennung"] = ereignis.ProcessId,
["abschiedscode"] = ereignis.ExitCode,
["beobachtet"] = ereignis.At.ToString("O")
},
ct: ct).ConfigureAwait(false);
logger.LogInformation(
"Stellvertretend gemeldet: {Art} an Instanz {Name}.",
ereignis.Kind, ereignis.InstanceName);
return true;
}
catch (Exception ex)
{
logger.LogWarning(ex,
"Die stellvertretende Meldung für Instanz {Name} ging nicht raus. "
+ "Der Befund steht im Protokoll des Managers.", ereignis.InstanceName);
return false;
}
}
private static string Title(SupervisionEvent e) => e.Kind switch
{
InstanceEventKind.Crashed =>
$"Instanz '{e.InstanceName}' unerwartet beendet",
InstanceEventKind.Unresponsive =>
$"Instanz '{e.InstanceName}' laeuft, antwortet aber nicht mehr",
InstanceEventKind.StartFailed =>
$"Instanz '{e.InstanceName}' liess sich nicht starten",
_ => $"Instanz '{e.InstanceName}': {e.Kind}"
};
private string Description(SupervisionEvent e)
{
var text = new StringBuilder();
text.AppendLine(e.Message).AppendLine();
text.AppendLine("Gemeldet vom Instanzmanager, weil die Instanz es selbst nicht mehr konnte.");
text.AppendLine();
text.AppendLine($"Instanz : {e.InstanceName}");
text.AppendLine($"Verzeichnis : {e.InstanceDirectory}");
text.AppendLine($"Prozesskennung : {e.ProcessId?.ToString() ?? "unbekannt"}");
text.AppendLine($"Abschiedscode : {e.ExitCode?.ToString() ?? "unbekannt"}");
text.AppendLine($"Beobachtet : {e.At:yyyy-MM-dd HH:mm:ss} UTC");
text.AppendLine();
var zeilen = _logExcerpt(e.InstanceDirectory);
if (zeilen.Count == 0)
{
// Kein Versehen, sondern die aussagekraeftigste Zeile der ganzen Meldung.
text.AppendLine(
"Letzte Protokollzeilen: KEINE. Die Instanz hat nichts hinterlassen — "
+ "ihr Schreibpuffer wurde nie geleert. Genau dieser Zustand (#529) ist "
+ "der Grund, warum diese Meldung von aussen kommt.");
}
else
{
text.AppendLine($"Letzte {zeilen.Count} Protokollzeilen der Instanz:");
foreach (var zeile in zeilen) text.AppendLine(" " + zeile);
}
return text.ToString();
}
}