diff --git a/docs/Instanzmanager-Konzept.md b/docs/Instanzmanager-Konzept.md
index dd0db23..0b4bf22 100644
--- a/docs/Instanzmanager-Konzept.md
+++ b/docs/Instanzmanager-Konzept.md
@@ -323,7 +323,7 @@ Verbindung und führt sie zusammen, mit der Instanzkennung an jedem Ereignis.
| 2 ✔ | Instanzprozess kopflos lauffähig, Kestrel auf dem Socket | Eine Instanz läuft ohne Fenster, der Socket antwortet |
| 3 ◑ | Manager: anlegen, starten, anhalten, Prozessaufsicht, Lizenz, Sub-Token | Eine Instanz abschießen — der Manager merkt es und meldet es |
| 4 ◑ | Weboberfläche zieht um, Weiterleitung, ein Ereignisstrom | Eine Adresse, ein Zugang, Wechsel ohne Neustart |
-| 5 | Instanzfenster als eigener Prozess, Wechsel holt das Fenster nach vorn | Kein Neustart mehr beim Wechseln |
+| 5 ✔ | Instanzfenster als eigener Prozess, Wechsel holt das Fenster nach vorn | Kein Neustart mehr beim Wechseln |
| 6 | Sicherungen im Manager (falls Abschnitt 7 dafür entscheidet) | Zurückspielen hält die Instanz an und startet sie wieder |
**Stufe 1 ist die, die man überspringen möchte und nicht überspringen darf.**
@@ -611,3 +611,40 @@ spricht `/api/…` an und bekommt die eine laufende. Instanzauswahl, `X-Instanz`
Verwaltungsansicht sind Arbeit an `wwwroot` und gehören damit in Geminis Spur. Und
`webServerPort` steht noch in der Instanz: Ihr TCP-Zugang ist erst überflüssig, wenn die
Oberfläche im Manager vollständig ist.
+
+### 9.6 Was Stufe 5 gebracht hat
+
+**Der Wechsel ist kein Neustart mehr.** Vorher hieß „Instanz wechseln": 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 die Anwendung am 2026-09-08 aufgehängt. Jetzt laufen alle, und es kommt eine nach
+vorn.
+
+**Die Instanz holt ihr Fenster selbst.** Neu ist `POST /api/instanz/fenster`, wieder
+ausschließlich über den Socket. Der Manager kennt kein Fenstersystem und soll keines
+kennen; er bittet, und wer ein Fenster hat, holt es nach vorn. Kopflos läuft die Bitte ins
+Leere, und das ist die richtige Antwort. **Das letzte Wort hat der Fenstermanager** —
+Windows lässt einen Hintergrundprozess den Vordergrund nicht einfach an sich reißen; der
+kurze Ausflug über `Topmost` half im Versuch, unter Linux entscheidet der Fenstermanager.
+Mehr als bitten kann eine Anwendung nicht.
+
+**`--managed` schaltet den Wechsel im Instanzfenster ab.** Der Manager gibt es jedem
+Instanzprozess mit; das Fenster gräut seinen Instanz-Schalter aus und erklärt auf Nachfrage,
+wo gewechselt wird. Zwei Schlösser an derselben Tür: Der Knopf ist aus, und der Weg
+dahinter prüft es noch einmal — hinter ihm wartet ein Selbst-Neustart.
+
+**`instanceWindows` in `manager.json`**, vorbelegt auf `true`: Jede Instanz behält ihr
+Fenster (E-M2). Auf einer Maschine ohne Bildschirmsitzung gehört `false` hin.
+
+**Abnahme, an echten Fenstern gemessen:**
+
+| Geprüft | Ergebnis |
+|---|---|
+| Instanz über den Manager gestartet | PID 18128, Fenstertitel „ClawdDotNet" |
+| Befehlszeile | `--managed --instance …` |
+| Fenster von außen minimiert | `IsIconic = True` |
+| `POST /instanzen/Instance-Test/fenster` | „Fenster von 'Test' nach vorn geholt" |
+| Danach | `IsIconic = False`, **im Vordergrund** |
+| Prozess danach | **PID 18128, unverändert** — nichts neu gestartet |
+
+Die letzte Zeile ist die Abnahmebedingung: kein Neustart mehr beim Wechseln.
diff --git a/src/ClawdDotNet.Desktop/App.axaml.cs b/src/ClawdDotNet.Desktop/App.axaml.cs
index 4d362ef..3748d60 100644
--- a/src/ClawdDotNet.Desktop/App.axaml.cs
+++ b/src/ClawdDotNet.Desktop/App.axaml.cs
@@ -32,6 +32,16 @@ public partial class App : Application
/// Vorgewaehlte Instanz aus der Befehlszeile; wird genau einmal verbraucht.
private string? _preselectedInstancePath;
+ ///
+ /// Vom Instanzmanager gestartet (--managed).
+ ///
+ /// Dann gehoert der Instanzwechsel dorthin: Der Manager haelt die Fenster aller
+ /// Instanzen im Hintergrund und holt das gewuenschte nach vorn (E-M2). Ein Fenster,
+ /// das sich selbst mit einer anderen Instanz neu startet, naehme genau die Instanz
+ /// mit, aus der heraus jemand wechselt.
+ ///
+ private bool _managed;
+
public override void Initialize() => AvaloniaXamlLoader.Load(this);
public override void OnFrameworkInitializationCompleted()
@@ -44,6 +54,7 @@ public partial class App : Application
desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown;
_preselectedInstancePath = CommandLine.ReadInstancePath(desktop.Args);
+ _managed = CommandLine.IsManaged(desktop.Args);
ApplySavedThemeEarly();
@@ -127,7 +138,11 @@ public partial class App : Application
// Instanzverzeichnis, ueber den der Instanzmanager die Instanz erreicht (#528).
_web = InstanceHttpHost.TryStart(_host, _host.LoggerFactory);
- var mainViewModel = new MainWindowViewModel(_host);
+ var mainViewModel = new MainWindowViewModel(_host)
+ {
+ // Wer beaufsichtigt wird, wechselt nicht selbst - siehe _managed.
+ CanSwitchInstance = !_managed
+ };
// Der Wechsel gehoert hierher und nicht ins Fenster: Er braucht den Aufbau und
// die Lebensdauer der Anwendung, und beides kennt nur diese Klasse.
@@ -135,6 +150,10 @@ public partial class App : Application
desktop.MainWindow = new MainWindow { DataContext = mainViewModel };
+ // Der Manager bittet ueber den Socket, dieses Fenster nach vorn zu holen (E-M2).
+ if (_web is not null)
+ _web.Control.FocusRequested += () => BringToFront(desktop);
+
desktop.MainWindow.Show();
desktop.MainWindow.Closed += (_, _) => desktop.Shutdown();
@@ -311,6 +330,38 @@ public partial class App : Application
return await completion.Task;
});
+ ///
+ /// Holt das Fenster dieser Instanz nach vorn — auf Bitte des Instanzmanagers.
+ ///
+ /// Die Instanz macht das selbst, statt dass jemand von aussen an ihrem
+ /// Fenster zieht. Der Manager kennt kein Fenstersystem und soll keines kennen; er
+ /// bittet über den Socket, und wer ein Fenster hat, holt es nach vorn. Kopflos läuft
+ /// die Bitte ins Leere, und das ist die richtige Antwort.
+ ///
+ /// Das letzte Wort hat der Fenstermanager. Windows lässt einen Prozess
+ /// im Hintergrund den Vordergrund nicht einfach an sich reissen — Activate
+ /// blinkt dann nur in der Leiste. Der kurze Ausflug über Topmost bringt es in
+ /// den meisten Fällen trotzdem nach vorn; unter Linux entscheidet der Fenstermanager,
+ /// und manche sind da streng. Mehr als bitten kann eine Anwendung nicht, und mehr
+ /// sollte sie auch nicht tun.
+ ///
+ private static void BringToFront(IClassicDesktopStyleApplicationLifetime desktop)
+ => Dispatcher.UIThread.Post(() =>
+ {
+ if (desktop.MainWindow is not { } fenster) return;
+
+ if (fenster.WindowState == WindowState.Minimized)
+ fenster.WindowState = WindowState.Normal;
+
+ fenster.Show();
+
+ var vorher = fenster.Topmost;
+ fenster.Topmost = true;
+ fenster.Topmost = vorher;
+
+ fenster.Activate();
+ });
+
///
/// Instanzwechsel aus der laufenden Anwendung.
///
@@ -326,6 +377,19 @@ public partial class App : Application
{
if (_host is null) return;
+ if (_managed)
+ {
+ // Sollte gar nicht erst aufrufbar sein (CanSwitchInstance), aber ein zweites
+ // Schloss an der Tuer, hinter der ein Selbst-Neustart wartet, ist billig.
+ await new AvaloniaLicensePrompt().ShowInfoAsync(
+ "ClawdDotNet – Instanzwechsel",
+ "Diese Instanz wird vom Instanzmanager geführt. Gewechselt wird dort — "
+ + "er holt das Fenster der gewünschten Instanz nach vorn, statt eine "
+ + "laufende zu beenden.");
+
+ return;
+ }
+
var viewModel = new InstancePickerViewModel(_host.Directories, _host.InstancePath);
var window = new InstancePickerWindow { DataContext = viewModel };
diff --git a/src/ClawdDotNet.Desktop/CommandLine.cs b/src/ClawdDotNet.Desktop/CommandLine.cs
index 32cb68e..ddc7dd3 100644
--- a/src/ClawdDotNet.Desktop/CommandLine.cs
+++ b/src/ClawdDotNet.Desktop/CommandLine.cs
@@ -16,6 +16,17 @@ internal static class CommandLine
/// Läuft ohne Fenster: nur Aufbau, Socket und Warten (#528, Stufe 2).
public const string HeadlessArgument = "--headless";
+ ///
+ /// Gestartet und beaufsichtigt vom Instanzmanager (#528, Stufe 5).
+ ///
+ /// Dann gehört der Instanzwechsel dorthin und nicht in dieses Fenster: Der
+ /// Manager hält die Fenster aller Instanzen im Hintergrund und holt das gewünschte
+ /// nach vorn. Ein Fenster, das sich selbst mit einer anderen Instanz neu startet,
+ /// nähme dabei genau die Instanz mit, aus der heraus jemand wechselt — und das ist
+ /// der Vorfall, mit dem dieses Vorhaben angefangen hat (#529).
+ ///
+ public const string ManagedArgument = "--managed";
+
///
/// Der Instanzpfad hinter --instance, oder null.
///
@@ -37,6 +48,13 @@ internal static class CommandLine
}
public static bool IsHeadless(IReadOnlyList? args)
+ => Has(args, HeadlessArgument);
+
+ ///
+ public static bool IsManaged(IReadOnlyList? args)
+ => Has(args, ManagedArgument);
+
+ private static bool Has(IReadOnlyList? args, string flag)
=> args is not null
- && args.Any(a => string.Equals(a, HeadlessArgument, StringComparison.OrdinalIgnoreCase));
+ && args.Any(a => string.Equals(a, flag, StringComparison.OrdinalIgnoreCase));
}
diff --git a/src/ClawdDotNet.Desktop/HeadlessRun.cs b/src/ClawdDotNet.Desktop/HeadlessRun.cs
index 8eac663..c4f5ae1 100644
--- a/src/ClawdDotNet.Desktop/HeadlessRun.cs
+++ b/src/ClawdDotNet.Desktop/HeadlessRun.cs
@@ -90,7 +90,7 @@ internal static class HeadlessRun
"Instanz {Name} läuft ohne Fenster auf {Endpunkt}",
host.Instance.InstanceName, web.Endpoint);
- await WaitForShutdownAsync(logger, web.ShutdownSignal);
+ await WaitForShutdownAsync(logger, web.Control);
logger.LogInformation("Instanz {Name} wird beendet.", host.Instance.InstanceName);
}
@@ -111,7 +111,7 @@ internal static class HeadlessRun
/// das geordnete Herunterfahren noch läuft — und genau dabei wird der Schreibpuffer
/// des Protokolls geleert. Ein Ende ohne diese Zeilen ist der Zustand aus #529.
///
- private static async Task WaitForShutdownAsync(ILogger logger, ShutdownSignal signal)
+ private static async Task WaitForShutdownAsync(ILogger logger, InstanceControl steuerung)
{
var schluss = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
@@ -124,7 +124,7 @@ internal static class HeadlessRun
// Der Instanzmanager, ueber den Socket. Der uebliche Weg, sobald es ihn gibt:
// Er bittet, statt zu schiessen - ein Instanzprozess haelt eine offene Datenbank
// und laufende Agenten, und beides will zu Ende gebracht werden.
- signal.Requested += () => Beenden("Instanzmanager");
+ steuerung.ShutdownRequested += () => Beenden("Instanzmanager");
using var sigterm = PosixSignalRegistration.Create(PosixSignal.SIGTERM, context =>
{
diff --git a/src/ClawdDotNet.Desktop/ViewModels/MainWindowViewModel.cs b/src/ClawdDotNet.Desktop/ViewModels/MainWindowViewModel.cs
index e7edeac..8dda372 100644
--- a/src/ClawdDotNet.Desktop/ViewModels/MainWindowViewModel.cs
+++ b/src/ClawdDotNet.Desktop/ViewModels/MainWindowViewModel.cs
@@ -97,6 +97,16 @@ public sealed partial class MainWindowViewModel : ViewModelBase
public event Action? RequestExit;
public event Action? RequestInstanceManagerDialog;
+ ///
+ /// Ob der Instanzwechsel aus diesem Fenster heraus angeboten wird.
+ ///
+ /// false, sobald der Instanzmanager diese Instanz führt (#528, E-M2):
+ /// Dann hält er die Fenster aller Instanzen im Hintergrund und holt das gewünschte
+ /// nach vorn. Ein Wechsel von hier aus wäre ein Selbst-Neustart und nähme genau die
+ /// Instanz mit, aus der heraus jemand wechselt.
+ ///
+ public bool CanSwitchInstance { get; init; } = true;
+
/// Für den Entwurfsmodus des Editors — ohne laufenden Aufbau.
public MainWindowViewModel() : this(null) { }
diff --git a/src/ClawdDotNet.Desktop/Views/MainWindow.axaml b/src/ClawdDotNet.Desktop/Views/MainWindow.axaml
index 7476aa7..6576eb9 100644
--- a/src/ClawdDotNet.Desktop/Views/MainWindow.axaml
+++ b/src/ClawdDotNet.Desktop/Views/MainWindow.axaml
@@ -52,9 +52,14 @@
IsVisible="{Binding IsSidebarExpanded}" />
+ einziger die ganze Anwendung umschaltet.
+
+ Ausgegraut, sobald der Instanzmanager diese Instanz fuehrt (#528, E-M2):
+ Gewechselt wird dann dort, und er holt das Fenster der gewuenschten Instanz
+ nach vorn, statt eine laufende zu beenden. -->
Task RequestStopAsync(string instanceDirectory, CancellationToken ct = default);
+
+ ///
+ /// Bittet die Instanz, ihr Fenster nach vorn zu holen (E-M2).
+ ///
+ /// false heißt: Die Bitte kam nicht an, oder diese Instanz hat kein
+ /// Fenster — sie läuft kopflos. Beides ist kein Fehler, sondern eine Auskunft.
+ ///
+ Task RequestFocusAsync(string instanceDirectory, CancellationToken ct = default);
}
///
@@ -93,8 +101,15 @@ public sealed class SocketInstanceChannel(TimeSpan? timeout = null) : IInstanceC
}
}
- public async Task RequestStopAsync(
+ public Task RequestFocusAsync(
string instanceDirectory, CancellationToken ct = default)
+ => PostAsync(instanceDirectory, "/api/instanz/fenster", ct);
+
+ public Task RequestStopAsync(
+ string instanceDirectory, CancellationToken ct = default)
+ => PostAsync(instanceDirectory, "/api/instanz/beenden", ct);
+
+ private async Task PostAsync(string instanceDirectory, string pfad, CancellationToken ct)
{
try
{
@@ -102,7 +117,7 @@ public sealed class SocketInstanceChannel(TimeSpan? timeout = null) : IInstanceC
client.Timeout = _timeout;
using var antwort = await client
- .PostAsync("/api/instanz/beenden", content: null, ct)
+ .PostAsync(pfad, content: null, ct)
.ConfigureAwait(false);
return antwort.IsSuccessStatusCode;
diff --git a/src/ClawdDotNet.Manager/InstanceLauncher.cs b/src/ClawdDotNet.Manager/InstanceLauncher.cs
index 1d870f9..940892f 100644
--- a/src/ClawdDotNet.Manager/InstanceLauncher.cs
+++ b/src/ClawdDotNet.Manager/InstanceLauncher.cs
@@ -7,7 +7,7 @@ namespace ClawdDotNet.Manager;
///
/// Bewusst schmal. Die Aufsicht fragt nicht die Prozessliste, ob eine Instanz
/// lebt — das beantwortet der Socket, und nur er beantwortet es richtig (siehe
-/// ). Vom Prozess braucht sie dreierlei: seine Kennung
+/// ). Vom Prozess braucht sie dreierlei: seine Kennung
/// fürs Protokoll, den Abschiedscode, wenn er weg ist, und die Möglichkeit, ihn zu
/// beenden, wenn er nicht mehr auf Bitten hört.
///
@@ -50,11 +50,14 @@ public interface IInstanceLauncher
///
/// Der echte Start: ClawdDotNet --headless --instance <Verzeichnis>.
///
-/// Vorerst ohne Fenster. Nach E-M2 behält jede Instanz ihr eigenes
-/// Desktop-Fenster; das kommt mit Stufe 5, wenn der Wechsel das Fenster nach vorn holt.
-/// Bis dahin ist kopflos die Betriebsart, die sich vollständig beobachten lässt — und
-/// eine Instanz, die auf einem Server ohne Bildschirmsitzung gestartet wird, braucht sie
-/// ohnehin.
+/// Mit oder ohne Fenster. Nach E-M2 behält jede Instanz ihr eigenes
+/// Desktop-Fenster; sie laufen im Hintergrund, und beim Wechsel kommt eines nach vorn.
+/// Auf einer Maschine ohne Bildschirmsitzung gibt es keines, und dann ist kopflos die
+/// einzige Betriebsart, die trägt.
+///
+/// --managed geht immer mit. Daran erkennt die Instanz, dass sie
+/// geführt wird, und bietet den Instanzwechsel nicht mehr selbst an: Der wäre ein
+/// Selbst-Neustart und nähme genau die Instanz mit, aus der heraus jemand wechselt.
///
public sealed class ProcessInstanceLauncher(string executablePath, bool headless = true)
: IInstanceLauncher
@@ -85,6 +88,7 @@ public sealed class ProcessInstanceLauncher(string executablePath, bool headless
if (headless) start.ArgumentList.Add("--headless");
+ start.ArgumentList.Add("--managed");
start.ArgumentList.Add("--instance");
start.ArgumentList.Add(Path.GetFullPath(instanceDirectory));
diff --git a/src/ClawdDotNet.Manager/InstanceRegistry.cs b/src/ClawdDotNet.Manager/InstanceRegistry.cs
index 4ff80c1..f184a84 100644
--- a/src/ClawdDotNet.Manager/InstanceRegistry.cs
+++ b/src/ClawdDotNet.Manager/InstanceRegistry.cs
@@ -201,6 +201,28 @@ public sealed class InstanceRegistry : IAsyncDisposable
public Task StopAsync(string instanceDirectory, CancellationToken ct = default)
=> Find(instanceDirectory).StopAsync(ct);
+ ///
+ /// Holt das Fenster einer Instanz nach vorn — der Wechsel, wie E-M2 ihn meint.
+ ///
+ /// Das ist der Ersatz für den Selbst-Neustart. Vorher hiess „Instanz
+ /// wechseln": 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. Jetzt laufen alle, und es kommt nur eine nach vorn.
+ ///
+ /// false heisst: Sie läuft nicht, sie hört nicht, oder sie hat kein
+ /// Fenster.
+ ///
+ public async Task FocusAsync(string instanceDirectory, CancellationToken ct = default)
+ {
+ var aufsicht = Find(instanceDirectory);
+
+ if (aufsicht.Condition != InstanceCondition.Running)
+ return false;
+
+ return await _channel.RequestFocusAsync(aufsicht.InstanceDirectory, ct)
+ .ConfigureAwait(false);
+ }
+
///
/// Hält alle laufenden Instanzen an. Nicht beim Beenden des Managers, sondern
/// nur, wenn es einen Grund gibt, der sie alle betrifft — ein Lizenzwiderruf.
diff --git a/src/ClawdDotNet.Manager/ManagerCore.cs b/src/ClawdDotNet.Manager/ManagerCore.cs
index 28aff16..f3a1850 100644
--- a/src/ClawdDotNet.Manager/ManagerCore.cs
+++ b/src/ClawdDotNet.Manager/ManagerCore.cs
@@ -189,6 +189,10 @@ public sealed class ManagerCore : IAsyncDisposable
public Task StopInstanceAsync(string instanceDirectory, CancellationToken ct = default)
=> Instances.StopAsync(instanceDirectory, ct);
+ ///
+ public Task FocusInstanceAsync(string instanceDirectory, CancellationToken ct = default)
+ => Instances.FocusAsync(instanceDirectory, ct);
+
// ─── Das Urteil ───
///
diff --git a/src/ClawdDotNet.Manager/ManagerSettings.cs b/src/ClawdDotNet.Manager/ManagerSettings.cs
index 744088d..f5b94fd 100644
--- a/src/ClawdDotNet.Manager/ManagerSettings.cs
+++ b/src/ClawdDotNet.Manager/ManagerSettings.cs
@@ -39,6 +39,18 @@ public sealed class ManagerSettings
[JsonPropertyName("instanceExecutable")]
public string InstanceExecutable { get; set; } = "";
+ ///
+ /// Ob gestartete Instanzen ein eigenes Fenster bekommen (E-M2).
+ ///
+ /// Vorbelegt auf true: Jede Instanz behält ihr Desktop-Fenster, sie laufen
+ /// im Hintergrund, und beim Wechsel kommt eines nach vorn. Auf einer Maschine ohne
+ /// Bildschirmsitzung — einem Server, einem Bauknecht — gehört hier false hin;
+ /// dann laufen die Instanzen kopflos und sind nur über die Weboberfläche zu
+ /// bedienen.
+ ///
+ [JsonPropertyName("instanceWindows")]
+ public bool InstanceWindows { get; set; } = true;
+
// ─── Die eine Weboberfläche (E-M5) ───
[JsonPropertyName("webUiPort")]
diff --git a/src/ClawdDotNet.ManagerApp/Program.cs b/src/ClawdDotNet.ManagerApp/Program.cs
index 61a0f38..9f054b6 100644
--- a/src/ClawdDotNet.ManagerApp/Program.cs
+++ b/src/ClawdDotNet.ManagerApp/Program.cs
@@ -72,6 +72,10 @@ internal static class Program
? "(neben dem Manager)"
: settings.Settings.InstanceExecutable);
+ logger.LogInformation(
+ "Instanzen laufen {Art}.",
+ settings.Settings.InstanceWindows ? "mit eigenem Fenster" : "ohne Fenster");
+
await using var manager = Build(settings, loggerFactory, logger);
if (!await manager.StartAsync())
@@ -110,7 +114,8 @@ internal static class Program
var directories = new InstanceDirectoryManager(
Path.GetFullPath(s.InstancesDirectory));
- var launcher = new ProcessInstanceLauncher(ResolveInstanceExecutable(s, logger));
+ var launcher = new ProcessInstanceLauncher(
+ ResolveInstanceExecutable(s, logger), headless: !s.InstanceWindows);
var channel = new SocketInstanceChannel();
var registry = new InstanceRegistry(
diff --git a/src/ClawdDotNet.Web/Endpoints/ControlEndpoints.cs b/src/ClawdDotNet.Web/Endpoints/ControlEndpoints.cs
index 8be8aaf..6dafca1 100644
--- a/src/ClawdDotNet.Web/Endpoints/ControlEndpoints.cs
+++ b/src/ClawdDotNet.Web/Endpoints/ControlEndpoints.cs
@@ -6,31 +6,46 @@ using Microsoft.AspNetCore.Routing;
namespace ClawdDotNet.Web.Endpoints;
///
-/// Der Wunsch, dass dieser Instanzprozess endet.
+/// Was der Instanzmanager diesem Prozess sagen kann — und was der Prozess damit macht.
///
-/// Ein eigener Typ und kein Rückruf am Endpunkt, weil zwei Seiten ihn brauchen:
-/// Der Endpunkt löst ihn aus, der Wirtsprozess wartet darauf. Wer wartet, ist verschieden
-/// — kopflos die Schleife in HeadlessRun, mit Fenster die Avalonia-Anwendung.
+/// Ein eigener Typ und keine Rückrufe am Endpunkt, weil zwei Seiten ihn brauchen:
+/// Der Endpunkt löst aus, der Wirtsprozess reagiert. Und wer reagiert, ist verschieden —
+/// kopflos die Warteschleife in HeadlessRun, mit Fenster die
+/// Avalonia-Anwendung.
///
-public sealed class ShutdownSignal
+public sealed class InstanceControl
{
- /// Wird höchstens einmal ausgelöst.
- public event Action? Requested;
+ private int _shutdownRaised;
- private int _raised;
+ /// Der Manager bittet, diesen Prozess zu beenden.
+ public event Action? ShutdownRequested;
///
- /// Meldet den Wunsch an. Mehrfaches Aufrufen tut nichts — ein zweiter Aufruf würde
- /// sonst mitten im Herunterfahren ein zweites Herunterfahren anstoßen.
+ /// Der Manager bittet, das Fenster dieser Instanz nach vorn zu holen (E-M2).
+ ///
+ /// Kopflos gibt es nichts nach vorn zu holen; dann läuft dieses Ereignis ins
+ /// Leere, und das ist die richtige Antwort.
///
- public bool Raise()
+ public event Action? FocusRequested;
+
+ ///
+ /// Meldet den Wunsch zu enden an. Ein zweiter Aufruf tut nichts — sonst stiesse er
+ /// mitten im Herunterfahren ein zweites Herunterfahren an.
+ ///
+ public bool RequestShutdown()
{
- if (Interlocked.Exchange(ref _raised, 1) != 0)
+ if (Interlocked.Exchange(ref _shutdownRaised, 1) != 0)
return false;
- Requested?.Invoke();
+ ShutdownRequested?.Invoke();
return true;
}
+
+ ///
+ /// Holt das Fenster nach vorn. Beliebig oft — anders als beim Beenden ist die
+ /// Wiederholung hier der Normalfall: Jeder Wechsel auf diese Instanz ist einer.
+ ///
+ public void RequestFocus() => FocusRequested?.Invoke();
}
///
@@ -40,8 +55,8 @@ public sealed class ShutdownSignal
/// 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. Der Grund ist der Schnitt aus
/// #528 — die Lebensdauer einer Instanz gehört dem Instanzmanager. Eine Browsersitzung
-/// soll den Prozess, der sie gerade bedient, nicht beenden können; der Manager soll es,
-/// und er kommt über den Socket.
+/// soll den Prozess, der sie gerade bedient, nicht beenden und auch nicht sein Fenster
+/// aufreissen können; der Manager soll es, und er kommt über den Socket.
///
internal static class ControlEndpoints
{
@@ -49,7 +64,7 @@ internal static class ControlEndpoints
{
var control = app.MapGroup("/api/instanz");
- control.MapPost("/beenden", (HttpContext context, ShutdownSignal signal) =>
+ control.MapPost("/beenden", (HttpContext context, InstanceControl steuerung) =>
{
if (!LocalTransport.Applies(context))
return Results.NotFound();
@@ -61,12 +76,25 @@ internal static class ControlEndpoints
// haengt aber ab, ob er anschliessend geduldig wartet oder abschiesst.
context.Response.OnCompleted(() =>
{
- signal.Raise();
+ steuerung.RequestShutdown();
return Task.CompletedTask;
});
// 202 und nicht 200: Beim Antworten laeuft der Prozess noch.
return Results.Accepted(value: new { angenommen = true });
});
+
+ control.MapPost("/fenster", (HttpContext context, InstanceControl steuerung) =>
+ {
+ if (!LocalTransport.Applies(context))
+ return Results.NotFound();
+
+ // Hier ohne OnCompleted: Das Fenster nach vorn zu holen beendet nichts, die
+ // Antwort geht ohnehin raus. Und der Manager soll gleich erfahren, ob dieser
+ // Prozess ueberhaupt eines hat.
+ steuerung.RequestFocus();
+
+ return Results.Ok(new { angenommen = true });
+ });
}
}
diff --git a/src/ClawdDotNet.Web/InstanceHttpHost.cs b/src/ClawdDotNet.Web/InstanceHttpHost.cs
index 7e2e25f..619abe0 100644
--- a/src/ClawdDotNet.Web/InstanceHttpHost.cs
+++ b/src/ClawdDotNet.Web/InstanceHttpHost.cs
@@ -51,19 +51,20 @@ public sealed class InstanceHttpHost : IAsyncDisposable
Endpoint = endpoint;
Url = url;
- ShutdownSignal = app.Services.GetRequiredService();
+ Control = app.Services.GetRequiredService();
}
///
- /// Der Instanzmanager hat gebeten, diesen Prozess zu beenden
- /// (POST /api/instanz/beenden, nur über den Socket).
+ /// Was der Instanzmanager diesem Prozess über den Socket sagen kann: sich beenden
+ /// (POST /api/instanz/beenden) oder sein Fenster nach vorn holen
+ /// (POST /api/instanz/fenster).
///
- /// Der Wirtsprozess hängt sich hier an und beendet sich: kopflos die
- /// Warteschleife, mit Fenster die Avalonia-Anwendung. Diese Klasse tut es nicht
- /// selbst — sie kennt nur ihren Kestrel und wüsste gar nicht, was sonst noch zu
- /// beenden wäre.
+ /// Der Wirtsprozess hängt sich hier an und tut es: kopflos beendet sich die
+ /// Warteschleife, mit Fenster reagiert die Avalonia-Anwendung. Diese Klasse tut es
+ /// nicht selbst — sie kennt nur ihren Kestrel und wüsste gar nicht, was sonst noch zu
+ /// beenden oder anzuzeigen wäre.
///
- public ShutdownSignal ShutdownSignal { get; }
+ public InstanceControl Control { get; }
/// Socket oder Named Pipe — der Weg, den der Instanzmanager nimmt.
public string Endpoint { get; }
@@ -184,7 +185,7 @@ public sealed class InstanceHttpHost : IAsyncDisposable
builder.WebHost.ConfigureKestrel(kestrel => Listen(kestrel, host.InstancePath, options));
builder.Services.AddSingleton(host);
- builder.Services.AddSingleton();
+ builder.Services.AddSingleton();
if (options is not null)
{
diff --git a/tests/ClawdDotNet.Manager.Tests/Doubles.cs b/tests/ClawdDotNet.Manager.Tests/Doubles.cs
index b48d4f2..f881867 100644
--- a/tests/ClawdDotNet.Manager.Tests/Doubles.cs
+++ b/tests/ClawdDotNet.Manager.Tests/Doubles.cs
@@ -118,4 +118,16 @@ internal sealed class FakeChannel : IInstanceChannel
StopRequests++;
return Task.FromResult(StopRequestArrives);
}
+
+ /// Welche Instanzen gebeten wurden, ihr Fenster nach vorn zu holen.
+ public List FocusRequests { get; } = [];
+
+ /// Ob die Instanz ein Fenster hat, das nach vorn kommen kann.
+ public bool HasWindow { get; set; } = true;
+
+ public Task RequestFocusAsync(string instanceDirectory, CancellationToken ct = default)
+ {
+ FocusRequests.Add(Path.GetFullPath(instanceDirectory));
+ return Task.FromResult(HasWindow);
+ }
}
diff --git a/tests/ClawdDotNet.Manager.Tests/ManagerCoreTests.cs b/tests/ClawdDotNet.Manager.Tests/ManagerCoreTests.cs
index 39247b5..2d1cee2 100644
--- a/tests/ClawdDotNet.Manager.Tests/ManagerCoreTests.cs
+++ b/tests/ClawdDotNet.Manager.Tests/ManagerCoreTests.cs
@@ -217,3 +217,93 @@ public sealed class ManagerCoreTests : IAsyncDisposable
(await manager.StartInstanceAsync(pfad)).ShouldBe(StartRefusal.DidNotStart);
}
}
+
+///
+/// Der Wechsel, wie E-M2 ihn meint (#528, Stufe 5).
+///
+/// Vorher hiess „Instanz wechseln": 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.
+///
+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();
+ }
+
+ ///
+ /// Eine kopflose Instanz hat nichts nach vorn zu holen — und sagt das, statt Erfolg
+ /// zu melden.
+ ///
+ [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();
+ }
+}
diff --git a/tests/ClawdDotNet.Web.Tests/InstanceSocketTests.cs b/tests/ClawdDotNet.Web.Tests/InstanceSocketTests.cs
index 7411834..fd626f0 100644
--- a/tests/ClawdDotNet.Web.Tests/InstanceSocketTests.cs
+++ b/tests/ClawdDotNet.Web.Tests/InstanceSocketTests.cs
@@ -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();
}
}
+
+///
+/// Die Steuerung des Instanzprozesses: beenden und Fenster nach vorn holen
+/// (#528, Stufen 3 und 5).
+///
+/// Beides gibt es ausschließlich über den Socket. Ü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.
+///
+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();
+ }
+}