Files
ClawdDotNet/src/ClawdDotNet.Manager/ManagerCore.cs
T
RichardandClaude Opus 5 dcffcdcff5 Instanzmanager: Weboberflaeche kennt Instanzen, geordnetes Ende, Puls (#528, #531)
Weboberflaeche (#531): X-Instanz an der einen fetch-Stelle, Instanzauswahl in der
Kopfzeile, Verwaltungsansicht mit Anlegen/Starten/Anhalten/Fenster, Herkunft der
Ereignisse, Sicherungs- und Einstellungsansicht. 402/409/502 werden eigen behandelt.

Sicherungen im Manager (#530): Auflisten, Anlegen, Wiederherstellen, Loeschen. Das
Zurueckspielen haelt die Instanz vorher an und startet sie danach wieder.

Geordnetes Ende (Konzept 9.8). Zwei Befunde waren der Anlass:

Der Manager konnte eine Instanz MIT FENSTER nie geordnet beenden.
Control.ShutdownRequested war nur in HeadlessRun verdrahtet, nicht in der
Avalonia-Anwendung - und ein Fenster ist die Vorgabe. Die Bitte wurde mit 202
beantwortet, niemand hoerte zu, und der Manager schoss den Prozess nach Ablauf
seiner Frist jedes Mal ab.

InstanceSupervisor.StopAsync hielt Socket-Schweigen fuer einen Todesnachweis und
meldete "beendet", ohne den Prozess anzufassen. Bei einer verstummten Instanz ist
der Socket von vornherein still - der Neustart haette einen zweiten Prozess auf
dieselbe state.db gesetzt. Genau der Schaden, gegen den #528 gebaut ist.

- ShutdownCoordinator: erst die Tuer schliessen, dann auf offene Arbeiten warten,
  Frist zwei Minuten. Vorher brach TaskScanner.DisposeAsync mitten in ProcessAsync
  ab, sodass die Aufgabe bis zum Lease-Ablauf auf "running" stehenblieb.
- GET /api/instanz/puls hinter dem Socket-Gate: Phase und offene Arbeiten. Weiter
  gefragt statt gemeldet - ein Puls, den die Instanz von sich aus schickt, fehlt
  genau dann, wenn sie haengt.
- Eskalationsleiter: drei Durchgaenge ohne Antwort, einmal um ein Ende bitten,
  nach 30s beenden und den Vollzug abwarten, gemeldet als Absturz.
- EnsureDeadAsync vor jedem Start.

Weiteres aus dem Review:
- .gitignore verankert: "Instances/" passte auch auf src/ClawdDotNet.Core/Instances/.
  InstanceEndpoint.cs und InstanceLayout.cs waren dadurch nie eingecheckt.
- Das WebUI-Token stand im Protokoll des Managers. Konsole ja, Datei nein.
- Ein Klick auf "Instanz anlegen" loeste requestSubmit() zweimal aus.
- [Obsolete] auf WebServerPort wieder entfernt: sechs Warnungen, die niemand
  abstellen konnte, solange E-M5 nicht fertig ist.
- Die Wiederherstellung bricht nicht mehr ab, wenn der Browser weggeht.
- Entwicklungspfade zum Instanzmanager aus Segmenten statt mit Rueckstrichen, und
  der Port kommt aus manager.json statt fest aus der Zeile.

Offen und gemeldet: --managed hebelt die Lizenzpruefung aus (#535).

806 Tests gruen, 0 Warnungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 11:15:59 +02:00

337 lines
13 KiB
C#

using ClawdDotNet.Core.Deploymentcenter;
using ClawdDotNet.Core.Instances;
using ClawdDotNet.Licensing;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Manager;
/// <summary>Die Ablage des Managers als Ort für den Lizenzschlüssel (E-M3).</summary>
public sealed class ManagerLicenseStore(ManagerSettingsStore store) : ILicenseStore
{
public string ServerUrl => store.Settings.DeploymentcenterUrl;
public string LicenseKey
{
get => store.Settings.LicenseKey;
set => store.Settings.LicenseKey = value;
}
public void Save() => store.Save();
}
/// <summary>Warum eine Instanz nicht startet.</summary>
public enum StartRefusal
{
/// <summary>Sie startet.</summary>
None,
/// <summary>Keine gültige Lizenz. Eine Lizenz gilt der Installation, nicht der Instanz.</summary>
NoLicense,
/// <summary>Der Prozess kam nicht hoch oder hat sich nicht gemeldet.</summary>
DidNotStart
}
/// <summary>
/// Der Instanzmanager als Ganzes — ohne Fenster, ohne Weboberfläche, ohne Prozess.
///
/// <para><b>Was er zusammenhält:</b> die eigenen Einstellungen, die Lizenz, das Register
/// der Instanzen und den Weg, auf dem er stellvertretend meldet. Alles darüber — ein
/// Fenster, die eine Weboberfläche für alle (E-M5) — setzt hier auf und gehört nicht
/// hierher.</para>
///
/// <para><b>Die Lizenz ist die eine Sperre, die er kennt</b> (E-M3). Sie gilt der
/// Installation und erlaubt beliebig viele Instanzen; ohne sie startet er keine. Und ein
/// Widerruf ist kein Ausfall, sondern ein Urteil: Dann hält er die laufenden an — als
/// einziger Fall, in dem er das von sich aus tut.</para>
///
/// <para><b>Er hält am Laufen, was ihm anvertraut ist.</b> Eine Instanz mit
/// <c>autostartByManager</c> startet er beim Hochkommen und nach einem Absturz wieder —
/// begrenzt durch <see cref="RestartLedger"/>, denn was an einer kaputten Konfiguration
/// scheitert, scheitert beim zehnten Versuch genauso. Ohne den Schalter rührt er nichts
/// an: Ein Manager, der nach einem Neustart der Maschine alles hochfährt, was er findet,
/// startet auch das, was jemand mit Absicht angehalten hat.</para>
///
/// <para><b>Was er nicht tut:</b> Instanzen beim eigenen Ende beenden (E-M6: wer den
/// Manager schließt, will den Manager schließen).</para>
/// </summary>
public sealed class ManagerCore : IAsyncDisposable
{
private readonly ManagerSettingsStore _settings;
private readonly IInstanceTokenSource _tokens;
private readonly SupervisionReporter? _reporter;
private readonly LicenseGate? _license;
private readonly LicenseWatch? _watch;
private readonly RestartLedger _restarts;
private readonly ILogger _logger;
/// <summary>
/// Was die letzte Lizenzprüfung ergeben hat.
///
/// <para>Ohne Torwächter <c>true</c>: Ein Manager ohne hinterlegte Lizenzprüfung
/// sperrt nichts — sonst gäbe es eine Henne-Ei-Sperre, bevor überhaupt jemand einen
/// Schlüssel eintragen kann.</para>
/// </summary>
public bool LicenseHolds { get; private set; } = true;
public ManagerCore(
ManagerSettingsStore settings,
InstanceRegistry instances,
IInstanceTokenSource tokens,
ILoggerFactory loggerFactory,
SupervisionReporter? reporter = null,
LicenseGate? license = null,
LicenseWatch? watch = null,
RestartPolicy? restartPolicy = null,
TimeProvider? clock = null)
{
_settings = settings;
Instances = instances;
_tokens = tokens;
_reporter = reporter;
_license = license;
_watch = watch;
_logger = loggerFactory.CreateLogger("ClawdDotNet.Manager");
_restarts = new RestartLedger(restartPolicy ?? RestartPolicy.Default, clock);
// Jedes Vorkommnis geht ins Protokoll und, wenn es niemand bestellt hat, nach
// draussen. Bewusst nicht abgewartet: Eine Aufsicht darf nicht auf einen Server
// warten muessen, um weiter aufzupassen.
Instances.Observed += ereignis =>
{
if (_reporter is not null)
_ = _reporter.ReportAsync(ereignis);
// Nur beim Absturz, und das schliesst die verstummte Instanz ein: Die
// Aufsicht beendet sie nach ihrer Frist und meldet das als Absturz - denn
// gewollt hat es niemand. Ein zweiter Einstieg bei "Unresponsive" waere ein
// zweiter Neustart fuer denselben Vorfall.
if (ereignis.Kind == InstanceEventKind.Crashed)
_ = RestartAfterCrashAsync(ereignis);
};
if (_watch is not null)
_watch.Revoked += HandleLicenseRevokedAsync;
}
public InstanceRegistry Instances { get; }
public ManagerSettings Settings => _settings.Settings;
// ─── Hochfahren ───
/// <summary>
/// Prüft die Lizenz, sieht nach, was schon läuft, und übernimmt es.
///
/// <para><b>In dieser Reihenfolge.</b> Erst die Lizenz, weil sie die Sperre ist. Dann
/// übernehmen, nicht starten — was läuft, lief schon, und was aus war, bleibt aus,
/// bis jemand es verlangt.</para>
/// </summary>
/// <returns><c>false</c>, wenn die Lizenz die Arbeit verbietet.</returns>
public async Task<bool> StartAsync(CancellationToken ct = default)
{
if (_license is not null)
{
LicenseHolds = await _license.RunStartupCheckAsync(ct).ConfigureAwait(false);
if (!LicenseHolds)
{
_logger.LogWarning(
"Ohne gültige Lizenz startet der Instanzmanager keine Instanz.");
// Trotzdem nachsehen: Laufende Instanzen gehoeren in die Liste, damit
// jemand sie sieht - und damit sie sich anhalten lassen.
await Instances.RefreshAsync(ct).ConfigureAwait(false);
return false;
}
}
_watch?.Start();
await Instances.RefreshAsync(ct).ConfigureAwait(false);
var laufen = Instances.Instances.Count(i => i.Condition == InstanceCondition.Running);
_logger.LogInformation(
"Instanzmanager bereit: {Gesamt} Instanzen bekannt, {Laufend} laufen bereits.",
Instances.Instances.Count, laufen);
await StartAutostartInstancesAsync(ct).ConfigureAwait(false);
return true;
}
/// <summary>
/// Startet, was der Manager am Laufen halten soll und was gerade nicht läuft.
///
/// <para><b>Nach dem Übernehmen, nicht davor.</b> Was schon läuft, wird nicht noch
/// einmal gestartet — es lief schon, und ein zweiter Prozess auf derselben
/// <c>state.db</c> wäre das Gegenteil von hilfreich.</para>
/// </summary>
public async Task StartAutostartInstancesAsync(CancellationToken ct = default)
{
var offen = Instances.Instances
.Where(i => i.AutostartByManager && i.Condition != InstanceCondition.Running)
.ToList();
if (offen.Count == 0) return;
_logger.LogInformation(
"{Anzahl} Instanzen sind auf Autostart eingestellt und laufen nicht.", offen.Count);
foreach (var instanz in offen)
{
// Der Zaehler gehoert dem Absturz, nicht dem Hochfahren: Ein frischer Manager
// faengt bei null an.
_restarts.Forget(instanz.Directory);
var ergebnis = await StartInstanceAsync(instanz.Directory, ct).ConfigureAwait(false);
if (ergebnis != StartRefusal.None)
{
_logger.LogWarning(
"Autostart von {Name} nicht möglich: {Grund}", instanz.Name, ergebnis);
}
}
}
/// <summary>
/// Startet eine abgestürzte Instanz wieder — wenn sie darauf eingestellt ist und die
/// Grenze es zulässt.
///
/// <para><b>Der Sicherungsautomat ist der eigentliche Inhalt.</b> Wieder hochzufahren
/// ist die einfache Hälfte; die schwierige ist zu erkennen, wann man es lassen muss.
/// Nach <see cref="RestartPolicy.MaxRestarts"/> Versuchen im Zeitfenster setzt der
/// Manager aus und meldet es — die Instanz bleibt dann aus, bis jemand sie von Hand
/// startet.</para>
///
/// <para><b>Nur bei einem Absturz</b>, nicht wenn eine Instanz verstummt ist. Eine
/// stumme Instanz lebt noch; sie neu zu starten hieße, sie erst abzuschießen, und das
/// ist eine eigene Entscheidung — sie steht als offene Frage im Konzept.</para>
///
/// <para>Öffentlich, damit sich der Weg prüfen lässt, ohne einen echten Absturz
/// herbeizuführen.</para>
/// </summary>
public async Task RestartAfterCrashAsync(SupervisionEvent ereignis)
{
var instanz = Instances.Instances.FirstOrDefault(
i => ClawdDotNet.Core.Storage.PathBoundary.Comparer.Equals(
i.Directory, ereignis.InstanceDirectory));
if (instanz is not { AutostartByManager: true })
return;
if (!_restarts.TryRecord(instanz.Directory, out var versuche))
{
_logger.LogError(
"Instanz {Name} ist nach {Versuche} Neustarts innerhalb von {Fenster} erneut "
+ "abgestürzt. Der Manager setzt aus — sie bleibt aus, bis jemand sie startet.",
instanz.Name, versuche, _restarts.Policy.Window);
return;
}
_logger.LogWarning(
"Instanz {Name} wird nach einem Absturz wieder gestartet (Versuch {Nummer} von "
+ "{Grenze} innerhalb von {Fenster}).",
instanz.Name, versuche, _restarts.Policy.MaxRestarts, _restarts.Policy.Window);
try
{
await StartInstanceAsync(instanz.Directory).ConfigureAwait(false);
}
catch (Exception ex)
{
// Diese Methode laeuft ohne Aufrufer, der etwas auffangen koennte - sie haengt
// an einem Ereignis. Was hier durchrutscht, waere sonst eine unbeobachtete
// Ausnahme, und die nimmt im Zweifel den Manager mit.
_logger.LogError(ex, "Neustart von {Name} fehlgeschlagen.", instanz.Name);
}
}
// ─── Instanzen ───
/// <summary>
/// Legt eine Instanz an und besorgt ihr gleich ihr eigenes Melde-Token.
///
/// <para>Beim Anlegen und nicht beim ersten Start, weil die Instanzdatei dann
/// zweifelsfrei niemandem gehört. Sie später zu schreiben, während der Instanzprozess
/// läuft, hieße zwei Schreiber auf einer Datei.</para>
/// </summary>
public async Task<string> CreateInstanceAsync(string name, CancellationToken ct = default)
{
var verzeichnis = Instances.Create(name);
await _tokens.EnsureTokenAsync(verzeichnis, ct).ConfigureAwait(false);
return verzeichnis;
}
/// <summary>
/// Startet eine Instanz — wenn die Lizenz es erlaubt.
///
/// <para>Das Token wird vorher besorgt, falls noch keines da ist: bei einer Instanz,
/// die es vor dem Manager schon gab, oder wenn das Deploymentcenter beim Anlegen
/// nicht erreichbar war. Danach ist die Datei dem Instanzprozess überlassen.</para>
/// </summary>
public async Task<StartRefusal> StartInstanceAsync(
string instanceDirectory, CancellationToken ct = default)
{
if (!LicenseHolds)
{
_logger.LogWarning(
"Start von {Pfad} abgelehnt: keine gültige Lizenz.", instanceDirectory);
return StartRefusal.NoLicense;
}
await _tokens.EnsureTokenAsync(instanceDirectory, ct).ConfigureAwait(false);
return await Instances.StartAsync(instanceDirectory, ct).ConfigureAwait(false)
? StartRefusal.None
: StartRefusal.DidNotStart;
}
public Task<bool> StopInstanceAsync(string instanceDirectory, CancellationToken ct = default)
=> Instances.StopAsync(instanceDirectory, ct);
/// <inheritdoc cref="InstanceRegistry.FocusAsync"/>
public Task<bool> FocusInstanceAsync(string instanceDirectory, CancellationToken ct = default)
=> Instances.FocusAsync(instanceDirectory, ct);
// ─── Das Urteil ───
/// <summary>
/// Die Lizenz gilt nicht mehr.
///
/// <para><b>Der einzige Fall, in dem der Manager von sich aus Instanzen anhält.</b> Ein
/// Serverausfall gehört ausdrücklich nicht dazu — das SDK trennt beides, und nur ein
/// Urteil kommt hier an. Sonst nähme ein Netzproblem sämtliche Installationen
/// gleichzeitig mit.</para>
///
/// <para>Öffentlich, weil sich das so prüfen lässt, ohne einen Lizenzserver zu
/// betreiben: Der Weg vom Widerruf zum Anhalten ist die Zusage, nicht die Frage, wer
/// ihn ausgelöst hat.</para>
/// </summary>
public async Task HandleLicenseRevokedAsync(string grund)
{
LicenseHolds = false;
_logger.LogError("Lizenz gilt nicht mehr: {Grund}", grund);
await Instances.StopAllAsync($"Lizenz gilt nicht mehr: {grund}").ConfigureAwait(false);
}
public async ValueTask DisposeAsync()
{
if (_watch is not null)
{
_watch.Revoked -= HandleLicenseRevokedAsync;
await _watch.DisposeAsync().ConfigureAwait(false);
}
// Die Instanzen laufen weiter (E-M6).
await Instances.DisposeAsync().ConfigureAwait(false);
}
}