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
@@ -118,4 +118,16 @@ internal sealed class FakeChannel : IInstanceChannel
StopRequests++;
return Task.FromResult(StopRequestArrives);
}
/// <summary>Welche Instanzen gebeten wurden, ihr Fenster nach vorn zu holen.</summary>
public List<string> FocusRequests { get; } = [];
/// <summary>Ob die Instanz ein Fenster hat, das nach vorn kommen kann.</summary>
public bool HasWindow { get; set; } = true;
public Task<bool> RequestFocusAsync(string instanceDirectory, CancellationToken ct = default)
{
FocusRequests.Add(Path.GetFullPath(instanceDirectory));
return Task.FromResult(HasWindow);
}
}
@@ -217,3 +217,93 @@ public sealed class ManagerCoreTests : IAsyncDisposable
(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));
public InstanceFocusTests()
{
Directory.CreateDirectory(_root);
_verzeichnisse = new InstanceDirectoryManager(Path.Combine(_root, "Instances"));
}
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();
}
}