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); + } +}