Files
ClawdDotNet/src/ClawdDotNet.Manager/InstanceState.cs
RichardandClaude Opus 5 4ef6d714a8 Instanzmanager Stufe 3: Prozessaufsicht, Start und Stopp (#528)
Der Anlass des ganzen Vorhabens: Am 2026-09-08 hat sich eine Instanz
aufgehaengt und wurde von Windows beendet. Zu diesem Vorgang steht keine
einzige Zeile in den Instanzprotokollen, kein Bugtracker-Eintrag, kein
Watchdog-Alarm - weil Meldeweg und Watchdog in genau dem Prozess lebten,
ueber den sie haetten berichten sollen. Ab jetzt sieht jemand anders hin.

Neu ist ClawdDotNet.Manager mit genau EINEM Projektverweis: Core. Das ist
keine Sparsamkeit, sondern die halbe Architektur - App schleppt sechzehn
Werkzeugprojekte hinter sich her, und "keine Werkzeuge im Manager" soll
eine Eigenschaft des Bauplans sein und keine Absichtserklaerung.

DIE ENTSCHEIDUNG, AN DER ALLES HAENGT: Die Aufsicht fragt den Socket, nicht
die Prozessliste. Die Prozessliste sagt, dass etwas da ist. Sie sagt nicht,
ob es noch arbeitet - und genau das war der Zustand, den niemand bemerkt
hat. Ein Durchgang kennt drei Antworten: Socket antwortet, sie laeuft.
Socket schweigt und der Prozess ist weg, Absturz mit Abschiedscode. Socket
schweigt und der Prozess lebt, verstummt - aber erst nach mehreren
Fehlversuchen in Folge, denn ein Alarm bei jedem Aussetzer ist nach einer
Woche der Alarm, den niemand mehr liest.

Ein Start gilt erst, wenn die Instanz antwortet. Zwischen "der Prozess
laeuft" und "die Instanz laeuft" liegen Datenbank, Agenten und Kestrel; wer
nur auf Process.Start wartet, haelt eine Instanz fuer gestartet, die gleich
an einer kaputten Konfiguration scheitert.

Angehalten wird durch Bitten. Neu auf der Instanzseite:
POST /api/instanz/beenden, ausschliesslich ueber den Socket - ueber TCP
gibt es den Endpunkt nicht, auch mit gueltigem Token nicht, und er
antwortet dort 404 statt 403. Die Lebensdauer einer Instanz gehoert dem
Manager; eine Browsersitzung soll den Prozess, der sie bedient, nicht
beenden koennen. Ausgeloest wird der Wunsch erst nach dem Absenden der
Antwort (Response.OnCompleted), sonst bekaeme der Manager statt einer
Zusage einen Verbindungsabbruch und hielte ihn fuer einen Fehlschlag.

Wiederfinden nach einem Managerausfall (E-M6): Die Zustandsdatei
instance-state.json ist der Hinweis, der Socket ist der Beweis. Erst wird
gefragt, wer da antwortet; nur bei gleicher Instanzkennung wird die
Prozesskennung aus der Datei angefasst. Andersherum fuehrt eine
wiederverwendete Nummer nach einem Neustart der Maschine geradewegs dazu,
ein fremdes Programm zu beaufsichtigen und beim Anhalten zu beenden.

Selbsttaetiger Neustart ist bewusst NICHT gebaut. Die Abnahmebedingung
lautet "merkt es und meldet es", und zum Neustarten trifft das Konzept
keine Entscheidung; eine Instanz in einer Schleife wieder hochzufahren,
die jedes Mal abstuerzt, waere die falsche Vorgabe fuer etwas, das niemand
entschieden hat. Als offene Frage aufgeschrieben.

ABNAHME, mit echten Prozessen gemessen: Manager startet Instanz Test in
0,7 s, identifiziert sie ueber die Named Pipe, schreibt die Zustandsdatei
(pid 54476, id d68af6fd). Prozess von aussen abgeschossen. Naechster
Durchgang: Crashed, Code -1, meldenswert, Zustandsdatei weggeraeumt. Dazu
20 Tests gegen gesteuerte Prozesse - ein Absturz zum gewuenschten Zeitpunkt
und eine wiederverwendete Prozesskennung sind mit einem echten Programm
nicht herzustellen.

Noch offen in dieser Stufe: Instanzen anlegen, Lizenz im Manager,
Sub-Token je Instanz, und der Managerprozess selbst.

Build ohne Warnungen, 737 Tests gruen, 8 uebersprungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 22:32:28 +02:00

119 lines
4.7 KiB
C#

using System.Text.Json;
using System.Text.Json.Serialization;
using ClawdDotNet.Core.Storage;
namespace ClawdDotNet.Manager;
/// <summary>
/// Was der Manager über einen laufenden Instanzprozess auf die Platte schreibt.
///
/// <para><b>Warum es diese Datei gibt (E-M6).</b> Fällt der Manager aus, laufen die
/// Instanzen weiter — er darf nicht der Einzelpunkt sein, gegen den dieses ganze Vorhaben
/// gebaut wird. Der neu gestartete Manager muss sie also wiederfinden, und zwar ohne
/// Datenbank: Alles, was er dafür braucht, liegt als JSON im Instanzverzeichnis.</para>
///
/// <para><b>Die Prozesskennung allein taugt dafür nicht.</b> Nach einem Neustart der
/// Maschine ist 4711 mit einiger Wahrscheinlichkeit irgendein anderer Prozess — und den
/// dann für die eigene Instanz zu halten, wäre schlimmer als sie gar nicht zu finden. Der
/// verlässliche Beweis ist der Socket: Antwortet er mit derselben Instanzkennung, ist es
/// dieselbe Instanz. Deshalb steht sie hier mit drin. <see cref="StartedAt"/> und
/// <see cref="ProcessId"/> sind das Zweitmerkmal — sie sagen, welchen Prozess man
/// anfassen muss, sobald die Identität feststeht.</para>
/// </summary>
public sealed class InstanceState
{
/// <summary>Der Dateiname im Instanzverzeichnis.</summary>
public const string FileName = "instance-state.json";
[JsonPropertyName("instanceId")]
public string InstanceId { get; set; } = "";
[JsonPropertyName("instanceName")]
public string InstanceName { get; set; } = "";
[JsonPropertyName("processId")]
public int ProcessId { get; set; }
/// <summary>
/// Startzeit des Prozesses, nicht der Instanz.
///
/// <para>Zusammen mit <see cref="ProcessId"/> ergibt sie ein Paar, das eine
/// wiederverwendete Kennung entlarvt: Ein Prozess, der später gestartet wurde als
/// hier steht, ist nicht der, den wir gestartet haben.</para>
/// </summary>
[JsonPropertyName("startedAt")]
public DateTimeOffset StartedAt { get; set; }
/// <summary>Socket oder Named Pipe — wie man mit dieser Instanz spricht.</summary>
[JsonPropertyName("endpoint")]
public string Endpoint { get; set; } = "";
/// <summary>Welcher Managerprozess sie gestartet hat. Nur zur Diagnose.</summary>
[JsonPropertyName("managerProcessId")]
public int ManagerProcessId { get; set; }
}
/// <summary>
/// Liest und schreibt die Zustandsdatei einer Instanz.
///
/// <para>Bewusst nachsichtig beim Lesen: Eine kaputte oder halb geschriebene Datei heißt
/// „ich weiß nichts über diese Instanz" und nicht „Ausnahme". Der Manager kann damit
/// umgehen — er fragt dann eben den Socket, und wenn der schweigt, läuft sie nicht.
/// Geschrieben wird über <see cref="AtomicFile"/>, damit ein Absturz mitten im Schreiben
/// keine halbe Datei hinterlässt.</para>
/// </summary>
public static class InstanceStateStore
{
private static readonly JsonSerializerOptions JsonOpts = new()
{
WriteIndented = true,
PropertyNameCaseInsensitive = true
};
public static string PathFor(string instanceDirectory)
=> Path.Combine(Path.GetFullPath(instanceDirectory), InstanceState.FileName);
/// <summary>Der hinterlegte Zustand, oder <c>null</c>: keine Datei, keine lesbare.</summary>
public static InstanceState? Read(string instanceDirectory)
{
var path = PathFor(instanceDirectory);
try
{
if (!File.Exists(path)) return null;
return JsonSerializer.Deserialize<InstanceState>(
AtomicFile.ReadAllText(path), JsonOpts);
}
catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException)
{
return null;
}
}
public static void Write(string instanceDirectory, InstanceState state)
=> AtomicFile.WriteAllText(
PathFor(instanceDirectory), JsonSerializer.Serialize(state, JsonOpts));
/// <summary>
/// Räumt die Zustandsdatei weg — nach einem geordneten Ende und nachdem festgestellt
/// wurde, dass ein Eintrag ins Leere zeigt.
///
/// <para>Eine liegengebliebene Datei ist kein Drama: Der Manager prüft ohnehin den
/// Socket, bevor er ihr glaubt. Sie wegzuräumen erspart aber die Frage, warum eine
/// Instanz „laut Datei läuft" und trotzdem nicht antwortet.</para>
/// </summary>
public static void Clear(string instanceDirectory)
{
try
{
var path = PathFor(instanceDirectory);
if (File.Exists(path)) File.Delete(path);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// Nicht der Rede wert - siehe oben.
}
}
}