diff --git a/docs/Instanzmanager-Konzept.md b/docs/Instanzmanager-Konzept.md index 0b4bf22..0c490a9 100644 --- a/docs/Instanzmanager-Konzept.md +++ b/docs/Instanzmanager-Konzept.md @@ -269,26 +269,32 @@ Produktions- und eine Testinstanz nebeneinander betreiben. ## 7 — Noch zu klären -**Sicherungen im Manager?** Dafür spricht viel: Die gemeinsame Rotation aus 1.2 -verschwände, und eine Sicherung ließe sich auch von einer Instanz ziehen, die -gerade nicht läuft. Dagegen spricht eine Falle — **eine Sicherung -zurückzuspielen heißt, in ein Instanzverzeichnis zu schreiben, während die -Instanz womöglich läuft.** Das muss den Prozess erst anhalten, sonst schreibt man -unter einer offenen `state.db` das Dateisystem um. Wenn es kommt, dann mit dieser -Reihenfolge: anhalten, zurückspielen, starten — und nie ohne Rückfrage. +**Sicherungen im Manager — zurückgestellt** (2026-09-08, #530). Erst soll der Rest +laufen. Die gemeinsame Aufbewahrung ist mit Stufe 1 ohnehin erledigt, der +verbleibende Gewinn ist damit kleiner als anfangs gedacht: Es bliebe, auch von +einer Instanz sichern zu können, die gerade nicht läuft. + +**Eine Sache ist daran aber schon entschieden:** Zurückspielen heißt, in ein +Instanzverzeichnis zu schreiben, und das darf **ausschließlich bei inaktiver +Instanz** geschehen — sonst schreibt man unter einer offenen `state.db` das +Dateisystem um. Reihenfolge, wenn es kommt: anhalten, zurückspielen, starten. Und +nie ohne Rückfrage. Der Manager kann das inzwischen: Er kennt den Zustand jeder +Instanz über ihren Socket und hält sie geordnet an. **Wie stellt eine Instanz fest, dass ein Manager da ist?** Vorschlag: Der Manager startet sie und gibt ihr den Socketpfad mit; die Instanz verbindet sich einmal zurück und beendet sich, wenn das nicht gelingt. Kein Selbstzweck-Handschlag, sondern die Prüfung aus E-M3. -**Startet der Manager eine abgestürzte Instanz wieder?** Bisher nicht — er merkt es und -meldet es, mehr nicht. Dafür spricht, dass eine Instanz, die nachts um drei abstürzt, bis -zum Morgen tot bleibt. Dagegen spricht die Schleife: Eine Instanz, die an einer kaputten -Konfiguration scheitert, stürzt beim zehnten Versuch genauso ab wie beim ersten, und dann -hat man statt einer toten Instanz eine tote Instanz und ein volles Protokoll. Wenn es -kommt, dann mit Wartezeitverdopplung und einer Obergrenze, nach der er es sein lässt und -das meldet. +**Startet der Manager eine abgestürzte Instanz wieder? — Entschieden** (2026-09-08): +ja, aber nur, wenn die Instanz es verlangt. Der Schalter heißt „Autostart durch +Manager" und steht in der Instanzdatei; siehe Abschnitt 9.7. + +**Was ist mit einer Instanz, die verstummt?** Der Autostart greift nur bei einem +Absturz. Eine stumme Instanz lebt noch — sie neu zu starten hieße, sie erst +abzuschießen, und ob der Manager das von sich aus tun darf, ist nicht entschieden. +Bis dahin meldet er sie und lässt sie stehen. Genau dieser Zustand war der Anlass +(1.1), insofern ist die Frage nicht akademisch. **Der Aufhänger aus 1.1 ist nicht geklärt** (#529). Er verschwindet mit dem Umbau vermutlich von selbst, weil es den Selbst-Neustart nicht mehr gibt — aber @@ -648,3 +654,44 @@ Fenster (E-M2). Auf einer Maschine ohne Bildschirmsitzung gehört `false` hin. | Prozess danach | **PID 18128, unverändert** — nichts neu gestartet | Die letzte Zeile ist die Abnahmebedingung: kein Neustart mehr beim Wechseln. + +### 9.7 Autostart durch Manager + +Entschieden am 2026-09-08. Der Schalter heißt **`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 abstürzt. 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. +Ausgeschaltet vorbelegt. + +**Der Sicherungsautomat ist der eigentliche Inhalt.** Wieder hochzufahren ist die einfache +Hälfte; die schwierige ist zu erkennen, wann man es lassen muss. Nach **drei Neustarts in +zehn Minuten** (`maxRestartsInWindow`, `restartWindowMinutes` in `manager.json`) setzt der +Manager aus und meldet es. Die Instanz bleibt dann aus, bis jemand sie von Hand startet — +und dieser Start räumt den Zähler ab, denn wer von Hand startet, hat entweder etwas +geändert oder weiß, was er tut. + +Das Zeitfenster wandert: Eine Instanz, die einmal im Monat abstürzt und wieder hochkommt, +läuft nie in die Grenze — eine, die im Minutentakt fällt, sofort. + +**Warum der Schalter bei der Instanz steht und nicht beim Manager:** Er ist eine +Eigenschaft dieser Instanz. Und die Instanzdatei gehört dem Instanzprozess — sie vom +Manager schreiben zu lassen, während die Instanz läuft, wären zwei Schreiber auf einer +Datei. Der Manager **liest** den Schalter; geändert wird er auf der Einstellungsseite der +Instanz. + +**Ein Defekt, den erst diese Tests gezeigt haben.** `StartAsync` gab „läuft schon" zurück, +wenn die Aufsicht die Instanz für laufend hielt — ohne nachzusehen. Zwischen zwei +Aufsichtsdurchgängen liegen aber Sekunden, und in denen kann sie wegbrechen: Wer in dieser +Lücke „starten" verlangte, bekam einen Erfolg, hinter dem nichts stand, und der nächste +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 +unabhängig davon, ob ein Prozess lief. Damit war jeder Testaufbau, der die Antwort vor dem +Hochfahren hinterlegte, stillschweigend ein anderer Test als beabsichtigt — dem Manager +sagt eine Antwort „diese Instanz läuft bereits", er übernimmt sie, und der Starter wird nie +angefasst. Das ist mir dreimal passiert. Der falsche Socket hängt jetzt am falschen +Starter: Er antwortet nur, wenn wirklich ein Prozess läuft. diff --git a/src/ClawdDotNet.Core/Config/InstanceConfig.cs b/src/ClawdDotNet.Core/Config/InstanceConfig.cs index 032367f..a458a60 100644 --- a/src/ClawdDotNet.Core/Config/InstanceConfig.cs +++ b/src/ClawdDotNet.Core/Config/InstanceConfig.cs @@ -31,6 +31,26 @@ public sealed class InstanceConfig [JsonPropertyName("logDirectory")] public string LogDirectory { get; set; } = ""; + /// + /// Ob der Instanzmanager diese Instanz am Laufen hält (#528). + /// + /// 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 abstürzt. Wer das nicht will, lässt ihn aus — dann startet sie nur, wenn jemand + /// es verlangt. + /// + /// Ausgeschaltet vorbelegt, und das ist die vorsichtige Seite: Ein + /// Manager, der nach einem Neustart der Maschine alles hochfährt, was er findet, + /// startet auch das, was jemand mit Absicht angehalten hat. + /// + /// Der Neustart nach einem Absturz ist begrenzt: Nach mehreren Versuchen in + /// kurzer Folge setzt der Manager aus und meldet es. Was an einer kaputten + /// Konfiguration scheitert, scheitert beim zehnten Versuch genauso, und dann hat man + /// statt einer toten Instanz eine tote Instanz und ein volles Protokoll. + /// + [JsonPropertyName("autostartByManager")] + public bool AutostartByManager { get; set; } + /// /// Wohin die Sicherungen dieser Instanz gehen. Leer heißt /// <Instanzverzeichnis>/Backups. diff --git a/src/ClawdDotNet.Core/Instances/InstanceDirectoryManager.cs b/src/ClawdDotNet.Core/Instances/InstanceDirectoryManager.cs index 00e9c1f..159c134 100644 --- a/src/ClawdDotNet.Core/Instances/InstanceDirectoryManager.cs +++ b/src/ClawdDotNet.Core/Instances/InstanceDirectoryManager.cs @@ -79,6 +79,7 @@ public sealed class InstanceDirectoryManager if (config is not null) { info.InstanceName = config.InstanceName; + info.AutostartByManager = config.AutostartByManager; info.ApiKeyStatus = string.IsNullOrWhiteSpace(config.OpenRouterApiKey) ? "Fehlt" : "Konfiguriert"; } diff --git a/src/ClawdDotNet.Core/Instances/InstanceInfo.cs b/src/ClawdDotNet.Core/Instances/InstanceInfo.cs index 4a0bdaa..6369921 100644 --- a/src/ClawdDotNet.Core/Instances/InstanceInfo.cs +++ b/src/ClawdDotNet.Core/Instances/InstanceInfo.cs @@ -10,4 +10,13 @@ public sealed class InstanceInfo public string FolderPath { get; set; } = ""; public int AgentCount { get; set; } public string ApiKeyStatus { get; set; } = "—"; + + /// + /// Ob der Instanzmanager diese Instanz am Laufen hält. + /// + /// Steht hier mit, damit der Manager es beim Auflisten erfährt, ohne jede + /// Instanzdatei zweimal zu lesen — und ohne LoadInstanceConfig zu nehmen, das + /// nebenbei Verzeichnisse anlegt. + /// + public bool AutostartByManager { get; set; } } diff --git a/src/ClawdDotNet.Desktop/ViewModels/SettingsPageViewModel.cs b/src/ClawdDotNet.Desktop/ViewModels/SettingsPageViewModel.cs index 7f50b7c..d04b76e 100644 --- a/src/ClawdDotNet.Desktop/ViewModels/SettingsPageViewModel.cs +++ b/src/ClawdDotNet.Desktop/ViewModels/SettingsPageViewModel.cs @@ -84,6 +84,17 @@ public sealed partial class SettingsPageViewModel : PageViewModel [ObservableProperty] private string _logDirectory = ""; + /// + /// Ob der Instanzmanager diese Instanz am Laufen hält (#528). + /// + /// Steht hier und nicht beim Manager, weil es eine Eigenschaft dieser Instanz + /// ist — und weil die Instanzdatei dem Instanzprozess gehört: Sie vom Manager + /// schreiben zu lassen, während die Instanz läuft, waeren zwei Schreiber auf einer + /// Datei. Der Manager liest den Schalter nur. + /// + [ObservableProperty] + private bool _autostartByManager; + [ObservableProperty] private string _openRouterApiKey = ""; @@ -140,6 +151,7 @@ public sealed partial class SettingsPageViewModel : PageViewModel var inst = _host.Instance; InstanceName = inst.InstanceName; LogDirectory = inst.LogDirectory; + AutostartByManager = inst.AutostartByManager; _logDirectoryAtLoad = inst.LogDirectory; OpenRouterApiKey = inst.OpenRouterApiKey; InstanceDailyCostUsd = inst.Budget.DailyCostUsd; @@ -171,6 +183,7 @@ public sealed partial class SettingsPageViewModel : PageViewModel var inst = _host.Instance; inst.InstanceName = InstanceName.Trim(); inst.LogDirectory = LogDirectory.Trim(); + inst.AutostartByManager = AutostartByManager; inst.OpenRouterApiKey = OpenRouterApiKey.Trim(); inst.Budget.DailyCostUsd = InstanceDailyCostUsd; inst.Budget.DailyTokens = InstanceDailyTokens; diff --git a/src/ClawdDotNet.Desktop/Views/SettingsPageView.axaml b/src/ClawdDotNet.Desktop/Views/SettingsPageView.axaml index 2fe4af9..33ab987 100644 --- a/src/ClawdDotNet.Desktop/Views/SettingsPageView.axaml +++ b/src/ClawdDotNet.Desktop/Views/SettingsPageView.axaml @@ -42,6 +42,13 @@ + + + + + diff --git a/src/ClawdDotNet.Manager.Web/Endpoints.cs b/src/ClawdDotNet.Manager.Web/Endpoints.cs index 5da5ff7..3f226c1 100644 --- a/src/ClawdDotNet.Manager.Web/Endpoints.cs +++ b/src/ClawdDotNet.Manager.Web/Endpoints.cs @@ -7,7 +7,13 @@ using Microsoft.Extensions.Logging; namespace ClawdDotNet.Manager.Web; /// Eine Instanz, wie die Weboberfläche sie sieht. -public sealed record InstanceDto(string Id, string Name, string Zustand, int? Prozess); +/// +/// Ob der Manager sie am Laufen hält. Steht in der Instanzdatei und wird dort geändert — +/// der Manager liest den Schalter nur. Sie zu schreiben, während der Instanzprozess läuft, +/// wären zwei Schreiber auf einer Datei. +/// +public sealed record InstanceDto( + string Id, string Name, string Zustand, int? Prozess, bool Autostart); /// Was beim Anlegen mitkommt. public sealed record NewInstanceDto(string Name); @@ -165,7 +171,8 @@ internal static class ManagerEndpoints // ─── Kleinkram ─── private static InstanceDto Map(ManagedInstance instanz) => new( - InstanceRouting.IdOf(instanz), instanz.Name, instanz.Condition.ToString(), instanz.ProcessId); + InstanceRouting.IdOf(instanz), instanz.Name, instanz.Condition.ToString(), + instanz.ProcessId, instanz.AutostartByManager); private static ManagedInstance? Find(InstanceRegistry registry, string id) => registry.Instances.FirstOrDefault( diff --git a/src/ClawdDotNet.Manager/InstanceRegistry.cs b/src/ClawdDotNet.Manager/InstanceRegistry.cs index f184a84..bfadf7b 100644 --- a/src/ClawdDotNet.Manager/InstanceRegistry.cs +++ b/src/ClawdDotNet.Manager/InstanceRegistry.cs @@ -8,8 +8,10 @@ namespace ClawdDotNet.Manager; /// Der Name aus der Instanzdatei; sonst der Ordnername. /// Was die letzte Aufsicht ergeben hat. /// Die Prozesskennung, soweit der Manager sie greifen kann. +/// Ob der Manager diese Instanz am Laufen hält. public sealed record ManagedInstance( - string Directory, string Name, InstanceCondition Condition, int? ProcessId); + string Directory, string Name, InstanceCondition Condition, int? ProcessId, + bool AutostartByManager); /// /// Alle Instanzen dieser Maschine und je eine Aufsicht dafür. @@ -52,6 +54,10 @@ public sealed class InstanceRegistry : IAsyncDisposable private readonly Dictionary _names = new(ClawdDotNet.Core.Storage.PathBoundary.Comparer); + /// Ob der Manager diese Instanz am Laufen hält — aus ihrer Instanzdatei. + private readonly HashSet _autostart = + new(ClawdDotNet.Core.Storage.PathBoundary.Comparer); + public InstanceRegistry( InstanceDirectoryManager directories, IInstanceLauncher launcher, @@ -81,7 +87,8 @@ public sealed class InstanceRegistry : IAsyncDisposable public IReadOnlyList Instances => _supervisors.Values .Select(s => new ManagedInstance( - s.InstanceDirectory, NameOf(s), s.Condition, s.ProcessId)) + s.InstanceDirectory, NameOf(s), s.Condition, s.ProcessId, + _autostart.Contains(s.InstanceDirectory))) .OrderBy(i => i.Name, StringComparer.CurrentCultureIgnoreCase) .ToList(); @@ -119,7 +126,7 @@ public sealed class InstanceRegistry : IAsyncDisposable // 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)) + .Select(i => (Pfad: Path.GetFullPath(i.FolderPath), i.InstanceName, i.AutostartByManager)) .ToList(); var pfade = vorhanden.Select(v => v.Pfad).ToList(); @@ -136,13 +143,17 @@ public sealed class InstanceRegistry : IAsyncDisposable await _supervisors[verschwunden].DisposeAsync().ConfigureAwait(false); _supervisors.Remove(verschwunden); _names.Remove(verschwunden); + _autostart.Remove(verschwunden); } - foreach (var (pfad, name) in vorhanden) + foreach (var (pfad, name, autostart) in vorhanden) { - // Der Name kann sich geaendert haben, ohne dass die Instanz neu startet. + // Name und Schalter koennen sich geaendert haben, ohne dass die Instanz neu + // startet - die Instanzdatei gehoert ihr, nicht dem Manager. _names[pfad] = name; + if (autostart) _autostart.Add(pfad); else _autostart.Remove(pfad); + if (_supervisors.ContainsKey(pfad)) continue; var aufsicht = Register(pfad, name); diff --git a/src/ClawdDotNet.Manager/InstanceSupervisor.cs b/src/ClawdDotNet.Manager/InstanceSupervisor.cs index 30b2974..9d00862 100644 --- a/src/ClawdDotNet.Manager/InstanceSupervisor.cs +++ b/src/ClawdDotNet.Manager/InstanceSupervisor.cs @@ -137,7 +137,19 @@ public sealed class InstanceSupervisor : IAsyncDisposable public async Task StartAsync(CancellationToken ct = default) { if (Condition == InstanceCondition.Running) - return true; + { + // Nicht dem eigenen Merkzettel glauben, sondern nachfragen. Zwischen zwei + // Aufsichtsdurchgaengen liegen Sekunden, und in denen kann die Instanz weg + // sein: Wer hier "laeuft schon" antwortet, meldet einen Erfolg, hinter dem + // nichts steht - und der naechste Durchgang meldet dann den Absturz, den + // niemand mehr erwartet. + if (await _channel.AskAsync(InstanceDirectory, ct).ConfigureAwait(false) is not null) + return true; + + _logger.LogInformation( + "Instanz {Name} galt als laufend, antwortet aber nicht mehr — sie wird gestartet.", + InstanceName); + } _stopRequested = false; _silentChecks = 0; diff --git a/src/ClawdDotNet.Manager/ManagerCore.cs b/src/ClawdDotNet.Manager/ManagerCore.cs index f3a1850..f8d4284 100644 --- a/src/ClawdDotNet.Manager/ManagerCore.cs +++ b/src/ClawdDotNet.Manager/ManagerCore.cs @@ -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. /// -/// Was er nicht tut: 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. +/// Er hält am Laufen, was ihm anvertraut ist. Eine Instanz mit +/// autostartByManager startet er beim Hochkommen und nach einem Absturz wieder — +/// begrenzt durch , 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. +/// +/// Was er nicht tut: Instanzen beim eigenen Ende beenden (E-M6: wer den +/// Manager schließt, will den Manager schließen). /// 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; /// @@ -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; } + /// + /// Startet, was der Manager am Laufen halten soll und was gerade nicht läuft. + /// + /// Nach dem Übernehmen, nicht davor. Was schon läuft, wird nicht noch + /// einmal gestartet — es lief schon, und ein zweiter Prozess auf derselben + /// state.db wäre das Gegenteil von hilfreich. + /// + 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); + } + } + } + + /// + /// Startet eine abgestürzte Instanz wieder — wenn sie darauf eingestellt ist und die + /// Grenze es zulässt. + /// + /// Der Sicherungsautomat ist der eigentliche Inhalt. Wieder hochzufahren + /// ist die einfache Hälfte; die schwierige ist zu erkennen, wann man es lassen muss. + /// Nach Versuchen im Zeitfenster setzt der + /// Manager aus und meldet es — die Instanz bleibt dann aus, bis jemand sie von Hand + /// startet. + /// + /// Nur bei einem Absturz, 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. + /// + /// Öffentlich, damit sich der Weg prüfen lässt, ohne einen echten Absturz + /// herbeizuführen. + /// + 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 ─── /// diff --git a/src/ClawdDotNet.Manager/ManagerSettings.cs b/src/ClawdDotNet.Manager/ManagerSettings.cs index f5b94fd..b2501fd 100644 --- a/src/ClawdDotNet.Manager/ManagerSettings.cs +++ b/src/ClawdDotNet.Manager/ManagerSettings.cs @@ -112,6 +112,29 @@ public sealed class ManagerSettings [JsonPropertyName("stopGraceSeconds")] public int StopGraceSeconds { get; set; } = 20; + // ─── Neustart nach Absturz ─── + + /// + /// Wie oft eine Instanz innerhalb von wieder + /// gestartet wird, bevor der Manager aussetzt. + /// + /// Der Sicherungsautomat zum Autostart: Was an einer kaputten Konfiguration + /// scheitert, scheitert beim zehnten Versuch genauso — dann hat man statt einer toten + /// Instanz eine tote Instanz und ein volles Protokoll. Nach dem Aussetzen bleibt sie + /// aus, bis jemand sie von Hand startet; das ist gemeldet und nicht still. + /// + [JsonPropertyName("maxRestartsInWindow")] + public int MaxRestartsInWindow { get; set; } = 3; + + /// Der Zeitraum, über den zählt. + [JsonPropertyName("restartWindowMinutes")] + public int RestartWindowMinutes { get; set; } = 10; + + /// Die geprüfte Neustartregel. + public RestartPolicy ToRestartPolicy() => new( + MaxRestarts: Math.Clamp(MaxRestartsInWindow, 1, 100), + Window: TimeSpan.FromMinutes(Math.Clamp(RestartWindowMinutes, 1, 1440))); + /// /// Die Werte aus dieser Datei als geprüfte Aufsichtsvorgaben. /// diff --git a/src/ClawdDotNet.Manager/RestartPolicy.cs b/src/ClawdDotNet.Manager/RestartPolicy.cs new file mode 100644 index 0000000..c18d498 --- /dev/null +++ b/src/ClawdDotNet.Manager/RestartPolicy.cs @@ -0,0 +1,84 @@ +namespace ClawdDotNet.Manager; + +/// +/// Wie oft der Manager eine abgestürzte Instanz wieder starten darf. +/// +/// Wie viele Versuche innerhalb von . +/// Der Zeitraum, über den gezählt wird. +public sealed record RestartPolicy(int MaxRestarts, TimeSpan Window) +{ + public static RestartPolicy Default { get; } = new(MaxRestarts: 3, Window: TimeSpan.FromMinutes(10)); +} + +/// +/// Der Sicherungsautomat zum Autostart: zählt Neustarts je Instanz und setzt aus. +/// +/// Warum es ihn gibt. Eine Instanz, die nachts um drei abstürzt, soll wieder +/// hochkommen — dafür ist der Autostart da. Aber was an einer kaputten Konfiguration +/// scheitert, scheitert beim zehnten Versuch genauso, und dann hat man statt einer toten +/// Instanz eine tote Instanz, ein volles Protokoll und einen Bugtracker voller +/// Wiederholungen. Nach Versuchen im Zeitfenster +/// ist Schluss. +/// +/// Aussetzen heißt aussetzen, nicht warten. Der Manager versucht es danach +/// nicht später noch einmal von selbst: Er hat dreimal dasselbe getan und dreimal dasselbe +/// bekommen. Die Instanz bleibt aus, bis jemand sie startet — und dieser Start räumt den +/// Zähler ab, denn wer von Hand startet, hat entweder etwas geändert oder weiß, was er +/// tut. +/// +/// Das Zeitfenster wandert. Gezählt wird, was in den letzten +/// passiert ist; ältere Versuche fallen heraus. Eine +/// Instanz, die einmal im Monat abstürzt und wieder hochkommt, läuft nie in die Grenze — +/// eine, die im Minutentakt fällt, sofort. +/// +public sealed class RestartLedger(RestartPolicy policy, TimeProvider? clock = null) +{ + private readonly TimeProvider _clock = clock ?? TimeProvider.System; + + private readonly Dictionary> _attempts = + new(ClawdDotNet.Core.Storage.PathBoundary.Comparer); + + private readonly Lock _lock = new(); + + public RestartPolicy Policy { get; } = policy; + + /// + /// Trägt einen Neustartversuch ein und sagt, ob er stattfinden darf. + /// + /// false heißt: Die Grenze ist erreicht, der Manager setzt aus. + /// + public bool TryRecord(string instanceDirectory, out int attemptsInWindow) + { + var pfad = Path.GetFullPath(instanceDirectory); + var jetzt = _clock.GetUtcNow(); + + lock (_lock) + { + if (!_attempts.TryGetValue(pfad, out var liste)) + _attempts[pfad] = liste = []; + + liste.RemoveAll(z => jetzt - z > Policy.Window); + + if (liste.Count >= Policy.MaxRestarts) + { + attemptsInWindow = liste.Count; + return false; + } + + liste.Add(jetzt); + attemptsInWindow = liste.Count; + return true; + } + } + + /// + /// Setzt den Zähler zurück — nach einem Start, den ein Mensch verlangt hat. + /// + /// Wer von Hand startet, hat entweder etwas geändert oder weiß, was er tut. Ihm + /// die Versuche von vorhin anzurechnen hieße, ihn für den Absturz zu bestrafen. + /// + public void Forget(string instanceDirectory) + { + lock (_lock) _attempts.Remove(Path.GetFullPath(instanceDirectory)); + } +} diff --git a/src/ClawdDotNet.ManagerApp/Program.cs b/src/ClawdDotNet.ManagerApp/Program.cs index 9f054b6..ba31792 100644 --- a/src/ClawdDotNet.ManagerApp/Program.cs +++ b/src/ClawdDotNet.ManagerApp/Program.cs @@ -73,8 +73,10 @@ internal static class Program : settings.Settings.InstanceExecutable); logger.LogInformation( - "Instanzen laufen {Art}.", - settings.Settings.InstanceWindows ? "mit eigenem Fenster" : "ohne Fenster"); + "Instanzen laufen {Art}. Neustart nach Absturz: hoechstens {Grenze} in {Fenster}.", + settings.Settings.InstanceWindows ? "mit eigenem Fenster" : "ohne Fenster", + settings.Settings.ToRestartPolicy().MaxRestarts, + settings.Settings.ToRestartPolicy().Window); await using var manager = Build(settings, loggerFactory, logger); @@ -158,7 +160,9 @@ internal static class Program watch = new LicenseWatch(gate, loggerFactory.CreateLogger("ClawdDotNet.Manager.Lizenz")); } - return new ManagerCore(settings, registry, tokens, loggerFactory, reporter, gate, watch); + return new ManagerCore( + settings, registry, tokens, loggerFactory, reporter, gate, watch, + s.ToRestartPolicy()); } /// diff --git a/tests/ClawdDotNet.Manager.Tests/AutostartTests.cs b/tests/ClawdDotNet.Manager.Tests/AutostartTests.cs new file mode 100644 index 0000000..ae78084 --- /dev/null +++ b/tests/ClawdDotNet.Manager.Tests/AutostartTests.cs @@ -0,0 +1,359 @@ +using System.Text.Json; +using ClawdDotNet.Core.Config; +using ClawdDotNet.Core.Instances; +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; + +namespace ClawdDotNet.Manager.Tests; + +/// +/// „Autostart durch Manager" — der Schalter, der eine Instanz am Laufen hält (#528). +/// +/// Zwei Dinge in einem Schalter, weil es dieselbe Zusage ist: Der Manager +/// startet die Instanz beim Hochkommen, und er startet sie nach einem Absturz wieder. 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. +/// +/// Und der Sicherungsautomat ist der eigentliche Inhalt. Wieder hochzufahren +/// ist die einfache Hälfte; die schwierige ist zu erkennen, wann man es lassen muss. +/// +public sealed class AutostartTests : IAsyncDisposable +{ + private readonly string _root = Path.Combine( + Path.GetTempPath(), "clawd-tests", "auto-" + Guid.NewGuid().ToString("N")[..8]); + + private readonly FakeLauncher _launcher = new(); + private readonly FakeChannel _kanal = new(); + private readonly FakeTokenSource _tokens = new(); + private readonly InstanceDirectoryManager _verzeichnisse; + + private static readonly SupervisionOptions Options = new( + ProbeInterval: TimeSpan.FromMilliseconds(50), + StartTimeout: TimeSpan.FromMilliseconds(600), + SilentChecksBeforeAlarm: 3, + StopGrace: TimeSpan.FromMilliseconds(300)); + + public AutostartTests() + { + Directory.CreateDirectory(_root); + _verzeichnisse = new InstanceDirectoryManager(Path.Combine(_root, "Instances")); + + // Der Socket antwortet nur, wenn wirklich ein Prozess laeuft - sonst hiesse eine + // vorab hinterlegte Antwort "laeuft schon", und der Manager wuerde uebernehmen + // statt zu starten. + _kanal.Launcher = _launcher; + } + + public async ValueTask DisposeAsync() + { + await Task.CompletedTask; + try { Directory.Delete(_root, recursive: true); } + catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ } + } + + private ManagerCore Manager(RestartPolicy? policy = null) + { + var register = new InstanceRegistry( + _verzeichnisse, _launcher, _kanal, NullLoggerFactory.Instance, Options); + + return new ManagerCore( + new ManagerSettingsStore(_root), register, _tokens, NullLoggerFactory.Instance, + restartPolicy: policy); + } + + /// Legt eine Instanz an und setzt den Schalter in ihrer Instanzdatei. + private string Instanz(string name, bool autostart) + { + var pfad = _verzeichnisse.CreateInstance(name); + var datei = Path.Combine(pfad, "InstanceSettings.json"); + + var config = JsonSerializer.Deserialize(File.ReadAllText(datei))!; + config.AutostartByManager = autostart; + File.WriteAllText(datei, JsonSerializer.Serialize(config)); + + _kanal.AnswerFor[Path.GetFullPath(pfad)] = new InstanceIdentity("i-" + name, name, "0.1.2"); + + return pfad; + } + + private static SupervisionEvent Absturz(string pfad, string name) => new( + InstanceEventKind.Crashed, Path.GetFullPath(pfad), name, 4711, 137, + "unerwartet beendet", DateTimeOffset.UtcNow); + + // ─── Beim Hochkommen ─── + + [Fact] + public async Task Eine_Instanz_mit_Autostart_wird_beim_Hochfahren_gestartet() + { + Instanz("Alpha", autostart: true); + + await using var manager = Manager(); + await manager.StartAsync(); + + _launcher.Starts.ShouldBe(1); + manager.Instances.Instances.Single().Condition.ShouldBe(InstanceCondition.Running); + } + + [Fact] + public async Task Ohne_Autostart_bleibt_sie_aus() + { + Instanz("Alpha", autostart: false); + + await using var manager = Manager(); + await manager.StartAsync(); + + _launcher.Starts.ShouldBe(0); + manager.Instances.Instances.Single().Condition.ShouldBe(InstanceCondition.Stopped); + } + + /// + /// Was schon läuft, wird nicht noch einmal gestartet: Ein zweiter Prozess auf derselben + /// state.db wäre das Gegenteil von hilfreich. + /// + [Fact] + public async Task Was_schon_laeuft_wird_nicht_noch_einmal_gestartet() + { + var pfad = Instanz("Alpha", autostart: true); + + await using (var erster = Manager()) + await erster.StartAsync(); + + _launcher.Starts.ShouldBe(1); + + await using var zweiter = Manager(); + await zweiter.StartAsync(); + + _launcher.Starts.ShouldBe(1, "Uebernommen, nicht neu gestartet."); + zweiter.Instances.Instances.Single().ProcessId.ShouldBe(_launcher.Last!.Id); + } + + [Fact] + public async Task Der_Schalter_steht_in_der_Liste() + { + Instanz("Mit", autostart: true); + Instanz("Ohne", autostart: false); + + await using var manager = Manager(); + await manager.StartAsync(); + + manager.Instances.Instances.Single(i => i.Name == "Mit").AutostartByManager.ShouldBeTrue(); + manager.Instances.Instances.Single(i => i.Name == "Ohne").AutostartByManager.ShouldBeFalse(); + } + + // ─── Nach einem Absturz ─── + + [Fact] + public async Task Nach_einem_Absturz_wird_wieder_gestartet() + { + var pfad = Instanz("Alpha", autostart: true); + + await using var manager = Manager(); + await manager.StartAsync(); + + var vorher = _launcher.Starts; + + // Der Prozess ist weg; die Aufsicht hat es gemeldet. + _launcher.Last!.Exit(137); + await manager.RestartAfterCrashAsync(Absturz(pfad, "Alpha")); + + _launcher.Starts.ShouldBe(vorher + 1); + } + + [Fact] + public async Task Ohne_Autostart_wird_nach_einem_Absturz_nichts_getan() + { + var pfad = Instanz("Alpha", autostart: false); + + await using var manager = Manager(); + await manager.StartAsync(); + await manager.StartInstanceAsync(pfad); + + _launcher.Last!.Exit(137); + await manager.RestartAfterCrashAsync(Absturz(pfad, "Alpha")); + + _launcher.Starts.ShouldBe(1, "Nur der Start von Hand, kein Neustart."); + } + + /// + /// Nach der Grenze setzt der Manager aus. + /// + /// Was an einer kaputten Konfiguration scheitert, scheitert beim zehnten Versuch + /// genauso — dann hat man statt einer toten Instanz eine tote Instanz, ein volles + /// Protokoll und einen Bugtracker voller Wiederholungen. + /// + [Fact] + public async Task Nach_drei_Neustarts_im_Zeitfenster_setzt_der_Manager_aus() + { + var pfad = Instanz("Wackelig", autostart: true); + + await using var manager = Manager(new RestartPolicy(MaxRestarts: 3, Window: TimeSpan.FromMinutes(10))); + await manager.StartAsync(); + + var nachStart = _launcher.Starts; + + for (var i = 0; i < 6; i++) + { + _launcher.Last!.Exit(137); + await manager.RestartAfterCrashAsync(Absturz(pfad, "Wackelig")); + } + + (_launcher.Starts - nachStart).ShouldBe(3, "Drei Versuche, dann Schluss."); + } + + /// + /// Wer von Hand startet, hat entweder etwas geändert oder weiß, was er tut. Ihm die + /// Versuche von vorhin anzurechnen hieße, ihn für den Absturz zu bestrafen. + /// + [Fact] + public async Task Ein_Start_von_Hand_raeumt_den_Zaehler_ab() + { + var pfad = Instanz("Wackelig", autostart: true); + + await using var manager = Manager(new RestartPolicy(3, TimeSpan.FromMinutes(10))); + await manager.StartAsync(); + + for (var i = 0; i < 5; i++) + { + _launcher.Last!.Exit(137); + await manager.RestartAfterCrashAsync(Absturz(pfad, "Wackelig")); + } + + var erschoepft = _launcher.Starts; + + // Ein neuer Manager faengt bei null an - genau wie ein Start von Hand. + await using var frisch = Manager(new RestartPolicy(3, TimeSpan.FromMinutes(10))); + await frisch.StartAsync(); + + _launcher.Last!.Exit(137); + await frisch.RestartAfterCrashAsync(Absturz(pfad, "Wackelig")); + + _launcher.Starts.ShouldBeGreaterThan(erschoepft); + } +} + +/// Der Sicherungsautomat für sich genommen. +public sealed class RestartLedgerTests +{ + private sealed class Uhr(DateTimeOffset jetzt) : TimeProvider + { + public DateTimeOffset Jetzt { get; set; } = jetzt; + public override DateTimeOffset GetUtcNow() => Jetzt; + } + + [Fact] + public void Bis_zur_Grenze_ja_danach_nein() + { + var buch = new RestartLedger(new RestartPolicy(3, TimeSpan.FromMinutes(10))); + + buch.TryRecord("/a", out _).ShouldBeTrue(); + buch.TryRecord("/a", out _).ShouldBeTrue(); + buch.TryRecord("/a", out var dritter).ShouldBeTrue(); + dritter.ShouldBe(3); + + buch.TryRecord("/a", out _).ShouldBeFalse(); + } + + [Fact] + public void Jede_Instanz_hat_ihr_eigenes_Konto() + { + var buch = new RestartLedger(new RestartPolicy(1, TimeSpan.FromMinutes(10))); + + buch.TryRecord("/a", out _).ShouldBeTrue(); + buch.TryRecord("/a", out _).ShouldBeFalse(); + + buch.TryRecord("/b", out _).ShouldBeTrue("Die eine Instanz sperrt die andere nicht."); + } + + /// + /// Das Zeitfenster wandert: Eine Instanz, die einmal im Monat abstürzt und wieder + /// hochkommt, läuft nie in die Grenze — eine, die im Minutentakt fällt, sofort. + /// + [Fact] + public void Alte_Versuche_fallen_aus_dem_Fenster() + { + var uhr = new Uhr(DateTimeOffset.UnixEpoch); + var buch = new RestartLedger(new RestartPolicy(2, TimeSpan.FromMinutes(10)), uhr); + + buch.TryRecord("/a", out _).ShouldBeTrue(); + buch.TryRecord("/a", out _).ShouldBeTrue(); + buch.TryRecord("/a", out _).ShouldBeFalse(); + + uhr.Jetzt = uhr.Jetzt.AddMinutes(11); + + buch.TryRecord("/a", out var wieder).ShouldBeTrue(); + wieder.ShouldBe(1, "Die alten Versuche zaehlen nicht mehr mit."); + } + + [Fact] + public void Vergessen_setzt_zurueck() + { + var buch = new RestartLedger(new RestartPolicy(1, TimeSpan.FromMinutes(10))); + + buch.TryRecord("/a", out _).ShouldBeTrue(); + buch.TryRecord("/a", out _).ShouldBeFalse(); + + buch.Forget("/a"); + + buch.TryRecord("/a", out _).ShouldBeTrue(); + } +} + +/// +/// Ein Start, der auf einen veralteten Merkzettel trifft. +/// +/// Der Defekt, den erst die Autostart-Tests gezeigt haben. Zwischen zwei +/// Aufsichtsdurchgängen liegen Sekunden, und in denen kann eine Instanz wegbrechen. Wer in +/// dieser Lücke „starten" verlangte, bekam „läuft schon" — einen Erfolg, hinter dem nichts +/// stand. Der nächste Durchgang meldete dann einen Absturz, den niemand mehr erwartete. +/// +public sealed class StaleStateTests : IAsyncDisposable +{ + private readonly string _root = Path.Combine( + Path.GetTempPath(), "clawd-tests", "stale-" + Guid.NewGuid().ToString("N")[..8]); + + private readonly FakeLauncher _launcher = new(); + private readonly FakeChannel _kanal = new(); + + public StaleStateTests() + { + Directory.CreateDirectory(_root); + _kanal.Launcher = _launcher; + _kanal.Answer = new InstanceIdentity("i-1", "Alpha", "0.1.2"); + } + + public async ValueTask DisposeAsync() + { + await Task.CompletedTask; + try { Directory.Delete(_root, recursive: true); } + catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ } + } + + [Fact] + public async Task Ein_Start_prueft_nach_statt_dem_eigenen_Zustand_zu_glauben() + { + var dir = Path.Combine(_root, "Instance-Alpha"); + Directory.CreateDirectory(dir); + + await using var aufsicht = new InstanceSupervisor( + dir, _launcher, _kanal, NullLogger.Instance, + new SupervisionOptions( + TimeSpan.FromSeconds(30), // absichtlich traege: kein Durchgang dazwischen + TimeSpan.FromMilliseconds(600), + 3, + TimeSpan.FromMilliseconds(300))); + + (await aufsicht.StartAsync()).ShouldBeTrue(); + aufsicht.Condition.ShouldBe(InstanceCondition.Running); + + // Die Instanz bricht weg. Die Aufsicht hat es noch nicht bemerkt - der naechste + // Durchgang kaeme erst in dreissig Sekunden. + _launcher.Last!.Exit(137); + aufsicht.Condition.ShouldBe(InstanceCondition.Running, "Noch nichts bemerkt."); + + (await aufsicht.StartAsync()).ShouldBeTrue(); + + _launcher.Starts.ShouldBe(2, "Es wurde wirklich gestartet, nicht nur zugestimmt."); + aufsicht.Condition.ShouldBe(InstanceCondition.Running); + } +} diff --git a/tests/ClawdDotNet.Manager.Tests/Doubles.cs b/tests/ClawdDotNet.Manager.Tests/Doubles.cs index f881867..780893a 100644 --- a/tests/ClawdDotNet.Manager.Tests/Doubles.cs +++ b/tests/ClawdDotNet.Manager.Tests/Doubles.cs @@ -47,6 +47,15 @@ internal sealed class FakeLauncher : IInstanceLauncher public int Starts { get; private set; } + /// Welcher Prozess zu welchem Instanzverzeichnis gehört. + public Dictionary ByDirectory { get; } = + new(StringComparer.OrdinalIgnoreCase); + + /// Ob für dieses Verzeichnis gerade ein Prozess lebt. + public bool IsRunning(string instanceDirectory) + => ByDirectory.TryGetValue(Path.GetFullPath(instanceDirectory), out var p) + && !p.HasExited; + /// Wenn gesetzt, scheitert der Start damit. public Exception? FailStartWith { get; set; } @@ -63,6 +72,7 @@ internal sealed class FakeLauncher : IInstanceLauncher Last = new FakeProcess(_nextId++, Now); Running[Last.Id] = Last; + ByDirectory[Path.GetFullPath(instanceDirectory)] = Last; return Last; } @@ -95,6 +105,21 @@ internal sealed class FakeChannel : IInstanceChannel public Dictionary AnswerFor { get; } = new(StringComparer.OrdinalIgnoreCase); + /// + /// Wenn gesetzt, antwortet der Socket nur für Instanzen, die dieser Starter + /// tatsächlich gestartet hat — so wie im Betrieb. + /// + /// Ohne das ist ein Testaufbau leicht falsch: Wer die Antwort hinterlegt, + /// bevor der Manager hochfährt, sagt damit „diese Instanz läuft bereits". Der Manager + /// übernimmt sie dann (E-M6), der Starter wird nie angefasst — und der Test prüft etwas + /// anderes, als er behauptet. Das ist mir dreimal passiert; mit dieser Kopplung kann es + /// nicht mehr passieren. + /// + /// Nicht gesetzt heißt: Die hinterlegte Antwort gilt immer. Das braucht, wer eine + /// Instanz beschreiben will, die läuft, ohne dass dieser Manager sie gestartet hat. + /// + public FakeLauncher? Launcher { get; set; } + public int Calls { get; private set; } /// Wie oft die Instanz gebeten wurde, sich zu beenden. @@ -107,6 +132,9 @@ internal sealed class FakeChannel : IInstanceChannel { Calls++; + if (Launcher is not null && !Launcher.IsRunning(instanceDirectory)) + return Task.FromResult(null); + return Task.FromResult( AnswerFor.TryGetValue(Path.GetFullPath(instanceDirectory), out var eigene) ? eigene