Files
RichardandClaude Opus 5 dcffcdcff5 Instanzmanager: Weboberflaeche kennt Instanzen, geordnetes Ende, Puls (#528, #531)
Weboberflaeche (#531): X-Instanz an der einen fetch-Stelle, Instanzauswahl in der
Kopfzeile, Verwaltungsansicht mit Anlegen/Starten/Anhalten/Fenster, Herkunft der
Ereignisse, Sicherungs- und Einstellungsansicht. 402/409/502 werden eigen behandelt.

Sicherungen im Manager (#530): Auflisten, Anlegen, Wiederherstellen, Loeschen. Das
Zurueckspielen haelt die Instanz vorher an und startet sie danach wieder.

Geordnetes Ende (Konzept 9.8). Zwei Befunde waren der Anlass:

Der Manager konnte eine Instanz MIT FENSTER nie geordnet beenden.
Control.ShutdownRequested war nur in HeadlessRun verdrahtet, nicht in der
Avalonia-Anwendung - und ein Fenster ist die Vorgabe. Die Bitte wurde mit 202
beantwortet, niemand hoerte zu, und der Manager schoss den Prozess nach Ablauf
seiner Frist jedes Mal ab.

InstanceSupervisor.StopAsync hielt Socket-Schweigen fuer einen Todesnachweis und
meldete "beendet", ohne den Prozess anzufassen. Bei einer verstummten Instanz ist
der Socket von vornherein still - der Neustart haette einen zweiten Prozess auf
dieselbe state.db gesetzt. Genau der Schaden, gegen den #528 gebaut ist.

- ShutdownCoordinator: erst die Tuer schliessen, dann auf offene Arbeiten warten,
  Frist zwei Minuten. Vorher brach TaskScanner.DisposeAsync mitten in ProcessAsync
  ab, sodass die Aufgabe bis zum Lease-Ablauf auf "running" stehenblieb.
- GET /api/instanz/puls hinter dem Socket-Gate: Phase und offene Arbeiten. Weiter
  gefragt statt gemeldet - ein Puls, den die Instanz von sich aus schickt, fehlt
  genau dann, wenn sie haengt.
- Eskalationsleiter: drei Durchgaenge ohne Antwort, einmal um ein Ende bitten,
  nach 30s beenden und den Vollzug abwarten, gemeldet als Absturz.
- EnsureDeadAsync vor jedem Start.

Weiteres aus dem Review:
- .gitignore verankert: "Instances/" passte auch auf src/ClawdDotNet.Core/Instances/.
  InstanceEndpoint.cs und InstanceLayout.cs waren dadurch nie eingecheckt.
- Das WebUI-Token stand im Protokoll des Managers. Konsole ja, Datei nein.
- Ein Klick auf "Instanz anlegen" loeste requestSubmit() zweimal aus.
- [Obsolete] auf WebServerPort wieder entfernt: sechs Warnungen, die niemand
  abstellen konnte, solange E-M5 nicht fertig ist.
- Die Wiederherstellung bricht nicht mehr ab, wenn der Browser weggeht.
- Entwicklungspfade zum Instanzmanager aus Segmenten statt mit Rueckstrichen, und
  der Port kommt aus manager.json statt fest aus der Zeile.

Offen und gemeldet: --managed hebelt die Lizenzpruefung aus (#535).

806 Tests gruen, 0 Warnungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 11:15:59 +02:00

445 lines
16 KiB
C#

using System.Text.Json;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Instances;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
namespace ClawdDotNet.Manager.Tests;
/// <summary>
/// „Autostart durch Manager" — der Schalter, der eine Instanz am Laufen hält (#528).
///
/// <para><b>Zwei Dinge in einem Schalter, weil es dieselbe Zusage ist:</b> Der Manager
/// startet die Instanz beim Hochkommen, und er startet sie nach einem Absturz wieder. Ohne
/// den Schalter rührt er nichts an — ein Manager, der nach einem Neustart der Maschine
/// alles hochfährt, was er findet, startet auch das, was jemand mit Absicht angehalten
/// hat.</para>
///
/// <para><b>Und der Sicherungsautomat ist der eigentliche Inhalt.</b> Wieder hochzufahren
/// ist die einfache Hälfte; die schwierige ist zu erkennen, wann man es lassen muss.</para>
/// </summary>
public sealed class AutostartTests : IAsyncDisposable
{
private readonly string _root = Path.Combine(
Path.GetTempPath(), "clawd-tests", "auto-" + Guid.NewGuid().ToString("N")[..8]);
private readonly FakeLauncher _launcher = new();
private readonly FakeChannel _kanal = new();
private readonly FakeTokenSource _tokens = new();
private readonly InstanceDirectoryManager _verzeichnisse;
private static readonly SupervisionOptions Options = new(
ProbeInterval: TimeSpan.FromMilliseconds(50),
StartTimeout: TimeSpan.FromMilliseconds(600),
SilentChecksBeforeAlarm: 3,
StopGrace: TimeSpan.FromMilliseconds(300),
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);
}
/// <summary>Legt eine Instanz an und setzt den Schalter in ihrer Instanzdatei.</summary>
private string Instanz(string name, bool autostart)
{
var pfad = _verzeichnisse.CreateInstance(name);
var datei = Path.Combine(pfad, "InstanceSettings.json");
var config = JsonSerializer.Deserialize<InstanceConfig>(File.ReadAllText(datei))!;
config.AutostartByManager = autostart;
File.WriteAllText(datei, JsonSerializer.Serialize(config));
_kanal.AnswerFor[Path.GetFullPath(pfad)] = new InstanceIdentity("i-" + name, name, "0.1.2");
return pfad;
}
private static SupervisionEvent Absturz(string pfad, string name) => new(
InstanceEventKind.Crashed, Path.GetFullPath(pfad), name, 4711, 137,
"unerwartet beendet", DateTimeOffset.UtcNow);
// ─── Beim Hochkommen ───
[Fact]
public async Task Eine_Instanz_mit_Autostart_wird_beim_Hochfahren_gestartet()
{
Instanz("Alpha", autostart: true);
await using var manager = Manager();
await manager.StartAsync();
_launcher.Starts.ShouldBe(1);
manager.Instances.Instances.Single().Condition.ShouldBe(InstanceCondition.Running);
}
[Fact]
public async Task Ohne_Autostart_bleibt_sie_aus()
{
Instanz("Alpha", autostart: false);
await using var manager = Manager();
await manager.StartAsync();
_launcher.Starts.ShouldBe(0);
manager.Instances.Instances.Single().Condition.ShouldBe(InstanceCondition.Stopped);
}
/// <summary>
/// Was schon läuft, wird nicht noch einmal gestartet: Ein zweiter Prozess auf derselben
/// <c>state.db</c> wäre das Gegenteil von hilfreich.
/// </summary>
[Fact]
public async Task Was_schon_laeuft_wird_nicht_noch_einmal_gestartet()
{
var pfad = Instanz("Alpha", autostart: true);
await using (var erster = Manager())
await erster.StartAsync();
_launcher.Starts.ShouldBe(1);
await using var zweiter = Manager();
await zweiter.StartAsync();
_launcher.Starts.ShouldBe(1, "Uebernommen, nicht neu gestartet.");
zweiter.Instances.Instances.Single().ProcessId.ShouldBe(_launcher.Last!.Id);
}
[Fact]
public async Task Der_Schalter_steht_in_der_Liste()
{
Instanz("Mit", autostart: true);
Instanz("Ohne", autostart: false);
await using var manager = Manager();
await manager.StartAsync();
manager.Instances.Instances.Single(i => i.Name == "Mit").AutostartByManager.ShouldBeTrue();
manager.Instances.Instances.Single(i => i.Name == "Ohne").AutostartByManager.ShouldBeFalse();
}
// ─── Nach einem Absturz ───
[Fact]
public async Task Nach_einem_Absturz_wird_wieder_gestartet()
{
var pfad = Instanz("Alpha", autostart: true);
await using var manager = Manager();
await manager.StartAsync();
var vorher = _launcher.Starts;
// Der Prozess ist weg; die Aufsicht hat es gemeldet.
_launcher.Last!.Exit(137);
await manager.RestartAfterCrashAsync(Absturz(pfad, "Alpha"));
_launcher.Starts.ShouldBe(vorher + 1);
}
[Fact]
public async Task Ohne_Autostart_wird_nach_einem_Absturz_nichts_getan()
{
var pfad = Instanz("Alpha", autostart: false);
await using var manager = Manager();
await manager.StartAsync();
await manager.StartInstanceAsync(pfad);
_launcher.Last!.Exit(137);
await manager.RestartAfterCrashAsync(Absturz(pfad, "Alpha"));
_launcher.Starts.ShouldBe(1, "Nur der Start von Hand, kein Neustart.");
}
/// <summary>
/// Eine verstummte Instanz wird beendet und neu gestartet — aber erst, nachdem der
/// alte Prozess nachweislich weg ist.
///
/// <para><b>Der Fall wird hier wirklich hergestellt und nicht behauptet.</b> 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.</para>
/// </summary>
[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.");
}
/// <summary>
/// Eine Instanz, die aufräumt, wird nicht abgeschossen — auch wenn es dauert.
///
/// <para>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".</para>
/// </summary>
[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<bool> bedingung)
{
for (var i = 0; i < 100 && !bedingung(); i++)
await Task.Delay(20);
}
/// <summary>
/// Nach der Grenze setzt der Manager aus.
///
/// <para>Was an einer kaputten Konfiguration scheitert, scheitert beim zehnten Versuch
/// genauso — dann hat man statt einer toten Instanz eine tote Instanz, ein volles
/// Protokoll und einen Bugtracker voller Wiederholungen.</para>
/// </summary>
[Fact]
public async Task Nach_drei_Neustarts_im_Zeitfenster_setzt_der_Manager_aus()
{
var pfad = Instanz("Wackelig", autostart: true);
await using var manager = Manager(new RestartPolicy(MaxRestarts: 3, Window: TimeSpan.FromMinutes(10)));
await manager.StartAsync();
var nachStart = _launcher.Starts;
for (var i = 0; i < 6; i++)
{
_launcher.Last!.Exit(137);
await manager.RestartAfterCrashAsync(Absturz(pfad, "Wackelig"));
}
(_launcher.Starts - nachStart).ShouldBe(3, "Drei Versuche, dann Schluss.");
}
/// <summary>
/// Wer von Hand startet, hat entweder etwas geändert oder weiß, was er tut. Ihm die
/// Versuche von vorhin anzurechnen hieße, ihn für den Absturz zu bestrafen.
/// </summary>
[Fact]
public async Task Ein_Start_von_Hand_raeumt_den_Zaehler_ab()
{
var pfad = Instanz("Wackelig", autostart: true);
await using var manager = Manager(new RestartPolicy(3, TimeSpan.FromMinutes(10)));
await manager.StartAsync();
for (var i = 0; i < 5; i++)
{
_launcher.Last!.Exit(137);
await manager.RestartAfterCrashAsync(Absturz(pfad, "Wackelig"));
}
var erschoepft = _launcher.Starts;
// Ein neuer Manager faengt bei null an - genau wie ein Start von Hand.
await using var frisch = Manager(new RestartPolicy(3, TimeSpan.FromMinutes(10)));
await frisch.StartAsync();
_launcher.Last!.Exit(137);
await frisch.RestartAfterCrashAsync(Absturz(pfad, "Wackelig"));
_launcher.Starts.ShouldBeGreaterThan(erschoepft);
}
}
/// <summary>Der Sicherungsautomat für sich genommen.</summary>
public sealed class RestartLedgerTests
{
private sealed class Uhr(DateTimeOffset jetzt) : TimeProvider
{
public DateTimeOffset Jetzt { get; set; } = jetzt;
public override DateTimeOffset GetUtcNow() => Jetzt;
}
[Fact]
public void Bis_zur_Grenze_ja_danach_nein()
{
var buch = new RestartLedger(new RestartPolicy(3, TimeSpan.FromMinutes(10)));
buch.TryRecord("/a", out _).ShouldBeTrue();
buch.TryRecord("/a", out _).ShouldBeTrue();
buch.TryRecord("/a", out var dritter).ShouldBeTrue();
dritter.ShouldBe(3);
buch.TryRecord("/a", out _).ShouldBeFalse();
}
[Fact]
public void Jede_Instanz_hat_ihr_eigenes_Konto()
{
var buch = new RestartLedger(new RestartPolicy(1, TimeSpan.FromMinutes(10)));
buch.TryRecord("/a", out _).ShouldBeTrue();
buch.TryRecord("/a", out _).ShouldBeFalse();
buch.TryRecord("/b", out _).ShouldBeTrue("Die eine Instanz sperrt die andere nicht.");
}
/// <summary>
/// Das Zeitfenster wandert: Eine Instanz, die einmal im Monat abstürzt und wieder
/// hochkommt, läuft nie in die Grenze — eine, die im Minutentakt fällt, sofort.
/// </summary>
[Fact]
public void Alte_Versuche_fallen_aus_dem_Fenster()
{
var uhr = new Uhr(DateTimeOffset.UnixEpoch);
var buch = new RestartLedger(new RestartPolicy(2, TimeSpan.FromMinutes(10)), uhr);
buch.TryRecord("/a", out _).ShouldBeTrue();
buch.TryRecord("/a", out _).ShouldBeTrue();
buch.TryRecord("/a", out _).ShouldBeFalse();
uhr.Jetzt = uhr.Jetzt.AddMinutes(11);
buch.TryRecord("/a", out var wieder).ShouldBeTrue();
wieder.ShouldBe(1, "Die alten Versuche zaehlen nicht mehr mit.");
}
[Fact]
public void Vergessen_setzt_zurueck()
{
var buch = new RestartLedger(new RestartPolicy(1, TimeSpan.FromMinutes(10)));
buch.TryRecord("/a", out _).ShouldBeTrue();
buch.TryRecord("/a", out _).ShouldBeFalse();
buch.Forget("/a");
buch.TryRecord("/a", out _).ShouldBeTrue();
}
}
/// <summary>
/// Ein Start, der auf einen veralteten Merkzettel trifft.
///
/// <para><b>Der Defekt, den erst die Autostart-Tests gezeigt haben.</b> Zwischen zwei
/// Aufsichtsdurchgängen liegen Sekunden, und in denen kann eine Instanz wegbrechen. Wer in
/// dieser Lücke „starten" verlangte, bekam „läuft schon" — einen Erfolg, hinter dem nichts
/// stand. Der nächste Durchgang meldete dann einen Absturz, den niemand mehr erwartete.</para>
/// </summary>
public sealed class StaleStateTests : IAsyncDisposable
{
private readonly string _root = Path.Combine(
Path.GetTempPath(), "clawd-tests", "stale-" + Guid.NewGuid().ToString("N")[..8]);
private readonly FakeLauncher _launcher = new();
private readonly FakeChannel _kanal = new();
public StaleStateTests()
{
Directory.CreateDirectory(_root);
_kanal.Launcher = _launcher;
_kanal.Answer = new InstanceIdentity("i-1", "Alpha", "0.1.2");
}
public async ValueTask DisposeAsync()
{
await Task.CompletedTask;
try { Directory.Delete(_root, recursive: true); }
catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ }
}
[Fact]
public async Task Ein_Start_prueft_nach_statt_dem_eigenen_Zustand_zu_glauben()
{
var dir = Path.Combine(_root, "Instance-Alpha");
Directory.CreateDirectory(dir);
await using var aufsicht = new InstanceSupervisor(
dir, _launcher, _kanal, NullLogger.Instance,
new SupervisionOptions(
TimeSpan.FromSeconds(30), // absichtlich traege: kein Durchgang dazwischen
TimeSpan.FromMilliseconds(600),
3,
TimeSpan.FromMilliseconds(300),
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);
}
}