Instanzmanager Stufe 1: Protokolle und Sicherungen je Instanz (#528)

Mehrere Instanzen sollen unabhaengig voneinander laufen. Heute tun sie das
nicht: Sie teilen sich Protokollverzeichnis, Sicherungsordner und
Sicherungszeitplan. In einer gemeinsamen Startup.log standen abwechselnd
Zeilen zweier Instanzen, ohne Kennzeichnung, welche gerade spricht -- im
Mehrinstanzbetrieb ist damit jede Fehlersuche Ratearbeit. Genau in dieser
Lage ist der Aufhaenger aus #529 entstanden.

Fuenf Schluessel wandern aus AppSettings nach InstanceSettings.json:
logDirectory, backupDirectory, backupKeepCount, autoBackupEnabled,
autoBackupTime. Leer heisst jetzt <Instanz>/Logs beziehungsweise
<Instanz>/Backups; ein relativer Wert zaehlt weiterhin gegen das
Instanzverzeichnis, nicht gegen das Arbeitsverzeichnis des Prozesses
(#375 bleibt gewahrt).

autoBackupEnabled und autoBackupTime standen in der Planung nicht, sind
aber mitgegangen: Ein Schalter, der die taegliche Sicherung fuer eine
Instanz einschaltet und sie damit fuer alle einschaltet, ist derselbe
Fehler wie ein Ordner fuer alle -- und zwei von vier Sicherungsfeldern zu
verschieben hinterliesse eine Einstellungsseite, auf der die Haelfte
anwendungsweit gilt.

Keine Uebernahme der Altwerte, aber eine Ansage: SettingsManager.
AbandonedKeys meldet die aufgegebenen Schluessel, solange sie noch in
Settings.json stehen, und der Start protokolliert einmal, was nicht mehr
gilt und wohin diese Instanz stattdessen schreibt. Ein anwendungsweiter
Pfad, in alle Instanzen kopiert, waere wieder ein Ordner fuer alle.

webServerPort bleibt vorerst stehen. Es entfaellt mit Stufe 2, sobald
Kestrel auf dem Socket hoert; vorher wegzunehmen hiesse, die
Weboberflaeche ohne Ersatz abzuschalten.

Korrektur an der eigenen Befundlage: "Instanz A wirft die Sicherungen von
Instanz B weg" war zu breit. Beide Rotationen filtern bereits nach dem
Praefix backup_<Instanzname>_. Der reale Defekt ist enger, und ein Praefix
ist keine Grenze: backup_Test_2_*.zip faengt mit backup_Test_ an, also
zaehlt Test im gemeinsamen Ordner die Archive von Test_2 mit. Belegt in
BackupSchedulerTests, das den alten Zustand eigens wiederherstellt.

Nachgeprueft an der laufenden Installation, nicht nur im Test: Nach dem
Umbau schreibt Instance-Test nach Instances/Instance-Test/Logs, das alte
gemeinsame Verzeichnis bekommt keine Zeile mehr, und die Warnung zu den
aufgegebenen Schluesseln erscheint genau einmal.

Enthaelt ausserdem, was seit der Weboberflaeche liegengeblieben war:

- #526: Instanzgeheimnisse wurden beim Speichern verschluesselt und beim
  Laden nie entschluesselt. LoadInstanceConfig hatte kein
  ConfigSecrets.Unprotect; ConfigLoader.LoadAsync macht es richtig, wird
  zur Laufzeit aber gar nicht aufgerufen. Der OpenRouter-Schluessel ging
  so verschluesselt an die API.
- Ein offener Ereignisstrom hielt das Beenden fuenf Sekunden auf. Die
  Stroeme hingen nur an RequestAborted, das beim geordneten Herunterfahren
  nicht feuert; jetzt zusaetzlich an ApplicationStopping. Der Fang in
  WebUiHost gilt dazu ohne Bedingung auf RequestAborted -- mit ihr waere
  jeder offene Strom beim Beenden als Fehler im Bugtracker gelandet.
- Nach der Anmeldung blieb die Weboberflaeche leer: #anmeldung
  { display:flex } schlaegt als ID-Selektor das hidden-Attribut.
- docs/Instanzmanager-Konzept.md: Befund, die Entscheidungen E-M1 bis
  E-M6, der Schnitt zwischen Manager und Instanz und die sechs Stufen.

Build ohne Warnungen, 709 Tests gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-08 21:06:40 +02:00
co-authored by Claude Opus 5
parent 928da7e6af
commit facd21459c
20 changed files with 1404 additions and 158 deletions
@@ -5,6 +5,7 @@ using ClawdDotNet.App.Services;
using ClawdDotNet.Web.Contracts;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Hosting;
namespace ClawdDotNet.Web.Endpoints;
@@ -43,8 +44,11 @@ internal static class EventEndpoints
}
private static async Task StreamCoreEvents(
HttpContext context, AppHost host, CancellationToken ct)
HttpContext context, AppHost host, IHostApplicationLifetime lifetime, CancellationToken ct)
{
using var leben = Verbinde(lifetime, ct);
ct = leben.Token;
var queue = Channel.CreateBounded<string>(new BoundedChannelOptions(QueueDepth)
{
FullMode = BoundedChannelFullMode.DropOldest,
@@ -122,8 +126,12 @@ internal static class EventEndpoints
/// Zeilen bekommt und alle anderen nichts.</para>
/// </summary>
private static async Task StreamLogs(
HttpContext context, AppHost host, CancellationToken ct, string? module = null, string? level = null)
HttpContext context, AppHost host, IHostApplicationLifetime lifetime,
CancellationToken ct, string? module = null, string? level = null)
{
using var leben = Verbinde(lifetime, ct);
ct = leben.Token;
var tail = new LogTail(host.LogDirectory);
var filter = Enum.TryParse<LogLevelFilter>(level, ignoreCase: true, out var parsed)
@@ -216,6 +224,24 @@ internal static class EventEndpoints
}
}
/// <summary>
/// Verbindet den Abbruch der Anfrage mit dem Herunterfahren der Anwendung.
///
/// <para><b>Ohne das hängt das Beenden an jedem offenen Ereignisstrom.</b>
/// <c>RequestAborted</c> feuert, wenn der Browser auflegt — beim geordneten
/// Herunterfahren tut er das aber nicht. Kestrel wartet dann darauf, dass die
/// Anfrage von selbst endet; ein Ereignisstrom endet nie von selbst. Erst nach der
/// Frist von <c>StopAsync</c> wird abgeschnitten, und so lange steht die Anwendung.</para>
///
/// <para>Sichtbar wurde es beim Instanzwechsel: Zwischen „Wechseln" und dem Neustart
/// lagen fünf Sekunden, in denen nichts geschah. Mit einem offenen Browserfenster
/// jedes Mal.</para>
/// </summary>
private static CancellationTokenSource Verbinde(
IHostApplicationLifetime lifetime, CancellationToken requestAborted)
=> CancellationTokenSource.CreateLinkedTokenSource(
requestAborted, lifetime.ApplicationStopping);
private static void PrepareResponse(HttpContext context)
{
context.Response.Headers.ContentType = "text/event-stream";
+16 -3
View File
@@ -103,6 +103,14 @@ public sealed class WebUiHost : IAsyncDisposable
builder.Logging.ClearProviders();
builder.Logging.AddProvider(new SharedLoggerProvider(loggerFactory));
// ASP.NET protokolliert auf Information jede einzelne Anfrage - mit eigener
// Zeile fuer Start und Ende, und das auch fuer jede Schriftart und jedes
// Stylesheet. In einer Viertelstunde Herumklicken standen so mehrere tausend
// Zeilen im Instanzprotokoll, zwischen denen die Meldungen der Agenten
// untergingen. Ab Warnung ist es das, was man wissen will.
builder.Logging.AddFilter("Microsoft", LogLevel.Warning);
builder.Logging.AddFilter("System", LogLevel.Warning);
builder.WebHost.UseUrls(options.Url);
builder.Services.AddSingleton(host);
@@ -144,10 +152,15 @@ public sealed class WebUiHost : IAsyncDisposable
{
await next(context);
}
catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested)
catch (OperationCanceledException)
{
// Der Browser hat aufgelegt. Das ist kein Fehler und schon gar keiner,
// der ins Deploymentcenter gehoert.
// Der Browser hat aufgelegt, oder die Anwendung faehrt herunter. Beides
// ist kein Fehler und schon gar keiner, der ins Deploymentcenter gehoert.
//
// Bewusst ohne Bedingung auf RequestAborted: Beim Herunterfahren bricht
// ApplicationStopping ab, und RequestAborted ist dabei gar nicht gesetzt.
// Mit der Bedingung landete jeder offene Ereignisstrom beim Beenden als
// gemeldeter Fehler im Bugtracker.
}
catch (Exception ex)
{
+16
View File
@@ -142,6 +142,22 @@ a:hover {
/* ─── Layout Shell ─── */
/*
Das hidden-Attribut muss gewinnen.
Der Browser setzt fuer [hidden] display:none, aber nur mit der Staerke einer
Attributregel. #anmeldung und #anwendung setzen display:flex ueber einen
ID-Selektor - und der ist staerker. Ohne diese Zeile blieb die Anmeldemaske
nach dem Anmelden stehen und lag ueber der Anwendung: Beide Bereiche waren
gleichzeitig sichtbar, die Anmeldung nur weiter vorn.
Gilt fuer den ganzen Baum, nicht nur fuer diese zwei: Ueberall, wo JavaScript
el.hidden setzt, soll das auch etwas bewirken.
*/
[hidden] {
display: none !important;
}
#anmeldung {
display: flex;
align-items: center;