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
+8 -49
View File
@@ -197,60 +197,19 @@ public sealed class AppHost : IAsyncDisposable
public readonly record struct StartupResult(AppHost? Host, string? Error);
/// <summary>
/// Wohin diese Instanz protokolliert. Zwei Regeln:
/// Wohin diese Instanz protokolliert.
///
/// <para><b>Leer heißt <c>&lt;Instanzverzeichnis&gt;/Logs</c></b> — nicht mehr das
/// anwendungsweite Verzeichnis. Solange alle Instanzen in dieselben Dateien
/// schrieben, stand in <c>Startup.log</c> abwechselnd, was zwei Instanzen zu sagen
/// hatten, ohne Kennzeichnung, welche gerade spricht; im Mehrinstanzbetrieb war jede
/// Fehlersuche damit Ratearbeit (#528, Stufe 1).</para>
///
/// <para><b>Ein relativer Wert zählt gegen das Instanzverzeichnis</b>, nicht gegen
/// das Arbeitsverzeichnis des Prozesses. Sonst hängt der Ort davon ab, aus welchem
/// Verzeichnis jemand gestartet hat: Ein systemd-Dienst ohne <c>WorkingDirectory=</c>
/// startet in <c>/</c>, und <c>"./Logs"</c> zeigt dann auf <c>/Logs</c> — für ein
/// Dienstkonto nicht anlegbar. Beim Start aus dem Installationsverzeichnis schrieb
/// die Anwendung stattdessen in sich selbst, wo ein Update sie überschreibt.</para>
/// <para>Die Regeln stehen in <see cref="InstanceLayout"/> — sie beschreiben, wie eine
/// Instanz auf der Platte liegt, und das muss auch der Instanzmanager wissen, der
/// diese Schicht nicht kennen darf. Hier steht nur die Weiterleitung, damit die
/// Aufrufer im Fenster nicht zwei Wege lernen müssen.</para>
/// </summary>
/// <param name="instance">Die geladene Instanz — liefert Vorrang und Bezugspunkt.</param>
/// <param name="instancePath">Das Verzeichnis, aus dem die Instanz geladen wurde.</param>
public static string ResolveLogDirectory(InstanceConfig instance, string instancePath)
=> ResolveInstanceDirectory(instance.LogDirectory, "Logs", instance, instancePath);
=> InstanceLayout.ResolveLogDirectory(instance, instancePath);
/// <summary>
/// Wohin die Sicherungen dieser Instanz gehen. Leer heißt
/// <c>&lt;Instanzverzeichnis&gt;/Backups</c>.
///
/// <para>Der gemeinsame Ordner war nicht nur unordentlich: Die Aufbewahrung räumt
/// dort nach dem Präfix <c>backup_&lt;Instanzname&gt;_</c> auf, und bei zwei Namen,
/// die ineinander liegen (<c>Test</c> und <c>Test_2</c>), zählt die eine Instanz die
/// Archive der anderen mit (#528, Stufe 1).</para>
/// </summary>
/// <inheritdoc cref="InstanceLayout.ResolveBackupDirectory"/>
public static string ResolveBackupDirectory(InstanceConfig instance, string instancePath)
=> ResolveInstanceDirectory(instance.BackupDirectory, "Backups", instance, instancePath);
/// <summary>
/// Gemeinsame Auflösung: eingetragener Wert vor Vorgabe, relativ vor dem
/// Instanzverzeichnis.
/// </summary>
private static string ResolveInstanceDirectory(
string configured, string fallbackFolder, InstanceConfig instance, string instancePath)
{
// InstanceConfig.WorkingDirectory steht seit CreateInstance mit dem absoluten
// Instanzpfad in der Datei; instancePath ist derselbe Ort und trägt auch dann,
// wenn jemand das Feld von Hand geleert oder relativ gesetzt hat.
var instanceBase = Path.IsPathRooted(instance.WorkingDirectory)
? instance.WorkingDirectory
: instancePath;
if (string.IsNullOrWhiteSpace(configured))
return Path.GetFullPath(Path.Combine(instanceBase, fallbackFolder));
return Path.GetFullPath(
Path.IsPathRooted(configured)
? configured
: Path.Combine(instanceBase, configured));
}
=> InstanceLayout.ResolveBackupDirectory(instance, instancePath);
public static async Task<StartupResult> StartAsync(Callbacks callbacks, CancellationToken ct = default)
{