using System.Text.Json; using ClawdDotNet.Core.Config; using ClawdDotNet.Core.Instances; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; namespace ClawdDotNet.Manager.Tests; /// /// „Autostart durch Manager" — der Schalter, der eine Instanz am Laufen hält (#528). /// /// Zwei Dinge in einem Schalter, weil es dieselbe Zusage ist: Der Manager /// startet die Instanz beim Hochkommen, und er startet sie nach einem Absturz wieder. Ohne /// den Schalter rührt er nichts an — ein Manager, der nach einem Neustart der Maschine /// alles hochfährt, was er findet, startet auch das, was jemand mit Absicht angehalten /// hat. /// /// Und der Sicherungsautomat ist der eigentliche Inhalt. Wieder hochzufahren /// ist die einfache Hälfte; die schwierige ist zu erkennen, wann man es lassen muss. /// public sealed class AutostartTests : IAsyncDisposable { private readonly string _root = Path.Combine( Path.GetTempPath(), "clawd-tests", "auto-" + Guid.NewGuid().ToString("N")[..8]); private readonly FakeLauncher _launcher = new(); private readonly FakeChannel _kanal = new(); private readonly FakeTokenSource _tokens = new(); private readonly InstanceDirectoryManager _verzeichnisse; private static readonly SupervisionOptions Options = new( ProbeInterval: TimeSpan.FromMilliseconds(50), StartTimeout: TimeSpan.FromMilliseconds(600), SilentChecksBeforeAlarm: 3, StopGrace: TimeSpan.FromMilliseconds(300), SilentKillGrace: TimeSpan.FromMilliseconds(300), MaxDrainWait: TimeSpan.FromSeconds(2)); public AutostartTests() { Directory.CreateDirectory(_root); _verzeichnisse = new InstanceDirectoryManager(Path.Combine(_root, "Instances")); // Der Socket antwortet nur, wenn wirklich ein Prozess laeuft - sonst hiesse eine // vorab hinterlegte Antwort "laeuft schon", und der Manager wuerde uebernehmen // statt zu starten. _kanal.Launcher = _launcher; } public async ValueTask DisposeAsync() { await Task.CompletedTask; try { Directory.Delete(_root, recursive: true); } catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ } } private ManagerCore Manager(RestartPolicy? policy = null) { var register = new InstanceRegistry( _verzeichnisse, _launcher, _kanal, NullLoggerFactory.Instance, Options); return new ManagerCore( new ManagerSettingsStore(_root), register, _tokens, NullLoggerFactory.Instance, restartPolicy: policy); } /// Legt eine Instanz an und setzt den Schalter in ihrer Instanzdatei. private string Instanz(string name, bool autostart) { var pfad = _verzeichnisse.CreateInstance(name); var datei = Path.Combine(pfad, "InstanceSettings.json"); var config = JsonSerializer.Deserialize(File.ReadAllText(datei))!; config.AutostartByManager = autostart; File.WriteAllText(datei, JsonSerializer.Serialize(config)); _kanal.AnswerFor[Path.GetFullPath(pfad)] = new InstanceIdentity("i-" + name, name, "0.1.2"); return pfad; } private static SupervisionEvent Absturz(string pfad, string name) => new( InstanceEventKind.Crashed, Path.GetFullPath(pfad), name, 4711, 137, "unerwartet beendet", DateTimeOffset.UtcNow); // ─── Beim Hochkommen ─── [Fact] public async Task Eine_Instanz_mit_Autostart_wird_beim_Hochfahren_gestartet() { Instanz("Alpha", autostart: true); await using var manager = Manager(); await manager.StartAsync(); _launcher.Starts.ShouldBe(1); manager.Instances.Instances.Single().Condition.ShouldBe(InstanceCondition.Running); } [Fact] public async Task Ohne_Autostart_bleibt_sie_aus() { Instanz("Alpha", autostart: false); await using var manager = Manager(); await manager.StartAsync(); _launcher.Starts.ShouldBe(0); manager.Instances.Instances.Single().Condition.ShouldBe(InstanceCondition.Stopped); } /// /// Was schon läuft, wird nicht noch einmal gestartet: Ein zweiter Prozess auf derselben /// state.db wäre das Gegenteil von hilfreich. /// [Fact] public async Task Was_schon_laeuft_wird_nicht_noch_einmal_gestartet() { var pfad = Instanz("Alpha", autostart: true); await using (var erster = Manager()) await erster.StartAsync(); _launcher.Starts.ShouldBe(1); await using var zweiter = Manager(); await zweiter.StartAsync(); _launcher.Starts.ShouldBe(1, "Uebernommen, nicht neu gestartet."); zweiter.Instances.Instances.Single().ProcessId.ShouldBe(_launcher.Last!.Id); } [Fact] public async Task Der_Schalter_steht_in_der_Liste() { Instanz("Mit", autostart: true); Instanz("Ohne", autostart: false); await using var manager = Manager(); await manager.StartAsync(); manager.Instances.Instances.Single(i => i.Name == "Mit").AutostartByManager.ShouldBeTrue(); manager.Instances.Instances.Single(i => i.Name == "Ohne").AutostartByManager.ShouldBeFalse(); } // ─── Nach einem Absturz ─── [Fact] public async Task Nach_einem_Absturz_wird_wieder_gestartet() { var pfad = Instanz("Alpha", autostart: true); await using var manager = Manager(); await manager.StartAsync(); var vorher = _launcher.Starts; // Der Prozess ist weg; die Aufsicht hat es gemeldet. _launcher.Last!.Exit(137); await manager.RestartAfterCrashAsync(Absturz(pfad, "Alpha")); _launcher.Starts.ShouldBe(vorher + 1); } [Fact] public async Task Ohne_Autostart_wird_nach_einem_Absturz_nichts_getan() { var pfad = Instanz("Alpha", autostart: false); await using var manager = Manager(); await manager.StartAsync(); await manager.StartInstanceAsync(pfad); _launcher.Last!.Exit(137); await manager.RestartAfterCrashAsync(Absturz(pfad, "Alpha")); _launcher.Starts.ShouldBe(1, "Nur der Start von Hand, kein Neustart."); } /// /// Eine verstummte Instanz wird beendet und neu gestartet — aber erst, nachdem der /// alte Prozess nachweislich weg ist. /// /// Der Fall wird hier wirklich hergestellt und nicht behauptet. Der /// Prozess lebt weiter, nur der Socket schweigt — genau die Lage aus #529. Ein Test, /// der stattdessen das Ereignis von Hand baut und die Neustartmethode aufruft, prüft /// die eine Hälfte, die ohnehin funktioniert, und lässt die gefährliche aus: dass vor /// dem Start niemand mehr auf der Datenbank sitzt. /// [Fact] public async Task Eine_verstummte_Instanz_wird_erst_beendet_und_dann_neu_gestartet() { var pfad = Instanz("Alpha", autostart: true); await using var manager = Manager(); await manager.StartAsync(); var alt = _launcher.Last.ShouldNotBeNull(); _launcher.Starts.ShouldBe(1); // Ab jetzt haengt sie: Der Prozess lebt, der Socket antwortet nicht mehr. _kanal.Verstummen(pfad); var aufsicht = manager.Instances.SupervisorFor(pfad).ShouldNotBeNull(); // Drei Durchgaenge bis zum Alarm - vorher wird nichts unternommen. for (var i = 0; i < 3; i++) await aufsicht.CheckAsync(); aufsicht.Condition.ShouldBe(InstanceCondition.Silent); alt.WasKilled.ShouldBeFalse("Vor Ablauf der Frist wird nicht abgeschossen."); // Die Frist laeuft ab (SilentKillGrace: 300ms). await Task.Delay(400); await aufsicht.CheckAsync(); alt.WasKilled.ShouldBeTrue("Nach der Frist wird der haengende Prozess beendet."); // Der Absturzweg startet neu - und der neue Prozess ist ein anderer. await Warte(() => _launcher.Starts >= 2); _launcher.Last.ShouldNotBeNull().Id.ShouldNotBe(alt.Id); alt.HasExited.ShouldBeTrue("Der alte Prozess darf beim Start nicht mehr leben."); } /// /// Eine Instanz, die aufräumt, wird nicht abgeschossen — auch wenn es dauert. /// /// Sie antwortet ja; sie tut genau das, worum gebeten wurde. Der Unterschied /// zwischen „hängt" und „räumt auf" ist der ganze Grund, warum der Puls mehr sagt als /// „jemand hat geantwortet". /// [Fact] public async Task Wer_aufraeumt_gilt_nicht_als_verstummt() { var pfad = Instanz("Alpha", autostart: true); await using var manager = Manager(); await manager.StartAsync(); var prozess = _launcher.Last.ShouldNotBeNull(); _kanal.AnswerFor[Path.GetFullPath(pfad)] = new InstanceIdentity( "i-Alpha", "Alpha", "0.1.2", ShutdownPhase.Draining, OpenWork: 2, OpenSummary: "Agentenläufe: 2"); var aufsicht = manager.Instances.SupervisorFor(pfad).ShouldNotBeNull(); for (var i = 0; i < 5; i++) await aufsicht.CheckAsync(); aufsicht.Condition.ShouldBe(InstanceCondition.Running); prozess.WasKilled.ShouldBeFalse(); _launcher.Starts.ShouldBe(1); } private static async Task Warte(Func bedingung) { for (var i = 0; i < 100 && !bedingung(); i++) await Task.Delay(20); } /// /// Nach der Grenze setzt der Manager aus. /// /// Was an einer kaputten Konfiguration scheitert, scheitert beim zehnten Versuch /// genauso — dann hat man statt einer toten Instanz eine tote Instanz, ein volles /// Protokoll und einen Bugtracker voller Wiederholungen. /// [Fact] public async Task Nach_drei_Neustarts_im_Zeitfenster_setzt_der_Manager_aus() { var pfad = Instanz("Wackelig", autostart: true); await using var manager = Manager(new RestartPolicy(MaxRestarts: 3, Window: TimeSpan.FromMinutes(10))); await manager.StartAsync(); var nachStart = _launcher.Starts; for (var i = 0; i < 6; i++) { _launcher.Last!.Exit(137); await manager.RestartAfterCrashAsync(Absturz(pfad, "Wackelig")); } (_launcher.Starts - nachStart).ShouldBe(3, "Drei Versuche, dann Schluss."); } /// /// Wer von Hand startet, hat entweder etwas geändert oder weiß, was er tut. Ihm die /// Versuche von vorhin anzurechnen hieße, ihn für den Absturz zu bestrafen. /// [Fact] public async Task Ein_Start_von_Hand_raeumt_den_Zaehler_ab() { var pfad = Instanz("Wackelig", autostart: true); await using var manager = Manager(new RestartPolicy(3, TimeSpan.FromMinutes(10))); await manager.StartAsync(); for (var i = 0; i < 5; i++) { _launcher.Last!.Exit(137); await manager.RestartAfterCrashAsync(Absturz(pfad, "Wackelig")); } var erschoepft = _launcher.Starts; // Ein neuer Manager faengt bei null an - genau wie ein Start von Hand. await using var frisch = Manager(new RestartPolicy(3, TimeSpan.FromMinutes(10))); await frisch.StartAsync(); _launcher.Last!.Exit(137); await frisch.RestartAfterCrashAsync(Absturz(pfad, "Wackelig")); _launcher.Starts.ShouldBeGreaterThan(erschoepft); } } /// Der Sicherungsautomat für sich genommen. public sealed class RestartLedgerTests { private sealed class Uhr(DateTimeOffset jetzt) : TimeProvider { public DateTimeOffset Jetzt { get; set; } = jetzt; public override DateTimeOffset GetUtcNow() => Jetzt; } [Fact] public void Bis_zur_Grenze_ja_danach_nein() { var buch = new RestartLedger(new RestartPolicy(3, TimeSpan.FromMinutes(10))); buch.TryRecord("/a", out _).ShouldBeTrue(); buch.TryRecord("/a", out _).ShouldBeTrue(); buch.TryRecord("/a", out var dritter).ShouldBeTrue(); dritter.ShouldBe(3); buch.TryRecord("/a", out _).ShouldBeFalse(); } [Fact] public void Jede_Instanz_hat_ihr_eigenes_Konto() { var buch = new RestartLedger(new RestartPolicy(1, TimeSpan.FromMinutes(10))); buch.TryRecord("/a", out _).ShouldBeTrue(); buch.TryRecord("/a", out _).ShouldBeFalse(); buch.TryRecord("/b", out _).ShouldBeTrue("Die eine Instanz sperrt die andere nicht."); } /// /// Das Zeitfenster wandert: Eine Instanz, die einmal im Monat abstürzt und wieder /// hochkommt, läuft nie in die Grenze — eine, die im Minutentakt fällt, sofort. /// [Fact] public void Alte_Versuche_fallen_aus_dem_Fenster() { var uhr = new Uhr(DateTimeOffset.UnixEpoch); var buch = new RestartLedger(new RestartPolicy(2, TimeSpan.FromMinutes(10)), uhr); buch.TryRecord("/a", out _).ShouldBeTrue(); buch.TryRecord("/a", out _).ShouldBeTrue(); buch.TryRecord("/a", out _).ShouldBeFalse(); uhr.Jetzt = uhr.Jetzt.AddMinutes(11); buch.TryRecord("/a", out var wieder).ShouldBeTrue(); wieder.ShouldBe(1, "Die alten Versuche zaehlen nicht mehr mit."); } [Fact] public void Vergessen_setzt_zurueck() { var buch = new RestartLedger(new RestartPolicy(1, TimeSpan.FromMinutes(10))); buch.TryRecord("/a", out _).ShouldBeTrue(); buch.TryRecord("/a", out _).ShouldBeFalse(); buch.Forget("/a"); buch.TryRecord("/a", out _).ShouldBeTrue(); } } /// /// Ein Start, der auf einen veralteten Merkzettel trifft. /// /// Der Defekt, den erst die Autostart-Tests gezeigt haben. Zwischen zwei /// Aufsichtsdurchgängen liegen Sekunden, und in denen kann eine Instanz wegbrechen. Wer in /// dieser Lücke „starten" verlangte, bekam „läuft schon" — einen Erfolg, hinter dem nichts /// stand. Der nächste Durchgang meldete dann einen Absturz, den niemand mehr erwartete. /// public sealed class StaleStateTests : IAsyncDisposable { private readonly string _root = Path.Combine( Path.GetTempPath(), "clawd-tests", "stale-" + Guid.NewGuid().ToString("N")[..8]); private readonly FakeLauncher _launcher = new(); private readonly FakeChannel _kanal = new(); public StaleStateTests() { Directory.CreateDirectory(_root); _kanal.Launcher = _launcher; _kanal.Answer = new InstanceIdentity("i-1", "Alpha", "0.1.2"); } public async ValueTask DisposeAsync() { await Task.CompletedTask; try { Directory.Delete(_root, recursive: true); } catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ } } [Fact] public async Task Ein_Start_prueft_nach_statt_dem_eigenen_Zustand_zu_glauben() { var dir = Path.Combine(_root, "Instance-Alpha"); Directory.CreateDirectory(dir); await using var aufsicht = new InstanceSupervisor( dir, _launcher, _kanal, NullLogger.Instance, new SupervisionOptions( TimeSpan.FromSeconds(30), // absichtlich traege: kein Durchgang dazwischen TimeSpan.FromMilliseconds(600), 3, TimeSpan.FromMilliseconds(300), TimeSpan.FromMilliseconds(300), TimeSpan.FromSeconds(2))); (await aufsicht.StartAsync()).ShouldBeTrue(); aufsicht.Condition.ShouldBe(InstanceCondition.Running); // Die Instanz bricht weg. Die Aufsicht hat es noch nicht bemerkt - der naechste // Durchgang kaeme erst in dreissig Sekunden. _launcher.Last!.Exit(137); aufsicht.Condition.ShouldBe(InstanceCondition.Running, "Noch nichts bemerkt."); (await aufsicht.StartAsync()).ShouldBeTrue(); _launcher.Starts.ShouldBe(2, "Es wurde wirklich gestartet, nicht nur zugestimmt."); aufsicht.Condition.ShouldBe(InstanceCondition.Running); } }