diff --git a/ClawdDotNet.slnx b/ClawdDotNet.slnx
index 3c18e87..f56edb6 100644
--- a/ClawdDotNet.slnx
+++ b/ClawdDotNet.slnx
@@ -20,11 +20,13 @@
+
+
diff --git a/docs/Instanzmanager-Konzept.md b/docs/Instanzmanager-Konzept.md
index b0126c6..27f7993 100644
--- a/docs/Instanzmanager-Konzept.md
+++ b/docs/Instanzmanager-Konzept.md
@@ -282,6 +282,14 @@ 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.
+
**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
„vermutlich" ist kein Befund. Zwei unbelegte Verdachtsmomente: `BackupScheduler`
@@ -313,7 +321,7 @@ Verbindung und führt sie zusammen, mit der Instanzkennung an jedem Ereignis.
|---|---|---|
| 1 ✔ | Einstellungen und Protokolle je Instanz trennen (Abschnitt 6) | Zwei Instanzen gleichzeitig, keine schreibt der anderen ins Protokoll |
| 2 ✔ | Instanzprozess kopflos lauffähig, Kestrel auf dem Socket | Eine Instanz läuft ohne Fenster, der Socket antwortet |
-| 3 | Manager: anlegen, starten, anhalten, Prozessaufsicht, Lizenz, Sub-Token | Eine Instanz abschießen — der Manager merkt es und meldet es |
+| 3 ◑ | Manager: anlegen, starten, anhalten, Prozessaufsicht, Lizenz, Sub-Token | Eine Instanz abschießen — der Manager merkt es und meldet es |
| 4 | Weboberfläche zieht um, Weiterleitung, ein Ereignisstrom | Eine Adresse, ein Zugang, Wechsel ohne Neustart |
| 5 | Instanzfenster als eigener Prozess, Wechsel holt das Fenster nach vorn | Kein Neustart mehr beim Wechseln |
| 6 | Sicherungen im Manager (falls Abschnitt 7 dafür entscheidet) | Zurückspielen hält die Instanz an und startet sie wieder |
@@ -399,3 +407,65 @@ Aufräumen beim Beenden) sind unter Windows gegenstandslos und werden dort mit B
ungeprüft, obwohl er die Zielplattform ist. Und das geordnete Ende auf `SIGTERM` ließ sich
hier nicht auslösen: Windows kennt das Signal für einen fensterlosen Prozess nicht.
Beides gehört auf die Linux-Testmaschine.
+
+### 9.3 Was Stufe 3 bisher gebracht hat
+
+**Die Aufsicht steht, Lizenz und Sub-Token fehlen noch.** Neu ist das Projekt
+`ClawdDotNet.Manager` mit genau **einem** Projektverweis: `ClawdDotNet.Core`. Das ist
+keine Sparsamkeit, sondern die halbe Architektur — `ClawdDotNet.App` schleppt sechzehn
+Werkzeugprojekte hinter sich her, und „keine Werkzeuge im Manager" soll eine Eigenschaft
+des Bauplans sein und keine Absichtserklärung. Damit `InstanceDirectoryManager` von beiden
+Seiten erreichbar ist, ist er nach `Core/Instances` gezogen; dorthin gehört er ohnehin,
+denn er beschreibt, wie eine Instanz auf der Platte liegt.
+
+**Der Socket ist die Auskunftsquelle, nicht die Prozessliste.** Das ist die eine
+Entscheidung, an der alles hängt. Die Prozessliste sagt, *dass* etwas da ist — sie sagt
+nicht, ob es noch arbeitet, und genau das war der Zustand aus 1.1: Die Anwendung stand
+einwandfrei in der Prozessliste, bis Windows sie als „reagiert nicht mehr" beendet hat.
+Ein Aufsichtsdurchgang fragt deshalb den Socket und kennt drei Antworten:
+
+| Socket | Prozess | Befund |
+|---|---|---|
+| antwortet | — | läuft |
+| schweigt | ist weg | **Absturz**, mit Abschiedscode |
+| schweigt | lebt | **verstummt** — nach genügend Fehlversuchen in Folge |
+
+Nicht beim ersten Aussetzer: Eine einzelne verpasste Antwort ist Alltag, und ein Alarm bei
+jedem Aussetzer ist nach einer Woche der Alarm, den niemand mehr liest.
+
+**Ein Start gilt erst, wenn die Instanz antwortet.** Zwischen „der Prozess läuft" und „die
+Instanz läuft" liegen Datenbank, Agenten und Kestrel. Wer nur auf `Process.Start` wartet,
+hält eine Instanz für gestartet, die gleich an einer kaputten Konfiguration scheitert.
+
+**Angehalten wird durch Bitten, nicht durch Schießen.** Neu auf der Instanzseite:
+`POST /api/instanz/beenden`, **ausschließlich über den Socket** — über TCP gibt es den
+Endpunkt nicht, auch mit gültigem Token nicht, und er antwortet dort mit 404 statt 403.
+Die Lebensdauer einer Instanz gehört dem Manager; eine Browsersitzung soll den Prozess,
+der sie bedient, nicht beenden können. Erst nach Ablauf der Frist wird hart beendet.
+
+**Wiederfinden nach einem Managerausfall (E-M6).** Die Zustandsdatei
+`/instance-state.json` ist der *Hinweis*, der Socket ist der *Beweis*: Erst wird
+gefragt, wer da antwortet, und nur wenn dieselbe Instanzkennung zurückkommt, wird die
+Prozesskennung aus der Datei überhaupt angefasst. Andersherum — Prozess suchen, finden,
+für die eigene Instanz halten — führt nach einem Neustart der Maschine geradewegs dazu,
+ein fremdes Programm zu beaufsichtigen und beim Anhalten zu beenden. Startzeit und
+Prozesskennung zusammen entlarven eine wiederverwendete Nummer.
+
+**Selbsttätiger Neustart ist bewusst nicht gebaut.** Die Abnahmebedingung lautet „merkt es
+und meldet es", und zum Neustarten trifft dieses Papier keine Entscheidung. Eine Instanz
+in einer Schleife wieder hochzufahren, die jedes Mal abstürzt, wäre die falsche Vorgabe
+für etwas, das niemand entschieden hat. **Das ist eine offene Frage** — siehe Abschnitt 7.
+
+**Abnahme, mit echten Prozessen gemessen.** Der Manager hat die Instanz `Test` in 0,7 s
+gestartet und über die Named Pipe identifiziert, die Zustandsdatei geschrieben
+(`pid=54476`, `id=d68af6fd`), dann wurde der Prozess von außen abgeschossen. Der nächste
+Durchgang meldete `Crashed`, Abschiedscode −1, meldenswert, und räumte die Zustandsdatei
+weg. Dazu 20 Tests gegen gesteuerte Prozesse — ein Absturz zum gewünschten Zeitpunkt und
+eine wiederverwendete Prozesskennung sind mit einem echten Programm nicht herzustellen.
+
+**Ein Hinweis zur Messumgebung.** Die Abnahme lief über einen eigens gebauten Treiber
+außerhalb der Werkzeugumgebung. Ein Prozess, den ein Agentenwerkzeug hier startet, landet
+in einem Job-Objekt, das seine Kinder sofort mitnimmt; der Instanzprozess starb dadurch,
+bevor er die erste Protokollzeile schreiben konnte. Das ist ein Artefakt der Umgebung und
+keine Eigenschaft des Managers — aber es heißt, dass ein Abnahmetest dieser Art nicht in
+die gewöhnliche Testreihe gehört.
diff --git a/src/ClawdDotNet.Desktop/HeadlessRun.cs b/src/ClawdDotNet.Desktop/HeadlessRun.cs
index 32b86be..5014cb1 100644
--- a/src/ClawdDotNet.Desktop/HeadlessRun.cs
+++ b/src/ClawdDotNet.Desktop/HeadlessRun.cs
@@ -2,6 +2,7 @@ using System.Runtime.InteropServices;
using ClawdDotNet.App;
using ClawdDotNet.App.Services;
using ClawdDotNet.Web;
+using ClawdDotNet.Web.Endpoints;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Desktop;
@@ -88,7 +89,7 @@ internal static class HeadlessRun
"Instanz {Name} läuft ohne Fenster auf {Endpunkt}",
host.Instance.InstanceName, web.Endpoint);
- await WaitForShutdownAsync(logger);
+ await WaitForShutdownAsync(logger, web.ShutdownSignal);
logger.LogInformation("Instanz {Name} wird beendet.", host.Instance.InstanceName);
}
@@ -100,16 +101,16 @@ internal static class HeadlessRun
///
/// Wartet, bis jemand Schluss sagt.
///
- /// Drei Wege, weil drei Absender in Frage kommen: SIGTERM von systemd
- /// oder vom Instanzmanager, SIGINT vom Terminal, und
- /// als letzte Gelegenheit.
+ /// Vier Wege, weil vier Absender in Frage kommen: die Bitte des Instanzmanagers
+ /// über den Socket (der Regelfall), SIGTERM von systemd, SIGINT vom
+ /// Terminal, und als letzte Gelegenheit.
///
/// Das Signal wird abgefangen, nicht durchgelassen
/// (context.Cancel = true). Sonst räumte die Laufzeit den Prozess ab, während
/// das geordnete Herunterfahren noch läuft — und genau dabei wird der Schreibpuffer
/// des Protokolls geleert. Ein Ende ohne diese Zeilen ist der Zustand aus #529.
///
- private static async Task WaitForShutdownAsync(ILogger logger)
+ private static async Task WaitForShutdownAsync(ILogger logger, ShutdownSignal signal)
{
var schluss = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
@@ -119,6 +120,11 @@ internal static class HeadlessRun
logger.LogInformation("Beendigung angefordert ({Grund}).", grund);
}
+ // Der Instanzmanager, ueber den Socket. Der uebliche Weg, sobald es ihn gibt:
+ // Er bittet, statt zu schiessen - ein Instanzprozess haelt eine offene Datenbank
+ // und laufende Agenten, und beides will zu Ende gebracht werden.
+ signal.Requested += () => Beenden("Instanzmanager");
+
using var sigterm = PosixSignalRegistration.Create(PosixSignal.SIGTERM, context =>
{
context.Cancel = true;
diff --git a/src/ClawdDotNet.Manager/ClawdDotNet.Manager.csproj b/src/ClawdDotNet.Manager/ClawdDotNet.Manager.csproj
new file mode 100644
index 0000000..b50353c
--- /dev/null
+++ b/src/ClawdDotNet.Manager/ClawdDotNet.Manager.csproj
@@ -0,0 +1,29 @@
+
+
+
+ net10.0
+ enable
+ enable
+ ClawdDotNet.Manager
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ClawdDotNet.Manager/InstanceChannel.cs b/src/ClawdDotNet.Manager/InstanceChannel.cs
new file mode 100644
index 0000000..235bdb7
--- /dev/null
+++ b/src/ClawdDotNet.Manager/InstanceChannel.cs
@@ -0,0 +1,127 @@
+using System.Text.Json;
+using ClawdDotNet.Core.Instances;
+
+namespace ClawdDotNet.Manager;
+
+/// Was eine Instanz über sich sagt, wenn man sie über ihren Socket fragt.
+public sealed record InstanceIdentity(string InstanceId, string InstanceName, string AppVersion);
+
+///
+/// Das Gespräch mit einer Instanz über ihren Socket: Wer bist du — und bitte beende dich.
+///
+/// Beides gehört zusammen, weil es derselbe Weg ist. Und beides steht hinter einer
+/// Schnittstelle, damit die Aufsicht prüfbar bleibt, ohne echte Prozesse zu starten: Ein
+/// Absturz zum gewünschten Zeitpunkt, ein Prozess, der lebt und schweigt, eine
+/// wiederverwendete Prozesskennung — das alles ist mit einem laufenden Programm entweder
+/// nicht oder nur mit Wartezeiten herzustellen, und Tests mit Wartezeiten sind Tests, die
+/// man irgendwann abschaltet.
+///
+public interface IInstanceChannel
+{
+ ///
+ /// null heißt: Da antwortet niemand. Alles andere ist die Auskunft der
+ /// laufenden Instanz.
+ ///
+ Task AskAsync(string instanceDirectory, CancellationToken ct = default);
+
+ ///
+ /// Bittet die Instanz, sich zu beenden.
+ ///
+ /// false heißt nur, dass die Bitte nicht angekommen ist — nicht, dass die
+ /// Instanz noch läuft. Ob sie endet, beantwortet allein der nächste Blick auf ihren
+ /// Socket.
+ ///
+ Task RequestStopAsync(string instanceDirectory, CancellationToken ct = default);
+}
+
+///
+/// Der echte Weg: eine HTTP-Anfrage über den Socket im Instanzverzeichnis.
+///
+/// Warum der Socket und nicht die Prozessliste. Zwei Gründe, und der zweite
+/// ist der wichtigere:
+///
+/// Erstens ist eine Prozesskennung kein Beweis. Nach einem Neustart der Maschine
+/// gehört 4711 mit einiger Wahrscheinlichkeit einem fremden Programm; der Socket dagegen
+/// antwortet mit der Instanzkennung, und die ist eindeutig.
+///
+/// Zweitens — und das ist der Anlass des ganzen Vorhabens — sagt die Prozessliste
+/// nur, dass etwas läuft, nicht ob es noch arbeitet. Am 2026-09-08 hat Windows
+/// eine Anwendung als „reagiert nicht mehr" beendet; bis dahin stand sie einwandfrei in
+/// der Prozessliste (#529). Ein Prozess, der auf seinem eigenen Socket nicht mehr
+/// antwortet, ist genau der Fall, den niemand mitbekommen hat.
+///
+public sealed class SocketInstanceChannel(TimeSpan? timeout = null) : IInstanceChannel
+{
+ ///
+ /// Kurz bemessen. Der Socket liegt auf derselben Maschine; wer hier länger als ein
+ /// paar Sekunden braucht, antwortet nicht mehr im Sinne der Frage.
+ ///
+ private readonly TimeSpan _timeout = timeout ?? TimeSpan.FromSeconds(5);
+
+ public async Task AskAsync(
+ string instanceDirectory, CancellationToken ct = default)
+ {
+ try
+ {
+ using var client = InstanceEndpoint.CreateClient(instanceDirectory);
+ client.Timeout = _timeout;
+
+ using var antwort = await client.GetAsync("/api/info", ct).ConfigureAwait(false);
+
+ if (!antwort.IsSuccessStatusCode)
+ return null;
+
+ var json = await antwort.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
+ using var doc = JsonDocument.Parse(json);
+
+ return new InstanceIdentity(
+ Text(doc.RootElement, "instanceId"),
+ Text(doc.RootElement, "instanceName"),
+ Text(doc.RootElement, "appVersion"));
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ // Der Aufrufer hat abgebrochen - das ist keine Aussage ueber die Instanz.
+ throw;
+ }
+ catch (Exception)
+ {
+ // Alles andere heisst dasselbe: Da antwortet niemand. Ob die Socketdatei
+ // fehlt, die Verbindung abgewiesen wurde oder die Zeit ablief, ist fuer die
+ // Aufsicht derselbe Befund - und keiner, der sie aus dem Tritt bringen darf.
+ return null;
+ }
+ }
+
+ public async Task RequestStopAsync(
+ string instanceDirectory, CancellationToken ct = default)
+ {
+ try
+ {
+ using var client = InstanceEndpoint.CreateClient(instanceDirectory);
+ client.Timeout = _timeout;
+
+ using var antwort = await client
+ .PostAsync("/api/instanz/beenden", content: null, ct)
+ .ConfigureAwait(false);
+
+ return antwort.IsSuccessStatusCode;
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ throw;
+ }
+ catch (Exception)
+ {
+ // Nicht erreichbar. Das ist keine gute Nachricht, aber auch kein Fehler, den
+ // die Aufsicht behandeln muesste - gleich beginnt ohnehin die Frist, nach der
+ // haerter durchgegriffen wird.
+ return false;
+ }
+ }
+
+ private static string Text(JsonElement root, string name)
+ => root.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String
+ ? v.GetString() ?? ""
+ : "";
+}
diff --git a/src/ClawdDotNet.Manager/InstanceLauncher.cs b/src/ClawdDotNet.Manager/InstanceLauncher.cs
new file mode 100644
index 0000000..1d870f9
--- /dev/null
+++ b/src/ClawdDotNet.Manager/InstanceLauncher.cs
@@ -0,0 +1,169 @@
+using System.Diagnostics;
+
+namespace ClawdDotNet.Manager;
+
+///
+/// Ein gestarteter oder wiedergefundener Instanzprozess, soweit die Aufsicht ihn braucht.
+///
+/// Bewusst schmal. Die Aufsicht fragt nicht die Prozessliste, ob eine Instanz
+/// lebt — das beantwortet der Socket, und nur er beantwortet es richtig (siehe
+/// ). Vom Prozess braucht sie dreierlei: seine Kennung
+/// fürs Protokoll, den Abschiedscode, wenn er weg ist, und die Möglichkeit, ihn zu
+/// beenden, wenn er nicht mehr auf Bitten hört.
+///
+public interface IInstanceProcess : IDisposable
+{
+ int Id { get; }
+
+ DateTimeOffset StartedAt { get; }
+
+ bool HasExited { get; }
+
+ /// Der Abschiedscode, oder null, solange der Prozess läuft.
+ int? ExitCode { get; }
+
+ /// Das harte Ende. Nur, wenn die Bitte über den Socket nichts genützt hat.
+ void Kill();
+}
+
+/// Startet Instanzprozesse und findet laufende wieder.
+public interface IInstanceLauncher
+{
+ /// Startet eine Instanz. Wirft, wenn der Prozess nicht anläuft.
+ IInstanceProcess Start(string instanceDirectory);
+
+ ///
+ /// Nimmt einen bereits laufenden Prozess wieder in die Hand (E-M6).
+ ///
+ /// null, wenn es ihn nicht mehr gibt oder wenn er nicht der ist, der
+ /// gesucht wird — siehe .
+ ///
+ ///
+ /// Die Startzeit aus der Zustandsdatei. Sie entlarvt eine wiederverwendete
+ /// Prozesskennung: Nach einem Neustart der Maschine gehört 4711 mit einiger
+ /// Wahrscheinlichkeit einem fremden Programm, und das dann für die eigene Instanz zu
+ /// halten wäre schlimmer, als sie gar nicht zu finden.
+ ///
+ IInstanceProcess? Attach(int processId, DateTimeOffset startedAt);
+}
+
+///
+/// Der echte Start: ClawdDotNet --headless --instance <Verzeichnis>.
+///
+/// Vorerst ohne Fenster. Nach E-M2 behält jede Instanz ihr eigenes
+/// Desktop-Fenster; das kommt mit Stufe 5, wenn der Wechsel das Fenster nach vorn holt.
+/// Bis dahin ist kopflos die Betriebsart, die sich vollständig beobachten lässt — und
+/// eine Instanz, die auf einem Server ohne Bildschirmsitzung gestartet wird, braucht sie
+/// ohnehin.
+///
+public sealed class ProcessInstanceLauncher(string executablePath, bool headless = true)
+ : IInstanceLauncher
+{
+ /// Wo die Programmdatei liegt — in der Regel neben der des Managers.
+ public string ExecutablePath { get; } = Path.GetFullPath(executablePath);
+
+ ///
+ /// Wie weit Start- und Zustandszeit auseinanderliegen dürfen, damit es derselbe
+ /// Prozess ist.
+ ///
+ /// Nicht null: Die Startzeit kommt aus dem Betriebssystem, die Zustandsdatei
+ /// wurde einen Wimpernschlag später geschrieben, und beide runden unterschiedlich.
+ /// Zwei Sekunden sind großzügig genug für diese Differenz und weit zu eng, als dass
+ /// eine wiederverwendete Kennung hindurchpasste.
+ ///
+ private static readonly TimeSpan StartTimeTolerance = TimeSpan.FromSeconds(2);
+
+ public IInstanceProcess Start(string instanceDirectory)
+ {
+ var start = new ProcessStartInfo
+ {
+ FileName = ExecutablePath,
+ WorkingDirectory = Path.GetDirectoryName(ExecutablePath) ?? ".",
+ UseShellExecute = false,
+ CreateNoWindow = true
+ };
+
+ if (headless) start.ArgumentList.Add("--headless");
+
+ start.ArgumentList.Add("--instance");
+ start.ArgumentList.Add(Path.GetFullPath(instanceDirectory));
+
+ var process = Process.Start(start)
+ ?? throw new InvalidOperationException(
+ $"Der Instanzprozess ließ sich nicht starten: {ExecutablePath}");
+
+ return new RunningProcess(process);
+ }
+
+ public IInstanceProcess? Attach(int processId, DateTimeOffset startedAt)
+ {
+ try
+ {
+ var process = Process.GetProcessById(processId);
+
+ var actual = new DateTimeOffset(process.StartTime.ToUniversalTime(), TimeSpan.Zero);
+
+ if ((actual - startedAt).Duration() > StartTimeTolerance)
+ {
+ // Dieselbe Nummer, ein anderer Prozess.
+ process.Dispose();
+ return null;
+ }
+
+ return new RunningProcess(process);
+ }
+ catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
+ {
+ // Gibt es nicht (mehr), oder die Startzeit ist nicht abfragbar, weil er
+ // gerade endet. Beides heisst: nicht wiederzufinden.
+ return null;
+ }
+ }
+
+ private sealed class RunningProcess(Process process) : IInstanceProcess
+ {
+ public int Id { get; } = process.Id;
+
+ public DateTimeOffset StartedAt { get; } =
+ new(process.StartTime.ToUniversalTime(), TimeSpan.Zero);
+
+ public bool HasExited
+ {
+ get
+ {
+ try { return process.HasExited; }
+ catch (InvalidOperationException) { return true; }
+ }
+ }
+
+ public int? ExitCode
+ {
+ get
+ {
+ try { return process.HasExited ? process.ExitCode : null; }
+ catch (Exception ex) when (ex is InvalidOperationException or NotSupportedException)
+ {
+ return null;
+ }
+ }
+ }
+
+ public void Kill()
+ {
+ try
+ {
+ // Mit der ganzen Nachkommenschaft: Ein Instanzprozess, der selbst etwas
+ // gestartet hat, liesse sonst Waisen zurueck.
+ process.Kill(entireProcessTree: true);
+ }
+ catch (Exception ex) when (ex is InvalidOperationException or NotSupportedException)
+ {
+ // Schon weg, oder das Betriebssystem laesst es nicht zu. In beiden
+ // Faellen ist das Ziel - der Prozess ist fort - entweder erreicht oder
+ // nicht erreichbar, und beides sagt gleich der naechste Aufsichtsdurchgang.
+ }
+ }
+
+ public void Dispose() => process.Dispose();
+ }
+}
diff --git a/src/ClawdDotNet.Manager/InstanceState.cs b/src/ClawdDotNet.Manager/InstanceState.cs
new file mode 100644
index 0000000..cb40c1b
--- /dev/null
+++ b/src/ClawdDotNet.Manager/InstanceState.cs
@@ -0,0 +1,118 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using ClawdDotNet.Core.Storage;
+
+namespace ClawdDotNet.Manager;
+
+///
+/// Was der Manager über einen laufenden Instanzprozess auf die Platte schreibt.
+///
+/// Warum es diese Datei gibt (E-M6). Fällt der Manager aus, laufen die
+/// Instanzen weiter — er darf nicht der Einzelpunkt sein, gegen den dieses ganze Vorhaben
+/// gebaut wird. Der neu gestartete Manager muss sie also wiederfinden, und zwar ohne
+/// Datenbank: Alles, was er dafür braucht, liegt als JSON im Instanzverzeichnis.
+///
+/// Die Prozesskennung allein taugt dafür nicht. Nach einem Neustart der
+/// Maschine ist 4711 mit einiger Wahrscheinlichkeit irgendein anderer Prozess — und den
+/// dann für die eigene Instanz zu halten, wäre schlimmer als sie gar nicht zu finden. Der
+/// verlässliche Beweis ist der Socket: Antwortet er mit derselben Instanzkennung, ist es
+/// dieselbe Instanz. Deshalb steht sie hier mit drin. und
+/// sind das Zweitmerkmal — sie sagen, welchen Prozess man
+/// anfassen muss, sobald die Identität feststeht.
+///
+public sealed class InstanceState
+{
+ /// Der Dateiname im Instanzverzeichnis.
+ public const string FileName = "instance-state.json";
+
+ [JsonPropertyName("instanceId")]
+ public string InstanceId { get; set; } = "";
+
+ [JsonPropertyName("instanceName")]
+ public string InstanceName { get; set; } = "";
+
+ [JsonPropertyName("processId")]
+ public int ProcessId { get; set; }
+
+ ///
+ /// Startzeit des Prozesses, nicht der Instanz.
+ ///
+ /// Zusammen mit ergibt sie ein Paar, das eine
+ /// wiederverwendete Kennung entlarvt: Ein Prozess, der später gestartet wurde als
+ /// hier steht, ist nicht der, den wir gestartet haben.
+ ///
+ [JsonPropertyName("startedAt")]
+ public DateTimeOffset StartedAt { get; set; }
+
+ /// Socket oder Named Pipe — wie man mit dieser Instanz spricht.
+ [JsonPropertyName("endpoint")]
+ public string Endpoint { get; set; } = "";
+
+ /// Welcher Managerprozess sie gestartet hat. Nur zur Diagnose.
+ [JsonPropertyName("managerProcessId")]
+ public int ManagerProcessId { get; set; }
+}
+
+///
+/// Liest und schreibt die Zustandsdatei einer Instanz.
+///
+/// Bewusst nachsichtig beim Lesen: Eine kaputte oder halb geschriebene Datei heißt
+/// „ich weiß nichts über diese Instanz" und nicht „Ausnahme". Der Manager kann damit
+/// umgehen — er fragt dann eben den Socket, und wenn der schweigt, läuft sie nicht.
+/// Geschrieben wird über , damit ein Absturz mitten im Schreiben
+/// keine halbe Datei hinterlässt.
+///
+public static class InstanceStateStore
+{
+ private static readonly JsonSerializerOptions JsonOpts = new()
+ {
+ WriteIndented = true,
+ PropertyNameCaseInsensitive = true
+ };
+
+ public static string PathFor(string instanceDirectory)
+ => Path.Combine(Path.GetFullPath(instanceDirectory), InstanceState.FileName);
+
+ /// Der hinterlegte Zustand, oder null: keine Datei, keine lesbare.
+ public static InstanceState? Read(string instanceDirectory)
+ {
+ var path = PathFor(instanceDirectory);
+
+ try
+ {
+ if (!File.Exists(path)) return null;
+
+ return JsonSerializer.Deserialize(
+ AtomicFile.ReadAllText(path), JsonOpts);
+ }
+ catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException)
+ {
+ return null;
+ }
+ }
+
+ public static void Write(string instanceDirectory, InstanceState state)
+ => AtomicFile.WriteAllText(
+ PathFor(instanceDirectory), JsonSerializer.Serialize(state, JsonOpts));
+
+ ///
+ /// Räumt die Zustandsdatei weg — nach einem geordneten Ende und nachdem festgestellt
+ /// wurde, dass ein Eintrag ins Leere zeigt.
+ ///
+ /// Eine liegengebliebene Datei ist kein Drama: Der Manager prüft ohnehin den
+ /// Socket, bevor er ihr glaubt. Sie wegzuräumen erspart aber die Frage, warum eine
+ /// Instanz „laut Datei läuft" und trotzdem nicht antwortet.
+ ///
+ public static void Clear(string instanceDirectory)
+ {
+ try
+ {
+ var path = PathFor(instanceDirectory);
+ if (File.Exists(path)) File.Delete(path);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ // Nicht der Rede wert - siehe oben.
+ }
+ }
+}
diff --git a/src/ClawdDotNet.Manager/InstanceSupervisor.cs b/src/ClawdDotNet.Manager/InstanceSupervisor.cs
new file mode 100644
index 0000000..30b2974
--- /dev/null
+++ b/src/ClawdDotNet.Manager/InstanceSupervisor.cs
@@ -0,0 +1,496 @@
+using ClawdDotNet.Core.Instances;
+using Microsoft.Extensions.Logging;
+
+namespace ClawdDotNet.Manager;
+
+/// Was die Aufsicht über den Zustand einer Instanz weiß.
+public enum InstanceCondition
+{
+ /// Noch nicht nachgesehen.
+ Unknown,
+
+ /// Läuft nicht — und das ist in Ordnung so.
+ Stopped,
+
+ /// Läuft und antwortet.
+ Running,
+
+ /// Der Prozess ist da, antwortet aber nicht mehr.
+ Silent,
+
+ /// Weg, ohne dass jemand darum gebeten hätte.
+ Crashed
+}
+
+/// Stellschrauben der Aufsicht.
+/// Wie oft nachgesehen wird.
+/// Wie lange eine frisch gestartete Instanz Zeit hat, sich zu melden.
+///
+/// Wie viele Fehlversuche in Folge nötig sind, bevor „antwortet nicht" gilt.
+///
+/// Nicht einer: Eine einzelne verpasste Antwort ist Alltag — die Maschine war
+/// beschäftigt, ein Agentenlauf hat den Prozess gerade voll ausgelastet. Ein Alarm bei
+/// jedem Aussetzer wäre nach einer Woche der Alarm, den niemand mehr liest, und dann ist
+/// die Aufsicht ihr Geld nicht mehr wert.
+///
+/// Wie lange eine Instanz nach der Bitte zu enden noch bekommt.
+public sealed record SupervisionOptions(
+ TimeSpan ProbeInterval,
+ TimeSpan StartTimeout,
+ int SilentChecksBeforeAlarm,
+ TimeSpan StopGrace)
+{
+ public static SupervisionOptions Default { get; } = new(
+ ProbeInterval: TimeSpan.FromSeconds(15),
+ StartTimeout: TimeSpan.FromSeconds(60),
+ SilentChecksBeforeAlarm: 3,
+ StopGrace: TimeSpan.FromSeconds(20));
+}
+
+///
+/// Die Aufsicht über eine Instanz: starten, anhalten, nachsehen, wiederfinden.
+///
+/// Der Anlass. Am 2026-09-08 hat sich eine Instanz aufgehängt und wurde von
+/// Windows beendet. Zu diesem Vorgang steht keine einzige Zeile in den Instanzprotokollen
+/// — der Schreibpuffer wurde nie geleert. Keine Fehlermeldung, kein Bugtracker-Eintrag,
+/// kein Watchdog-Alarm. Der Grund ist bauartbedingt: Meldeweg, Watchdog und Fehler-Stream
+/// lebten in dem Prozess, über dessen Zustand sie berichten sollten. Fällt er
+/// hart, fällt der Meldeweg mit — genau in dem Fall, der gemeldet werden müsste (#528,
+/// #529).
+///
+/// Deshalb fragt diese Klasse den Socket und nicht die Prozessliste. Die
+/// Prozessliste sagt, dass etwas da ist. Sie sagt nicht, ob es noch arbeitet — und genau
+/// das war der Zustand, den niemand bemerkt hat. Ein Prozess, der auf seinem eigenen
+/// Socket dreimal hintereinander nicht antwortet, ist ein Befund, auch wenn er in jeder
+/// Prozessliste steht.
+///
+/// Ein Durchgang, drei mögliche Antworten. Der Socket antwortet: Sie lebt.
+/// Der Socket schweigt und der Prozess ist weg: Absturz, mit Abschiedscode. Der Socket
+/// schweigt und der Prozess ist da: verstummt — nach genügend Fehlversuchen in Folge.
+/// Mehr Fälle gibt es nicht, und das ist der Grund, warum die Aufsicht so wenig Code
+/// braucht.
+///
+/// Was hier nicht passiert: Neustarten. Die Abnahmebedingung für diese Stufe
+/// lautet „der Manager merkt es und meldet es", und das Konzept trifft zum
+/// selbsttätigen Neustart keine Entscheidung. Eine Instanz in einer Schleife wieder
+/// hochzufahren, die jedes Mal abstürzt, wäre die falsche Vorgabe für etwas, das niemand
+/// entschieden hat — sie gehört als eigene Entscheidung aufgeschrieben, nicht als
+/// Nebenwirkung eingebaut.
+///
+public sealed class InstanceSupervisor : IAsyncDisposable
+{
+ private readonly SupervisionOptions _options;
+ private readonly IInstanceLauncher _launcher;
+ private readonly IInstanceChannel _channel;
+ private readonly ILogger _logger;
+ private readonly TimeProvider _clock;
+ private readonly CancellationTokenSource _cts = new();
+
+ private IInstanceProcess? _process;
+ private Task? _loop;
+ private int _silentChecks;
+ private bool _stopRequested;
+
+ public InstanceSupervisor(
+ string instanceDirectory,
+ IInstanceLauncher launcher,
+ IInstanceChannel channel,
+ ILogger logger,
+ SupervisionOptions? options = null,
+ TimeProvider? clock = null)
+ {
+ InstanceDirectory = Path.GetFullPath(instanceDirectory);
+ _launcher = launcher;
+ _channel = channel;
+ _logger = logger;
+ _options = options ?? SupervisionOptions.Default;
+ _clock = clock ?? TimeProvider.System;
+ }
+
+ public string InstanceDirectory { get; }
+
+ /// Der Name, sobald die Instanz einmal geantwortet hat; sonst der Ordnername.
+ public string InstanceName { get; private set; } = "";
+
+ public InstanceCondition Condition { get; private set; } = InstanceCondition.Unknown;
+
+ public int? ProcessId => _process?.Id;
+
+ ///
+ /// Jedes Vorkommnis, einmal.
+ ///
+ /// Kommt auf dem Faden des Verursachers — wer eine Oberfläche daran
+ /// hängt, geht über den Dispatcher.
+ ///
+ public event Action? Observed;
+
+ // ─── Starten ───
+
+ ///
+ /// Startet die Instanz und wartet, bis sie sich über ihren Socket meldet.
+ ///
+ /// Der Prozess läuft ist nicht dasselbe wie die Instanz läuft. Zwischen
+ /// beidem liegen Datenbank, Agenten und Kestrel. Wer nur auf Process.Start
+ /// wartet, hält eine Instanz für gestartet, die gleich an einer kaputten
+ /// Konfiguration scheitert — und merkt es erst, wenn jemand sie braucht.
+ ///
+ public async Task StartAsync(CancellationToken ct = default)
+ {
+ if (Condition == InstanceCondition.Running)
+ return true;
+
+ _stopRequested = false;
+ _silentChecks = 0;
+
+ try
+ {
+ _process?.Dispose();
+ _process = _launcher.Start(InstanceDirectory);
+ }
+ catch (Exception ex)
+ {
+ Raise(InstanceEventKind.StartFailed, null, null,
+ $"Der Instanzprozess ließ sich nicht starten: {ex.Message}");
+
+ Condition = InstanceCondition.Stopped;
+ return false;
+ }
+
+ var identity = await WaitForAnswerAsync(ct).ConfigureAwait(false);
+
+ if (identity is null)
+ {
+ var exitCode = _process.ExitCode;
+
+ // Aufraeumen: Ein Prozess, der laeuft und nicht antwortet, ist nichts, was
+ // man stehen laesst - er haelt den Socket besetzt und blockiert damit den
+ // naechsten Versuch.
+ if (!_process.HasExited) _process.Kill();
+
+ Raise(InstanceEventKind.StartFailed, _process.Id, exitCode,
+ $"Die Instanz hat sich binnen {_options.StartTimeout.TotalSeconds:0}s nicht "
+ + $"auf {InstanceEndpoint.Describe(InstanceDirectory)} gemeldet.");
+
+ Condition = InstanceCondition.Stopped;
+ InstanceStateStore.Clear(InstanceDirectory);
+ return false;
+ }
+
+ InstanceName = identity.InstanceName;
+ Condition = InstanceCondition.Running;
+
+ InstanceStateStore.Write(InstanceDirectory, new InstanceState
+ {
+ InstanceId = identity.InstanceId,
+ InstanceName = identity.InstanceName,
+ ProcessId = _process.Id,
+ StartedAt = _process.StartedAt,
+ Endpoint = InstanceEndpoint.Describe(InstanceDirectory),
+ ManagerProcessId = Environment.ProcessId
+ });
+
+ Raise(InstanceEventKind.Started, _process.Id, null,
+ $"Instanz {identity.InstanceName} läuft (Version {identity.AppVersion}).");
+
+ return true;
+ }
+
+ private async Task WaitForAnswerAsync(CancellationToken ct)
+ {
+ var deadline = _clock.GetUtcNow() + _options.StartTimeout;
+
+ while (_clock.GetUtcNow() < deadline)
+ {
+ if (_process is { HasExited: true })
+ return null; // Aufgegeben, bevor er antworten konnte.
+
+ var identity = await _channel.AskAsync(InstanceDirectory, ct).ConfigureAwait(false);
+ if (identity is not null) return identity;
+
+ await Task.Delay(TimeSpan.FromMilliseconds(250), _clock, ct).ConfigureAwait(false);
+ }
+
+ return null;
+ }
+
+ // ─── Wiederfinden (E-M6) ───
+
+ ///
+ /// Nimmt eine Instanz wieder in die Aufsicht, die schon lief.
+ ///
+ /// Fällt der Manager aus, laufen die Instanzen weiter — er darf nicht der
+ /// Einzelpunkt sein, gegen den dieses Vorhaben gebaut wird. Der neu gestartete Manager
+ /// findet sie über die Zustandsdatei im Instanzverzeichnis wieder.
+ ///
+ /// Die Datei ist der Hinweis, der Socket ist der Beweis. Erst wird
+ /// gefragt, wer da antwortet; nur wenn dieselbe Instanzkennung zurückkommt, wird die
+ /// Prozesskennung aus der Datei überhaupt angefasst. Andersherum — Prozess suchen,
+ /// finden, für die eigene Instanz halten — führt nach einem Neustart der Maschine
+ /// geradewegs dazu, ein fremdes Programm zu beaufsichtigen und im Zweifel zu
+ /// beenden.
+ ///
+ /// true, wenn eine laufende Instanz übernommen wurde.
+ public async Task ReattachAsync(CancellationToken ct = default)
+ {
+ var state = InstanceStateStore.Read(InstanceDirectory);
+ var identity = await _channel.AskAsync(InstanceDirectory, ct).ConfigureAwait(false);
+
+ if (identity is null)
+ {
+ // Nichts da. Eine Zustandsdatei, die auf niemanden zeigt, ist Altpapier -
+ // und solange sie liegt, sieht es aus, als liefe etwas.
+ if (state is not null) InstanceStateStore.Clear(InstanceDirectory);
+
+ Condition = InstanceCondition.Stopped;
+ return false;
+ }
+
+ if (state is not null
+ && !string.IsNullOrEmpty(state.InstanceId)
+ && !string.Equals(state.InstanceId, identity.InstanceId, StringComparison.Ordinal))
+ {
+ // Da antwortet jemand, aber nicht der, der in der Datei steht. Die Datei ist
+ // veraltet; die Auskunft vom Socket gilt.
+ _logger.LogWarning(
+ "Zustandsdatei nennt Instanz {Erwartet}, geantwortet hat {Tatsaechlich} — "
+ + "die Datei wird überschrieben.", state.InstanceId, identity.InstanceId);
+
+ state = null;
+ }
+
+ InstanceName = identity.InstanceName;
+ Condition = InstanceCondition.Running;
+ _silentChecks = 0;
+ _stopRequested = false;
+
+ _process?.Dispose();
+ _process = state is not null
+ ? _launcher.Attach(state.ProcessId, state.StartedAt)
+ : null;
+
+ if (_process is null)
+ {
+ // Sie antwortet, aber der Prozess dahinter ist nicht zu greifen: keine
+ // Zustandsdatei, eine veraltete, oder die Kennung gehoert inzwischen einem
+ // anderen. Beaufsichtigen laesst sie sich trotzdem - nur beenden nicht mit
+ // Gewalt, sondern ausschliesslich ueber ihren Socket.
+ _logger.LogInformation(
+ "Instanz {Name} übernommen, ohne den Prozess zu fassen zu bekommen — "
+ + "sie lässt sich über ihren Socket ansprechen, aber nicht abschießen.",
+ identity.InstanceName);
+ }
+
+ InstanceStateStore.Write(InstanceDirectory, new InstanceState
+ {
+ InstanceId = identity.InstanceId,
+ InstanceName = identity.InstanceName,
+ ProcessId = _process?.Id ?? state?.ProcessId ?? 0,
+ StartedAt = _process?.StartedAt ?? state?.StartedAt ?? _clock.GetUtcNow(),
+ Endpoint = InstanceEndpoint.Describe(InstanceDirectory),
+ ManagerProcessId = Environment.ProcessId
+ });
+
+ _logger.LogInformation(
+ "Laufende Instanz {Name} wieder in die Aufsicht genommen.", identity.InstanceName);
+
+ return true;
+ }
+
+ // ─── Nachsehen ───
+
+ ///
+ /// Ein Aufsichtsdurchgang. Öffentlich, damit Tests ihn auslösen können, statt auf
+ /// eine Uhr zu warten.
+ ///
+ public async Task CheckAsync(CancellationToken ct = default)
+ {
+ if (Condition is InstanceCondition.Stopped or InstanceCondition.Crashed)
+ return; // Nichts zu beaufsichtigen.
+
+ var identity = await _channel.AskAsync(InstanceDirectory, ct).ConfigureAwait(false);
+
+ if (identity is not null)
+ {
+ _silentChecks = 0;
+
+ if (Condition == InstanceCondition.Silent)
+ {
+ Condition = InstanceCondition.Running;
+ Raise(InstanceEventKind.Recovered, _process?.Id, null,
+ $"Instanz {InstanceName} antwortet wieder.");
+ }
+ else
+ {
+ Condition = InstanceCondition.Running;
+ }
+
+ return;
+ }
+
+ // Ab hier: Der Socket schweigt.
+
+ if (_process is { HasExited: true })
+ {
+ var exitCode = _process.ExitCode;
+
+ Condition = _stopRequested ? InstanceCondition.Stopped : InstanceCondition.Crashed;
+ InstanceStateStore.Clear(InstanceDirectory);
+
+ Raise(_stopRequested ? InstanceEventKind.StoppedOrderly : InstanceEventKind.Crashed,
+ _process.Id, exitCode,
+ _stopRequested
+ ? $"Instanz {InstanceName} beendet (Code {Describe(exitCode)})."
+ : $"Instanz {InstanceName} ist unerwartet beendet worden "
+ + $"(Code {Describe(exitCode)}). Niemand hat darum gebeten.");
+
+ return;
+ }
+
+ // Der Prozess laeuft (oder ist nicht zu fassen) und antwortet nicht. Genau der
+ // Zustand aus #529 - deshalb nicht beim ersten Mal Alarm schlagen, aber auch
+ // nicht ewig zusehen.
+ _silentChecks++;
+
+ if (Condition == InstanceCondition.Silent)
+ return; // Schon gemeldet. Ein Alarm je Vorfall, nicht je Durchgang.
+
+ if (_silentChecks < _options.SilentChecksBeforeAlarm)
+ return;
+
+ Condition = InstanceCondition.Silent;
+
+ Raise(InstanceEventKind.Unresponsive, _process?.Id, null,
+ $"Instanz {InstanceName} läuft, antwortet aber seit {_silentChecks} Durchgängen "
+ + $"nicht mehr auf {InstanceEndpoint.Describe(InstanceDirectory)}.");
+ }
+
+ /// Sieht von selbst nach, im eingestellten Takt.
+ public void BeginWatching() => _loop ??= WatchAsync(_cts.Token);
+
+ private async Task WatchAsync(CancellationToken ct)
+ {
+ using var timer = new PeriodicTimer(_options.ProbeInterval, _clock);
+
+ while (await timer.WaitForNextTickAsync(ct).ConfigureAwait(false))
+ {
+ try
+ {
+ await CheckAsync(ct).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ // Eine Aufsicht, die an einem Fehler stirbt, ist ab dann keine mehr -
+ // und zwar still, was der schlechteste aller Zustaende waere.
+ _logger.LogError(ex, "Aufsichtsdurchgang für {Pfad} fehlgeschlagen.",
+ InstanceDirectory);
+ }
+ }
+ }
+
+ // ─── Anhalten ───
+
+ ///
+ /// Bittet die Instanz zu enden und wartet, bis sie es tut.
+ ///
+ /// Erst bitten, dann zwingen. Ein Instanzprozess hält eine offene
+ /// SQLite-Datenbank, laufende Agenten und Chatverläufe im Arbeitsspeicher; ihn
+ /// abzuschießen heißt, das alles mitten im Satz abzuschneiden. Gefragt wird über den
+ /// Socket — den Weg, den es ohnehin gibt. Erst wenn nach
+ /// nichts geschehen ist, wird beendet.
+ ///
+ public async Task StopAsync(CancellationToken ct = default)
+ {
+ if (Condition is InstanceCondition.Stopped or InstanceCondition.Crashed)
+ return true;
+
+ _stopRequested = true;
+
+ if (!await _channel.RequestStopAsync(InstanceDirectory, ct).ConfigureAwait(false))
+ {
+ _logger.LogInformation(
+ "Instanz {Name} war für die Bitte zu enden nicht erreichbar — "
+ + "es gilt trotzdem die Frist.", InstanceName);
+ }
+
+ var deadline = _clock.GetUtcNow() + _options.StopGrace;
+
+ while (_clock.GetUtcNow() < deadline)
+ {
+ if (_process is { HasExited: true }
+ || await _channel.AskAsync(InstanceDirectory, ct).ConfigureAwait(false) is null)
+ {
+ Finish(_process?.ExitCode, gewaltsam: false);
+ return true;
+ }
+
+ await Task.Delay(TimeSpan.FromMilliseconds(250), _clock, ct).ConfigureAwait(false);
+ }
+
+ if (_process is null)
+ {
+ // Sie hoert nicht, und zu fassen ist sie auch nicht (siehe ReattachAsync).
+ _logger.LogWarning(
+ "Instanz {Name} reagiert nicht auf die Bitte zu enden und ist nicht zu "
+ + "beenden — es gibt keinen Prozess, den dieser Manager greifen kann.",
+ InstanceName);
+
+ return false;
+ }
+
+ _logger.LogWarning(
+ "Instanz {Name} hat binnen {Frist}s nicht geendet — sie wird beendet.",
+ InstanceName, _options.StopGrace.TotalSeconds);
+
+ _process.Kill();
+ Finish(_process.ExitCode, gewaltsam: true);
+ return true;
+ }
+
+ private void Finish(int? exitCode, bool gewaltsam)
+ {
+ Condition = InstanceCondition.Stopped;
+ InstanceStateStore.Clear(InstanceDirectory);
+
+ Raise(InstanceEventKind.StoppedOrderly, _process?.Id, exitCode,
+ gewaltsam
+ ? $"Instanz {InstanceName} musste beendet werden (Code {Describe(exitCode)})."
+ : $"Instanz {InstanceName} beendet (Code {Describe(exitCode)}).");
+ }
+
+ // ─── Kleinkram ───
+
+ private static string Describe(int? exitCode) => exitCode?.ToString() ?? "unbekannt";
+
+ private void Raise(InstanceEventKind kind, int? pid, int? exitCode, string message)
+ {
+ var ereignis = new SupervisionEvent(
+ kind, InstanceDirectory,
+ string.IsNullOrEmpty(InstanceName) ? Path.GetFileName(InstanceDirectory) : InstanceName,
+ pid, exitCode, message, _clock.GetUtcNow());
+
+ if (ereignis.WorthReporting)
+ _logger.LogError("{Meldung}", message);
+ else
+ _logger.LogInformation("{Meldung}", message);
+
+ Observed?.Invoke(ereignis);
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ await _cts.CancelAsync().ConfigureAwait(false);
+
+ if (_loop is not null)
+ {
+ try { await _loop.ConfigureAwait(false); }
+ catch (OperationCanceledException) { /* erwartet */ }
+ }
+
+ _cts.Dispose();
+
+ // Der Prozess wird NICHT beendet: Faellt der Manager weg, laufen die Instanzen
+ // weiter (E-M6). Losgelassen wird nur das Handle.
+ _process?.Dispose();
+ _process = null;
+ }
+}
diff --git a/src/ClawdDotNet.Manager/SupervisionEvent.cs b/src/ClawdDotNet.Manager/SupervisionEvent.cs
new file mode 100644
index 0000000..2abc16b
--- /dev/null
+++ b/src/ClawdDotNet.Manager/SupervisionEvent.cs
@@ -0,0 +1,70 @@
+namespace ClawdDotNet.Manager;
+
+/// Was der Manager an einer Instanz beobachtet hat.
+public enum InstanceEventKind
+{
+ /// Gestartet und über ihren Socket erreichbar.
+ Started,
+
+ /// Der Prozess kam hoch, meldete sich aber nicht — oder kam gar nicht hoch.
+ StartFailed,
+
+ /// Auf Geheiß beendet.
+ StoppedOrderly,
+
+ /// Weg, ohne dass jemand darum gebeten hätte.
+ Crashed,
+
+ ///
+ /// Der Prozess läuft, antwortet aber nicht mehr auf seinem Socket.
+ ///
+ /// Der Fall, der dieses ganze Vorhaben ausgelöst hat: Am 2026-09-08 stand die
+ /// Anwendung einwandfrei in der Prozessliste und arbeitete längst nicht mehr; Windows
+ /// hat sie schließlich erschlagen (#529). Bis dahin hatte niemand etwas gemerkt — die
+ /// Prozessliste sagt nur, dass etwas da ist.
+ ///
+ Unresponsive,
+
+ /// Antwortet wieder, nachdem sie stumm war.
+ Recovered
+}
+
+///
+/// Ein Vorkommnis in der Aufsicht.
+///
+/// Das ist die Liste aus Abschnitt 4 des Konzepts, und sie ist absichtlich
+/// kurz. Der Manager protokolliert und meldet, was aus seiner Sicht
+/// relevant ist: Start, Stopp, Absturz, Verstummen. Ein fehlgeschlagener Werkzeugaufruf,
+/// ein abgebrochener Agentenlauf, eine überschrittene Budgetgrenze — all das bleibt in
+/// der Instanz und wird von ihr selbst gemeldet, mit ihrem eigenen Sub-Token. Der Manager
+/// sieht davon nichts und soll davon nichts sehen.
+///
+/// Was passiert ist.
+/// Um welche Instanz es geht — der Pfad ist ihre Kennung.
+/// Für Menschen. Kann leer sein, solange die Instanz nie geantwortet hat.
+/// Die Prozesskennung, soweit bekannt.
+/// Nur bei und .
+/// Ein Satz, der ins Protokoll passt.
+/// Wann.
+public sealed record SupervisionEvent(
+ InstanceEventKind Kind,
+ string InstanceDirectory,
+ string InstanceName,
+ int? ProcessId,
+ int? ExitCode,
+ string Message,
+ DateTimeOffset At)
+{
+ ///
+ /// Ob dieses Vorkommnis über das Protokoll hinaus ins Deploymentcenter gehört.
+ ///
+ /// Start und geordnetes Ende sind Zustandswechsel und stehen im Protokoll.
+ /// Gemeldet wird, was niemand bestellt hat — und zwar stellvertretend, weil die
+ /// Instanz es selbst nicht mehr kann. Genau dafür gibt es den Manager: Fällt ein
+ /// Prozess hart, fällt sein eigener Meldeweg mit.
+ ///
+ public bool WorthReporting
+ => Kind is InstanceEventKind.Crashed
+ or InstanceEventKind.Unresponsive
+ or InstanceEventKind.StartFailed;
+}
diff --git a/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs b/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs
index bb6dca2..150dc64 100644
--- a/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs
+++ b/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs
@@ -33,6 +33,7 @@ internal static class ApiEndpoints
api.MapLogs();
app.MapEvents();
+ app.MapControl();
}
///
diff --git a/src/ClawdDotNet.Web/Endpoints/ControlEndpoints.cs b/src/ClawdDotNet.Web/Endpoints/ControlEndpoints.cs
new file mode 100644
index 0000000..8be8aaf
--- /dev/null
+++ b/src/ClawdDotNet.Web/Endpoints/ControlEndpoints.cs
@@ -0,0 +1,72 @@
+using ClawdDotNet.Web.Security;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Routing;
+
+namespace ClawdDotNet.Web.Endpoints;
+
+///
+/// Der Wunsch, dass dieser Instanzprozess endet.
+///
+/// Ein eigener Typ und kein Rückruf am Endpunkt, weil zwei Seiten ihn brauchen:
+/// Der Endpunkt löst ihn aus, der Wirtsprozess wartet darauf. Wer wartet, ist verschieden
+/// — kopflos die Schleife in HeadlessRun, mit Fenster die Avalonia-Anwendung.
+///
+public sealed class ShutdownSignal
+{
+ /// Wird höchstens einmal ausgelöst.
+ public event Action? Requested;
+
+ private int _raised;
+
+ ///
+ /// Meldet den Wunsch an. Mehrfaches Aufrufen tut nichts — ein zweiter Aufruf würde
+ /// sonst mitten im Herunterfahren ein zweites Herunterfahren anstoßen.
+ ///
+ public bool Raise()
+ {
+ if (Interlocked.Exchange(ref _raised, 1) != 0)
+ return false;
+
+ Requested?.Invoke();
+ return true;
+ }
+}
+
+///
+/// Was den Instanzprozess selbst betrifft — nicht seinen Inhalt.
+///
+/// Ausschließlich über den Socket erreichbar. Über TCP gibt es diese
+/// Endpunkte nicht, auch mit gültigem Token nicht, und sie antworten dort mit 404 statt
+/// mit 403: Was es nicht gibt, muss man auch nicht erklären. Der Grund ist der Schnitt aus
+/// #528 — die Lebensdauer einer Instanz gehört dem Instanzmanager. Eine Browsersitzung
+/// soll den Prozess, der sie gerade bedient, nicht beenden können; der Manager soll es,
+/// und er kommt über den Socket.
+///
+internal static class ControlEndpoints
+{
+ public static void MapControl(this WebApplication app)
+ {
+ var control = app.MapGroup("/api/instanz");
+
+ control.MapPost("/beenden", (HttpContext context, ShutdownSignal signal) =>
+ {
+ if (!LocalTransport.Applies(context))
+ return Results.NotFound();
+
+ // Erst antworten, dann enden - und deshalb OnCompleted statt eines Aufrufs
+ // an dieser Stelle. Ein hier ausgeloester Wunsch faehrt den Wirt sofort
+ // herunter, mitten in dieser Anfrage; der Manager bekaeme statt einer Zusage
+ // einen Verbindungsabbruch und haelt ihn fuer einen Fehlschlag. Genau davon
+ // haengt aber ab, ob er anschliessend geduldig wartet oder abschiesst.
+ context.Response.OnCompleted(() =>
+ {
+ signal.Raise();
+ return Task.CompletedTask;
+ });
+
+ // 202 und nicht 200: Beim Antworten laeuft der Prozess noch.
+ return Results.Accepted(value: new { angenommen = true });
+ });
+ }
+}
diff --git a/src/ClawdDotNet.Web/InstanceHttpHost.cs b/src/ClawdDotNet.Web/InstanceHttpHost.cs
index 5709db2..265689b 100644
--- a/src/ClawdDotNet.Web/InstanceHttpHost.cs
+++ b/src/ClawdDotNet.Web/InstanceHttpHost.cs
@@ -50,8 +50,21 @@ public sealed class InstanceHttpHost : IAsyncDisposable
_instancePath = instancePath;
Endpoint = endpoint;
Url = url;
+
+ ShutdownSignal = app.Services.GetRequiredService();
}
+ ///
+ /// Der Instanzmanager hat gebeten, diesen Prozess zu beenden
+ /// (POST /api/instanz/beenden, nur über den Socket).
+ ///
+ /// Der Wirtsprozess hängt sich hier an und beendet sich: kopflos die
+ /// Warteschleife, mit Fenster die Avalonia-Anwendung. Diese Klasse tut es nicht
+ /// selbst — sie kennt nur ihren Kestrel und wüsste gar nicht, was sonst noch zu
+ /// beenden wäre.
+ ///
+ public ShutdownSignal ShutdownSignal { get; }
+
/// Socket oder Named Pipe — der Weg, den der Instanzmanager nimmt.
public string Endpoint { get; }
@@ -171,6 +184,7 @@ public sealed class InstanceHttpHost : IAsyncDisposable
builder.WebHost.ConfigureKestrel(kestrel => Listen(kestrel, host.InstancePath, options));
builder.Services.AddSingleton(host);
+ builder.Services.AddSingleton();
if (options is not null)
{
diff --git a/tests/ClawdDotNet.Manager.Tests/ClawdDotNet.Manager.Tests.csproj b/tests/ClawdDotNet.Manager.Tests/ClawdDotNet.Manager.Tests.csproj
new file mode 100644
index 0000000..6247c45
--- /dev/null
+++ b/tests/ClawdDotNet.Manager.Tests/ClawdDotNet.Manager.Tests.csproj
@@ -0,0 +1,24 @@
+
+
+
+ net10.0
+ enable
+ enable
+ ClawdDotNet.Manager.Tests
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/ClawdDotNet.Manager.Tests/Doubles.cs b/tests/ClawdDotNet.Manager.Tests/Doubles.cs
new file mode 100644
index 0000000..ac1d8d7
--- /dev/null
+++ b/tests/ClawdDotNet.Manager.Tests/Doubles.cs
@@ -0,0 +1,106 @@
+namespace ClawdDotNet.Manager.Tests;
+
+///
+/// Ein Instanzprozess, den der Test steuert.
+///
+/// Ein echter Prozess ließe die Aufsicht nur oberflächlich prüfen: Ein Absturz zum
+/// gewünschten Zeitpunkt, ein Prozess, der lebt und schweigt, eine wiederverwendete
+/// Prozesskennung — das alles ist mit einem laufenden Programm entweder nicht oder nur
+/// mit Wartezeiten herzustellen, und Tests mit Wartezeiten sind Tests, die man
+/// irgendwann abschaltet.
+///
+internal sealed class FakeProcess(int id, DateTimeOffset startedAt) : IInstanceProcess
+{
+ public int Id { get; } = id;
+
+ public DateTimeOffset StartedAt { get; } = startedAt;
+
+ public bool HasExited { get; private set; }
+
+ public int? ExitCode { get; private set; }
+
+ public bool WasKilled { get; private set; }
+
+ public bool Disposed { get; private set; }
+
+ /// Der Prozess endet — mit oder ohne Zutun.
+ public void Exit(int code)
+ {
+ HasExited = true;
+ ExitCode = code;
+ }
+
+ public void Kill()
+ {
+ WasKilled = true;
+ if (!HasExited) Exit(-1);
+ }
+
+ public void Dispose() => Disposed = true;
+}
+
+internal sealed class FakeLauncher : IInstanceLauncher
+{
+ private int _nextId = 1000;
+
+ public FakeProcess? Last { get; private set; }
+
+ public int Starts { get; private set; }
+
+ /// Wenn gesetzt, scheitert der Start damit.
+ public Exception? FailStartWith { get; set; }
+
+ /// Was liefern soll — bereits laufende Prozesse.
+ public Dictionary Running { get; } = [];
+
+ public DateTimeOffset Now { get; set; } = DateTimeOffset.UnixEpoch;
+
+ public IInstanceProcess Start(string instanceDirectory)
+ {
+ Starts++;
+
+ if (FailStartWith is not null) throw FailStartWith;
+
+ Last = new FakeProcess(_nextId++, Now);
+ Running[Last.Id] = Last;
+ return Last;
+ }
+
+ public IInstanceProcess? Attach(int processId, DateTimeOffset startedAt)
+ {
+ if (!Running.TryGetValue(processId, out var process))
+ return null;
+
+ // Dieselbe Pruefung wie im Echtbetrieb: gleiche Kennung, aber andere Startzeit
+ // heisst anderer Prozess.
+ return (process.StartedAt - startedAt).Duration() > TimeSpan.FromSeconds(2)
+ ? null
+ : process;
+ }
+}
+
+/// Der Socket, den der Test antworten oder schweigen lässt.
+internal sealed class FakeChannel : IInstanceChannel
+{
+ public InstanceIdentity? Answer { get; set; }
+
+ public int Calls { get; private set; }
+
+ /// Wie oft die Instanz gebeten wurde, sich zu beenden.
+ public int StopRequests { get; private set; }
+
+ /// Ob die Bitte ankommt. Falsch heisst: Die Instanz hoert gar nicht mehr zu.
+ public bool StopRequestArrives { get; set; } = true;
+
+ public Task AskAsync(string instanceDirectory, CancellationToken ct = default)
+ {
+ Calls++;
+ return Task.FromResult(Answer);
+ }
+
+ public Task RequestStopAsync(string instanceDirectory, CancellationToken ct = default)
+ {
+ StopRequests++;
+ return Task.FromResult(StopRequestArrives);
+ }
+}
diff --git a/tests/ClawdDotNet.Manager.Tests/GlobalUsings.cs b/tests/ClawdDotNet.Manager.Tests/GlobalUsings.cs
new file mode 100644
index 0000000..c802f44
--- /dev/null
+++ b/tests/ClawdDotNet.Manager.Tests/GlobalUsings.cs
@@ -0,0 +1 @@
+global using Xunit;
diff --git a/tests/ClawdDotNet.Manager.Tests/InstanceSupervisorTests.cs b/tests/ClawdDotNet.Manager.Tests/InstanceSupervisorTests.cs
new file mode 100644
index 0000000..c614da2
--- /dev/null
+++ b/tests/ClawdDotNet.Manager.Tests/InstanceSupervisorTests.cs
@@ -0,0 +1,387 @@
+using Microsoft.Extensions.Logging.Abstractions;
+using Shouldly;
+
+namespace ClawdDotNet.Manager.Tests;
+
+///
+/// Die Aufsicht über eine Instanz (#528, Stufe 3).
+///
+/// Der Anlass. Am 2026-09-08 hat sich eine Instanz aufgehängt und wurde von
+/// Windows beendet. Zu diesem Vorgang steht keine einzige Zeile in den Instanzprotokollen,
+/// kein Bugtracker-Eintrag, kein Watchdog-Alarm — weil Meldeweg und Watchdog in genau dem
+/// Prozess lebten, über den sie hätten berichten sollen (#529). Die Abnahmebedingung für
+/// diese Stufe lautet deshalb: eine Instanz abschießen, und der Manager merkt es und
+/// meldet es.
+///
+/// Der schwierigere Fall steht darunter: ein Prozess, der lebt und nicht
+/// mehr arbeitet. Den sieht keine Prozessliste — nur der Socket.
+///
+public sealed class InstanceSupervisorTests : IDisposable
+{
+ private readonly string _dir = Path.Combine(
+ Path.GetTempPath(), "clawd-tests", "sup-" + Guid.NewGuid().ToString("N")[..8]);
+
+ private readonly FakeLauncher _launcher = new();
+ private readonly FakeChannel _kanal = new();
+ private readonly List _ereignisse = [];
+
+ private static readonly InstanceIdentity Test = new("i-1", "Test", "0.1.2");
+
+ ///
+ /// Kurze Fristen: Der Test soll die Aufsicht prüfen, nicht die Geduld.
+ ///
+ /// Knapp über dem Abfragetakt von 250 ms, damit die Warteschleifen je einen
+ /// Durchgang schaffen — es geht um die Entscheidung am Ende der Frist, nicht darum,
+ /// wie oft dazwischen gefragt wurde.
+ ///
+ private static readonly SupervisionOptions Options = new(
+ ProbeInterval: TimeSpan.FromMilliseconds(50),
+ StartTimeout: TimeSpan.FromMilliseconds(600),
+ SilentChecksBeforeAlarm: 3,
+ StopGrace: TimeSpan.FromMilliseconds(300));
+
+ public InstanceSupervisorTests() => Directory.CreateDirectory(_dir);
+
+ public void Dispose()
+ {
+ try { Directory.Delete(_dir, recursive: true); }
+ catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ }
+ }
+
+ private InstanceSupervisor Aufsicht()
+ {
+ var supervisor = new InstanceSupervisor(
+ _dir, _launcher, _kanal, NullLogger.Instance, Options);
+
+ supervisor.Observed += _ereignisse.Add;
+ return supervisor;
+ }
+
+ private IEnumerable Arten => _ereignisse.Select(e => e.Kind);
+
+ // ─── Starten ───
+
+ [Fact]
+ public async Task Ein_Start_gilt_erst_wenn_die_Instanz_antwortet()
+ {
+ _kanal.Answer = Test;
+
+ await using var aufsicht = Aufsicht();
+ (await aufsicht.StartAsync()).ShouldBeTrue();
+
+ aufsicht.Condition.ShouldBe(InstanceCondition.Running);
+ aufsicht.InstanceName.ShouldBe("Test");
+ Arten.ShouldBe([InstanceEventKind.Started]);
+
+ var state = InstanceStateStore.Read(_dir).ShouldNotBeNull();
+ state.InstanceId.ShouldBe("i-1");
+ state.ProcessId.ShouldBe(_launcher.Last!.Id);
+ state.ManagerProcessId.ShouldBe(Environment.ProcessId);
+ }
+
+ ///
+ /// „Der Prozess läuft" ist nicht dasselbe wie „die Instanz läuft".
+ ///
+ /// Zwischen beidem liegen Datenbank, Agenten und Kestrel. Wer nur auf
+ /// Process.Start wartet, hält eine Instanz für gestartet, die gleich an einer
+ /// kaputten Konfiguration scheitert.
+ ///
+ [Fact]
+ public async Task Ein_Prozess_der_sich_nie_meldet_gilt_nicht_als_gestartet()
+ {
+ _kanal.Answer = null; // laeuft, sagt aber nichts
+
+ await using var aufsicht = Aufsicht();
+ (await aufsicht.StartAsync()).ShouldBeFalse();
+
+ aufsicht.Condition.ShouldBe(InstanceCondition.Stopped);
+ Arten.ShouldBe([InstanceEventKind.StartFailed]);
+
+ // Nicht stehen lassen: Ein Prozess, der laeuft und schweigt, haelt den Socket
+ // besetzt und blockiert damit den naechsten Versuch.
+ _launcher.Last!.WasKilled.ShouldBeTrue();
+ InstanceStateStore.Read(_dir).ShouldBeNull();
+ }
+
+ [Fact]
+ public async Task Ein_Prozess_der_gar_nicht_startet_meldet_das()
+ {
+ _launcher.FailStartWith = new InvalidOperationException("Programmdatei fehlt");
+
+ await using var aufsicht = Aufsicht();
+ (await aufsicht.StartAsync()).ShouldBeFalse();
+
+ Arten.ShouldBe([InstanceEventKind.StartFailed]);
+ _ereignisse[0].Message.ShouldContain("Programmdatei fehlt");
+ }
+
+ // ─── Der Fall, um den es geht ───
+
+ [Fact]
+ public async Task Ein_abgeschossener_Prozess_wird_bemerkt_und_gemeldet()
+ {
+ _kanal.Answer = Test;
+
+ await using var aufsicht = Aufsicht();
+ await aufsicht.StartAsync();
+
+ // Jemand schiesst die Instanz ab. Niemand hat darum gebeten.
+ _launcher.Last!.Exit(code: 137);
+ _kanal.Answer = null;
+
+ await aufsicht.CheckAsync();
+
+ aufsicht.Condition.ShouldBe(InstanceCondition.Crashed);
+
+ var absturz = _ereignisse.Single(e => e.Kind == InstanceEventKind.Crashed);
+ absturz.ExitCode.ShouldBe(137);
+ absturz.InstanceName.ShouldBe("Test");
+ absturz.WorthReporting.ShouldBeTrue("Das ist die Meldung, die es 2026-09-08 nicht gab.");
+
+ // Und die Zustandsdatei zeigt nicht mehr auf einen Prozess, den es nicht gibt.
+ InstanceStateStore.Read(_dir).ShouldBeNull();
+ }
+
+ ///
+ /// Der schwierigere Fall: Der Prozess lebt und arbeitet nicht mehr.
+ ///
+ /// Genau das war der Zustand am 2026-09-08 — die Anwendung stand einwandfrei in
+ /// der Prozessliste, bis Windows sie als „reagiert nicht mehr" beendet hat. Eine
+ /// Aufsicht, die nur Prozesse zählt, sieht davon nichts.
+ ///
+ [Fact]
+ public async Task Ein_lebender_aber_stummer_Prozess_faellt_auf()
+ {
+ _kanal.Answer = Test;
+
+ await using var aufsicht = Aufsicht();
+ await aufsicht.StartAsync();
+
+ _kanal.Answer = null; // haengt - der Prozess lebt weiter
+
+ // Nicht beim ersten Aussetzer: Der waere Alltag, und ein Alarm bei jedem
+ // Aussetzer ist nach einer Woche der Alarm, den niemand mehr liest.
+ await aufsicht.CheckAsync();
+ await aufsicht.CheckAsync();
+ Arten.ShouldNotContain(InstanceEventKind.Unresponsive);
+ aufsicht.Condition.ShouldBe(InstanceCondition.Running);
+
+ await aufsicht.CheckAsync();
+
+ aufsicht.Condition.ShouldBe(InstanceCondition.Silent);
+ var alarm = _ereignisse.Single(e => e.Kind == InstanceEventKind.Unresponsive);
+ alarm.WorthReporting.ShouldBeTrue();
+ _launcher.Last!.HasExited.ShouldBeFalse("Der Prozess laeuft ja - das ist der Punkt.");
+ }
+
+ [Fact]
+ public async Task Ein_stummer_Prozess_wird_einmal_gemeldet_und_nicht_bei_jedem_Durchgang()
+ {
+ _kanal.Answer = Test;
+
+ await using var aufsicht = Aufsicht();
+ await aufsicht.StartAsync();
+
+ _kanal.Answer = null;
+
+ for (var i = 0; i < 20; i++)
+ await aufsicht.CheckAsync();
+
+ _ereignisse.Count(e => e.Kind == InstanceEventKind.Unresponsive).ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task Wer_wieder_antwortet_wird_wieder_als_laufend_gefuehrt()
+ {
+ _kanal.Answer = Test;
+
+ await using var aufsicht = Aufsicht();
+ await aufsicht.StartAsync();
+
+ _kanal.Answer = null;
+ for (var i = 0; i < 3; i++) await aufsicht.CheckAsync();
+ aufsicht.Condition.ShouldBe(InstanceCondition.Silent);
+
+ _kanal.Answer = Test;
+ await aufsicht.CheckAsync();
+
+ aufsicht.Condition.ShouldBe(InstanceCondition.Running);
+ Arten.ShouldContain(InstanceEventKind.Recovered);
+ }
+
+ // ─── Anhalten ───
+
+ [Fact]
+ public async Task Ein_erbetenes_Ende_ist_kein_Absturz()
+ {
+ _kanal.Answer = Test;
+
+ await using var aufsicht = Aufsicht();
+ await aufsicht.StartAsync();
+
+ // Die Instanz endet auf die Bitte hin - so wie im Betrieb.
+ _launcher.Last!.Exit(code: 0);
+ _kanal.Answer = null;
+
+ (await aufsicht.StopAsync()).ShouldBeTrue();
+
+ _kanal.StopRequests.ShouldBe(1, "Erst bitten, nicht schiessen.");
+ _launcher.Last.WasKilled.ShouldBeFalse();
+
+ aufsicht.Condition.ShouldBe(InstanceCondition.Stopped);
+ Arten.ShouldNotContain(InstanceEventKind.Crashed);
+ _ereignisse.Last().Kind.ShouldBe(InstanceEventKind.StoppedOrderly);
+ _ereignisse.Last().WorthReporting.ShouldBeFalse();
+ }
+
+ [Fact]
+ public async Task Wer_nicht_hoert_wird_nach_der_Frist_beendet()
+ {
+ _kanal.Answer = Test;
+
+ await using var aufsicht = Aufsicht();
+ await aufsicht.StartAsync();
+
+ // Antwortet weiter und endet nicht - die Bitte verhallt.
+ _kanal.StopRequestArrives = false;
+
+ (await aufsicht.StopAsync()).ShouldBeTrue();
+
+ _kanal.StopRequests.ShouldBe(1);
+ _launcher.Last!.WasKilled.ShouldBeTrue();
+ aufsicht.Condition.ShouldBe(InstanceCondition.Stopped);
+ }
+
+ ///
+ /// Eine Instanz, die der Manager nicht selbst gestartet hat und die nicht hört, kann
+ /// er auch nicht beenden — und das muss er sagen, statt Erfolg zu melden.
+ ///
+ /// Der Fall entsteht nach einem Managerneustart, wenn die Prozesskennung nicht
+ /// mehr zu greifen ist (siehe ).
+ ///
+ [Fact]
+ public async Task Was_nicht_zu_fassen_ist_wird_nicht_als_beendet_gemeldet()
+ {
+ _kanal.Answer = Test;
+ _kanal.StopRequestArrives = false;
+
+ await using var aufsicht = Aufsicht();
+ await aufsicht.ReattachAsync(); // uebernommen, aber ohne Prozess
+ aufsicht.ProcessId.ShouldBeNull();
+
+ (await aufsicht.StopAsync()).ShouldBeFalse();
+
+ aufsicht.Condition.ShouldBe(InstanceCondition.Running);
+ Arten.ShouldNotContain(InstanceEventKind.StoppedOrderly);
+ }
+
+ // ─── Wiederfinden (E-M6) ───
+
+ [Fact]
+ public async Task Eine_laufende_Instanz_wird_nach_einem_Managerneustart_uebernommen()
+ {
+ _kanal.Answer = Test;
+
+ // Erster Manager startet sie.
+ await using (var erster = Aufsicht())
+ await erster.StartAsync();
+
+ _ereignisse.Clear();
+
+ // Zweiter Manager, derselbe Rechner, dieselbe laufende Instanz.
+ await using var zweiter = Aufsicht();
+ (await zweiter.ReattachAsync()).ShouldBeTrue();
+
+ zweiter.Condition.ShouldBe(InstanceCondition.Running);
+ zweiter.InstanceName.ShouldBe("Test");
+ zweiter.ProcessId.ShouldBe(_launcher.Last!.Id);
+
+ // Uebernehmen ist kein Starten - es gibt nichts zu melden.
+ _ereignisse.ShouldBeEmpty();
+ _launcher.Starts.ShouldBe(1);
+ }
+
+ ///
+ /// Eine wiederverwendete Prozesskennung darf nicht zur Übernahme eines fremden
+ /// Programms führen.
+ ///
+ /// Nach einem Neustart der Maschine gehört 4711 mit einiger Wahrscheinlichkeit
+ /// etwas ganz anderem. Es dann zu beaufsichtigen — und beim Anhalten zu beenden —
+ /// wäre deutlich schlimmer, als die Instanz gar nicht wiederzufinden.
+ ///
+ [Fact]
+ public async Task Eine_wiederverwendete_Prozesskennung_wird_nicht_fuer_die_Instanz_gehalten()
+ {
+ _kanal.Answer = Test;
+
+ await using (var erster = Aufsicht())
+ await erster.StartAsync();
+
+ var alte = _launcher.Last!;
+
+ // Dieselbe Kennung, viel spaeter gestartet: ein anderer Prozess.
+ _launcher.Running[alte.Id] = new FakeProcess(alte.Id, alte.StartedAt.AddHours(3));
+
+ await using var zweiter = Aufsicht();
+ (await zweiter.ReattachAsync()).ShouldBeTrue("Der Socket antwortet ja.");
+
+ zweiter.ProcessId.ShouldBeNull(
+ "Der fremde Prozess darf nicht als der eigene uebernommen werden.");
+ }
+
+ [Fact]
+ public async Task Eine_Zustandsdatei_ohne_laufende_Instanz_wird_weggeraeumt()
+ {
+ _kanal.Answer = Test;
+
+ await using (var erster = Aufsicht())
+ await erster.StartAsync();
+
+ InstanceStateStore.Read(_dir).ShouldNotBeNull();
+
+ // Der Manager war weg, die Instanz ist inzwischen auch weg.
+ _kanal.Answer = null;
+
+ await using var zweiter = Aufsicht();
+ (await zweiter.ReattachAsync()).ShouldBeFalse();
+
+ zweiter.Condition.ShouldBe(InstanceCondition.Stopped);
+ InstanceStateStore.Read(_dir).ShouldBeNull(
+ "Eine Datei, die auf niemanden zeigt, laesst es aussehen, als liefe etwas.");
+ }
+
+ [Fact]
+ public async Task Ohne_Zustandsdatei_wird_eine_antwortende_Instanz_trotzdem_uebernommen()
+ {
+ // Der Fall nach einem Absturz des Managers, bevor er schreiben konnte - oder
+ // nachdem jemand die Datei geloescht hat. Der Socket antwortet, also laeuft sie.
+ _kanal.Answer = Test;
+
+ await using var aufsicht = Aufsicht();
+ (await aufsicht.ReattachAsync()).ShouldBeTrue();
+
+ aufsicht.Condition.ShouldBe(InstanceCondition.Running);
+ aufsicht.ProcessId.ShouldBeNull("Es gibt keinen Prozess, den dieser Manager kennt.");
+ InstanceStateStore.Read(_dir).ShouldNotBeNull();
+ }
+
+ // ─── Was gemeldet wird und was nicht ───
+
+ ///
+ /// Der Manager meldet, was aus seiner Sicht relevant ist — und nur das (Konzept,
+ /// Abschnitt 4). Was die Instanz betrifft, bleibt in der Instanz und geht mit ihrem
+ /// eigenen Sub-Token raus.
+ ///
+ [Theory]
+ [InlineData(InstanceEventKind.Crashed, true)]
+ [InlineData(InstanceEventKind.Unresponsive, true)]
+ [InlineData(InstanceEventKind.StartFailed, true)]
+ [InlineData(InstanceEventKind.Started, false)]
+ [InlineData(InstanceEventKind.StoppedOrderly, false)]
+ [InlineData(InstanceEventKind.Recovered, false)]
+ public void Gemeldet_wird_nur_was_niemand_bestellt_hat(InstanceEventKind kind, bool erwartet)
+ {
+ new SupervisionEvent(kind, _dir, "Test", 1, null, "", DateTimeOffset.UtcNow)
+ .WorthReporting.ShouldBe(erwartet);
+ }
+}