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:
@@ -31,6 +31,26 @@ public sealed class InstanceConfig
|
||||
[JsonPropertyName("logDirectory")]
|
||||
public string LogDirectory { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Ob der Instanzmanager diese Instanz am Laufen hält (#528).
|
||||
///
|
||||
/// <para><b>Zwei Dinge in einem Schalter, weil es dieselbe Zusage ist:</b> 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.</para>
|
||||
///
|
||||
/// <para><b>Ausgeschaltet vorbelegt</b>, 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.</para>
|
||||
///
|
||||
/// <para>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.</para>
|
||||
/// </summary>
|
||||
[JsonPropertyName("autostartByManager")]
|
||||
public bool AutostartByManager { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Wohin die Sicherungen dieser Instanz gehen. Leer heißt
|
||||
/// <c><Instanzverzeichnis>/Backups</c>.
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -10,4 +10,13 @@ public sealed class InstanceInfo
|
||||
public string FolderPath { get; set; } = "";
|
||||
public int AgentCount { get; set; }
|
||||
public string ApiKeyStatus { get; set; } = "—";
|
||||
|
||||
/// <summary>
|
||||
/// Ob der Instanzmanager diese Instanz am Laufen hält.
|
||||
///
|
||||
/// <para>Steht hier mit, damit der Manager es beim Auflisten erfährt, ohne jede
|
||||
/// Instanzdatei zweimal zu lesen — und ohne <c>LoadInstanceConfig</c> zu nehmen, das
|
||||
/// nebenbei Verzeichnisse anlegt.</para>
|
||||
/// </summary>
|
||||
public bool AutostartByManager { get; set; }
|
||||
}
|
||||
|
||||
@@ -84,6 +84,17 @@ public sealed partial class SettingsPageViewModel : PageViewModel
|
||||
[ObservableProperty]
|
||||
private string _logDirectory = "";
|
||||
|
||||
/// <summary>
|
||||
/// Ob der Instanzmanager diese Instanz am Laufen hält (#528).
|
||||
///
|
||||
/// <para>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.</para>
|
||||
/// </summary>
|
||||
[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;
|
||||
|
||||
@@ -42,6 +42,13 @@
|
||||
<TextBox Text="{Binding OpenRouterApiKey}" PasswordChar="•" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel>
|
||||
<CheckBox IsChecked="{Binding AutostartByManager}"
|
||||
Content="Autostart durch Manager" />
|
||||
<TextBlock Classes="caption" TextWrapping="Wrap" Margin="0,4,0,0"
|
||||
Text="Der Instanzmanager startet diese Instanz, wenn er hochkommt, und wieder, wenn sie abstürzt. Nach mehreren Neustarts in kurzer Folge setzt er aus und meldet es." />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel>
|
||||
<TextBlock Classes="label" Text="Protokoll-Verzeichnis" />
|
||||
<TextBox Text="{Binding LogDirectory}" />
|
||||
|
||||
@@ -7,7 +7,13 @@ using Microsoft.Extensions.Logging;
|
||||
namespace ClawdDotNet.Manager.Web;
|
||||
|
||||
/// <summary>Eine Instanz, wie die Weboberfläche sie sieht.</summary>
|
||||
public sealed record InstanceDto(string Id, string Name, string Zustand, int? Prozess);
|
||||
/// <param name="Autostart">
|
||||
/// 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.
|
||||
/// </param>
|
||||
public sealed record InstanceDto(
|
||||
string Id, string Name, string Zustand, int? Prozess, bool Autostart);
|
||||
|
||||
/// <summary>Was beim Anlegen mitkommt.</summary>
|
||||
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(
|
||||
|
||||
@@ -8,8 +8,10 @@ namespace ClawdDotNet.Manager;
|
||||
/// <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>
|
||||
/// <param name="AutostartByManager">Ob der Manager diese Instanz am Laufen hält.</param>
|
||||
public sealed record ManagedInstance(
|
||||
string Directory, string Name, InstanceCondition Condition, int? ProcessId);
|
||||
string Directory, string Name, InstanceCondition Condition, int? ProcessId,
|
||||
bool AutostartByManager);
|
||||
|
||||
/// <summary>
|
||||
/// Alle Instanzen dieser Maschine und je eine Aufsicht dafür.
|
||||
@@ -52,6 +54,10 @@ public sealed class InstanceRegistry : IAsyncDisposable
|
||||
private readonly Dictionary<string, string> _names =
|
||||
new(ClawdDotNet.Core.Storage.PathBoundary.Comparer);
|
||||
|
||||
/// <summary>Ob der Manager diese Instanz am Laufen hält — aus ihrer Instanzdatei.</summary>
|
||||
private readonly HashSet<string> _autostart =
|
||||
new(ClawdDotNet.Core.Storage.PathBoundary.Comparer);
|
||||
|
||||
public InstanceRegistry(
|
||||
InstanceDirectoryManager directories,
|
||||
IInstanceLauncher launcher,
|
||||
@@ -81,7 +87,8 @@ public sealed class InstanceRegistry : IAsyncDisposable
|
||||
public IReadOnlyList<ManagedInstance> 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);
|
||||
|
||||
@@ -137,7 +137,19 @@ public sealed class InstanceSupervisor : IAsyncDisposable
|
||||
public async Task<bool> 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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -112,6 +112,29 @@ public sealed class ManagerSettings
|
||||
[JsonPropertyName("stopGraceSeconds")]
|
||||
public int StopGraceSeconds { get; set; } = 20;
|
||||
|
||||
// ─── Neustart nach Absturz ───
|
||||
|
||||
/// <summary>
|
||||
/// Wie oft eine Instanz innerhalb von <see cref="RestartWindowMinutes"/> wieder
|
||||
/// gestartet wird, bevor der Manager aussetzt.
|
||||
///
|
||||
/// <para>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.</para>
|
||||
/// </summary>
|
||||
[JsonPropertyName("maxRestartsInWindow")]
|
||||
public int MaxRestartsInWindow { get; set; } = 3;
|
||||
|
||||
/// <summary>Der Zeitraum, über den <see cref="MaxRestartsInWindow"/> zählt.</summary>
|
||||
[JsonPropertyName("restartWindowMinutes")]
|
||||
public int RestartWindowMinutes { get; set; } = 10;
|
||||
|
||||
/// <summary>Die geprüfte Neustartregel.</summary>
|
||||
public RestartPolicy ToRestartPolicy() => new(
|
||||
MaxRestarts: Math.Clamp(MaxRestartsInWindow, 1, 100),
|
||||
Window: TimeSpan.FromMinutes(Math.Clamp(RestartWindowMinutes, 1, 1440)));
|
||||
|
||||
/// <summary>
|
||||
/// Die Werte aus dieser Datei als geprüfte Aufsichtsvorgaben.
|
||||
///
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
namespace ClawdDotNet.Manager;
|
||||
|
||||
/// <summary>
|
||||
/// Wie oft der Manager eine abgestürzte Instanz wieder starten darf.
|
||||
/// </summary>
|
||||
/// <param name="MaxRestarts">Wie viele Versuche innerhalb von <paramref name="Window"/>.</param>
|
||||
/// <param name="Window">Der Zeitraum, über den gezählt wird.</param>
|
||||
public sealed record RestartPolicy(int MaxRestarts, TimeSpan Window)
|
||||
{
|
||||
public static RestartPolicy Default { get; } = new(MaxRestarts: 3, Window: TimeSpan.FromMinutes(10));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Der Sicherungsautomat zum Autostart: zählt Neustarts je Instanz und setzt aus.
|
||||
///
|
||||
/// <para><b>Warum es ihn gibt.</b> 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 <see cref="RestartPolicy.MaxRestarts"/> Versuchen im Zeitfenster
|
||||
/// ist Schluss.</para>
|
||||
///
|
||||
/// <para><b>Aussetzen heißt aussetzen, nicht warten.</b> 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.</para>
|
||||
///
|
||||
/// <para><b>Das Zeitfenster wandert.</b> Gezählt wird, was in den letzten
|
||||
/// <see cref="RestartPolicy.Window"/> 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.</para>
|
||||
/// </summary>
|
||||
public sealed class RestartLedger(RestartPolicy policy, TimeProvider? clock = null)
|
||||
{
|
||||
private readonly TimeProvider _clock = clock ?? TimeProvider.System;
|
||||
|
||||
private readonly Dictionary<string, List<DateTimeOffset>> _attempts =
|
||||
new(ClawdDotNet.Core.Storage.PathBoundary.Comparer);
|
||||
|
||||
private readonly Lock _lock = new();
|
||||
|
||||
public RestartPolicy Policy { get; } = policy;
|
||||
|
||||
/// <summary>
|
||||
/// Trägt einen Neustartversuch ein und sagt, ob er stattfinden darf.
|
||||
///
|
||||
/// <para><c>false</c> heißt: Die Grenze ist erreicht, der Manager setzt aus.</para>
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Setzt den Zähler zurück — nach einem Start, den ein Mensch verlangt hat.
|
||||
///
|
||||
/// <para>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.</para>
|
||||
/// </summary>
|
||||
public void Forget(string instanceDirectory)
|
||||
{
|
||||
lock (_lock) _attempts.Remove(Path.GetFullPath(instanceDirectory));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
Reference in New Issue
Block a user