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>
This commit is contained in:
Richard
2026-09-08 22:45:22 +02:00
co-authored by Claude Opus 5
parent 4ef6d714a8
commit 72b0764d1a
10 changed files with 1357 additions and 50 deletions
@@ -0,0 +1,117 @@
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Instances;
namespace ClawdDotNet.Manager;
/// <summary>
/// Die letzten Protokollzeilen einer Instanz — das, was von ihr noch auf der Platte liegt.
///
/// <para><b>Warum das in die Absturzmeldung gehört.</b> Ohne diesen Ausschnitt lautet die
/// Meldung „Instanz X ist weg", und damit kann niemand etwas anfangen. Mit ihm steht
/// daneben, was sie zuletzt getan hat. Am 2026-09-08 war selbst das nicht zu haben — der
/// Schreibpuffer wurde nie geleert, und es stand nichts da. Deshalb steht hier auch
/// ausdrücklich, dass ein leerer Ausschnitt eine Aussage ist und kein Fehler.</para>
///
/// <para><b>Ausdrücklich nur lesend.</b> Der Manager fasst in einem Instanzverzeichnis
/// nichts an außer der Zustandsdatei, die ihm selbst gehört.</para>
/// </summary>
public static class InstanceLogExcerpt
{
/// <summary>Was ohne Not niemand liest — und was in eine Meldung noch hineinpasst.</summary>
private const int DefaultLines = 60;
/// <summary>Je Datei, damit eine sehr gesprächige nicht alle anderen verdrängt.</summary>
private const int LinesPerFile = 40;
/// <summary>
/// Die letzten Zeilen aus dem jüngsten Protokolltag dieser Instanz, über die Module
/// hinweg zusammengeführt.
///
/// <para>Zusammengeführt und nicht nacheinander: <c>Startup.log</c>,
/// <c>Deploymentcenter.log</c> und <c>Web.log</c> beschreiben denselben Zeitraum, und
/// bei einem Absturz will man wissen, was gleichzeitig passiert ist. Sortiert wird über
/// den Zeitstempel am Zeilenanfang — <c>[HH:mm:ss.fff]</c> —, und zwar als Zeichenkette:
/// Innerhalb eines Tages ordnet das richtig, und mehr als einen Tag umfasst der
/// Ausschnitt nicht.</para>
/// </summary>
/// <returns>
/// Leer, wenn es nichts gibt. Das ist ein Befund: Ein Prozess, der erschlagen wurde,
/// bevor sein Schreibpuffer geleert war, hinterlässt genau nichts.
/// </returns>
public static IReadOnlyList<string> Read(
string instanceDirectory, InstanceConfig? config = null, int lines = DefaultLines)
{
try
{
var logDir = InstanceLayout.ResolveLogDirectory(
config ?? new InstanceConfig { WorkingDirectory = instanceDirectory },
instanceDirectory);
if (!Directory.Exists(logDir)) return [];
// Die Ablage ist {LogDirectory}/{Datum}/{Modul}.log - der juengste Ordner ist
// der, der uns interessiert.
var tag = new DirectoryInfo(logDir)
.GetDirectories()
.OrderByDescending(d => d.Name, StringComparer.Ordinal)
.FirstOrDefault();
if (tag is null) return [];
var zeilen = new List<string>();
foreach (var datei in tag.GetFiles("*.log"))
{
foreach (var zeile in TailOf(datei.FullName, LinesPerFile))
zeilen.Add($"{datei.Name}: {zeile}");
}
return zeilen
.OrderBy(Timestamp, StringComparer.Ordinal)
.TakeLast(Math.Clamp(lines, 1, 1000))
.ToList();
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// Kein Zugriff auf das Protokoll ist ein Mangel an der Meldung, kein Grund,
// sie zu unterlassen - die Meldung selbst ist das Wichtigere.
return [];
}
}
/// <summary>
/// Die letzten <paramref name="count"/> Zeilen einer Datei.
///
/// <para>Mit <c>FileShare.ReadWrite</c>: Die Datei kann in genau diesem Moment noch
/// beschrieben werden — von einer Instanz, die gerade endet. Wer sie exklusiv öffnen
/// wollte, bekäme im Zweifel gar nichts.</para>
/// </summary>
private static IEnumerable<string> TailOf(string path, int count)
{
var ring = new Queue<string>(count);
using var strom = new FileStream(
path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
using var leser = new StreamReader(strom);
while (leser.ReadLine() is { } zeile)
{
if (zeile.Length == 0) continue;
ring.Enqueue(zeile);
if (ring.Count > count) ring.Dequeue();
}
return ring;
}
/// <summary>Der Zeitstempel einer Zeile, oder die ganze Zeile, wenn keiner dransteht.</summary>
private static string Timestamp(string line)
{
var start = line.IndexOf('[');
if (start < 0) return line;
var ende = line.IndexOf(']', start);
return ende < 0 ? line : line[(start + 1)..ende];
}
}
+237
View File
@@ -0,0 +1,237 @@
using ClawdDotNet.Core.Instances;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Manager;
/// <summary>Eine Instanz, wie der Manager sie sieht.</summary>
/// <param name="Directory">Das Instanzverzeichnis — die Kennung, unter der alles läuft.</param>
/// <param name="Name">Der Name aus der Instanzdatei; sonst der Ordnername.</param>
/// <param name="Condition">Was die letzte Aufsicht ergeben hat.</param>
/// <param name="ProcessId">Die Prozesskennung, soweit der Manager sie greifen kann.</param>
public sealed record ManagedInstance(
string Directory, string Name, InstanceCondition Condition, int? ProcessId);
/// <summary>
/// Alle Instanzen dieser Maschine und je eine Aufsicht dafür.
///
/// <para><b>Der Manager selbst ist dünn — das ist der Punkt.</b> Er kennt Verzeichnisse
/// und Prozesse, sonst nichts: keine Engine, keine Werkzeuge, keine Datenbank. Was eine
/// Instanz tut, tut sie in ihrem eigenen Prozess, und was ihr dabei zustößt, meldet sie
/// selbst. Hier steht nur, was aus der Sicht des Managers passiert.</para>
///
/// <para><b>Beim Start wird übernommen, nicht gestartet.</b> Fällt der Manager aus, laufen
/// die Instanzen weiter (E-M6) — er darf nicht der Einzelpunkt sein, gegen den dieses
/// Vorhaben gebaut wird. <see cref="RefreshAsync"/> sieht deshalb erst nach, wer schon
/// läuft, bevor irgendetwas angefasst wird.</para>
///
/// <para><b>Beim Beenden wird nichts beendet.</b> Aus demselben Grund: Wer den Manager
/// schließt, will ihn schließen und nicht seine Instanzen. Nur ein Lizenzwiderruf ist
/// etwas anderes — der ist kein Ausfall, sondern ein Urteil.</para>
/// </summary>
public sealed class InstanceRegistry : IAsyncDisposable
{
private readonly InstanceDirectoryManager _directories;
private readonly IInstanceLauncher _launcher;
private readonly IInstanceChannel _channel;
private readonly ILoggerFactory _loggerFactory;
private readonly ILogger _logger;
private readonly SupervisionOptions _options;
private readonly TimeProvider _clock;
private readonly Dictionary<string, InstanceSupervisor> _supervisors =
new(ClawdDotNet.Core.Storage.PathBoundary.Comparer);
/// <summary>
/// Der Name aus <c>InstanceSettings.json</c>, je Instanzverzeichnis.
///
/// <para>Nötig, weil eine Aufsicht den Namen erst kennt, wenn die Instanz einmal
/// geantwortet hat — eine angehaltene tut das nie. Ohne diese Liste hieße jede
/// gestoppte Instanz nach ihrem Ordner (<c>Instance-Alpha</c> statt
/// <c>Alpha</c>), und das ist genau die Ansicht, die der Manager anzeigt.</para>
/// </summary>
private readonly Dictionary<string, string> _names =
new(ClawdDotNet.Core.Storage.PathBoundary.Comparer);
public InstanceRegistry(
InstanceDirectoryManager directories,
IInstanceLauncher launcher,
IInstanceChannel channel,
ILoggerFactory loggerFactory,
SupervisionOptions? options = null,
TimeProvider? clock = null)
{
_directories = directories;
_launcher = launcher;
_channel = channel;
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger("ClawdDotNet.Manager");
_options = options ?? SupervisionOptions.Default;
_clock = clock ?? TimeProvider.System;
}
/// <summary>
/// Jedes Vorkommnis aus jeder Aufsicht, mit dem Instanzverzeichnis daran.
///
/// <para><b>Kommt auf dem Faden des Verursachers</b> — wer eine Oberfläche daran
/// hängt, geht über den Dispatcher.</para>
/// </summary>
public event Action<SupervisionEvent>? Observed;
/// <summary>Was der Manager gerade über seine Instanzen weiß.</summary>
public IReadOnlyList<ManagedInstance> Instances
=> _supervisors.Values
.Select(s => new ManagedInstance(
s.InstanceDirectory, NameOf(s), s.Condition, s.ProcessId))
.OrderBy(i => i.Name, StringComparer.CurrentCultureIgnoreCase)
.ToList();
/// <summary>
/// Die laufende Instanz sagt selbst, wie sie heißt; sonst gilt, was in ihrer
/// Konfiguration steht; und wenn auch die nichts hergibt, der Ordnername.
/// </summary>
private string NameOf(InstanceSupervisor supervisor)
{
if (!string.IsNullOrEmpty(supervisor.InstanceName))
return supervisor.InstanceName;
return _names.TryGetValue(supervisor.InstanceDirectory, out var name)
&& !string.IsNullOrWhiteSpace(name)
? name
: Path.GetFileName(supervisor.InstanceDirectory);
}
// ─── Nachsehen, was da ist ───
/// <summary>
/// Liest das Instanzenverzeichnis und übernimmt, was schon läuft.
///
/// <para>Beim ersten Aufruf ist das die Wiederaufnahme nach einem Managerneustart
/// (E-M6); später hält es das Register mit dem Dateisystem im Gleichklang — jemand
/// kann eine Instanz von Hand angelegt oder weggeräumt haben.</para>
///
/// <para><b>Startet nichts von selbst.</b> Was nicht läuft, bleibt aus, bis jemand es
/// verlangt. Ein Manager, der beim Hochkommen alles hochfährt, was er findet, würde
/// nach einem Neustart der Maschine auch das starten, was jemand mit Absicht
/// angehalten hat.</para>
/// </summary>
public async Task RefreshAsync(CancellationToken ct = default)
{
// ListInstances liest den Namen aus InstanceSettings.json gleich mit - und tut
// es nachsichtig: eine kaputte Datei ergibt Vorgabewerte, keinen Wurf.
var vorhanden = _directories.ListInstances()
.Select(i => (Pfad: Path.GetFullPath(i.FolderPath), i.InstanceName))
.ToList();
var pfade = vorhanden.Select(v => v.Pfad).ToList();
// Weggeraeumte Instanzen loslassen. Ihr Prozess wird nicht beendet: Wenn das
// Verzeichnis weg ist und trotzdem etwas laeuft, ist das ein Fall fuer einen
// Menschen und nicht fuer einen stillen Abschuss.
foreach (var verschwunden in _supervisors.Keys.Except(pfade, _supervisors.Comparer).ToList())
{
_logger.LogInformation(
"Instanzverzeichnis {Pfad} gibt es nicht mehr — aus der Aufsicht genommen.",
verschwunden);
await _supervisors[verschwunden].DisposeAsync().ConfigureAwait(false);
_supervisors.Remove(verschwunden);
_names.Remove(verschwunden);
}
foreach (var (pfad, name) in vorhanden)
{
// Der Name kann sich geaendert haben, ohne dass die Instanz neu startet.
_names[pfad] = name;
if (_supervisors.ContainsKey(pfad)) continue;
var aufsicht = Register(pfad, name);
// Laeuft sie schon? Der Socket sagt es - und nur er sagt es richtig.
await aufsicht.ReattachAsync(ct).ConfigureAwait(false);
if (aufsicht.Condition == InstanceCondition.Running)
aufsicht.BeginWatching();
}
}
private InstanceSupervisor Register(string instanceDirectory, string name)
{
var aufsicht = new InstanceSupervisor(
instanceDirectory, _launcher, _channel,
_loggerFactory.CreateLogger($"ClawdDotNet.Manager.{Path.GetFileName(instanceDirectory)}"),
_options, _clock);
aufsicht.Observed += e => Observed?.Invoke(e);
var pfad = Path.GetFullPath(instanceDirectory);
_supervisors[pfad] = aufsicht;
_names[pfad] = name;
return aufsicht;
}
// ─── Anlegen, starten, anhalten ───
/// <summary>
/// Legt eine Instanz an und nimmt sie in die Aufsicht — startet sie aber nicht.
/// </summary>
/// <exception cref="InvalidOperationException">Wenn es sie schon gibt.</exception>
public string Create(string instanceName)
{
var verzeichnis = _directories.CreateInstance(instanceName);
Register(verzeichnis, instanceName);
_logger.LogInformation("Instanz {Name} angelegt: {Pfad}", instanceName, verzeichnis);
return verzeichnis;
}
public async Task<bool> StartAsync(string instanceDirectory, CancellationToken ct = default)
{
var aufsicht = Find(instanceDirectory);
var gestartet = await aufsicht.StartAsync(ct).ConfigureAwait(false);
if (gestartet) aufsicht.BeginWatching();
return gestartet;
}
public Task<bool> StopAsync(string instanceDirectory, CancellationToken ct = default)
=> Find(instanceDirectory).StopAsync(ct);
/// <summary>
/// Hält alle laufenden Instanzen an. <b>Nicht</b> beim Beenden des Managers, sondern
/// nur, wenn es einen Grund gibt, der sie alle betrifft — ein Lizenzwiderruf.
/// </summary>
public async Task StopAllAsync(string grund, CancellationToken ct = default)
{
_logger.LogWarning("Alle Instanzen werden angehalten: {Grund}", grund);
foreach (var aufsicht in _supervisors.Values.ToList())
{
if (aufsicht.Condition is InstanceCondition.Running or InstanceCondition.Silent)
await aufsicht.StopAsync(ct).ConfigureAwait(false);
}
}
private InstanceSupervisor Find(string instanceDirectory)
{
var pfad = Path.GetFullPath(instanceDirectory);
return _supervisors.TryGetValue(pfad, out var aufsicht)
? aufsicht
: throw new KeyNotFoundException($"Keine Instanz unter {pfad} im Register.");
}
public async ValueTask DisposeAsync()
{
// Die Instanzen laufen weiter (E-M6). Losgelassen werden nur die Aufsichten.
foreach (var aufsicht in _supervisors.Values)
await aufsicht.DisposeAsync().ConfigureAwait(false);
_supervisors.Clear();
_names.Clear();
}
}
+208
View File
@@ -0,0 +1,208 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using ClawdDotNet.Core.Security;
using ClawdDotNet.Core.Storage;
namespace ClawdDotNet.Manager;
/// <summary>
/// Alles, was der Instanzmanager weiß — und mehr soll er nicht wissen (E-M4).
///
/// <para><b>Die Kürze dieser Klasse ist die Zusage.</b> 99 Prozent der Einstellungen
/// gehören der Instanz und gelten für jede einzeln; hier steht nur, was sich gar nicht je
/// Instanz sagen lässt: eine Weboberfläche für alle, eine Lizenz für die Installation, ein
/// Master-Token, aus dem die Sub-Token entstehen, und der Ort, an dem die Instanzen
/// liegen — den muss er wissen, bevor es überhaupt eine gibt.</para>
///
/// <para>Farbschema, Teilerstellungen, Protokollstufe, Sicherungszeiten: alles Werte einer
/// Oberfläche oder einer Instanz. Wer hier ein Feld ergänzt, sollte erst beantworten,
/// warum es nicht je Instanz gelten kann.</para>
///
/// <para><b>Nicht dabei: eine Datenbank.</b> Was der Manager braucht, liegt als JSON im
/// Projektverzeichnis — diese Datei und je Instanz eine kleine Zustandsdatei. Die
/// <c>state.db</c> einer Instanz fasst er nicht an: Sie gehört genau einem Prozess, und
/// prozessübergreifend gilt die Schreibsperre von <c>SqliteStorage</c> nicht.</para>
/// </summary>
public sealed class ManagerSettings
{
// ─── Wo die Instanzen liegen ───
[JsonPropertyName("instancesDirectory")]
public string InstancesDirectory { get; set; } = Path.Combine(AppPaths.DataDirectory, "Instances");
/// <summary>
/// Die Programmdatei, mit der ein Instanzprozess gestartet wird.
///
/// <para>Leer heißt: neben der des Managers. Das ist der Normalfall und der Grund,
/// warum hier fast immer nichts steht — beide kommen aus demselben Paket.</para>
/// </summary>
[JsonPropertyName("instanceExecutable")]
public string InstanceExecutable { get; set; } = "";
// ─── Die eine Weboberfläche (E-M5) ───
[JsonPropertyName("webUiPort")]
public int WebUiPort { get; set; } = 7000;
/// <summary><c>loopback</c> (Vorgabe) oder <c>any</c>.</summary>
[JsonPropertyName("webUiBinding")]
public string WebUiBinding { get; set; } = "loopback";
/// <summary>Zugangstoken der Weboberfläche. Verschlüsselt gespeichert.</summary>
[JsonPropertyName("webUiToken")]
public string WebUiToken { get; set; } = "";
// ─── Lizenz (E-M3) ───
/// <summary>
/// Der Lizenzschlüssel der Installation. Verschlüsselt gespeichert.
///
/// <para>Er liegt hier und nicht in den Instanzen: Eine Lizenz gilt der Installation
/// und erlaubt beliebig viele Instanzen.</para>
/// </summary>
[JsonPropertyName("licenseKey")]
public string LicenseKey { get; set; } = "";
// ─── Deploymentcenter ───
[JsonPropertyName("deploymentcenterUrl")]
public string DeploymentcenterUrl { get; set; } = "https://dc.mhdf.de";
/// <summary>
/// Das Master-Token. Verschlüsselt gespeichert.
///
/// <para>Aus ihm entstehen die Sub-Token der Instanzen — jedes eingeschränkt auf
/// <c>watchdog:ping</c> und <c>bugtracker:report</c> und einzeln widerrufbar. Nach dem
/// Umbau liegt auf keiner Instanz mehr das Master-Token.</para>
/// </summary>
[JsonPropertyName("deploymentcenterMasterToken")]
public string DeploymentcenterMasterToken { get; set; } = "";
/// <summary>Aktualisiert wird das Programm, nicht die Instanz.</summary>
[JsonPropertyName("updateChannel")]
public string UpdateChannel { get; set; } = "prod";
// ─── Aufsicht ───
/// <summary>Wie oft nachgesehen wird, in Sekunden.</summary>
[JsonPropertyName("probeIntervalSeconds")]
public int ProbeIntervalSeconds { get; set; } = 15;
/// <summary>Wie lange eine frisch gestartete Instanz Zeit hat, sich zu melden.</summary>
[JsonPropertyName("startTimeoutSeconds")]
public int StartTimeoutSeconds { get; set; } = 60;
/// <summary>Wie viele Fehlversuche in Folge „antwortet nicht" ergeben.</summary>
[JsonPropertyName("silentChecksBeforeAlarm")]
public int SilentChecksBeforeAlarm { get; set; } = 3;
/// <summary>Wie lange eine Instanz nach der Bitte zu enden noch bekommt.</summary>
[JsonPropertyName("stopGraceSeconds")]
public int StopGraceSeconds { get; set; } = 20;
/// <summary>
/// Die Werte aus dieser Datei als geprüfte Aufsichtsvorgaben.
///
/// <para>Untergrenzen, weil unsinnige Zahlen in einer von Hand bearbeitbaren Datei
/// stehen können: Ein Abfragetakt von 0 wäre eine Endlosschleife, ein Alarm nach null
/// Fehlversuchen ein Alarm bei jedem Aussetzer.</para>
/// </summary>
public SupervisionOptions ToSupervisionOptions() => new(
ProbeInterval: TimeSpan.FromSeconds(Math.Clamp(ProbeIntervalSeconds, 1, 3600)),
StartTimeout: TimeSpan.FromSeconds(Math.Clamp(StartTimeoutSeconds, 5, 600)),
SilentChecksBeforeAlarm: Math.Clamp(SilentChecksBeforeAlarm, 1, 100),
StopGrace: TimeSpan.FromSeconds(Math.Clamp(StopGraceSeconds, 1, 600)));
}
/// <summary>
/// Lädt und speichert <c>manager.json</c>.
///
/// <para><b>Dieselbe Bauart wie die Anwendungseinstellungen, mit Absicht.</b> Der Ort
/// kommt von <see cref="AppPaths.ConfigDirectory"/>, Geheimnisse liegen in der Datei
/// verschlüsselt und zur Laufzeit im Klartext, geschrieben wird atomar, und eine
/// unlesbare Datei verhindert den Start nicht. Wer eine der beiden kennt, kennt die
/// andere.</para>
/// </summary>
public sealed class ManagerSettingsStore
{
private const string FileName = "manager.json";
private static readonly JsonSerializerOptions JsonOpts = new()
{
WriteIndented = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
PropertyNameCaseInsensitive = true
};
public ManagerSettingsStore(string? basePath = null)
=> SettingsPath = Path.Combine(basePath ?? AppPaths.ConfigDirectory, FileName);
/// <summary>Der Ort der Datei — für Meldungen und Diagnose.</summary>
public string SettingsPath { get; }
public ManagerSettings Settings { get; private set; } = new();
public ManagerSettings Load()
{
if (!File.Exists(SettingsPath))
{
Settings = new ManagerSettings();
Save(); // Vorgaben festschreiben, damit der Ort sichtbar wird
return Settings;
}
try
{
Settings = JsonSerializer.Deserialize<ManagerSettings>(
AtomicFile.ReadAllText(SettingsPath), JsonOpts) ?? new ManagerSettings();
}
catch (Exception ex) when (ex is JsonException or IOException)
{
// Eine unlesbare Datei darf den Manager nicht am Start hindern - dann liefe
// gar keine Instanz mehr, und das ist der schlechtere Zustand.
Settings = new ManagerSettings();
return Settings;
}
Settings.LicenseKey = TryUnprotect(Settings.LicenseKey);
Settings.DeploymentcenterMasterToken = TryUnprotect(Settings.DeploymentcenterMasterToken);
Settings.WebUiToken = TryUnprotect(Settings.WebUiToken);
return Settings;
}
public void Save()
{
var lizenz = Settings.LicenseKey;
var master = Settings.DeploymentcenterMasterToken;
var web = Settings.WebUiToken;
try
{
Settings.LicenseKey = SecretProtector.Protect(lizenz) ?? "";
Settings.DeploymentcenterMasterToken = SecretProtector.Protect(master) ?? "";
Settings.WebUiToken = SecretProtector.Protect(web) ?? "";
AppPaths.EnsureDirectory(Path.GetDirectoryName(SettingsPath)!);
AtomicFile.WriteAllText(SettingsPath, JsonSerializer.Serialize(Settings, JsonOpts));
AppPaths.RestrictToOwner(SettingsPath);
}
finally
{
Settings.LicenseKey = lizenz;
Settings.DeploymentcenterMasterToken = master;
Settings.WebUiToken = web;
}
}
/// <summary>
/// Entschlüsselt einen Wert; bei Nicht-Lesbarkeit (anderer Benutzer, anderer Rechner)
/// leer, damit er neu eingetragen wird, statt unbrauchbar an eine Gegenstelle zu gehen.
/// </summary>
private static string TryUnprotect(string value)
{
try { return SecretProtector.Unprotect(value) ?? ""; }
catch (SecretProtectionException) { return ""; }
}
}
@@ -0,0 +1,129 @@
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();
}
}