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