Files
ClawdDotNet/src/ClawdDotNet.App/AppHost.cs
T
RichardandClaude Opus 5.5 8552755bb6 Stufe 1 (#764): Agent bearbeiten, Lizenz und Einstellungen in der WebUI
- PUT /api/agents/{id} und /texts: Stammdaten, Grenzen, Budget, Identity/Soul/
  Systemprompt (L6/L7); "Jetzt ausfuehren" in der Agentenansicht (L9)
- Lizenzschluessel ueber die Weboberflaeche (L1): erst vom Lizenzserver
  bestaetigt, dann gespeichert; hebt die Sperre ohne Neustart auf
- GET/PUT /api/settings: Instanz- und Anwendungseinstellungen inkl.
  OpenRouter-Key (L3/L4/L5), Geheimnisse nur schreibend
- GET/PUT /api/verwaltung/settings: Deploymentcenter und Update-Kanal des Managers
- Einstellungsseite zeigt echte Werte statt fester Texte
- NonInteractiveLicensePrompt verwies auf --license-set-key, den es nie gab
- Sprachpakete werden bei jedem Lauf geprueft (LanguagePackTests)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:30:20 +02:00

1138 lines
53 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using ClawdDotNet.App.Services;
using ClawdDotNet.App.Settings;
using ClawdDotNet.Core.Accounting;
using ClawdDotNet.Core.Api;
using ClawdDotNet.Core.Audit;
using ClawdDotNet.Core.Budget;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Engine;
using ClawdDotNet.Core.Logging;
using ClawdDotNet.Core.Memory;
using ClawdDotNet.Core.Security;
using ClawdDotNet.Core.Staging;
using ClawdDotNet.Core.State;
using ClawdDotNet.Core.Storage;
using ClawdDotNet.Core.Tasks;
using ClawdDotNet.Core.Deploymentcenter;
using ClawdDotNet.Core.Deploymentcenter.Watchdog;
using ClawdDotNet.Core.Tools;
using ClawdDotNet.Tools.AgentComm;
using ClawdDotNet.Tools.AgentEditor;
using ClawdDotNet.Tools.AgentSpawn;
using ClawdDotNet.Tools.Database;
using ClawdDotNet.Tools.DirectAPI;
using ClawdDotNet.Tools.FileRW;
using ClawdDotNet.Tools.FTP;
using ClawdDotNet.Tools.Mail;
using ClawdDotNet.Tools.SocialMediaManager;
using ClawdDotNet.Tools.Telegram;
using ClawdDotNet.Tools.TelegramClient;
using ClawdDotNet.Tools.WebFetch;
using ClawdDotNet.Tools.WebMonitor;
using Microsoft.Extensions.Logging;
using ClawdDotNet.Core.Instances;
using ClawdDotNet.Licensing;
namespace ClawdDotNet.App;
/// <summary>
/// Baut alles auf, was ClawdDotNet zum Laufen braucht — <b>ohne</b> eine einzige Zeile
/// Oberflächencode.
///
/// <para>Vorher lag das in <c>Program.cs</c> der WinForms-Anwendung: 350 Zeilen zwischen
/// <c>ApplicationConfiguration.Initialize()</c> und <c>Application.Run(form)</c>. Die
/// Trennung bestand faktisch schon — alles war fertig aufgebaut, bevor das Fenster
/// überhaupt entstand. Sie war nur nirgends festgehalten.</para>
///
/// <para>Damit setzen zwei Aufrufer auf demselben Aufbau auf: die Avalonia-Anwendung
/// und der geplante systemd-Dienst. Was ein Fenster braucht — Instanzauswahl,
/// Lizenzabfrage, Telegram-Anmeldung — kommt als Rückruf herein, statt hier
/// festgeschrieben zu sein.</para>
/// </summary>
public sealed class AppHost : IAsyncDisposable
{
private readonly List<Func<ValueTask>> _shutdown = [];
public required SettingsManager Settings { get; init; }
public required InstanceDirectoryManager Directories { get; init; }
public required InstanceConfig Instance { get; init; }
public required string InstancePath { get; init; }
public required string LogDirectory { get; init; }
public required ILoggerFactory LoggerFactory { get; init; }
public required ToolRegistry Tools { get; init; }
/// <summary>#689: Escalations, conflicts and decisions — read by the interfaces, acted on through the desk.</summary>
public Core.Cases.ICaseRepository? Cases { get; init; }
/// <summary>#689: The desk a human decides at; <c>null</c> without a task board.</summary>
public Core.Cases.ICaseDesk? CaseDesk => Engine?.CaseDesk;
/// <summary>#713: The tokens of the MCP server — a run's, and personal ones.</summary>
public Core.Mcp.McpGrants? Mcp { get; init; }
/// <summary>
/// #713: Whom the MCP server hands calls to — the engine. Settable so the HTTP surface can
/// be tested without a model behind it.
/// </summary>
public Core.Mcp.IMcpToolHost? McpToolHost
{
get => _mcpToolHost ?? Engine;
init => _mcpToolHost = value;
}
private readonly Core.Mcp.IMcpToolHost? _mcpToolHost;
/// <summary>#717: Special budgets — one pot per task tree, approved by a human.</summary>
public Core.Budget.ISpecialBudgetRepository? SpecialBudgets { get; init; }
/// <summary>
/// Zustandsspeicher und Audit-Log der Instanz. Nicht oeffentlich — sie gehoeren dem
/// Aufbau, nicht den Oberflaechen; gebraucht werden sie hier fuer die Sichtung
/// offener Werkzeugaufrufe beim Start (#554).
/// </summary>
internal IStateStore? StateStore { get; private init; }
internal IAuditRepository? AuditRepository { get; private init; }
/// <summary>
/// Das geordnete Ende dieser Instanz (#528).
///
/// <para>Wer die Instanz beenden will — der Instanzmanager über den Socket, ein
/// Mensch über das Fenster — ruft <see cref="ShutdownCoordinator.BeginAsync"/> und
/// wartet, bevor er <see cref="DisposeAsync"/> anstößt. Der Instanzmanager liest
/// unterdessen <see cref="ShutdownCoordinator.Snapshot"/> über den Puls und weiß
/// dadurch, dass hier aufgeräumt und nicht gehangen wird.</para>
/// </summary>
/// <para>Nicht <c>required</c>, sondern mit einem leeren Koordinator vorbelegt: Ein
/// Aufbau ohne angemeldete Arbeitsquellen hat nichts abzuwarten und endet sofort. So
/// gibt es diese Zusage <em>immer</em>, und niemand muss auf <c>null</c> prüfen,
/// bevor er eine Instanz geordnet beendet.</para>
public ShutdownCoordinator Shutdown { get; init; } =
new(Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance);
/// <summary>Null, wenn kein OpenRouter-Schlüssel hinterlegt ist — dann laufen keine Agenten.</summary>
public AgentEngine? Engine { get; private init; }
public TaskScanner? Scanner { get; private init; }
public StagingService? Staging { get; private init; }
/// <summary>
/// Das Taskboard. Null, wenn den Agenten dieser Instanz ein gemeinsamer
/// Arbeitsbereich fehlt — dann gibt es keinen Ort, an dem Aufgaben lägen.
/// </summary>
public TaskboardService? Board { get; private init; }
public SqliteUsageRepository? Usage { get; private init; }
/// <summary>#660: Prompt history - prompt and response of every run.</summary>
public Core.Transcripts.IRunTranscriptStore? Transcripts { get; private init; }
/// <summary>#660: Usage analysis, run details and prompt history for the user interfaces.</summary>
public RunInsights? Insights { get; private init; }
/// <summary>#699: Costs per unit and the alerts on them.</summary>
public Core.Accounting.CostWatch? CostWatch { get; private set; }
/// <summary>#698: The units' doctrines.</summary>
public Core.Organisation.IDoctrineStore? Doctrines { get; private set; }
/// <summary>#693: The leads' spot checks and the measures they proposed.</summary>
public Core.Inspection.SpotChecks? SpotChecks { get; private set; }
/// <summary>#725: Inbound triggers — n8n or any other system hands an agent a task.</summary>
public Core.Triggers.InboundTriggers? Triggers { get; private set; }
/// <summary>#646: Findings on work that lives but does not move.</summary>
public Core.Tasks.TaskFollowUp? FollowUp { get; private set; }
/// <summary>#700: Day values and the month archive of audit and receipts.</summary>
public Core.Audit.AuditArchive? AuditArchive { get; private set; }
/// <summary>#197: The external MCP servers, each registered as a tool.</summary>
public Core.Mcp.McpConnections? McpServers { get; private set; }
/// <summary>#726: Workflows agents propose; a human decides and imports by hand.</summary>
public Core.Automation.AutomationProposals? Automations { get; private set; }
/// <summary>Modellpreise von OpenRouter - fuer Budget und Kostenprognose (#595). Laedt im Hintergrund.</summary>
public ModelPricingCatalog? Pricing { get; private init; }
/// <summary>Das Taskboard fuer die Oberflaechen (#594). Zustandslos, deshalb je Zugriff neu.</summary>
public TaskAdministration TaskAdmin => new(
Board, AuditRepository, Scanner, Usage, Pricing, () => Instance.Agents,
LoggerFactory.CreateLogger("ClawdDotNet.App.Tasks"),
organisation: () => Core.Organisation.OrgChart.From(Instance));
/// <summary>#711: The team overview. Stateless, like <see cref="TaskAdmin"/>.</summary>
public TeamOverview Team => new(Engine, Staging, AuditRepository, Usage, () => Instance.Agents,
organisation: () => Core.Organisation.OrgChart.From(Instance),
board: Board, cases: Cases,
costAlerts: CostWatch is { } watch ? ct => watch.RecentAsync(1, ct) : null);
public TelegramClientManager? TelegramClient { get; private init; }
public OpenRouterStatusService? Status { get; private init; }
/// <summary>
/// Die taegliche Sicherung. Immer gesetzt — ob sie etwas tut, entscheidet
/// <c>AutoBackupEnabled</c> bei jedem Durchlauf neu, damit eine Aenderung in den
/// Einstellungen ohne Neustart greift.
/// </summary>
public BackupScheduler? Backups { get; private set; }
/// <summary>
/// Anbindung ans Deploymentcenter (Heartbeat, Fehler-Stream, Bugtracker, Updates).
/// Null, wenn Adresse oder Token fehlen.
/// </summary>
public DeploymentcenterService? Deploymentcenter { get; private set; }
/// <summary>
/// Meldeweg für ungefangene Ausnahmen. Immer gesetzt — ohne Anbindung ist es der
/// Leerlauf, damit Aufrufer nicht auf null prüfen müssen.
/// </summary>
public IErrorReporter Errors => Deploymentcenter?.Errors ?? NullErrorReporter.Instance;
/// <summary>
/// Der Lizenz-Torwächter bleibt über die Laufzeit erhalten: Ein Widerruf soll auch
/// eine bereits laufende Instanz erreichen, nicht erst den nächsten Start.
/// </summary>
public LicenseGate? License { get; private set; }
/// <summary>
/// Ob diese Instanz nachweislich vom Instanzmanager geführt wird (#535).
///
/// <para><b>Der geprüfte Befund, nicht der Schalter.</b> <c>--managed</c> ist eine
/// Behauptung, die jeder aufstellen kann; hier steht das Ergebnis der Prüfung von
/// <see cref="ManagerHandshake"/>. Wer daran eine Entscheidung hängt — ob die Instanz
/// sich selbst wechseln darf, ob die Lizenz zentral geprüft wurde —, fragt diesen
/// Wert und nicht die Befehlszeile.</para>
/// </summary>
public bool IsManaged { get; private set; }
/// <summary>
/// Die laufende Nachprüfung. Der Aufrufer hängt sich an
/// <see cref="LicenseWatch.Revoked"/> und beendet die Anwendung, wenn es feuert.
/// </summary>
public LicenseWatch? LicenseWatch { get; private set; }
/// <summary>
/// Meldet, dass die Instanzkonfiguration geschrieben wurde.
///
/// <para>Es gibt dieses Ereignis erst, seit es zwei Oberflächen gibt.
/// <see cref="Instance"/> ist ein veränderliches Objekt, das beide teilen — die
/// Ansichtsmodelle lesen es aber einmal beim Aufbau in ihre Listen. Ohne eine
/// Meldung erschiene ein in der Weboberfläche angelegter Agent im laufenden Fenster
/// nicht, und umgekehrt genauso.</para>
///
/// <para><b>Kommt auf dem Faden des Verursachers</b>, nicht auf dem der Oberfläche.
/// Wer eine <c>ObservableCollection</c> daran hängt, geht über den Dispatcher.</para>
/// </summary>
public event Action<InstanceChange>? InstanceChanged;
/// <summary>
/// Schreibt die Instanzkonfiguration und sagt Bescheid.
///
/// <para>Der einzige Weg, auf dem <see cref="Instance"/> gespeichert werden soll.
/// <c>Directories.SaveInstanceConfig</c> von Hand zu rufen schreibt zwar dieselbe
/// Datei, lässt aber die andere Oberfläche in Unkenntnis.</para>
/// </summary>
public void SaveInstance(InstanceChange change)
{
Directories.SaveInstanceConfig(InstancePath, Instance);
InstanceChanged?.Invoke(change);
}
// ─── Verwaltung ───
//
// Die Regeln zum Anlegen, Ändern und Ausführen — bis zum 2026-09-06 in den
// Avalonia-Ansichtsmodellen, jetzt hier. Der Grund ist die zweite Oberfläche: Was
// dort stand, hätte sie ein zweites Mal schreiben müssen.
//
// Je Aufbau genau eine Ausprägung, und deshalb hier und nicht beim Aufrufer: Der
// RunCoordinator führt den Verlauf und merkt sich die letzten Handläufe. Zwei
// Ausprägungen hiessen zwei Verläufe, von denen jede Oberfläche einen anderen zeigt.
private readonly Lazy<AgentAdministration> _agentAdmin;
private readonly Lazy<ServiceAdministration> _serviceAdmin;
private readonly Lazy<JobAdministration> _jobAdmin;
private readonly Lazy<RunCoordinator> _runs;
private readonly Lazy<SettingsAdministration> _settingsAdmin;
public AppHost()
{
_agentAdmin = new Lazy<AgentAdministration>(() => new AgentAdministration(this));
_serviceAdmin = new Lazy<ServiceAdministration>(() => new ServiceAdministration(this));
_jobAdmin = new Lazy<JobAdministration>(() => new JobAdministration(this));
_runs = new Lazy<RunCoordinator>(() => new RunCoordinator(this));
_settingsAdmin = new Lazy<SettingsAdministration>(() => new SettingsAdministration(this));
}
/// <summary>Agenten anlegen, entfernen, Werkzeuge zulassen und einstellen.</summary>
public AgentAdministration AgentAdmin => _agentAdmin.Value;
/// <summary>#683: Units, moves, tool ceilings, unit-wide values. Stateless.</summary>
public OrgAdministration Organisation => new(this, LoggerFactory.CreateLogger("ClawdDotNet.App.Organisation"));
/// <summary>Dienste der Instanz anlegen, entfernen, umschalten.</summary>
public ServiceAdministration ServiceAdmin => _serviceAdmin.Value;
/// <summary>Geplante Läufe anlegen, ändern, entfernen, abschalten.</summary>
public JobAdministration JobAdmin => _jobAdmin.Value;
/// <summary>„Jetzt ausführen" samt Verlauf.</summary>
public RunCoordinator Runs => _runs.Value;
/// <summary>#764: The settings the WebUI reads and writes.</summary>
public SettingsAdministration SettingsAdmin => _settingsAdmin.Value;
/// <summary>Was der Aufrufer beim Start erfragen muss.</summary>
public sealed class Callbacks
{
/// <summary>
/// Wählt die Instanz. Gibt <c>null</c> zurück, wenn der Nutzer abbricht.
/// Ein Dienst liefert hier den fest eingestellten Pfad, ohne zu fragen.
/// </summary>
public required Func<InstanceDirectoryManager, Task<string?>> SelectInstance { get; init; }
/// <summary>Wie der Lizenz-Torwächter mit dem Benutzer spricht. Null im geführten Modus.</summary>
public ILicensePrompt? License { get; init; }
/// <summary>
/// Die Behauptung, vom Instanzmanager geführt zu werden — der Schalter
/// <c>--managed</c>.
///
/// <para><b>Eine Behauptung, kein Befund.</b> Ob sie stimmt, entscheidet allein
/// die Unterschrift des Managers (<see cref="ManagerHandshake"/>); dieser Schalter
/// sagt nur, dass überhaupt jemand den Anspruch erhebt. Ohne gültige Unterschrift
/// prüft die Instanz ihre Lizenz selbst.</para>
/// </summary>
public bool IsManaged { get; init; }
/// <summary>
/// Telegram-Anmeldecode und 2FA-Passwort. Null lässt die MTProto-Anmeldung aus —
/// im kopflosen Betrieb der richtige Weg, weil ein Eingabefenster dort einen
/// Dienst dauerhaft blockieren würde.
/// </summary>
public Func<string, Task<string>>? TelegramLogin { get; init; }
public Func<Task<string>>? Telegram2FA { get; init; }
}
/// <summary>
/// Ergebnis des Aufbaus. <see cref="Host"/> ist null, wenn der Nutzer abgebrochen hat
/// oder die Lizenz fehlt — der Aufrufer beendet dann, ohne eine Fehlermeldung
/// nachzureichen: Die hat der Torwächter schon gezeigt.
/// </summary>
public readonly record struct StartupResult(AppHost? Host, string? Error);
/// <summary>
/// Wohin diese Instanz protokolliert.
///
/// <para>Die Regeln stehen in <see cref="InstanceLayout"/> — sie beschreiben, wie eine
/// Instanz auf der Platte liegt, und das muss auch der Instanzmanager wissen, der
/// diese Schicht nicht kennen darf. Hier steht nur die Weiterleitung, damit die
/// Aufrufer im Fenster nicht zwei Wege lernen müssen.</para>
/// </summary>
public static string ResolveLogDirectory(InstanceConfig instance, string instancePath)
=> InstanceLayout.ResolveLogDirectory(instance, instancePath);
/// <inheritdoc cref="InstanceLayout.ResolveBackupDirectory"/>
public static string ResolveBackupDirectory(InstanceConfig instance, string instancePath)
=> InstanceLayout.ResolveBackupDirectory(instance, instancePath);
public static async Task<StartupResult> StartAsync(Callbacks callbacks, CancellationToken ct = default)
{
// ─── 1. Anwendungseinstellungen ───
var settings = new SettingsManager();
settings.Load();
// ─── 2. Instanz wählen ───
var directories = new InstanceDirectoryManager(
Path.GetFullPath(settings.AppSettings.InstancesDirectory));
var instancePath = await callbacks.SelectInstance(directories);
if (string.IsNullOrWhiteSpace(instancePath))
return new StartupResult(null, null); // Abbruch, keine Meldung nötig
InstanceConfig instance;
try
{
instance = directories.LoadInstanceConfig(instancePath);
}
catch (Exception ex)
{
return new StartupResult(null,
$"Fehler beim Laden der Instanz:\n{instancePath}\n\n{ex.Message}");
}
// ─── 3. Protokollierung ───
var logDirectory = ResolveLogDirectory(instance, instancePath);
var minLevel = Enum.TryParse<Core.Logging.LogLevel>(
settings.AppSettings.MinimumLogLevel, true, out var parsed)
? parsed
: Core.Logging.LogLevel.Info;
var loggerFactory = LoggingExtensions.CreateClawdLoggerFactory(logDirectory, minLevel);
var logger = loggerFactory.CreateLogger("ClawdDotNet.Startup");
logger.LogInformation("ClawdDotNet startet – Instanz: {Instance} ({Id})",
instance.InstanceName, instance.InstanceId);
logger.LogInformation("Instanz-Verzeichnis: {Path}", instancePath);
logger.LogInformation("Protokoll-Verzeichnis: {Path}", logDirectory);
logger.LogInformation("Einstellungen: {Path}", settings.SettingsPath);
// Ein Wechsel ohne Ausweichlogik braucht wenigstens eine Ansage: Wer ein eigenes
// Log- oder Sicherungsverzeichnis in den Anwendungseinstellungen stehen hatte,
// soll nicht raten muessen, warum dort nichts mehr ankommt.
if (settings.AbandonedKeys.Count > 0)
{
logger.LogWarning(
"Diese Einstellungen gelten nicht mehr anwendungsweit und wurden aus "
+ "{Path} entfernt: {Keys}. Sie stehen jetzt je Instanz in "
+ "InstanceSettings.json; die Vorgaben dieser Instanz sind {Logs} und {Backups}.",
settings.SettingsPath,
string.Join(", ", settings.AbandonedKeys),
logDirectory,
ResolveBackupDirectory(instance, instancePath));
}
// Der Schluesselbund, mit dem die Zugangsdaten verschluesselt sind (#190). Was
// hier fehlt, faellt im Alltag nicht auf und kostet an dem einen Tag alles: Ein
// Bund ohne Wiederherstellungsfach laeuft tadellos, bis die Maschine wechselt -
// und dann ist jeder verschluesselte Wert verloren, auch der aus einer Sicherung.
// Deshalb steht es beim Start da und nicht erst in der Fehlermeldung danach.
try
{
foreach (var problem in SecretKeyStore.Problems)
logger.LogWarning("Schlüsselbund: {Problem}", problem);
}
catch (Exception ex)
{
// Der Bund wird gleich ohnehin gebraucht; hier nur melden, nicht abbrechen.
logger.LogError(ex, "Der Schlüsselbund liess sich nicht pruefen.");
}
// ─── 4. Lizenz ───
//
// Gefuehrt ist, wer es nachweisen kann (#535). Der Schalter --managed allein
// genuegte frueher und war damit der Weg an der Lizenz vorbei: Die Instanz
// uebersprang die Pruefung, weil sie glaubte, der Manager habe sie schon
// erledigt. Jetzt zaehlt die Unterschrift, und zwar gegen das Verzeichnis, in
// dem diese Instanz tatsaechlich laeuft.
var gefuehrt = callbacks.IsManaged
&& ManagerHandshake.Verify(
instancePath, Environment.GetEnvironmentVariable(
ManagerHandshake.EnvironmentVariable));
if (callbacks.IsManaged && !gefuehrt)
{
// Kein Abbruch: Ohne Nachweis ist die Instanz eben nicht gefuehrt und prueft
// selbst - der Weg von vor E-M3. Wer eine Lizenz hat, merkt davon nichts.
logger.LogWarning(
"Start mit --managed, aber ohne gültige Unterschrift des Instanzmanagers. "
+ "Die Instanz gilt als eigenständig und prüft ihre Lizenz selbst.");
}
LicenseGate? license = null;
if (!gefuehrt)
{
var prompt = callbacks.License ?? new NonInteractiveLicensePrompt(Console.Error.WriteLine);
license = new LicenseGate(
new SettingsLicenseStore(settings),
loggerFactory.CreateLogger("ClawdDotNet.License"),
prompt,
ReleaseInfo.Version);
if (!await license.RunStartupCheckAsync(ct))
{
logger.LogWarning("Start abgebrochen: keine gültige Lizenz.");
loggerFactory.Dispose();
return new StartupResult(null, null);
}
}
else
{
logger.LogInformation(
"Instanz wird vom Instanzmanager geführt (Unterschrift geprüft): "
+ "Die Lizenzprüfung ist dort schon erfolgt.");
}
// ─── 5. Werkzeuge ───
var tools = RegisterTools();
TelegramClientManager? telegram = null;
if (instance.TelegramClient is not null && callbacks.TelegramLogin is not null)
{
telegram = new TelegramClientManager(instance, instancePath,
loggerFactory.CreateLogger("ClawdDotNet.Tools.TelegramClient"));
tools.Register(new TelegramClientTool(telegram));
logger.LogInformation("TelegramClient-Tool registriert");
}
else if (instance.TelegramClient is not null)
{
logger.LogInformation(
"TelegramClient konfiguriert, aber kein Anmeldeweg vorhanden – übersprungen.");
}
var host = BuildCore(settings, directories, instance, instancePath,
logDirectory, loggerFactory, tools, telegram, logger);
// #554: Nachsehen, was beim letzten Ende offen geblieben ist. Ein geordnetes Ende
// hinterlaesst nichts; ist hier etwas, ist die Instanz gestorben, waehrend
// Werkzeuge liefen.
//
// Bewusst nur sichten und protokollieren, nicht ausfuehren: Ob die Wirkung eines
// Aufrufs eingetreten ist, weiss auch dieser Dienst nicht. Ein automatisches
// "dann eben nochmal" versendete im Zweifel die Mail ein zweites Mal.
await host.SichteOffeneAufrufeAsync(logger);
// Nichts freizugeben: Der Torwächter nutzt den gemeinsamen HttpClient des SDK.
host.License = license;
host.IsManaged = gefuehrt;
if (license is not null && license.IsEnforcementConfigured)
{
host.LicenseWatch = new LicenseWatch(
license, loggerFactory.CreateLogger("ClawdDotNet.License"));
host.LicenseWatch.Start();
host._shutdown.Add(host.LicenseWatch.DisposeAsync);
}
await host.ConnectTelegramAsync(callbacks, logger);
await host.StartDeploymentcenterAsync(loggerFactory, ct);
// Die Oberflaeche bietet die taegliche Sicherung an und schreibt Uhrzeit und
// Zielordner in die Instanzkonfiguration — gestartet wurde der Takt dazu bisher
// nicht, die Einstellung lief also ins Leere.
host.Backups = new BackupScheduler(
instancePath, instance,
loggerFactory.CreateLogger("ClawdDotNet.Backup"));
// Eine gefuehrte Instanz sichert sich nicht selbst: Das tut der Manager, und nur er
// hat den Lizenzschluessel, mit dem die Sicherung verschluesselt wird. Liefen
// beide, laegen jeden Tag zwei Archive im Ordner - eines davon ohne Zugangsdaten
// und unverschluesselt. Eigenstaendige Instanzen sichern weiter wie bisher.
if (gefuehrt)
{
logger.LogInformation(
"Die tägliche Sicherung dieser Instanz übernimmt der Instanzmanager.");
}
else
{
host.Backups.Start();
}
host._shutdown.Add(host.Backups.DisposeAsync);
return new StartupResult(host, null);
}
// ─── Aufbau ───
private static ToolRegistry RegisterTools()
{
var registry = new ToolRegistry();
registry.Register(new FileRWTool());
registry.Register(new TelegramTool());
registry.Register(new MailTool());
registry.Register(new DatabaseTool());
registry.Register(new FTPTool());
registry.Register(new DirectApiTool());
registry.Register(new WebFetchTool());
registry.Register(new WebMonitorTool());
registry.Register(new AgentCommTool());
registry.Register(new SocialMediaManagerTool());
registry.Register(new AgentSpawnTool());
registry.Register(new AgentEditorTool());
registry.Register(new Tools.Memory.MemoryTool());
registry.Register(new Tools.Taskboard.TaskboardTool());
registry.Register(new Tools.Taskboard.EscalationTool());
registry.Register(new Tools.RocketChat.RocketChatTool());
return registry;
}
private static AppHost BuildCore(
SettingsManager settings,
InstanceDirectoryManager directories,
InstanceConfig instance,
string instancePath,
string logDirectory,
ILoggerFactory loggerFactory,
ToolRegistry tools,
TelegramClientManager? telegram,
ILogger logger)
{
if (string.IsNullOrWhiteSpace(instance.OpenRouterApiKey))
{
logger.LogWarning("Kein OpenRouter API-Key konfiguriert – Agenten sind deaktiviert");
return new AppHost
{
Settings = settings,
Directories = directories,
Instance = instance,
InstancePath = instancePath,
LogDirectory = logDirectory,
LoggerFactory = loggerFactory,
Tools = tools,
TelegramClient = telegram,
Shutdown = new ShutdownCoordinator(
loggerFactory.CreateLogger("ClawdDotNet.Shutdown"))
};
}
var openRouter = new OpenRouterClient(instance.OpenRouterApiKey,
loggerFactory.CreateLogger("ClawdDotNet.Core.Api.OpenRouterClient"));
// Eine Datenbank je Instanz; StateStore, Gedächtnis und Taskboard teilen sie sich.
var storage = new SqliteStorage(Path.Combine(instancePath, "state.db"));
var stateStore = new SqliteStateStore(storage);
var memory = new SqliteMemoryRepository(storage);
var taskRepository = new SqliteTaskRepository(storage);
var audit = new SqliteAuditRepository(storage);
var stagingRepository = new SqliteStagingRepository(storage);
var dauerfreigaben = new SqliteDauerfreigabeRepository(storage);
var stagingGate = new StagingGate(new StagingPolicy(), stagingRepository, dauerfreigaben: dauerfreigaben);
var usage = new SqliteUsageRepository(storage);
var transcripts = new Core.Transcripts.SqliteRunTranscriptStore(storage);
var cases = new Core.Cases.SqliteCaseRepository(storage);
var specialBudgets = new SqliteSpecialBudgetRepository(storage);
// Preise fürs Budget: Ohne sie greift nur die Token-Grenze.
var pricing = new ModelPricingCatalog();
_ = Task.Run(async () =>
{
try { pricing.Load(await openRouter.GetAvailableModelsAsync()); }
catch { /* Ohne Preise bleibt die Kostengrenze wirkungslos, die Token-Grenze nicht. */ }
});
var engine = new AgentEngine(
openRouter, tools, new PermissionGate(), stateStore, loggerFactory,
memory, usage, pricing, taskRepository, audit, stagingGate)
{
InstanceBudget = instance.Budget,
TranscriptStore = transcripts,
DefaultTranscriptRetention = () => instance.PromptHistory.RetentionDays,
MaxDelegationDepth = instance.MaxDelegationDepth,
DelegationGateTimeout = TimeSpan.FromSeconds(instance.DelegationGateTimeoutSeconds),
// #717: runs in a funded task tree draw on its pot, not on the daily limits.
SpecialBudgets = specialBudgets
};
// #654: Code runs only in the sandbox. Found once at start; without it, nothing that
// needs it runs — and the log says why.
var sandbox = Core.Sandbox.BwrapSandbox.Detect(loggerFactory.CreateLogger("ClawdDotNet.Core.Sandbox"));
if (!sandbox.IsAvailable)
logger.LogWarning("No sandbox: {Reason}", sandbox.UnavailableReason);
engine.Sandbox = sandbox;
foreach (var user in tools.GetAll().OfType<Core.Sandbox.ISandboxUser>())
user.Sandbox = sandbox;
// #720: which version of a shared file each agent read — a write cannot silently lose another.
engine.Files = new Core.Files.FileLedger(storage);
// #719: the history of the workspaces, next to them in Agents/.history.
var fileHistory = new Core.Files.FileHistory(Path.Combine(instancePath, "Agents"), storage, () => instance.FileHistory);
engine.History = fileHistory;
// #712: Foreign runtimes. The application itself is the launcher inside the sandbox
// (network through the proxy, secrets from a file) and the MCP bridge back to us.
var mcpGrants = new Core.Mcp.McpGrants(storage);
var helper = Core.Sandbox.SandboxHelper.ForThisProcess();
if (sandbox is Core.Sandbox.BwrapSandbox bwrap)
{
bwrap.Helper = helper;
bwrap.LoggerFactory = loggerFactory;
}
engine.ForeignRunner = new Core.Runtimes.ForeignRunner(
sandbox,
[new Core.Runtimes.ClaudeCodeRuntime(() => instance.ClaudeCode), new Core.Runtimes.CommandRuntime()],
mcpGrants,
OperatingSystem.IsWindows() ? null : Core.Instances.InstanceEndpoint.McpSocketPath(instancePath),
helper,
loggerFactory.CreateLogger("ClawdDotNet.Core.Runtimes"));
engine.SetAgentConfigProvider(
() => instance.Agents,
instance.InstanceId,
agentId =>
{
var agent = instance.Agents.FirstOrDefault(a => a.AgentId == agentId);
return string.IsNullOrWhiteSpace(agent?.AgentDir) ? null : agent.AgentDir;
});
// #683: The organisation tree, fresh from the configuration on every question, so a
// change made through the administration applies to the next run without a restart.
engine.OrganisationProvider = () => Core.Organisation.OrgChart.From(instance);
foreach (var problem in Core.Organisation.OrgChart.From(instance).Problems())
logger.LogWarning("Organisation: {Problem}", problem);
engine.LoadPersistedChats();
var (scanner, staging, board, followUp) = BuildTaskboard(
instance, taskRepository, engine, tools, stateStore, audit,
stagingRepository, dauerfreigaben, stagingGate, cases, storage, loggerFactory, logger);
var host = new AppHost
{
Settings = settings,
Directories = directories,
Instance = instance,
InstancePath = instancePath,
LogDirectory = logDirectory,
LoggerFactory = loggerFactory,
Tools = tools,
StateStore = stateStore,
AuditRepository = audit,
Engine = engine,
Scanner = scanner,
Staging = staging,
Board = board,
Usage = usage,
Transcripts = transcripts,
Cases = cases,
SpecialBudgets = specialBudgets,
Mcp = mcpGrants,
Insights = new RunInsights(audit, transcripts, board, staging,
agentId => instance.Agents.FirstOrDefault(a => a.AgentId == agentId) is { } agent
? engine.TranscriptRetentionFor(agent)
: Core.Transcripts.PromptHistorySettings.Normalize(instance.PromptHistory.RetentionDays)),
Pricing = pricing,
TelegramClient = telegram,
Status = new OpenRouterStatusService(instance.OpenRouterApiKey),
Shutdown = new ShutdownCoordinator(
loggerFactory.CreateLogger("ClawdDotNet.Shutdown"))
};
// Was beim Beenden auslaufen darf, bevor abgeschnitten wird (#528). Beide Quellen
// koennen nur zweierlei: die Tuer schliessen und sagen, wie viel noch laeuft.
host.Shutdown.Register(new WorkSource(
"Agentenläufe", () => engine?.OpenRunCount ?? 0, () => engine?.StopAccepting()));
host.Shutdown.Register(new WorkSource(
"Aufgaben", () => scanner?.OpenCount ?? 0, () => scanner?.StopAccepting()));
host._shutdown.Add(() => { openRouter.Dispose(); return ValueTask.CompletedTask; });
// #660: Retention of the prompt history - per agent, falling back to the instance.
var janitor = new Core.Transcripts.TranscriptJanitor(
transcripts,
agentId => instance.Agents.FirstOrDefault(a => a.AgentId == agentId) is { } agent
? engine.TranscriptRetentionFor(agent)
: Core.Transcripts.PromptHistorySettings.Normalize(instance.PromptHistory.RetentionDays),
loggerFactory.CreateLogger("ClawdDotNet.Core.Transcripts"));
janitor.Start();
host._shutdown.Add(janitor.DisposeAsync);
// #719: changes made outside the tools, and the retention of the history.
var historyKeeper = new Core.Files.FileHistoryKeeper(
fileHistory,
() => Core.Files.FileHistoryKeeper.WorkspacesOf(instance.Agents, Core.Organisation.OrgChart.From(instance)),
() => instance.FileHistory,
loggerFactory.CreateLogger("ClawdDotNet.Core.Files"));
historyKeeper.Start();
host._shutdown.Add(historyKeeper.DisposeAsync);
// #700: day values, and runs older than the hot window move to the month archive.
var auditArchive = new Core.Audit.AuditArchive(
storage, () => instance.AuditArchive,
agentId => instance.Agents.FirstOrDefault(a => string.Equals(a.AgentId, agentId, StringComparison.OrdinalIgnoreCase))?.UnitId,
loggerFactory.CreateLogger("ClawdDotNet.Core.Audit"));
auditArchive.Start();
host.AuditArchive = auditArchive;
host.FollowUp = followUp;
host._shutdown.Add(auditArchive.DisposeAsync);
// #698: what holds in a unit, set by its lead.
var doctrines = new Core.Organisation.SqliteDoctrineStore(storage);
engine.Doctrines = doctrines;
host.Doctrines = doctrines;
// #693: the leads look at the runs of the agents they lead.
var spotChecks = new Core.Inspection.SpotChecks(
storage, audit,
new Core.Inspection.RunInspector(audit, taskRepository, transcripts,
() => Core.Inspection.RunInspector.VocabularyOf(tools.GetAll())),
() => Core.Organisation.OrgChart.From(instance), board,
loggerFactory.CreateLogger("ClawdDotNet.Core.Inspection.SpotChecks"));
engine.SpotChecks = spotChecks;
host.SpotChecks = spotChecks;
spotChecks.Start();
host._shutdown.Add(spotChecks.DisposeAsync);
// #197: external MCP servers, each one tool — refreshed now and every quarter of an hour.
var mcpServers = new Core.Mcp.McpConnections(tools, () => instance.McpServers,
loggerFactory.CreateLogger("ClawdDotNet.Core.Mcp.Client"));
host.McpServers = mcpServers;
mcpServers.Start();
host._shutdown.Add(mcpServers.DisposeAsync);
// #726: agents propose workflows; recurring tasks that do the same each time are put to them.
var automations = new Core.Automation.AutomationProposals(storage, audit, taskRepository, board,
loggerFactory.CreateLogger("ClawdDotNet.Core.Automation"), () => instance.Automation);
// The escalation tool only mentions proposals to agents of an instance that works with n8n.
if (tools.Get(Core.Cases.CaseDesk.ToolName) is Tools.Taskboard.EscalationTool escalation)
escalation.AutomationEnabled = () => instance.Automation.Enabled;
engine.Automations = automations;
host.Automations = automations;
automations.Start();
host._shutdown.Add(automations.DisposeAsync);
// #725: what comes in from outside through a trigger becomes a task.
host.Triggers = new Core.Triggers.InboundTriggers(storage, board, audit, () => instance.Agents);
// #702: votes whose time is up are counted without waiting for the next ballot.
if (engine.Votes is Core.Staging.Votes votes)
{
votes.Start();
host._shutdown.Add(votes.DisposeAsync);
}
// #699: costs per unit, and what changed in them.
var costWatch = new Core.Accounting.CostWatch(
storage, usage, () => Core.Organisation.OrgChart.From(instance), () => instance.CostWatch, board,
loggerFactory.CreateLogger("ClawdDotNet.Core.Accounting.CostWatch"));
host.CostWatch = costWatch;
costWatch.Start();
host._shutdown.Add(costWatch.DisposeAsync);
// #593: Angelegt wurde der Dienst schon immer, gestartet nie — weder kopflos noch
// mit Fenster. Die Kopfzeile der Weboberflaeche stand deshalb dauerhaft auf
// "OpenRouter: —" und /api/status auf "Prüfe…".
if (host.Status is { } status)
{
status.Start();
host._shutdown.Add(status.DisposeAsync);
}
logger.LogInformation("AgentEngine und Scanner erstellt, Chat-Verläufe geladen");
return host;
}
/// <summary>
/// Taskboard und Scanner. Der Scanner ist der einzige periodische Treiber (A1) —
/// geplante Agentenläufe wie Tool-Job-Polls sind Tasks.
/// </summary>
/// <summary>
/// Sichtet beim Start die Werkzeugaufrufe, die beim letzten Ende offen blieben (#554),
/// und schreibt das Ergebnis ins Protokoll.
///
/// <para>Scheitert die Sichtung selbst, hält sie den Start nicht auf. Eine Instanz,
/// die wegen einer misslungenen Nachschau gar nicht erst hochkommt, wäre schlimmer
/// als eine, die mit einer Wissenslücke startet.</para>
/// </summary>
private async Task SichteOffeneAufrufeAsync(ILogger logger)
{
// Ohne Protokoll gibt es nichts zu sichten, ohne Zustandsspeicher keine
// Nachschau. Beides fehlt nur beim Aufbau ohne Datenbank — dann gab es auch
// keine Werkzeugaufrufe, die offen sein könnten.
if (AuditRepository is not { } audit || StateStore is not { } stateStore)
return;
var instance = Instance;
var tools = Tools;
var loggerFactory = LoggerFactory;
try
{
// Die Nachschau braucht die Arbeitsverzeichnisse des Agenten — die kennt nur
// die Instanzkonfiguration. Ist der Agent inzwischen weg, gibt es keinen
// Kontext und damit keine Nachschau; die Einstufung bleibt trotzdem.
AgentToolContext? KontextFuer(string agentId, string toolName)
{
var agent = instance.Agents.FirstOrDefault(a => a.AgentId == agentId);
if (agent is null)
return null;
var toolConfig = Core.Organisation.OrgChart.From(instance).ToolSettingsFor(agent, toolName);
return new AgentToolContext(
agent.AgentId,
instance.InstanceId,
toolConfig,
stateStore,
loggerFactory.CreateLogger($"ClawdDotNet.Tools.{toolName}.Probe"),
CancellationToken.None,
agent.WorkspacePath,
agent.SharedWorkspacePath)
{
// #684: a call in a named workspace is only found with the organisation.
Organisation = Core.Organisation.OrgChart.From(instance)
};
}
var recovery = new CrashRecovery(audit, tools, loggerFactory, KontextFuer);
var faelle = await recovery.SichtenAsync(CancellationToken.None);
var offen = faelle.Where(f => !f.Erledigt).ToList();
if (offen.Count > 0)
{
logger.LogWarning(
"{Anzahl} Werkzeugaufruf(e) brauchen eine Entscheidung. Sie wurden NICHT " +
"wiederholt — ob ihre Wirkung eintrat, ist nicht feststellbar.",
offen.Count);
}
}
catch (Exception ex)
{
logger.LogWarning(ex,
"Die Sichtung offener Werkzeugaufrufe ist gescheitert. Der Start geht weiter.");
}
}
private static (TaskScanner?, StagingService?, TaskboardService?, Core.Tasks.TaskFollowUp?) BuildTaskboard(
InstanceConfig instance,
SqliteTaskRepository taskRepository,
AgentEngine engine,
ToolRegistry tools,
SqliteStateStore stateStore,
SqliteAuditRepository audit,
SqliteStagingRepository stagingRepository,
SqliteDauerfreigabeRepository dauerfreigaben,
StagingGate stagingGate,
Core.Cases.ICaseRepository cases,
SqliteStorage storage,
ILoggerFactory loggerFactory,
ILogger logger)
{
var sharedWorkspace = instance.Agents
.Select(a => a.SharedWorkspacePath)
.FirstOrDefault(p => !string.IsNullOrWhiteSpace(p));
if (string.IsNullOrWhiteSpace(sharedWorkspace))
return (null, null, null, null);
var board = new TaskboardService(taskRepository, Path.Combine(sharedWorkspace, "tasks"));
var staging = new StagingService(stagingRepository, engine, board, loggerFactory, audit,
dauerfreigaben: dauerfreigaben);
// #687: A lead within its unit's limits decides instead of the human.
var leadApprovals = new LeadApprovals(
stagingRepository, board, () => engine.OrganisationProvider?.Invoke(),
agent => Core.Organisation.ReportingLine.TrustOf(agent, tools.Get),
loggerFactory.CreateLogger("ClawdDotNet.Core.Staging.Leads")) { Service = staging };
engine.LeadApprovals = leadApprovals;
// #702: What a unit puts to a blind vote goes there first; otherwise to the lead.
var votes = new Votes(
storage, stagingRepository, board, () => engine.OrganisationProvider?.Invoke(),
agent => Core.Organisation.ReportingLine.TrustOf(agent, tools.Get),
loggerFactory.CreateLogger("ClawdDotNet.Core.Staging.Votes")) { Service = staging, Audit = audit };
stagingGate.Route = async (call, ct) => await votes.RouteAsync(call, ct) ?? await leadApprovals.RouteAsync(call, ct);
engine.Votes = votes;
var dispatcher = new EngineTaskDispatcher(
engine, () => instance.Agents, instance.InstanceId, tools, stateStore, loggerFactory,
tasks: taskRepository);
// #689: Escalations, conflicts and decisions ride on the board; the engine hands the desk to the tools.
var caseDesk = new Core.Cases.CaseDesk(
cases, board, () => engine.OrganisationProvider?.Invoke(), () => instance.Cases,
loggerFactory.CreateLogger("ClawdDotNet.Core.Cases"));
engine.CaseDesk = caseDesk;
// #717: A run of a particular task is estimated from its own history first.
var estimator = engine.BudgetGuard is { } estimateGuard
? new TaskCostEstimator(audit, taskRepository, estimateGuard)
: null;
var headroom = engine.BudgetGuard is { } guard
? new BudgetHeadroom(guard, () => instance.Agents, () => engine.InstanceBudget,
estimator: estimator, fillSettings: () => instance.FillWork)
: null;
// #646: the board follows up on stalled, unblocked and waiting work.
var followUp = new Core.Tasks.TaskFollowUp(board, taskRepository, audit, storage, () => instance.FollowUp,
() => engine.OrganisationProvider?.Invoke(), loggerFactory.CreateLogger("ClawdDotNet.Core.Tasks.FollowUp"));
var scanner = new TaskScanner(taskRepository, dispatcher, loggerFactory,
maxParallelRuns: instance.MaxParallelTaskRuns,
budgetMissPolicy: task =>
{
// #716: The agent's rule. Bare '@new' names no agent — then the default applies.
var agentId = TaskAssignee.AgentId(task.Assignee);
return instance.Agents
.FirstOrDefault(a => string.Equals(a.AgentId, agentId, StringComparison.OrdinalIgnoreCase))
?.Budget.MissPolicy ?? BudgetMissPolicy.Expire;
},
// #716: Late work waits for headroom the rest of the day does not need, and a
// day the budget will not last is named while there is still time to act.
lateRunGate: headroom,
budgetOutlook: headroom,
treeWatch: new TaskWatches(
// #714: A task tree that has come to rest is reviewed by the lead of its unit.
new TaskTreeWatch(
taskRepository,
(task, ct) => board.CreateAsync(task, ct),
stateStore,
() => engine.OrganisationProvider?.Invoke(),
loggerFactory.CreateLogger("ClawdDotNet.Core.Tasks.TreeWatch")),
// #715: Whoever handed out a task hears when it is done or stopped — from the code.
new TaskReports(
board,
stateStore,
() => instance.Agents,
tools.Get,
loggerFactory.CreateLogger("ClawdDotNet.Core.Tasks.Reports")),
// #689: A case nobody solves in time goes to a human.
caseDesk,
// #646: work that lives but does not move is followed up.
followUp),
// #717: Fill work runs on budget the day leaves unused, and never overruns its
// deadline silently — a human is asked in time.
fillGate: headroom,
askAboutFill: async (task, ct) =>
{
var estimate = estimator is null ? TaskEstimate.Nothing : await estimator.EstimateAsync(task, ct);
await board.CreateAsync(FillWorkNotice.QuestionFor(task, estimate), ct);
},
fillWarnBefore: () => instance.FillWork.WarnBeforeDeadline,
// #717: A large task is not simply started — its run proposes a split or a special budget.
largeTaskCheck: estimator is null
? null
: new LargeTaskCheck(estimator, taskRepository, engine.SpecialBudgets, () => instance.Agents, () => instance.FillWork),
noteOnTask: async (taskId, note, ct) => await board.AddCommentAsync(taskId, "system", note, ct));
// Reconciliation nicht blockierend: Der Start soll nicht auf das Dateisystem warten.
_ = Task.Run(async () =>
{
try
{
var reset = await taskRepository.ReleaseStaleClaimsAsync(
DateTime.UtcNow.AddMinutes(-15), DateTime.UtcNow, CancellationToken.None);
var coordination = new CoordinationMigration(
board, Path.Combine(sharedWorkspace, "coordination"), loggerFactory);
var migratedCoordination = await coordination.RunAsync(CancellationToken.None);
var scheduler = new SchedulerTaskMigration(board, taskRepository, loggerFactory);
var migratedScheduler = await scheduler.RunAsync(instance.Agents, CancellationToken.None);
var imported = await board.ImportAllAsync(CancellationToken.None);
logger.LogInformation(
"Taskboard bereit: {Imported} Aufgabe(n), migriert {Coord} coordination + "
+ "{Sched} scheduler, {Reset} verwaiste Claims zurückgesetzt",
imported, migratedCoordination, migratedScheduler, reset);
scanner.Start();
}
catch (Exception ex)
{
logger.LogWarning(ex, "Taskboard-Reconciliation beim Start fehlgeschlagen");
}
});
return (scanner, staging, board, followUp);
}
private async Task ConnectTelegramAsync(Callbacks callbacks, ILogger logger)
{
if (TelegramClient is null || callbacks.TelegramLogin is null) return;
TelegramClient.OnLoginCodeRequired += prompt => callbacks.TelegramLogin(prompt);
if (callbacks.Telegram2FA is not null)
TelegramClient.On2FAPasswordRequired += () => callbacks.Telegram2FA();
try
{
await TelegramClient.ConnectAsync(CancellationToken.None);
}
catch (Exception ex)
{
logger.LogError(ex, "Telegram: Login fehlgeschlagen");
}
}
/// <summary>
/// Anbindung ans Deploymentcenter: Instanz-Heartbeat, Fehler-Stream, Bugtracker und
/// die einmalige Update-Prüfung.
///
/// <para>Jede laufende Instanz meldet sich als eigener Monitor — der Server führt
/// sie über <c>source</c> + <c>instance</c>. Stürzt eine von mehreren ab, fällt
/// genau deren Monitor, und der Evaluator schlägt nur dafür Alarm.</para>
/// </summary>
private async Task StartDeploymentcenterAsync(ILoggerFactory loggerFactory, CancellationToken ct)
{
Deploymentcenter = DeploymentcenterService.TryCreate(
Settings.AppSettings, AppVersion, loggerFactory);
if (Deploymentcenter is null)
return;
_shutdown.Add(Deploymentcenter.DisposeAsync);
var health = new InstanceHealthProvider(
Instance.InstanceName,
agentsEnabled: Engine is not null,
Instance.Budget,
Usage,
() => Instance.Agents.Count,
() => Engine?.RunningChatCount ?? 0,
// „Noch nicht gestartet" ist kein Fehler: Der Scanner läuft erst nach der
// Startabgleichung los, der erste Heartbeat geht sofort raus.
schedulerRunning: Scanner is null ? null : () => !Scanner.HasStopped);
await Deploymentcenter.StartWatchdogAsync(
Instance, health,
saveInstanceConfig: () => Directories.SaveInstanceConfig(InstancePath, Instance),
ct);
// Nicht abwarten: Ein langsamer oder stummer Server darf den Start nicht aufhalten.
_ = Deploymentcenter.CheckForUpdateAsync(Settings.AppSettings, AppVersion, CancellationToken.None);
}
/// <summary>
/// Die Version, die nach draußen geht: Aktivierungsliste, Heartbeat,
/// Fehlermeldungen, Versionsvergleich. Kommt aus <c>&lt;Version&gt;</c> in
/// <c>Directory.Build.props</c> und wird zur Übersetzungszeit eingebettet
/// (<c>Deploymentcenter.BuildInfo.targets</c>) — zusammen mit Commit und Build-Datum.
///
/// <para>Nicht zu verwechseln mit <c>ClawdDotNet.Core.BuildInfo.Build</c>: das ist
/// ein von Hand geführter Zähler mit Änderungstext, keine Versionsangabe.</para>
/// </summary>
public static string AppVersion => ReleaseInfo.Version;
/// <summary>Version, Commit, Build-Datum und Kanal in einer Zeile — für Anzeigen.</summary>
public static string BuildSummary => ReleaseInfo.Summary;
private bool _disposed;
public async ValueTask DisposeAsync()
{
// Beim Update fahren wir vor dem Beenden selbst herunter; danach ruft die
// Oberflaeche Shutdown, und deren Behandlung raeumt ein zweites Mal auf. Ohne
// diese Sperre gingen alle Schritte doppelt los — unter anderem ein zweiter
// Abmeldevorgang beim Watchdog, der den gerade gesetzten Wartungszustand
// wieder ueberschreibt.
if (_disposed) return;
_disposed = true;
// Erst auslaufen lassen, dann abschneiden (#528). Laeuft das Auslaufen schon,
// wartet dieser Aufruf darauf statt es ein zweites Mal anzustossen - und wenn
// niemand darum gebeten hat, ist es hier in einem Wimpernschlag vorbei, weil
// dann nichts offen ist. Wer DisposeAsync ohne vorheriges BeginAsync ruft,
// bekommt so trotzdem das geordnete Ende.
try { await Shutdown.BeginAsync(); }
catch (Exception) { /* Beim Beenden zaehlt, dass alle Schritte drankommen */ }
foreach (var step in _shutdown)
{
try { await step(); }
catch { /* Beim Beenden zaehlt, dass alle Schritte drankommen */ }
}
if (Status is not null) await Status.DisposeAsync();
if (Scanner is not null) await Scanner.DisposeAsync();
if (TelegramClient is not null) await TelegramClient.DisposeAsync();
LoggerFactory.Dispose();
}
}