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
+61 -14
View File
@@ -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.
@@ -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>&lt;Instanzverzeichnis&gt;/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}" />
+9 -2
View File
@@ -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(
+16 -5
View File
@@ -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);
+13 -1
View File
@@ -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;
+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>
@@ -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.
///
+84
View File
@@ -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));
}
}
+7 -3
View File
@@ -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>
@@ -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;
/// <summary>
/// „Autostart durch Manager" — der Schalter, der eine Instanz am Laufen hält (#528).
///
/// <para><b>Zwei Dinge in einem Schalter, weil es dieselbe Zusage ist:</b> 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.</para>
///
/// <para><b>Und der Sicherungsautomat ist der eigentliche Inhalt.</b> Wieder hochzufahren
/// ist die einfache Hälfte; die schwierige ist zu erkennen, wann man es lassen muss.</para>
/// </summary>
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);
}
/// <summary>Legt eine Instanz an und setzt den Schalter in ihrer Instanzdatei.</summary>
private string Instanz(string name, bool autostart)
{
var pfad = _verzeichnisse.CreateInstance(name);
var datei = Path.Combine(pfad, "InstanceSettings.json");
var config = JsonSerializer.Deserialize<InstanceConfig>(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);
}
/// <summary>
/// Was schon läuft, wird nicht noch einmal gestartet: Ein zweiter Prozess auf derselben
/// <c>state.db</c> wäre das Gegenteil von hilfreich.
/// </summary>
[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.");
}
/// <summary>
/// Nach der Grenze setzt der Manager aus.
///
/// <para>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.</para>
/// </summary>
[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.");
}
/// <summary>
/// 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.
/// </summary>
[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);
}
}
/// <summary>Der Sicherungsautomat für sich genommen.</summary>
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.");
}
/// <summary>
/// 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.
/// </summary>
[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();
}
}
/// <summary>
/// Ein Start, der auf einen veralteten Merkzettel trifft.
///
/// <para><b>Der Defekt, den erst die Autostart-Tests gezeigt haben.</b> 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.</para>
/// </summary>
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);
}
}
@@ -47,6 +47,15 @@ internal sealed class FakeLauncher : IInstanceLauncher
public int Starts { get; private set; }
/// <summary>Welcher Prozess zu welchem Instanzverzeichnis gehört.</summary>
public Dictionary<string, FakeProcess> ByDirectory { get; } =
new(StringComparer.OrdinalIgnoreCase);
/// <summary>Ob für dieses Verzeichnis gerade ein Prozess lebt.</summary>
public bool IsRunning(string instanceDirectory)
=> ByDirectory.TryGetValue(Path.GetFullPath(instanceDirectory), out var p)
&& !p.HasExited;
/// <summary>Wenn gesetzt, scheitert der Start damit.</summary>
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<string, InstanceIdentity?> AnswerFor { get; } =
new(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Wenn gesetzt, antwortet der Socket nur für Instanzen, die dieser Starter
/// tatsächlich gestartet hat — so wie im Betrieb.
///
/// <para><b>Ohne das ist ein Testaufbau leicht falsch:</b> 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.</para>
///
/// <para>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.</para>
/// </summary>
public FakeLauncher? Launcher { get; set; }
public int Calls { get; private set; }
/// <summary>Wie oft die Instanz gebeten wurde, sich zu beenden.</summary>
@@ -107,6 +132,9 @@ internal sealed class FakeChannel : IInstanceChannel
{
Calls++;
if (Launcher is not null && !Launcher.IsRunning(instanceDirectory))
return Task.FromResult<InstanceIdentity?>(null);
return Task.FromResult(
AnswerFor.TryGetValue(Path.GetFullPath(instanceDirectory), out var eigene)
? eigene