Instanzmanager: Weboberflaeche kennt Instanzen, geordnetes Ende, Puls (#528, #531)

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:
Richard
2026-09-10 11:15:59 +02:00
co-authored by Claude Opus 5
parent d44bbeac1d
commit dcffcdcff5
48 changed files with 4852 additions and 171 deletions
+11
View File
@@ -175,3 +175,14 @@ public sealed record UsageDto(
/// <summary>Eine Protokollzeile.</summary>
public sealed record LogLineDto(string Module, string Level, string Text);
/// <summary>Eine interne Kommunikation zwischen Agenten (A2A, #208).</summary>
public sealed record AgentCommunicationDto(
string TargetAgentId,
string TargetAgentName,
string? SourceAgentId,
string? SourceAgentName,
string Role,
string Content,
DateTime Timestamp);
@@ -101,6 +101,58 @@ internal static class ApiEndpoints
.ToList();
});
// ─── Interne Agenten-Kommunikation (A2A, #208) ───
agents.MapGet("/communications", (AppHost host) =>
{
if (host.Engine is not { } engine)
return Unavailable("Agent-Engine ist nicht aktiv (kein API-Schlüssel).");
var allAgents = host.AgentAdmin.All;
var list = new List<AgentCommunicationDto>();
foreach (var agent in allAgents)
{
var history = engine.GetChatHistory(agent.AgentId);
foreach (var entry in history.Where(e => string.Equals(e.Source, Core.Engine.ChatSource.AgentComm, StringComparison.OrdinalIgnoreCase)))
{
string? senderId = null;
string? senderName = null;
var content = entry.Content;
if (entry.Role == "user" && content.StartsWith("[Nachricht von Agent \""))
{
var endQuote = content.IndexOf('"', 22);
if (endQuote > 22)
{
senderName = content[22..endQuote];
var openParen = content.IndexOf('(', endQuote);
var closeParen = content.IndexOf(')', openParen);
if (openParen > 0 && closeParen > openParen)
{
senderId = content[(openParen + 1)..closeParen];
}
}
var doubleNl = content.IndexOf("\n\n", StringComparison.Ordinal);
if (doubleNl > 0)
{
content = content[(doubleNl + 2)..];
}
}
list.Add(new AgentCommunicationDto(
TargetAgentId: agent.AgentId,
TargetAgentName: agent.DisplayName,
SourceAgentId: senderId,
SourceAgentName: senderName,
Role: entry.Role,
Content: content,
Timestamp: entry.Timestamp));
}
}
return Results.Ok(list.OrderBy(c => c.Timestamp).ToList());
});
agents.MapGet("/{agentId}", (AppHost host, string agentId) =>
host.AgentAdmin.Find(agentId) is { } agent
? Results.Ok(agent.ToDto(
@@ -1,3 +1,5 @@
using ClawdDotNet.App;
using ClawdDotNet.Core.Instances;
using ClawdDotNet.Web.Security;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
@@ -5,6 +7,32 @@ using Microsoft.AspNetCore.Routing;
namespace ClawdDotNet.Web.Endpoints;
/// <summary>Was noch offen ist, für die Puls-Auskunft.</summary>
public sealed record OffeneArbeitDto(string Name, int Anzahl);
/// <summary>
/// Der Puls einer Instanz: Wer bin ich, lebe ich, und arbeite ich noch an etwas?
///
/// <para><b>Warum mehr als die Identität.</b> Bis hierher fragte der Manager
/// <c>/api/info</c> und wusste danach nur, dass überhaupt jemand geantwortet hat. Das
/// reicht, um „läuft" von „tot" zu unterscheiden, aber nicht, um zwei Fälle
/// auseinanderzuhalten, die völlig verschieden zu behandeln sind: eine Instanz, die
/// hängt, und eine, die gerade geordnet aufräumt. Die erste gehört nach einer Frist
/// abgeschossen, die zweite in Ruhe gelassen.</para>
/// </summary>
/// <param name="InstanceId">Die Kennung — der eigentliche Beweis, wer da antwortet.</param>
/// <param name="Phase">"Running", "Draining" oder "Done".</param>
/// <param name="OffeneArbeiten">Was gerade noch läuft. Leer heißt: nichts.</param>
/// <param name="RestsekundenFrist">Wie lange das Auslaufen noch dauern darf.</param>
public sealed record PulsDto(
string InstanceId,
string InstanceName,
string AppVersion,
string Phase,
IReadOnlyList<OffeneArbeitDto> OffeneArbeiten,
int OffenGesamt,
double RestsekundenFrist);
/// <summary>
/// Was der Instanzmanager diesem Prozess sagen kann — und was der Prozess damit macht.
///
@@ -64,6 +92,25 @@ internal static class ControlEndpoints
{
var control = app.MapGroup("/api/instanz");
// Der Puls. Nur ueber den Socket - was hier steht, geht ausschliesslich den
// Instanzmanager etwas an, und er ist der Einzige, der diesen Weg hat.
control.MapGet("/puls", (HttpContext context, AppHost host) =>
{
if (!LocalTransport.Applies(context))
return Results.NotFound();
var stand = host.Shutdown.Snapshot();
return Results.Ok(new PulsDto(
host.Instance.InstanceId,
host.Instance.InstanceName,
AppHost.AppVersion,
stand.Phase.ToString(),
[.. stand.Open.Select(o => new OffeneArbeitDto(o.Name, o.Count))],
stand.TotalOpen,
Math.Round(stand.Remaining.TotalSeconds, 1)));
});
control.MapPost("/beenden", (HttpContext context, InstanceControl steuerung) =>
{
if (!LocalTransport.Applies(context))