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

320 lines
11 KiB
C#

using ClawdDotNet.Core.Instances;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
namespace ClawdDotNet.Manager.Tests;
/// <summary>Zählt mit, wem ein Token besorgt wurde.</summary>
internal sealed class FakeTokenSource : IInstanceTokenSource
{
public List<string> Fuer { get; } = [];
public bool Erfolg { get; set; } = true;
public Task<bool> EnsureTokenAsync(string instanceDirectory, CancellationToken ct = default)
{
Fuer.Add(Path.GetFullPath(instanceDirectory));
return Task.FromResult(Erfolg);
}
}
/// <summary>
/// Der Instanzmanager als Ganzes (#528, Stufe 3).
///
/// <para><b>Die Lizenz wird hier ohne Lizenzserver geprüft</b>, und das ist Absicht: Die
/// Zusage lautet nicht „der Torwächter funktioniert" — das ist die Aufgabe des SDK —,
/// sondern „ein Urteil führt zum Anhalten aller Instanzen, ein Ausfall nicht". Genau
/// dieser Weg wird hier gegangen, ausgelöst über
/// <see cref="ManagerCore.HandleLicenseRevokedAsync"/>.</para>
/// </summary>
public sealed class ManagerCoreTests : IAsyncDisposable
{
private readonly string _root = Path.Combine(
Path.GetTempPath(), "clawd-tests", "core-" + 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 ManagerCoreTests()
{
Directory.CreateDirectory(_root);
_verzeichnisse = new InstanceDirectoryManager(Path.Combine(_root, "Instances"));
// Der Socket antwortet nur, wenn wirklich ein Prozess laeuft - genau wie im
// Betrieb (siehe FakeChannel.Launcher).
_kanal.Launcher = _launcher;
}
private ManagerCore Manager()
{
var register = new InstanceRegistry(
_verzeichnisse, _launcher, _kanal, NullLoggerFactory.Instance, Options);
return new ManagerCore(
new ManagerSettingsStore(_root), register, _tokens, NullLoggerFactory.Instance);
}
public async ValueTask DisposeAsync()
{
await Task.CompletedTask;
try { Directory.Delete(_root, recursive: true); }
catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ }
}
/// <summary>
/// Lässt die Instanz ab jetzt antworten — und prüft, dass sie danach wirklich
/// gestartet und nicht bloß übernommen wurde.
///
/// <para><b>Der Unterschied ist keine Feinheit.</b> Wer die Antwort schon vor dem
/// Hochfahren des Managers hinterlegt, sagt damit „diese Instanz läuft bereits" — der
/// Manager übernimmt sie dann (E-M6), der Starter wird nie angefasst, und der Test
/// prüft etwas anderes, als er behauptet. Genau das ist mir hier zweimal
/// passiert.</para>
/// </summary>
private async Task<StartRefusal> StarteUndPruefe(ManagerCore manager, string pfad, string name)
{
var vorher = _launcher.Starts;
_kanal.AnswerFor[Path.GetFullPath(pfad)] = new InstanceIdentity("i-1", name, "0.1.2");
var ergebnis = await manager.StartInstanceAsync(pfad);
_launcher.Starts.ShouldBe(vorher + 1, "Gestartet, nicht uebernommen.");
return ergebnis;
}
// ─── Hochfahren ───
[Fact]
public async Task Ohne_Lizenzpruefung_arbeitet_der_Manager()
{
// Kein Torwaechter heisst keine Sperre - sonst gaebe es eine Henne-Ei-Sperre,
// bevor ueberhaupt jemand einen Schluessel eintragen kann.
await using var manager = Manager();
(await manager.StartAsync()).ShouldBeTrue();
manager.LicenseHolds.ShouldBeTrue();
}
[Fact]
public async Task Beim_Hochfahren_wird_nichts_gestartet()
{
_verzeichnisse.CreateInstance("Alpha");
await using var manager = Manager();
await manager.StartAsync();
manager.Instances.Instances.Count.ShouldBe(1);
_launcher.Starts.ShouldBe(0);
}
// ─── Token ───
[Fact]
public async Task Eine_neue_Instanz_bekommt_gleich_ihr_eigenes_Token()
{
// Beim Anlegen und nicht beim ersten Start: Dann gehoert die Instanzdatei
// zweifelsfrei niemandem.
await using var manager = Manager();
var pfad = await manager.CreateInstanceAsync("Frisch");
_tokens.Fuer.ShouldBe([Path.GetFullPath(pfad)]);
}
[Fact]
public async Task Vor_dem_Start_wird_das_Token_nachgeholt()
{
// Der Fall einer Instanz, die es vor dem Manager schon gab - oder bei der das
// Deploymentcenter beim Anlegen nicht erreichbar war.
var pfad = _verzeichnisse.CreateInstance("Alt");
await using var manager = Manager();
await manager.StartAsync();
(await StarteUndPruefe(manager, pfad, "Alt")).ShouldBe(StartRefusal.None);
_tokens.Fuer.ShouldContain(Path.GetFullPath(pfad));
}
[Fact]
public async Task Ein_fehlgeschlagener_Tokenbezug_verhindert_den_Start_nicht()
{
// Monitoring, das nur bei perfekter Rechtelage laeuft, ist genau dann still,
// wenn man es braucht.
_tokens.Erfolg = false;
var pfad = _verzeichnisse.CreateInstance("Alt");
await using var manager = Manager();
await manager.StartAsync();
(await StarteUndPruefe(manager, pfad, "Alt")).ShouldBe(StartRefusal.None);
}
// ─── Das Urteil ───
/// <summary>
/// Der einzige Fall, in dem der Manager von sich aus Instanzen anhält.
///
/// <para>Ein Serverausfall gehört ausdrücklich nicht dazu — das SDK trennt Urteil und
/// gescheiterten Versuch, und nur das Urteil kommt hier an. Sonst nähme ein
/// Netzproblem sämtliche Installationen gleichzeitig mit.</para>
/// </summary>
[Fact]
public async Task Ein_Lizenzwiderruf_haelt_alle_laufenden_Instanzen_an()
{
var pfad = _verzeichnisse.CreateInstance("Alpha");
await using var manager = Manager();
await manager.StartAsync();
await StarteUndPruefe(manager, pfad, "Alpha");
manager.Instances.Instances.Single().Condition.ShouldBe(InstanceCondition.Running);
// Die Instanz endet auf die Bitte hin.
_launcher.Last!.Exit(0);
_kanal.AnswerFor[Path.GetFullPath(pfad)] = null;
await manager.HandleLicenseRevokedAsync("Widerrufen durch den Hersteller");
manager.LicenseHolds.ShouldBeFalse();
manager.Instances.Instances.Single().Condition.ShouldBe(InstanceCondition.Stopped);
_kanal.StopRequests.ShouldBe(1, "Erst bitten, nicht schiessen.");
}
[Fact]
public async Task Nach_einem_Widerruf_startet_keine_Instanz_mehr()
{
var pfad = _verzeichnisse.CreateInstance("Alpha");
await using var manager = Manager();
await manager.StartAsync();
await manager.HandleLicenseRevokedAsync("Widerrufen");
(await manager.StartInstanceAsync(pfad)).ShouldBe(StartRefusal.NoLicense);
_launcher.Starts.ShouldBe(0);
_tokens.Fuer.ShouldBeEmpty("Ohne Lizenz wird auch kein Token verbraucht.");
}
// ─── Wenn der Start scheitert ───
[Fact]
public async Task Eine_Instanz_die_sich_nicht_meldet_gilt_als_nicht_gestartet()
{
var pfad = _verzeichnisse.CreateInstance("Stumm");
await using var manager = Manager();
await manager.StartAsync();
// Der Prozess laeuft, die Instanz meldet sich nie.
(await manager.StartInstanceAsync(pfad)).ShouldBe(StartRefusal.DidNotStart);
}
}
/// <summary>
/// Der Wechsel, wie E-M2 ihn meint (#528, Stufe 5).
///
/// <para><b>Vorher hiess „Instanz wechseln":</b> Der laufende Prozess fährt sich herunter
/// und startet sich mit einer anderen Instanz neu. Wer wechselte, beendete zwangsläufig
/// die Instanz, aus der heraus er es tat — und genau dabei hat sich am 2026-09-08 die
/// Anwendung aufgehängt (#529). Jetzt laufen alle, und es kommt nur eine nach vorn.</para>
/// </summary>
public sealed class InstanceFocusTests : IAsyncDisposable
{
private readonly string _root = Path.Combine(
Path.GetTempPath(), "clawd-tests", "focus-" + Guid.NewGuid().ToString("N")[..8]);
private readonly FakeLauncher _launcher = new();
private readonly FakeChannel _kanal = 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 InstanceFocusTests()
{
Directory.CreateDirectory(_root);
_verzeichnisse = new InstanceDirectoryManager(Path.Combine(_root, "Instances"));
// Der Socket antwortet nur, wenn wirklich ein Prozess laeuft - genau wie im
// Betrieb (siehe FakeChannel.Launcher).
_kanal.Launcher = _launcher;
}
private InstanceRegistry Register() => new(
_verzeichnisse, _launcher, _kanal, NullLoggerFactory.Instance, Options);
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 Eine_laufende_Instanz_wird_nach_vorn_geholt()
{
await using var register = Register();
var pfad = register.Create("Alpha");
_kanal.AnswerFor[Path.GetFullPath(pfad)] = new InstanceIdentity("i-1", "Alpha", "0.1.2");
await register.StartAsync(pfad);
(await register.FocusAsync(pfad)).ShouldBeTrue();
_kanal.FocusRequests.ShouldBe([Path.GetFullPath(pfad)]);
// Und vor allem: Es wurde nichts beendet und nichts neu gestartet.
_launcher.Starts.ShouldBe(1);
_kanal.StopRequests.ShouldBe(0);
_launcher.Last!.HasExited.ShouldBeFalse();
}
[Fact]
public async Task Eine_Instanz_die_nicht_laeuft_hat_kein_Fenster()
{
await using var register = Register();
var pfad = register.Create("Alpha");
(await register.FocusAsync(pfad)).ShouldBeFalse();
// Nicht einmal gefragt: Wer nicht laeuft, antwortet auch nicht.
_kanal.FocusRequests.ShouldBeEmpty();
}
/// <summary>
/// Eine kopflose Instanz hat nichts nach vorn zu holen — und sagt das, statt Erfolg
/// zu melden.
/// </summary>
[Fact]
public async Task Eine_kopflose_Instanz_meldet_dass_sie_kein_Fenster_hat()
{
await using var register = Register();
var pfad = register.Create("Alpha");
_kanal.AnswerFor[Path.GetFullPath(pfad)] = new InstanceIdentity("i-1", "Alpha", "0.1.2");
await register.StartAsync(pfad);
_kanal.HasWindow = false;
(await register.FocusAsync(pfad)).ShouldBeFalse();
_kanal.FocusRequests.ShouldHaveSingleItem();
}
}