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:
@@ -0,0 +1,29 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>ClawdDotNet.Manager</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- EIN Verweis, und der ist die halbe Architektur (#528).
|
||||
|
||||
ClawdDotNet.App haengt sechzehn Werkzeugprojekte hinter sich (Telegram, Mail, FTP,
|
||||
Rocket.Chat, ...). Der Manager soll keines davon kennen: Er startet Instanzen,
|
||||
beobachtet sie und meldet, was mit ihnen passiert - Fachlogik laeuft ausschliesslich
|
||||
in den Instanzprozessen. Waere App hier verwiesen, wuerde das nicht ausgefuehrt,
|
||||
aber geladen, und "keine Werkzeuge im Manager" waere eine Absichtserklaerung statt
|
||||
einer Eigenschaft des Bauplans.
|
||||
|
||||
Wer hier einen Verweis auf ClawdDotNet.App, ClawdDotNet.Web oder ein
|
||||
Werkzeugprojekt ergaenzt, hat den Schnitt verletzt. -->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ClawdDotNet.Core\ClawdDotNet.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ClawdDotNet.Manager.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,127 @@
|
||||
using System.Text.Json;
|
||||
using ClawdDotNet.Core.Instances;
|
||||
|
||||
namespace ClawdDotNet.Manager;
|
||||
|
||||
/// <summary>Was eine Instanz über sich sagt, wenn man sie über ihren Socket fragt.</summary>
|
||||
public sealed record InstanceIdentity(string InstanceId, string InstanceName, string AppVersion);
|
||||
|
||||
/// <summary>
|
||||
/// Das Gespräch mit einer Instanz über ihren Socket: Wer bist du — und bitte beende dich.
|
||||
///
|
||||
/// <para>Beides gehört zusammen, weil es derselbe Weg ist. Und beides steht hinter einer
|
||||
/// Schnittstelle, damit die Aufsicht prüfbar bleibt, ohne echte Prozesse zu starten: Ein
|
||||
/// Absturz zum gewünschten Zeitpunkt, ein Prozess, der lebt und schweigt, eine
|
||||
/// wiederverwendete Prozesskennung — das alles ist mit einem laufenden Programm entweder
|
||||
/// nicht oder nur mit Wartezeiten herzustellen, und Tests mit Wartezeiten sind Tests, die
|
||||
/// man irgendwann abschaltet.</para>
|
||||
/// </summary>
|
||||
public interface IInstanceChannel
|
||||
{
|
||||
/// <summary>
|
||||
/// <c>null</c> heißt: Da antwortet niemand. Alles andere ist die Auskunft der
|
||||
/// laufenden Instanz.
|
||||
/// </summary>
|
||||
Task<InstanceIdentity?> AskAsync(string instanceDirectory, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Bittet die Instanz, sich zu beenden.
|
||||
///
|
||||
/// <para><c>false</c> heißt nur, dass die Bitte nicht angekommen ist — nicht, dass die
|
||||
/// Instanz noch läuft. Ob sie endet, beantwortet allein der nächste Blick auf ihren
|
||||
/// Socket.</para>
|
||||
/// </summary>
|
||||
Task<bool> RequestStopAsync(string instanceDirectory, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Der echte Weg: eine HTTP-Anfrage über den Socket im Instanzverzeichnis.
|
||||
///
|
||||
/// <para><b>Warum der Socket und nicht die Prozessliste.</b> Zwei Gründe, und der zweite
|
||||
/// ist der wichtigere:</para>
|
||||
///
|
||||
/// <para>Erstens ist eine Prozesskennung kein Beweis. Nach einem Neustart der Maschine
|
||||
/// gehört 4711 mit einiger Wahrscheinlichkeit einem fremden Programm; der Socket dagegen
|
||||
/// antwortet mit der Instanzkennung, und die ist eindeutig.</para>
|
||||
///
|
||||
/// <para>Zweitens — und das ist der Anlass des ganzen Vorhabens — sagt die Prozessliste
|
||||
/// nur, <em>dass</em> etwas läuft, nicht ob es noch arbeitet. Am 2026-09-08 hat Windows
|
||||
/// eine Anwendung als „reagiert nicht mehr" beendet; bis dahin stand sie einwandfrei in
|
||||
/// der Prozessliste (#529). Ein Prozess, der auf seinem eigenen Socket nicht mehr
|
||||
/// antwortet, ist genau der Fall, den niemand mitbekommen hat.</para>
|
||||
/// </summary>
|
||||
public sealed class SocketInstanceChannel(TimeSpan? timeout = null) : IInstanceChannel
|
||||
{
|
||||
/// <summary>
|
||||
/// Kurz bemessen. Der Socket liegt auf derselben Maschine; wer hier länger als ein
|
||||
/// paar Sekunden braucht, antwortet nicht mehr im Sinne der Frage.
|
||||
/// </summary>
|
||||
private readonly TimeSpan _timeout = timeout ?? TimeSpan.FromSeconds(5);
|
||||
|
||||
public async Task<InstanceIdentity?> AskAsync(
|
||||
string instanceDirectory, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = InstanceEndpoint.CreateClient(instanceDirectory);
|
||||
client.Timeout = _timeout;
|
||||
|
||||
using var antwort = await client.GetAsync("/api/info", ct).ConfigureAwait(false);
|
||||
|
||||
if (!antwort.IsSuccessStatusCode)
|
||||
return null;
|
||||
|
||||
var json = await antwort.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
|
||||
return new InstanceIdentity(
|
||||
Text(doc.RootElement, "instanceId"),
|
||||
Text(doc.RootElement, "instanceName"),
|
||||
Text(doc.RootElement, "appVersion"));
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
// Der Aufrufer hat abgebrochen - das ist keine Aussage ueber die Instanz.
|
||||
throw;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Alles andere heisst dasselbe: Da antwortet niemand. Ob die Socketdatei
|
||||
// fehlt, die Verbindung abgewiesen wurde oder die Zeit ablief, ist fuer die
|
||||
// Aufsicht derselbe Befund - und keiner, der sie aus dem Tritt bringen darf.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> RequestStopAsync(
|
||||
string instanceDirectory, CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = InstanceEndpoint.CreateClient(instanceDirectory);
|
||||
client.Timeout = _timeout;
|
||||
|
||||
using var antwort = await client
|
||||
.PostAsync("/api/instanz/beenden", content: null, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return antwort.IsSuccessStatusCode;
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Nicht erreichbar. Das ist keine gute Nachricht, aber auch kein Fehler, den
|
||||
// die Aufsicht behandeln muesste - gleich beginnt ohnehin die Frist, nach der
|
||||
// haerter durchgegriffen wird.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string Text(JsonElement root, string name)
|
||||
=> root.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String
|
||||
? v.GetString() ?? ""
|
||||
: "";
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace ClawdDotNet.Manager;
|
||||
|
||||
/// <summary>
|
||||
/// Ein gestarteter oder wiedergefundener Instanzprozess, soweit die Aufsicht ihn braucht.
|
||||
///
|
||||
/// <para>Bewusst schmal. Die Aufsicht fragt <b>nicht</b> die Prozessliste, ob eine Instanz
|
||||
/// lebt — das beantwortet der Socket, und nur er beantwortet es richtig (siehe
|
||||
/// <see cref="SocketInstanceProbe"/>). 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.</para>
|
||||
/// </summary>
|
||||
public interface IInstanceProcess : IDisposable
|
||||
{
|
||||
int Id { get; }
|
||||
|
||||
DateTimeOffset StartedAt { get; }
|
||||
|
||||
bool HasExited { get; }
|
||||
|
||||
/// <summary>Der Abschiedscode, oder <c>null</c>, solange der Prozess läuft.</summary>
|
||||
int? ExitCode { get; }
|
||||
|
||||
/// <summary>Das harte Ende. Nur, wenn die Bitte über den Socket nichts genützt hat.</summary>
|
||||
void Kill();
|
||||
}
|
||||
|
||||
/// <summary>Startet Instanzprozesse und findet laufende wieder.</summary>
|
||||
public interface IInstanceLauncher
|
||||
{
|
||||
/// <summary>Startet eine Instanz. Wirft, wenn der Prozess nicht anläuft.</summary>
|
||||
IInstanceProcess Start(string instanceDirectory);
|
||||
|
||||
/// <summary>
|
||||
/// Nimmt einen bereits laufenden Prozess wieder in die Hand (E-M6).
|
||||
///
|
||||
/// <para><c>null</c>, wenn es ihn nicht mehr gibt oder wenn er nicht der ist, der
|
||||
/// gesucht wird — siehe <paramref name="startedAt"/>.</para>
|
||||
/// </summary>
|
||||
/// <param name="startedAt">
|
||||
/// Die Startzeit aus der Zustandsdatei. Sie entlarvt eine wiederverwendete
|
||||
/// Prozesskennung: Nach einem Neustart der Maschine gehört 4711 mit einiger
|
||||
/// Wahrscheinlichkeit einem fremden Programm, und das dann für die eigene Instanz zu
|
||||
/// halten wäre schlimmer, als sie gar nicht zu finden.
|
||||
/// </param>
|
||||
IInstanceProcess? Attach(int processId, DateTimeOffset startedAt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Der echte Start: <c>ClawdDotNet --headless --instance <Verzeichnis></c>.
|
||||
///
|
||||
/// <para><b>Vorerst ohne Fenster.</b> 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.</para>
|
||||
/// </summary>
|
||||
public sealed class ProcessInstanceLauncher(string executablePath, bool headless = true)
|
||||
: IInstanceLauncher
|
||||
{
|
||||
/// <summary>Wo die Programmdatei liegt — in der Regel neben der des Managers.</summary>
|
||||
public string ExecutablePath { get; } = Path.GetFullPath(executablePath);
|
||||
|
||||
/// <summary>
|
||||
/// Wie weit Start- und Zustandszeit auseinanderliegen dürfen, damit es derselbe
|
||||
/// Prozess ist.
|
||||
///
|
||||
/// <para>Nicht null: Die Startzeit kommt aus dem Betriebssystem, die Zustandsdatei
|
||||
/// wurde einen Wimpernschlag später geschrieben, und beide runden unterschiedlich.
|
||||
/// Zwei Sekunden sind großzügig genug für diese Differenz und weit zu eng, als dass
|
||||
/// eine wiederverwendete Kennung hindurchpasste.</para>
|
||||
/// </summary>
|
||||
private static readonly TimeSpan StartTimeTolerance = TimeSpan.FromSeconds(2);
|
||||
|
||||
public IInstanceProcess Start(string instanceDirectory)
|
||||
{
|
||||
var start = new ProcessStartInfo
|
||||
{
|
||||
FileName = ExecutablePath,
|
||||
WorkingDirectory = Path.GetDirectoryName(ExecutablePath) ?? ".",
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
|
||||
if (headless) start.ArgumentList.Add("--headless");
|
||||
|
||||
start.ArgumentList.Add("--instance");
|
||||
start.ArgumentList.Add(Path.GetFullPath(instanceDirectory));
|
||||
|
||||
var process = Process.Start(start)
|
||||
?? throw new InvalidOperationException(
|
||||
$"Der Instanzprozess ließ sich nicht starten: {ExecutablePath}");
|
||||
|
||||
return new RunningProcess(process);
|
||||
}
|
||||
|
||||
public IInstanceProcess? Attach(int processId, DateTimeOffset startedAt)
|
||||
{
|
||||
try
|
||||
{
|
||||
var process = Process.GetProcessById(processId);
|
||||
|
||||
var actual = new DateTimeOffset(process.StartTime.ToUniversalTime(), TimeSpan.Zero);
|
||||
|
||||
if ((actual - startedAt).Duration() > StartTimeTolerance)
|
||||
{
|
||||
// Dieselbe Nummer, ein anderer Prozess.
|
||||
process.Dispose();
|
||||
return null;
|
||||
}
|
||||
|
||||
return new RunningProcess(process);
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or InvalidOperationException)
|
||||
{
|
||||
// Gibt es nicht (mehr), oder die Startzeit ist nicht abfragbar, weil er
|
||||
// gerade endet. Beides heisst: nicht wiederzufinden.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class RunningProcess(Process process) : IInstanceProcess
|
||||
{
|
||||
public int Id { get; } = process.Id;
|
||||
|
||||
public DateTimeOffset StartedAt { get; } =
|
||||
new(process.StartTime.ToUniversalTime(), TimeSpan.Zero);
|
||||
|
||||
public bool HasExited
|
||||
{
|
||||
get
|
||||
{
|
||||
try { return process.HasExited; }
|
||||
catch (InvalidOperationException) { return true; }
|
||||
}
|
||||
}
|
||||
|
||||
public int? ExitCode
|
||||
{
|
||||
get
|
||||
{
|
||||
try { return process.HasExited ? process.ExitCode : null; }
|
||||
catch (Exception ex) when (ex is InvalidOperationException or NotSupportedException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Kill()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Mit der ganzen Nachkommenschaft: Ein Instanzprozess, der selbst etwas
|
||||
// gestartet hat, liesse sonst Waisen zurueck.
|
||||
process.Kill(entireProcessTree: true);
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidOperationException or NotSupportedException)
|
||||
{
|
||||
// Schon weg, oder das Betriebssystem laesst es nicht zu. In beiden
|
||||
// Faellen ist das Ziel - der Prozess ist fort - entweder erreicht oder
|
||||
// nicht erreichbar, und beides sagt gleich der naechste Aufsichtsdurchgang.
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => process.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
namespace ClawdDotNet.Manager;
|
||||
|
||||
/// <summary>Was der Manager an einer Instanz beobachtet hat.</summary>
|
||||
public enum InstanceEventKind
|
||||
{
|
||||
/// <summary>Gestartet und über ihren Socket erreichbar.</summary>
|
||||
Started,
|
||||
|
||||
/// <summary>Der Prozess kam hoch, meldete sich aber nicht — oder kam gar nicht hoch.</summary>
|
||||
StartFailed,
|
||||
|
||||
/// <summary>Auf Geheiß beendet.</summary>
|
||||
StoppedOrderly,
|
||||
|
||||
/// <summary>Weg, ohne dass jemand darum gebeten hätte.</summary>
|
||||
Crashed,
|
||||
|
||||
/// <summary>
|
||||
/// Der Prozess läuft, antwortet aber nicht mehr auf seinem Socket.
|
||||
///
|
||||
/// <para>Der Fall, der dieses ganze Vorhaben ausgelöst hat: Am 2026-09-08 stand die
|
||||
/// Anwendung einwandfrei in der Prozessliste und arbeitete längst nicht mehr; Windows
|
||||
/// hat sie schließlich erschlagen (#529). Bis dahin hatte niemand etwas gemerkt — die
|
||||
/// Prozessliste sagt nur, dass etwas da ist.</para>
|
||||
/// </summary>
|
||||
Unresponsive,
|
||||
|
||||
/// <summary>Antwortet wieder, nachdem sie stumm war.</summary>
|
||||
Recovered
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ein Vorkommnis in der Aufsicht.
|
||||
///
|
||||
/// <para><b>Das ist die Liste aus Abschnitt 4 des Konzepts, und sie ist absichtlich
|
||||
/// kurz.</b> Der Manager protokolliert und meldet, was aus <em>seiner</em> Sicht
|
||||
/// relevant ist: Start, Stopp, Absturz, Verstummen. Ein fehlgeschlagener Werkzeugaufruf,
|
||||
/// ein abgebrochener Agentenlauf, eine überschrittene Budgetgrenze — all das bleibt in
|
||||
/// der Instanz und wird von ihr selbst gemeldet, mit ihrem eigenen Sub-Token. Der Manager
|
||||
/// sieht davon nichts und soll davon nichts sehen.</para>
|
||||
/// </summary>
|
||||
/// <param name="Kind">Was passiert ist.</param>
|
||||
/// <param name="InstanceDirectory">Um welche Instanz es geht — der Pfad ist ihre Kennung.</param>
|
||||
/// <param name="InstanceName">Für Menschen. Kann leer sein, solange die Instanz nie geantwortet hat.</param>
|
||||
/// <param name="ProcessId">Die Prozesskennung, soweit bekannt.</param>
|
||||
/// <param name="ExitCode">Nur bei <see cref="InstanceEventKind.Crashed"/> und <see cref="InstanceEventKind.StoppedOrderly"/>.</param>
|
||||
/// <param name="Message">Ein Satz, der ins Protokoll passt.</param>
|
||||
/// <param name="At">Wann.</param>
|
||||
public sealed record SupervisionEvent(
|
||||
InstanceEventKind Kind,
|
||||
string InstanceDirectory,
|
||||
string InstanceName,
|
||||
int? ProcessId,
|
||||
int? ExitCode,
|
||||
string Message,
|
||||
DateTimeOffset At)
|
||||
{
|
||||
/// <summary>
|
||||
/// Ob dieses Vorkommnis über das Protokoll hinaus ins Deploymentcenter gehört.
|
||||
///
|
||||
/// <para>Start und geordnetes Ende sind Zustandswechsel und stehen im Protokoll.
|
||||
/// Gemeldet wird, was niemand bestellt hat — und zwar stellvertretend, weil die
|
||||
/// Instanz es selbst nicht mehr kann. Genau dafür gibt es den Manager: Fällt ein
|
||||
/// Prozess hart, fällt sein eigener Meldeweg mit.</para>
|
||||
/// </summary>
|
||||
public bool WorthReporting
|
||||
=> Kind is InstanceEventKind.Crashed
|
||||
or InstanceEventKind.Unresponsive
|
||||
or InstanceEventKind.StartFailed;
|
||||
}
|
||||
Reference in New Issue
Block a user