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>
This commit is contained in:
Richard
2026-09-08 22:32:28 +02:00
co-authored by Claude Opus 5
parent b78e5d844f
commit 4ef6d714a8
16 changed files with 1698 additions and 6 deletions
@@ -0,0 +1,496 @@
using ClawdDotNet.Core.Instances;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Manager;
/// <summary>Was die Aufsicht über den Zustand einer Instanz weiß.</summary>
public enum InstanceCondition
{
/// <summary>Noch nicht nachgesehen.</summary>
Unknown,
/// <summary>Läuft nicht — und das ist in Ordnung so.</summary>
Stopped,
/// <summary>Läuft und antwortet.</summary>
Running,
/// <summary>Der Prozess ist da, antwortet aber nicht mehr.</summary>
Silent,
/// <summary>Weg, ohne dass jemand darum gebeten hätte.</summary>
Crashed
}
/// <summary>Stellschrauben der Aufsicht.</summary>
/// <param name="ProbeInterval">Wie oft nachgesehen wird.</param>
/// <param name="StartTimeout">Wie lange eine frisch gestartete Instanz Zeit hat, sich zu melden.</param>
/// <param name="SilentChecksBeforeAlarm">
/// Wie viele Fehlversuche in Folge nötig sind, bevor „antwortet nicht" gilt.
///
/// <para>Nicht einer: Eine einzelne verpasste Antwort ist Alltag — die Maschine war
/// beschäftigt, ein Agentenlauf hat den Prozess gerade voll ausgelastet. Ein Alarm bei
/// jedem Aussetzer wäre nach einer Woche der Alarm, den niemand mehr liest, und dann ist
/// die Aufsicht ihr Geld nicht mehr wert.</para>
/// </param>
/// <param name="StopGrace">Wie lange eine Instanz nach der Bitte zu enden noch bekommt.</param>
public sealed record SupervisionOptions(
TimeSpan ProbeInterval,
TimeSpan StartTimeout,
int SilentChecksBeforeAlarm,
TimeSpan StopGrace)
{
public static SupervisionOptions Default { get; } = new(
ProbeInterval: TimeSpan.FromSeconds(15),
StartTimeout: TimeSpan.FromSeconds(60),
SilentChecksBeforeAlarm: 3,
StopGrace: TimeSpan.FromSeconds(20));
}
/// <summary>
/// Die Aufsicht über <b>eine</b> Instanz: starten, anhalten, nachsehen, wiederfinden.
///
/// <para><b>Der Anlass.</b> Am 2026-09-08 hat sich eine Instanz aufgehängt und wurde von
/// Windows beendet. Zu diesem Vorgang steht keine einzige Zeile in den Instanzprotokollen
/// — der Schreibpuffer wurde nie geleert. Keine Fehlermeldung, kein Bugtracker-Eintrag,
/// kein Watchdog-Alarm. Der Grund ist bauartbedingt: Meldeweg, Watchdog und Fehler-Stream
/// lebten <em>in</em> dem Prozess, über dessen Zustand sie berichten sollten. Fällt er
/// hart, fällt der Meldeweg mit — genau in dem Fall, der gemeldet werden müsste (#528,
/// #529).</para>
///
/// <para><b>Deshalb fragt diese Klasse den Socket und nicht die Prozessliste.</b> 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 Prozess, der auf seinem eigenen
/// Socket dreimal hintereinander nicht antwortet, ist ein Befund, auch wenn er in jeder
/// Prozessliste steht.</para>
///
/// <para><b>Ein Durchgang, drei mögliche Antworten.</b> Der Socket antwortet: Sie lebt.
/// Der Socket schweigt und der Prozess ist weg: Absturz, mit Abschiedscode. Der Socket
/// schweigt und der Prozess ist da: verstummt — nach genügend Fehlversuchen in Folge.
/// Mehr Fälle gibt es nicht, und das ist der Grund, warum die Aufsicht so wenig Code
/// braucht.</para>
///
/// <para><b>Was hier nicht passiert: Neustarten.</b> Die Abnahmebedingung für diese Stufe
/// lautet „der Manager merkt es und meldet es", und das Konzept trifft zum
/// selbsttätigen Neustart keine Entscheidung. Eine Instanz in einer Schleife wieder
/// hochzufahren, die jedes Mal abstürzt, wäre die falsche Vorgabe für etwas, das niemand
/// entschieden hat — sie gehört als eigene Entscheidung aufgeschrieben, nicht als
/// Nebenwirkung eingebaut.</para>
/// </summary>
public sealed class InstanceSupervisor : IAsyncDisposable
{
private readonly SupervisionOptions _options;
private readonly IInstanceLauncher _launcher;
private readonly IInstanceChannel _channel;
private readonly ILogger _logger;
private readonly TimeProvider _clock;
private readonly CancellationTokenSource _cts = new();
private IInstanceProcess? _process;
private Task? _loop;
private int _silentChecks;
private bool _stopRequested;
public InstanceSupervisor(
string instanceDirectory,
IInstanceLauncher launcher,
IInstanceChannel channel,
ILogger logger,
SupervisionOptions? options = null,
TimeProvider? clock = null)
{
InstanceDirectory = Path.GetFullPath(instanceDirectory);
_launcher = launcher;
_channel = channel;
_logger = logger;
_options = options ?? SupervisionOptions.Default;
_clock = clock ?? TimeProvider.System;
}
public string InstanceDirectory { get; }
/// <summary>Der Name, sobald die Instanz einmal geantwortet hat; sonst der Ordnername.</summary>
public string InstanceName { get; private set; } = "";
public InstanceCondition Condition { get; private set; } = InstanceCondition.Unknown;
public int? ProcessId => _process?.Id;
/// <summary>
/// Jedes Vorkommnis, einmal.
///
/// <para><b>Kommt auf dem Faden des Verursachers</b> — wer eine Oberfläche daran
/// hängt, geht über den Dispatcher.</para>
/// </summary>
public event Action<SupervisionEvent>? Observed;
// ─── Starten ───
/// <summary>
/// Startet die Instanz und wartet, bis sie sich über ihren Socket meldet.
///
/// <para><b>Der Prozess läuft ist nicht dasselbe wie die Instanz läuft.</b> Zwischen
/// beidem liegen Datenbank, Agenten und Kestrel. Wer nur auf <c>Process.Start</c>
/// wartet, hält eine Instanz für gestartet, die gleich an einer kaputten
/// Konfiguration scheitert — und merkt es erst, wenn jemand sie braucht.</para>
/// </summary>
public async Task<bool> StartAsync(CancellationToken ct = default)
{
if (Condition == InstanceCondition.Running)
return true;
_stopRequested = false;
_silentChecks = 0;
try
{
_process?.Dispose();
_process = _launcher.Start(InstanceDirectory);
}
catch (Exception ex)
{
Raise(InstanceEventKind.StartFailed, null, null,
$"Der Instanzprozess ließ sich nicht starten: {ex.Message}");
Condition = InstanceCondition.Stopped;
return false;
}
var identity = await WaitForAnswerAsync(ct).ConfigureAwait(false);
if (identity is null)
{
var exitCode = _process.ExitCode;
// Aufraeumen: Ein Prozess, der laeuft und nicht antwortet, ist nichts, was
// man stehen laesst - er haelt den Socket besetzt und blockiert damit den
// naechsten Versuch.
if (!_process.HasExited) _process.Kill();
Raise(InstanceEventKind.StartFailed, _process.Id, exitCode,
$"Die Instanz hat sich binnen {_options.StartTimeout.TotalSeconds:0}s nicht "
+ $"auf {InstanceEndpoint.Describe(InstanceDirectory)} gemeldet.");
Condition = InstanceCondition.Stopped;
InstanceStateStore.Clear(InstanceDirectory);
return false;
}
InstanceName = identity.InstanceName;
Condition = InstanceCondition.Running;
InstanceStateStore.Write(InstanceDirectory, new InstanceState
{
InstanceId = identity.InstanceId,
InstanceName = identity.InstanceName,
ProcessId = _process.Id,
StartedAt = _process.StartedAt,
Endpoint = InstanceEndpoint.Describe(InstanceDirectory),
ManagerProcessId = Environment.ProcessId
});
Raise(InstanceEventKind.Started, _process.Id, null,
$"Instanz {identity.InstanceName} läuft (Version {identity.AppVersion}).");
return true;
}
private async Task<InstanceIdentity?> WaitForAnswerAsync(CancellationToken ct)
{
var deadline = _clock.GetUtcNow() + _options.StartTimeout;
while (_clock.GetUtcNow() < deadline)
{
if (_process is { HasExited: true })
return null; // Aufgegeben, bevor er antworten konnte.
var identity = await _channel.AskAsync(InstanceDirectory, ct).ConfigureAwait(false);
if (identity is not null) return identity;
await Task.Delay(TimeSpan.FromMilliseconds(250), _clock, ct).ConfigureAwait(false);
}
return null;
}
// ─── Wiederfinden (E-M6) ───
/// <summary>
/// Nimmt eine Instanz wieder in die Aufsicht, die schon lief.
///
/// <para>Fällt der Manager aus, laufen die Instanzen weiter — er darf nicht der
/// Einzelpunkt sein, gegen den dieses Vorhaben gebaut wird. Der neu gestartete Manager
/// findet sie über die Zustandsdatei im Instanzverzeichnis wieder.</para>
///
/// <para><b>Die Datei ist der Hinweis, der Socket ist der Beweis.</b> Erst wird
/// gefragt, wer da antwortet; nur wenn dieselbe Instanzkennung zurückkommt, wird die
/// Prozesskennung aus der Datei überhaupt angefasst. Andersherum — Prozess suchen,
/// finden, für die eigene Instanz halten — führt nach einem Neustart der Maschine
/// geradewegs dazu, ein fremdes Programm zu beaufsichtigen und im Zweifel zu
/// beenden.</para>
/// </summary>
/// <returns><c>true</c>, wenn eine laufende Instanz übernommen wurde.</returns>
public async Task<bool> ReattachAsync(CancellationToken ct = default)
{
var state = InstanceStateStore.Read(InstanceDirectory);
var identity = await _channel.AskAsync(InstanceDirectory, ct).ConfigureAwait(false);
if (identity is null)
{
// Nichts da. Eine Zustandsdatei, die auf niemanden zeigt, ist Altpapier -
// und solange sie liegt, sieht es aus, als liefe etwas.
if (state is not null) InstanceStateStore.Clear(InstanceDirectory);
Condition = InstanceCondition.Stopped;
return false;
}
if (state is not null
&& !string.IsNullOrEmpty(state.InstanceId)
&& !string.Equals(state.InstanceId, identity.InstanceId, StringComparison.Ordinal))
{
// Da antwortet jemand, aber nicht der, der in der Datei steht. Die Datei ist
// veraltet; die Auskunft vom Socket gilt.
_logger.LogWarning(
"Zustandsdatei nennt Instanz {Erwartet}, geantwortet hat {Tatsaechlich} — "
+ "die Datei wird überschrieben.", state.InstanceId, identity.InstanceId);
state = null;
}
InstanceName = identity.InstanceName;
Condition = InstanceCondition.Running;
_silentChecks = 0;
_stopRequested = false;
_process?.Dispose();
_process = state is not null
? _launcher.Attach(state.ProcessId, state.StartedAt)
: null;
if (_process is null)
{
// Sie antwortet, aber der Prozess dahinter ist nicht zu greifen: keine
// Zustandsdatei, eine veraltete, oder die Kennung gehoert inzwischen einem
// anderen. Beaufsichtigen laesst sie sich trotzdem - nur beenden nicht mit
// Gewalt, sondern ausschliesslich ueber ihren Socket.
_logger.LogInformation(
"Instanz {Name} übernommen, ohne den Prozess zu fassen zu bekommen — "
+ "sie lässt sich über ihren Socket ansprechen, aber nicht abschießen.",
identity.InstanceName);
}
InstanceStateStore.Write(InstanceDirectory, new InstanceState
{
InstanceId = identity.InstanceId,
InstanceName = identity.InstanceName,
ProcessId = _process?.Id ?? state?.ProcessId ?? 0,
StartedAt = _process?.StartedAt ?? state?.StartedAt ?? _clock.GetUtcNow(),
Endpoint = InstanceEndpoint.Describe(InstanceDirectory),
ManagerProcessId = Environment.ProcessId
});
_logger.LogInformation(
"Laufende Instanz {Name} wieder in die Aufsicht genommen.", identity.InstanceName);
return true;
}
// ─── Nachsehen ───
/// <summary>
/// Ein Aufsichtsdurchgang. Öffentlich, damit Tests ihn auslösen können, statt auf
/// eine Uhr zu warten.
/// </summary>
public async Task CheckAsync(CancellationToken ct = default)
{
if (Condition is InstanceCondition.Stopped or InstanceCondition.Crashed)
return; // Nichts zu beaufsichtigen.
var identity = await _channel.AskAsync(InstanceDirectory, ct).ConfigureAwait(false);
if (identity is not null)
{
_silentChecks = 0;
if (Condition == InstanceCondition.Silent)
{
Condition = InstanceCondition.Running;
Raise(InstanceEventKind.Recovered, _process?.Id, null,
$"Instanz {InstanceName} antwortet wieder.");
}
else
{
Condition = InstanceCondition.Running;
}
return;
}
// Ab hier: Der Socket schweigt.
if (_process is { HasExited: true })
{
var exitCode = _process.ExitCode;
Condition = _stopRequested ? InstanceCondition.Stopped : InstanceCondition.Crashed;
InstanceStateStore.Clear(InstanceDirectory);
Raise(_stopRequested ? InstanceEventKind.StoppedOrderly : InstanceEventKind.Crashed,
_process.Id, exitCode,
_stopRequested
? $"Instanz {InstanceName} beendet (Code {Describe(exitCode)})."
: $"Instanz {InstanceName} ist unerwartet beendet worden "
+ $"(Code {Describe(exitCode)}). Niemand hat darum gebeten.");
return;
}
// Der Prozess laeuft (oder ist nicht zu fassen) und antwortet nicht. Genau der
// Zustand aus #529 - deshalb nicht beim ersten Mal Alarm schlagen, aber auch
// nicht ewig zusehen.
_silentChecks++;
if (Condition == InstanceCondition.Silent)
return; // Schon gemeldet. Ein Alarm je Vorfall, nicht je Durchgang.
if (_silentChecks < _options.SilentChecksBeforeAlarm)
return;
Condition = InstanceCondition.Silent;
Raise(InstanceEventKind.Unresponsive, _process?.Id, null,
$"Instanz {InstanceName} läuft, antwortet aber seit {_silentChecks} Durchgängen "
+ $"nicht mehr auf {InstanceEndpoint.Describe(InstanceDirectory)}.");
}
/// <summary>Sieht von selbst nach, im eingestellten Takt.</summary>
public void BeginWatching() => _loop ??= WatchAsync(_cts.Token);
private async Task WatchAsync(CancellationToken ct)
{
using var timer = new PeriodicTimer(_options.ProbeInterval, _clock);
while (await timer.WaitForNextTickAsync(ct).ConfigureAwait(false))
{
try
{
await CheckAsync(ct).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
// Eine Aufsicht, die an einem Fehler stirbt, ist ab dann keine mehr -
// und zwar still, was der schlechteste aller Zustaende waere.
_logger.LogError(ex, "Aufsichtsdurchgang für {Pfad} fehlgeschlagen.",
InstanceDirectory);
}
}
}
// ─── Anhalten ───
/// <summary>
/// Bittet die Instanz zu enden und wartet, bis sie es tut.
///
/// <para><b>Erst bitten, dann zwingen.</b> Ein Instanzprozess hält eine offene
/// SQLite-Datenbank, laufende Agenten und Chatverläufe im Arbeitsspeicher; ihn
/// abzuschießen heißt, das alles mitten im Satz abzuschneiden. Gefragt wird über den
/// Socket — den Weg, den es ohnehin gibt. Erst wenn nach
/// <see cref="SupervisionOptions.StopGrace"/> nichts geschehen ist, wird beendet.</para>
/// </summary>
public async Task<bool> StopAsync(CancellationToken ct = default)
{
if (Condition is InstanceCondition.Stopped or InstanceCondition.Crashed)
return true;
_stopRequested = true;
if (!await _channel.RequestStopAsync(InstanceDirectory, ct).ConfigureAwait(false))
{
_logger.LogInformation(
"Instanz {Name} war für die Bitte zu enden nicht erreichbar — "
+ "es gilt trotzdem die Frist.", InstanceName);
}
var deadline = _clock.GetUtcNow() + _options.StopGrace;
while (_clock.GetUtcNow() < deadline)
{
if (_process is { HasExited: true }
|| await _channel.AskAsync(InstanceDirectory, ct).ConfigureAwait(false) is null)
{
Finish(_process?.ExitCode, gewaltsam: false);
return true;
}
await Task.Delay(TimeSpan.FromMilliseconds(250), _clock, ct).ConfigureAwait(false);
}
if (_process is null)
{
// Sie hoert nicht, und zu fassen ist sie auch nicht (siehe ReattachAsync).
_logger.LogWarning(
"Instanz {Name} reagiert nicht auf die Bitte zu enden und ist nicht zu "
+ "beenden — es gibt keinen Prozess, den dieser Manager greifen kann.",
InstanceName);
return false;
}
_logger.LogWarning(
"Instanz {Name} hat binnen {Frist}s nicht geendet — sie wird beendet.",
InstanceName, _options.StopGrace.TotalSeconds);
_process.Kill();
Finish(_process.ExitCode, gewaltsam: true);
return true;
}
private void Finish(int? exitCode, bool gewaltsam)
{
Condition = InstanceCondition.Stopped;
InstanceStateStore.Clear(InstanceDirectory);
Raise(InstanceEventKind.StoppedOrderly, _process?.Id, exitCode,
gewaltsam
? $"Instanz {InstanceName} musste beendet werden (Code {Describe(exitCode)})."
: $"Instanz {InstanceName} beendet (Code {Describe(exitCode)}).");
}
// ─── Kleinkram ───
private static string Describe(int? exitCode) => exitCode?.ToString() ?? "unbekannt";
private void Raise(InstanceEventKind kind, int? pid, int? exitCode, string message)
{
var ereignis = new SupervisionEvent(
kind, InstanceDirectory,
string.IsNullOrEmpty(InstanceName) ? Path.GetFileName(InstanceDirectory) : InstanceName,
pid, exitCode, message, _clock.GetUtcNow());
if (ereignis.WorthReporting)
_logger.LogError("{Meldung}", message);
else
_logger.LogInformation("{Meldung}", message);
Observed?.Invoke(ereignis);
}
public async ValueTask DisposeAsync()
{
await _cts.CancelAsync().ConfigureAwait(false);
if (_loop is not null)
{
try { await _loop.ConfigureAwait(false); }
catch (OperationCanceledException) { /* erwartet */ }
}
_cts.Dispose();
// Der Prozess wird NICHT beendet: Faellt der Manager weg, laufen die Instanzen
// weiter (E-M6). Losgelassen wird nur das Handle.
_process?.Dispose();
_process = null;
}
}