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>
This commit is contained in:
Richard
2026-09-10 11:15:59 +02:00
co-authored by Claude Opus 5
parent d44bbeac1d
commit dcffcdcff5
48 changed files with 4852 additions and 171 deletions
@@ -32,7 +32,9 @@ public sealed class AutostartTests : IAsyncDisposable
ProbeInterval: TimeSpan.FromMilliseconds(50),
StartTimeout: TimeSpan.FromMilliseconds(600),
SilentChecksBeforeAlarm: 3,
StopGrace: TimeSpan.FromMilliseconds(300));
StopGrace: TimeSpan.FromMilliseconds(300),
SilentKillGrace: TimeSpan.FromMilliseconds(300),
MaxDrainWait: TimeSpan.FromSeconds(2));
public AutostartTests()
{
@@ -175,6 +177,87 @@ public sealed class AutostartTests : IAsyncDisposable
_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.
///
@@ -341,7 +424,9 @@ public sealed class StaleStateTests : IAsyncDisposable
TimeSpan.FromSeconds(30), // absichtlich traege: kein Durchgang dazwischen
TimeSpan.FromMilliseconds(600),
3,
TimeSpan.FromMilliseconds(300)));
TimeSpan.FromMilliseconds(300),
TimeSpan.FromMilliseconds(300),
TimeSpan.FromSeconds(2)));
(await aufsicht.StartAsync()).ShouldBeTrue();
aufsicht.Condition.ShouldBe(InstanceCondition.Running);