Autostart durch Manager, mit Sicherungsautomat (#528)

Entschieden vom Benutzer am 2026-09-08. Der Schalter heisst
autostartByManager und steht in InstanceSettings.json - bei der Instanz,
nicht beim Manager.

ZWEI DINGE IN EINEM SCHALTER, weil es dieselbe Zusage ist: Der Manager
startet diese Instanz, wenn er selbst hochkommt, und er startet sie wieder,
wenn sie abstuerzt. Ohne den Schalter ruehrt er nichts an - ein Manager,
der nach einem Neustart der Maschine alles hochfaehrt, was er findet,
startet auch das, was jemand mit Absicht angehalten hat. Ausgeschaltet
vorbelegt.

DER SICHERUNGSAUTOMAT ist der eigentliche Inhalt. Wieder hochzufahren ist
die einfache Haelfte; die schwierige ist zu erkennen, wann man es lassen
muss. Nach drei Neustarts in zehn Minuten (maxRestartsInWindow,
restartWindowMinutes) setzt der Manager aus und meldet es. Die Instanz
bleibt dann aus, bis jemand sie von Hand startet - und dieser Start raeumt
den Zaehler ab, denn wer von Hand startet, hat entweder etwas geaendert
oder weiss, was er tut. Das Zeitfenster wandert: Eine Instanz, die einmal
im Monat abstuerzt und wieder hochkommt, laeuft nie in die Grenze.

Nur bei einem Absturz, nicht wenn eine Instanz verstummt. Eine stumme
Instanz lebt noch; sie neu zu starten hiesse, sie erst abzuschiessen, und
ob der Manager das von sich aus darf, ist nicht entschieden. Als offene
Frage aufgeschrieben - genau dieser Zustand war der Anlass des Vorhabens.

Der Schalter steht bei der Instanz, weil er eine Eigenschaft dieser Instanz
ist und weil die Instanzdatei dem Instanzprozess gehoert: Sie vom Manager
schreiben zu lassen, waehrend die Instanz laeuft, waeren zwei Schreiber auf
einer Datei. Der Manager liest ihn nur; geaendert wird er auf der
Einstellungsseite der Instanz.

EIN DEFEKT, den erst diese Tests gezeigt haben: StartAsync gab "laeuft
schon" zurueck, wenn die Aufsicht die Instanz fuer laufend hielt - ohne
nachzusehen. Zwischen zwei Aufsichtsdurchgaengen liegen aber Sekunden, und
in denen kann sie wegbrechen. Wer in dieser Luecke "starten" verlangte,
bekam einen Erfolg, hinter dem nichts stand, und der naechste Durchgang
meldete einen Absturz, den niemand mehr erwartete. Jetzt wird gefragt,
bevor zugestimmt wird.

UND EIN TESTDOUBLE, das die Wirklichkeit nicht abbildete: Der falsche
Socket antwortete unabhaengig davon, ob ein Prozess lief. Damit war jeder
Testaufbau, der die Antwort vor dem Hochfahren hinterlegte, stillschweigend
ein anderer Test als beabsichtigt - eine Antwort sagt dem Manager "laeuft
bereits", er uebernimmt, und der Starter wird nie angefasst. Dreimal
passiert. Der falsche Socket haengt jetzt am falschen Starter.

Sicherungen im Manager sind als #530 zurueckgestellt, mit Ausloeser.
Vorab entschieden ist dort nur eines: Zurueckspielen ausschliesslich bei
inaktiver Instanz.

19 neue Tests. Build ohne Warnungen, 793 Tests gruen, 8 uebersprungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-09 00:01:52 +02:00
co-authored by Claude Opus 5
parent db7abf699f
commit 7d4e8b3096
15 changed files with 757 additions and 30 deletions
+107 -5
View File
@@ -45,10 +45,15 @@ public enum StartRefusal
/// 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>Was er nicht tut:</b> Instanzen von selbst starten (das täte nach einem
/// Neustart der Maschine auch das, was jemand mit Absicht angehalten hat), Instanzen beim
/// eigenen Ende beenden (E-M6: wer den Manager schließt, will den Manager schließen), und
/// abgestürzte Instanzen wieder hochfahren — dazu ist keine Entscheidung getroffen.</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
{
@@ -57,6 +62,7 @@ public sealed class ManagerCore : IAsyncDisposable
private readonly SupervisionReporter? _reporter;
private readonly LicenseGate? _license;
private readonly LicenseWatch? _watch;
private readonly RestartLedger _restarts;
private readonly ILogger _logger;
/// <summary>
@@ -75,7 +81,9 @@ public sealed class ManagerCore : IAsyncDisposable
ILoggerFactory loggerFactory,
SupervisionReporter? reporter = null,
LicenseGate? license = null,
LicenseWatch? watch = null)
LicenseWatch? watch = null,
RestartPolicy? restartPolicy = null,
TimeProvider? clock = null)
{
_settings = settings;
Instances = instances;
@@ -84,6 +92,7 @@ public sealed class ManagerCore : IAsyncDisposable
_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
@@ -92,6 +101,9 @@ public sealed class ManagerCore : IAsyncDisposable
{
if (_reporter is not null)
_ = _reporter.ReportAsync(ereignis);
if (ereignis.Kind == InstanceEventKind.Crashed)
_ = RestartAfterCrashAsync(ereignis);
};
if (_watch is not null)
@@ -140,9 +152,99 @@ public sealed class ManagerCore : IAsyncDisposable
"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>