Instanzmanager Stufe 5: Wechseln heisst nach vorn holen (#528, E-M2)

Vorher hiess "Instanz wechseln": Der laufende Prozess faehrt sich herunter
und startet sich mit einer anderen Instanz neu. Wer wechselte, beendete
zwangslaeufig die Instanz, aus der heraus er es tat - und genau dabei hat
sich die Anwendung am 2026-09-08 aufgehaengt (#529). Jetzt laufen alle, und
es kommt eine nach vorn.

Neu: POST /api/instanz/fenster, wieder ausschliesslich ueber den Socket.
Die Instanz holt ihr Fenster SELBST nach vorn; der Manager kennt kein
Fenstersystem und soll keines kennen. Kopflos laeuft die Bitte ins Leere,
und das ist die richtige Antwort. Das letzte Wort hat der Fenstermanager -
Windows laesst einen Hintergrundprozess den Vordergrund nicht einfach an
sich reissen; der kurze Ausflug ueber Topmost half im Versuch, unter Linux
entscheidet der Fenstermanager. Mehr als bitten kann eine Anwendung nicht.

ShutdownSignal ist zu InstanceControl geworden: beenden und Fenster sind
zwei Bitten desselben Absenders ueber denselben Weg. Beenden gilt einmal,
das Fenster beliebig oft - jeder Wechsel auf diese Instanz ist einer.

--managed schaltet den Wechsel im Instanzfenster ab. Der Manager gibt es
jedem Instanzprozess mit; das Fenster graut seinen Instanz-Schalter aus und
erklaert auf Nachfrage, wo gewechselt wird. Zwei Schloesser an derselben
Tuer: Der Knopf ist aus, und der Weg dahinter prueft es noch einmal - denn
hinter ihm wartet ein Selbst-Neustart.

instanceWindows in manager.json, vorbelegt auf true: Jede Instanz behaelt
ihr Fenster. Auf einer Maschine ohne Bildschirmsitzung gehoert false hin.

ABNAHME an echten Fenstern: Instanz ueber den Manager gestartet, PID 18128,
Fenstertitel "ClawdDotNet", Befehlszeile mit --managed. Fenster von aussen
minimiert (IsIconic True), dann POST /instanzen/Instance-Test/fenster:
IsIconic False, im Vordergrund - und PID unveraendert 18128. Nichts neu
gestartet. Das ist die Abnahmebedingung dieser Stufe.

Build ohne Warnungen, 780 Tests gruen, 8 uebersprungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-08 23:39:50 +02:00
co-authored by Claude Opus 5
parent a511480b77
commit db7abf699f
18 changed files with 496 additions and 42 deletions
@@ -4,6 +4,7 @@ using ClawdDotNet.App.Settings;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Instances;
using ClawdDotNet.Core.Tools;
using ClawdDotNet.Web.Endpoints;
using ClawdDotNet.Web.Tests.Infrastructure;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
@@ -187,3 +188,110 @@ public sealed class InstanceSocketTests : IDisposable
File.Exists(InstanceEndpoint.SocketPath(host.InstancePath)).ShouldBeFalse();
}
}
/// <summary>
/// Die Steuerung des Instanzprozesses: beenden und Fenster nach vorn holen
/// (#528, Stufen 3 und 5).
///
/// <para><b>Beides gibt es ausschließlich über den Socket.</b> Über TCP existieren die
/// Endpunkte nicht — auch mit gültigem Token nicht, und sie antworten dort mit 404 statt
/// mit 403: Was es nicht gibt, muss man auch nicht erklären. Die Lebensdauer einer Instanz
/// gehört dem Instanzmanager; eine Browsersitzung soll den Prozess, der sie bedient, weder
/// beenden noch sein Fenster aufreißen können.</para>
/// </summary>
public sealed class InstanceControlTests : IDisposable
{
private readonly string _root = Path.Combine(
Path.GetTempPath(), "clawd-tests", "ctl-" + Guid.NewGuid().ToString("N")[..8]);
private const string Token = "ein-hinreichend-langes-testtoken";
public void Dispose()
{
try { Directory.Delete(_root, recursive: true); }
catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ }
}
private AppHost NeuerHost(int port)
{
var instancesDir = Path.Combine(_root, "Instances");
var instancePath = Path.Combine(instancesDir, "Test");
Directory.CreateDirectory(instancePath);
return new AppHost
{
Settings = new SettingsManager(_root),
Directories = new InstanceDirectoryManager(instancesDir),
Instance = new InstanceConfig
{
InstanceName = "Test",
WorkingDirectory = instancePath,
WebUiEnabled = true,
WebUiBinding = "loopback",
WebUiToken = Token,
WebServerPort = port
},
InstancePath = instancePath,
LogDirectory = Path.Combine(instancePath, "Logs"),
LoggerFactory = NullLoggerFactory.Instance,
Tools = new ToolRegistry()
};
}
private static int FreierPort()
{
using var probe = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
probe.Start();
var port = ((System.Net.IPEndPoint)probe.LocalEndpoint).Port;
probe.Stop();
return port;
}
[Fact]
public async Task Ueber_den_Socket_kommt_die_Bitte_um_das_Fenster_an()
{
await using var host = NeuerHost(FreierPort());
await using var web = InstanceHttpHost.TryStart(host, NullLoggerFactory.Instance);
web.ShouldNotBeNull();
var gebeten = 0;
web!.Control.FocusRequested += () => Interlocked.Increment(ref gebeten);
using var client = InstanceEndpoint.CreateClient(host.InstancePath);
(await client.PostAsync("/api/instanz/fenster", content: null)).EnsureSuccessStatusCode();
gebeten.ShouldBe(1);
// Und beliebig oft: Jeder Wechsel auf diese Instanz ist einer.
(await client.PostAsync("/api/instanz/fenster", content: null)).EnsureSuccessStatusCode();
gebeten.ShouldBe(2);
}
[Fact]
public async Task Ueber_TCP_gibt_es_die_Steuerung_nicht()
{
var port = FreierPort();
await using var host = NeuerHost(port);
await using var web = InstanceHttpHost.TryStart(host, NullLoggerFactory.Instance);
web.ShouldNotBeNull();
web!.Url.ShouldNotBeNull();
var gebeten = false;
web.Control.FocusRequested += () => gebeten = true;
using var client = new HttpClient { BaseAddress = new Uri($"http://127.0.0.1:{port}") };
client.DefaultRequestHeaders.Add("Authorization", $"Bearer {Token}");
// Mit gueltigem Token - und trotzdem nicht vorhanden.
(await client.PostAsync("/api/instanz/fenster", content: null)).StatusCode
.ShouldBe(System.Net.HttpStatusCode.NotFound);
(await client.PostAsync("/api/instanz/beenden", content: null)).StatusCode
.ShouldBe(System.Net.HttpStatusCode.NotFound);
gebeten.ShouldBeFalse();
}
}