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:
@@ -0,0 +1,160 @@
|
||||
using ClawdDotNet.Core.Instances;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Core.Tests.Instances;
|
||||
|
||||
/// <summary>
|
||||
/// Das geordnete Ende einer Instanz (#528).
|
||||
///
|
||||
/// <para><b>Worum es geht.</b> Bis hierher war jedes Beenden ein Abschneiden. Eine
|
||||
/// Aufgabe, die mitten in der Ausführung abgebrochen wird, ist bereits beansprucht und
|
||||
/// noch nicht abgeschlossen — sie steht danach auf <c>running</c> und bleibt dort bis zum
|
||||
/// Ablauf ihres Lease. Ein Agentenlauf verliert seine Antwort ganz.</para>
|
||||
/// </summary>
|
||||
public sealed class ShutdownCoordinatorTests
|
||||
{
|
||||
private static ShutdownCoordinator Koordinator(TimeSpan? frist = null)
|
||||
=> new(NullLogger.Instance, frist ?? TimeSpan.FromSeconds(5));
|
||||
|
||||
/// <summary>
|
||||
/// Die Reihenfolge ist der ganze Trick: erst schließen, dann warten.
|
||||
///
|
||||
/// <para>Andersherum wartet man ewig — die Quelle nähme im nächsten Takt die nächste
|
||||
/// Arbeit an, und die Zahl der offenen ginge nie auf null.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Erst_wird_die_Tuer_geschlossen_dann_gewartet()
|
||||
{
|
||||
var geschlossen = false;
|
||||
var offenBeimSchliessen = -1;
|
||||
var offen = 3;
|
||||
|
||||
var koordinator = Koordinator();
|
||||
|
||||
koordinator.Register(new WorkSource(
|
||||
"Aufgaben",
|
||||
() => offen,
|
||||
() =>
|
||||
{
|
||||
geschlossen = true;
|
||||
offenBeimSchliessen = offen;
|
||||
}));
|
||||
|
||||
// Die Arbeiten laufen im Hintergrund aus.
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
while (offen > 0)
|
||||
{
|
||||
await Task.Delay(30);
|
||||
offen--;
|
||||
}
|
||||
});
|
||||
|
||||
await koordinator.BeginAsync();
|
||||
|
||||
geschlossen.ShouldBeTrue();
|
||||
offenBeimSchliessen.ShouldBe(3, "Geschlossen wird, bevor gewartet wird.");
|
||||
koordinator.Phase.ShouldBe(ShutdownPhase.Done);
|
||||
koordinator.Snapshot().TotalOpen.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ohne offene Arbeit endet es sofort — kein Warten aus Prinzip.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Ohne_offene_Arbeit_endet_es_sofort()
|
||||
{
|
||||
var koordinator = Koordinator(TimeSpan.FromMinutes(10));
|
||||
koordinator.Register(new WorkSource("Aufgaben", () => 0, () => { }));
|
||||
|
||||
var begonnen = DateTimeOffset.UtcNow;
|
||||
await koordinator.BeginAsync();
|
||||
|
||||
(DateTimeOffset.UtcNow - begonnen).ShouldBeLessThan(TimeSpan.FromSeconds(2));
|
||||
koordinator.Phase.ShouldBe(ShutdownPhase.Done);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Die Frist ist eine Zusage, keine Hoffnung.
|
||||
///
|
||||
/// <para>Sonst hinge die Instanz an einem Agentenlauf, der auf ein Modell wartet, das
|
||||
/// nicht antwortet — und niemand käme je zum Ende.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Nach_der_Frist_endet_es_auch_mit_offener_Arbeit()
|
||||
{
|
||||
var koordinator = Koordinator(TimeSpan.FromMilliseconds(300));
|
||||
koordinator.Register(new WorkSource("Agentenläufe", () => 2, () => { }));
|
||||
|
||||
await koordinator.BeginAsync();
|
||||
|
||||
koordinator.Phase.ShouldBe(ShutdownPhase.Done);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Während des Auslaufens sagt der Puls, was noch offen ist — daran erkennt der
|
||||
/// Instanzmanager, dass hier aufgeräumt und nicht gehangen wird.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Waehrend_des_Auslaufens_meldet_der_Stand_was_offen_ist()
|
||||
{
|
||||
var offen = 2;
|
||||
var koordinator = Koordinator(TimeSpan.FromSeconds(5));
|
||||
koordinator.Register(new WorkSource("Agentenläufe", () => offen, () => { }));
|
||||
|
||||
koordinator.Snapshot().Phase.ShouldBe(ShutdownPhase.Running);
|
||||
|
||||
var lauf = koordinator.BeginAsync();
|
||||
|
||||
// Solange etwas offen ist, steht der Stand auf Draining und nennt die Zahl.
|
||||
var stand = koordinator.Snapshot();
|
||||
stand.Phase.ShouldBe(ShutdownPhase.Draining);
|
||||
stand.TotalOpen.ShouldBe(2);
|
||||
stand.Open.Single().Name.ShouldBe("Agentenläufe");
|
||||
|
||||
offen = 0;
|
||||
await lauf;
|
||||
|
||||
koordinator.Snapshot().Phase.ShouldBe(ShutdownPhase.Done);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Zwei Aufrufer, ein Vorgang.
|
||||
///
|
||||
/// <para>Beide Wege gibt es wirklich: Der Manager bittet über den Socket, und
|
||||
/// gleichzeitig kann jemand am Fenster auf das Kreuz klicken. Zweimal auszulaufen
|
||||
/// hieße, die Tür zweimal zu schließen und zweimal zu warten.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Ein_zweiter_Aufruf_wartet_auf_denselben_Vorgang()
|
||||
{
|
||||
var schliessungen = 0;
|
||||
var koordinator = Koordinator(TimeSpan.FromSeconds(5));
|
||||
koordinator.Register(new WorkSource("Aufgaben", () => 0, () => schliessungen++));
|
||||
|
||||
await Task.WhenAll(koordinator.BeginAsync(), koordinator.BeginAsync());
|
||||
|
||||
schliessungen.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Eine Quelle, die beim Zählen stolpert, darf das Ende nicht aufhalten.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Eine_stolpernde_Quelle_haelt_das_Ende_nicht_auf()
|
||||
{
|
||||
var koordinator = Koordinator(TimeSpan.FromSeconds(30));
|
||||
|
||||
koordinator.Register(new WorkSource(
|
||||
"Kaputt",
|
||||
() => throw new InvalidOperationException("zählt nicht"),
|
||||
() => throw new InvalidOperationException("schließt nicht")));
|
||||
|
||||
var begonnen = DateTimeOffset.UtcNow;
|
||||
await koordinator.BeginAsync();
|
||||
|
||||
(DateTimeOffset.UtcNow - begonnen).ShouldBeLessThan(TimeSpan.FromSeconds(2));
|
||||
koordinator.Phase.ShouldBe(ShutdownPhase.Done);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
using ClawdDotNet.Core.Backup;
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Instances;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Manager.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests für Sicherung und Wiederherstellung im Manager (#530, Stufe 6).
|
||||
/// </summary>
|
||||
public sealed class BackupManagementTests : IDisposable
|
||||
{
|
||||
private readonly string _root = Path.Combine(
|
||||
Path.GetTempPath(), "clawd-tests", "backup-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
|
||||
public BackupManagementTests()
|
||||
{
|
||||
Directory.CreateDirectory(_root);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_root, recursive: true); }
|
||||
catch { }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BackupDirectory_wird_standardmaessig_unter_Backups_aufgeloest()
|
||||
{
|
||||
var instancePath = Path.Combine(_root, "TestInstance");
|
||||
Directory.CreateDirectory(instancePath);
|
||||
|
||||
var config = new InstanceConfig { WorkingDirectory = instancePath };
|
||||
var backupDir = InstanceLayout.ResolveBackupDirectory(config, instancePath);
|
||||
|
||||
backupDir.ShouldBe(Path.Combine(instancePath, "Backups"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Backup_kann_erstellt_geprueft_und_wiederhergestellt_werden()
|
||||
{
|
||||
var instancePath = Path.Combine(_root, "InstanceAlpha");
|
||||
Directory.CreateDirectory(instancePath);
|
||||
|
||||
var settingsPath = Path.Combine(instancePath, "InstanceSettings.json");
|
||||
await File.WriteAllTextAsync(settingsPath, """
|
||||
{
|
||||
"instanceId": "alpha-123",
|
||||
"instanceName": "Alpha",
|
||||
"workingDirectory": ""
|
||||
}
|
||||
""");
|
||||
|
||||
var testDataPath = Path.Combine(instancePath, "data.txt");
|
||||
await File.WriteAllTextAsync(testDataPath, "Wichtige Testdaten");
|
||||
|
||||
var backupDir = InstanceLayout.ResolveBackupDirectory(
|
||||
new InstanceConfig { WorkingDirectory = instancePath }, instancePath);
|
||||
Directory.CreateDirectory(backupDir);
|
||||
|
||||
var zipPath = Path.Combine(backupDir, "backup_Alpha_test.zip");
|
||||
|
||||
var service = new BackupService();
|
||||
var backupResult = await service.CreateAsync(instancePath, zipPath, new BackupOptions
|
||||
{
|
||||
Secrets = SecretMode.Exclude,
|
||||
IncludeChatHistory = true,
|
||||
IncludeLogs = false
|
||||
});
|
||||
|
||||
File.Exists(zipPath).ShouldBeTrue();
|
||||
backupResult.SizeBytes.ShouldBeGreaterThan(0);
|
||||
|
||||
// Prüfen
|
||||
var manifest = await service.InspectAsync(zipPath);
|
||||
manifest.InstanceId.ShouldBe("alpha-123");
|
||||
manifest.InstanceName.ShouldBe("Alpha");
|
||||
manifest.Files.Count.ShouldBeGreaterThan(0);
|
||||
|
||||
// Datei im Ziel modifizieren und Restore prüfen
|
||||
await File.WriteAllTextAsync(testDataPath, "Überschriebene Testdaten");
|
||||
|
||||
var restoreResult = await service.RestoreAsync(
|
||||
zipPath,
|
||||
instancePath,
|
||||
new RestoreOptions { Overwrite = true });
|
||||
|
||||
restoreResult.Written.ShouldContain("data.txt");
|
||||
var restoredContent = await File.ReadAllTextAsync(testDataPath);
|
||||
restoredContent.ShouldBe("Wichtige Testdaten");
|
||||
|
||||
// Löschen
|
||||
File.Delete(zipPath);
|
||||
File.Exists(zipPath).ShouldBeFalse();
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,20 @@ internal sealed class FakeLauncher : IInstanceLauncher
|
||||
return Last;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ein Prozess, der schon lief, bevor dieser Manager kam (E-M6).
|
||||
///
|
||||
/// <para>Zählt nicht als Start — genau darin liegt der Unterschied, den mehrere Tests
|
||||
/// prüfen: Was läuft, wird übernommen und nicht ein zweites Mal hochgefahren.</para>
|
||||
/// </summary>
|
||||
public FakeProcess Fremdstart(string instanceDirectory, int processId)
|
||||
{
|
||||
var prozess = new FakeProcess(processId, Now);
|
||||
Running[processId] = prozess;
|
||||
ByDirectory[Path.GetFullPath(instanceDirectory)] = prozess;
|
||||
return prozess;
|
||||
}
|
||||
|
||||
public IInstanceProcess? Attach(int processId, DateTimeOffset startedAt)
|
||||
{
|
||||
if (!Running.TryGetValue(processId, out var process))
|
||||
@@ -120,6 +134,20 @@ internal sealed class FakeChannel : IInstanceChannel
|
||||
/// </summary>
|
||||
public FakeLauncher? Launcher { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Verzeichnisse, deren Instanz verstummt ist: Der Prozess lebt, der Socket schweigt.
|
||||
///
|
||||
/// <para>Der Fall aus #529 — und der einzige, in dem die Aufsicht von sich aus zur
|
||||
/// Gewalt greift. Ohne diesen Schalter lässt er sich nicht nachstellen: Die Kopplung
|
||||
/// an <see cref="Launcher"/> bindet die Antwort sonst genau an das, was hier
|
||||
/// auseinanderfallen soll.</para>
|
||||
/// </summary>
|
||||
public HashSet<string> Verstummt { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Die Instanz hört auf zu antworten, ohne dass ihr Prozess endet.</summary>
|
||||
public void Verstummen(string instanceDirectory)
|
||||
=> Verstummt.Add(Path.GetFullPath(instanceDirectory));
|
||||
|
||||
public int Calls { get; private set; }
|
||||
|
||||
/// <summary>Wie oft die Instanz gebeten wurde, sich zu beenden.</summary>
|
||||
@@ -132,6 +160,9 @@ internal sealed class FakeChannel : IInstanceChannel
|
||||
{
|
||||
Calls++;
|
||||
|
||||
if (Verstummt.Contains(Path.GetFullPath(instanceDirectory)))
|
||||
return Task.FromResult<InstanceIdentity?>(null);
|
||||
|
||||
if (Launcher is not null && !Launcher.IsRunning(instanceDirectory))
|
||||
return Task.FromResult<InstanceIdentity?>(null);
|
||||
|
||||
|
||||
@@ -27,10 +27,17 @@ public sealed class InstanceRegistryTests : 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 InstanceRegistryTests()
|
||||
=> _verzeichnisse = new InstanceDirectoryManager(Path.Combine(_root, "Instances"));
|
||||
{
|
||||
_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()
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using ClawdDotNet.Core.Instances;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
|
||||
@@ -38,9 +39,19 @@ public sealed class InstanceSupervisorTests : IDisposable
|
||||
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 InstanceSupervisorTests() => Directory.CreateDirectory(_dir);
|
||||
public InstanceSupervisorTests()
|
||||
{
|
||||
Directory.CreateDirectory(_dir);
|
||||
|
||||
// Der Socket antwortet nur, wenn wirklich ein Prozess laeuft - genau wie im
|
||||
// Betrieb. Eine vorab hinterlegte Antwort hiesse sonst "laeuft schon", und die
|
||||
// Aufsicht wuerde uebernehmen statt zu starten.
|
||||
_kanal.Launcher = _launcher;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
@@ -252,6 +263,72 @@ public sealed class InstanceSupervisorTests : IDisposable
|
||||
aufsicht.Condition.ShouldBe(InstanceCondition.Stopped);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schweigen ist kein Todesnachweis, solange ein Prozesshandle das Gegenteil sagt.
|
||||
///
|
||||
/// <para><b>Hier lag der gefährlichste Fehlschluss.</b> Die Warteschleife brach ab,
|
||||
/// sobald der Socket nicht mehr antwortete, meldete „beendet" und gab <c>true</c>
|
||||
/// zurück — ohne den Prozess anzufassen. Bei einer verstummten Instanz ist der Socket
|
||||
/// aber von vornherein still: Der Aufruf meldete sofort Erfolg, der Prozess lief
|
||||
/// weiter, und der Neustart setzte einen zweiten auf dieselbe <c>state.db</c>. Genau
|
||||
/// der Schaden, gegen den dieses Vorhaben gebaut ist.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Ein_stummer_aber_lebender_Prozess_gilt_nicht_als_beendet()
|
||||
{
|
||||
_kanal.Answer = Test;
|
||||
|
||||
await using var aufsicht = Aufsicht();
|
||||
await aufsicht.StartAsync();
|
||||
|
||||
var prozess = _launcher.Last.ShouldNotBeNull();
|
||||
|
||||
// Sie haengt: Der Socket schweigt, der Prozess lebt.
|
||||
_kanal.Verstummen(_dir);
|
||||
|
||||
(await aufsicht.StopAsync()).ShouldBeTrue();
|
||||
|
||||
prozess.WasKilled.ShouldBeTrue(
|
||||
"Wer schweigt und lebt, muss beendet werden - nicht fuer beendet erklaert.");
|
||||
|
||||
prozess.HasExited.ShouldBeTrue();
|
||||
aufsicht.Condition.ShouldBe(InstanceCondition.Stopped);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wer aufräumt, wird nicht abgeschossen — die Frist wird nachgezogen.
|
||||
///
|
||||
/// <para>Die Instanz gibt sich selbst eine Frist zum Auslaufen. Wäre die des Managers
|
||||
/// kürzer, schösse er mitten in das Aufräumen hinein, um das er gerade gebeten
|
||||
/// hat.</para>
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Wer_aufraeumt_bekommt_die_Frist_nachgezogen()
|
||||
{
|
||||
_kanal.Answer = Test;
|
||||
|
||||
await using var aufsicht = Aufsicht();
|
||||
await aufsicht.StartAsync();
|
||||
|
||||
var prozess = _launcher.Last.ShouldNotBeNull();
|
||||
|
||||
// Sie antwortet und meldet: Ich raeume auf. Laenger als StopGrace (300ms), aber
|
||||
// kuerzer als MaxDrainWait (2s).
|
||||
_kanal.Answer = Test with { Phase = ShutdownPhase.Draining, OpenWork = 1 };
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
await Task.Delay(700);
|
||||
_kanal.Answer = null;
|
||||
prozess.Exit(0);
|
||||
});
|
||||
|
||||
(await aufsicht.StopAsync()).ShouldBeTrue();
|
||||
|
||||
prozess.WasKilled.ShouldBeFalse("Wer aufraeumt, wird nicht abgeschossen.");
|
||||
aufsicht.Condition.ShouldBe(InstanceCondition.Stopped);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
@@ -265,6 +342,10 @@ public sealed class InstanceSupervisorTests : IDisposable
|
||||
_kanal.Answer = Test;
|
||||
_kanal.StopRequestArrives = false;
|
||||
|
||||
// Sie lief schon, bevor dieser Manager kam - und ohne Zustandsdatei ist ihr
|
||||
// Prozess fuer ihn nicht zu greifen.
|
||||
_launcher.Fremdstart(_dir, 4711);
|
||||
|
||||
await using var aufsicht = Aufsicht();
|
||||
await aufsicht.ReattachAsync(); // uebernommen, aber ohne Prozess
|
||||
aufsicht.ProcessId.ShouldBeNull();
|
||||
@@ -356,6 +437,7 @@ public sealed class InstanceSupervisorTests : IDisposable
|
||||
// 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;
|
||||
_launcher.Fremdstart(_dir, 4711);
|
||||
|
||||
await using var aufsicht = Aufsicht();
|
||||
(await aufsicht.ReattachAsync()).ShouldBeTrue();
|
||||
|
||||
@@ -41,12 +41,17 @@ public sealed class ManagerCoreTests : 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 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()
|
||||
@@ -239,12 +244,17 @@ public sealed class InstanceFocusTests : 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 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(
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
using ClawdDotNet.Web.Contracts;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Web.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests für die interne Agenten-Kommunikation A2A (#208).
|
||||
/// </summary>
|
||||
public sealed class AgentCommunicationTests
|
||||
{
|
||||
[Fact]
|
||||
public void AgentCommunicationDto_speichert_Felder_korrekt()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var dto = new AgentCommunicationDto(
|
||||
TargetAgentId: "editor-1",
|
||||
TargetAgentName: "Editor",
|
||||
SourceAgentId: "scout-1",
|
||||
SourceAgentName: "Scout",
|
||||
Role: "user",
|
||||
Content: "Bitte bearbeite Datei X",
|
||||
Timestamp: now);
|
||||
|
||||
dto.TargetAgentId.ShouldBe("editor-1");
|
||||
dto.TargetAgentName.ShouldBe("Editor");
|
||||
dto.SourceAgentId.ShouldBe("scout-1");
|
||||
dto.SourceAgentName.ShouldBe("Scout");
|
||||
dto.Role.ShouldBe("user");
|
||||
dto.Content.ShouldBe("Bitte bearbeite Datei X");
|
||||
dto.Timestamp.ShouldBe(now);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user