using ClawdDotNet.App.Services; using ClawdDotNet.App.Settings; using ClawdDotNet.Core.Accounting; using ClawdDotNet.Core.Api; using ClawdDotNet.Core.Audit; 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; /// /// Baut alles auf, was ClawdDotNet zum Laufen braucht — ohne eine einzige Zeile /// Oberflächencode. /// /// Vorher lag das in Program.cs der WinForms-Anwendung: 350 Zeilen zwischen /// ApplicationConfiguration.Initialize() und Application.Run(form). Die /// Trennung bestand faktisch schon — alles war fertig aufgebaut, bevor das Fenster /// überhaupt entstand. Sie war nur nirgends festgehalten. /// /// 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. /// public sealed class AppHost : IAsyncDisposable { private readonly List> _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; } /// /// 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). /// internal IStateStore? StateStore { get; private init; } internal IAuditRepository? AuditRepository { get; private init; } /// /// Das geordnete Ende dieser Instanz (#528). /// /// Wer die Instanz beenden will — der Instanzmanager über den Socket, ein /// Mensch über das Fenster — ruft und /// wartet, bevor er anstößt. Der Instanzmanager liest /// unterdessen über den Puls und weiß /// dadurch, dass hier aufgeräumt und nicht gehangen wird. /// /// Nicht required, sondern mit einem leeren Koordinator vorbelegt: Ein /// Aufbau ohne angemeldete Arbeitsquellen hat nichts abzuwarten und endet sofort. So /// gibt es diese Zusage immer, und niemand muss auf null prüfen, /// bevor er eine Instanz geordnet beendet. public ShutdownCoordinator Shutdown { get; init; } = new(Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); /// Null, wenn kein OpenRouter-Schlüssel hinterlegt ist — dann laufen keine Agenten. public AgentEngine? Engine { get; private init; } public TaskScanner? Scanner { get; private init; } public StagingService? Staging { get; private init; } /// /// Das Taskboard. Null, wenn den Agenten dieser Instanz ein gemeinsamer /// Arbeitsbereich fehlt — dann gibt es keinen Ort, an dem Aufgaben lägen. /// public TaskboardService? Board { get; private init; } public SqliteUsageRepository? Usage { get; private init; } public TelegramClientManager? TelegramClient { get; private init; } public OpenRouterStatusService? Status { get; private init; } /// /// Die taegliche Sicherung. Immer gesetzt — ob sie etwas tut, entscheidet /// AutoBackupEnabled bei jedem Durchlauf neu, damit eine Aenderung in den /// Einstellungen ohne Neustart greift. /// public BackupScheduler? Backups { get; private set; } /// /// Anbindung ans Deploymentcenter (Heartbeat, Fehler-Stream, Bugtracker, Updates). /// Null, wenn Adresse oder Token fehlen. /// public DeploymentcenterService? Deploymentcenter { get; private set; } /// /// Meldeweg für ungefangene Ausnahmen. Immer gesetzt — ohne Anbindung ist es der /// Leerlauf, damit Aufrufer nicht auf null prüfen müssen. /// public IErrorReporter Errors => Deploymentcenter?.Errors ?? NullErrorReporter.Instance; /// /// Der Lizenz-Torwächter bleibt über die Laufzeit erhalten: Ein Widerruf soll auch /// eine bereits laufende Instanz erreichen, nicht erst den nächsten Start. /// public LicenseGate? License { get; private set; } /// /// Ob diese Instanz nachweislich vom Instanzmanager geführt wird (#535). /// /// Der geprüfte Befund, nicht der Schalter. --managed ist eine /// Behauptung, die jeder aufstellen kann; hier steht das Ergebnis der Prüfung von /// . 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. /// public bool IsManaged { get; private set; } /// /// Die laufende Nachprüfung. Der Aufrufer hängt sich an /// und beendet die Anwendung, wenn es feuert. /// public LicenseWatch? LicenseWatch { get; private set; } /// /// Meldet, dass die Instanzkonfiguration geschrieben wurde. /// /// Es gibt dieses Ereignis erst, seit es zwei Oberflächen gibt. /// 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. /// /// Kommt auf dem Faden des Verursachers, nicht auf dem der Oberfläche. /// Wer eine ObservableCollection daran hängt, geht über den Dispatcher. /// public event Action? InstanceChanged; /// /// Schreibt die Instanzkonfiguration und sagt Bescheid. /// /// Der einzige Weg, auf dem gespeichert werden soll. /// Directories.SaveInstanceConfig von Hand zu rufen schreibt zwar dieselbe /// Datei, lässt aber die andere Oberfläche in Unkenntnis. /// 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 _agentAdmin; private readonly Lazy _serviceAdmin; private readonly Lazy _jobAdmin; private readonly Lazy _runs; public AppHost() { _agentAdmin = new Lazy(() => new AgentAdministration(this)); _serviceAdmin = new Lazy(() => new ServiceAdministration(this)); _jobAdmin = new Lazy(() => new JobAdministration(this)); _runs = new Lazy(() => new RunCoordinator(this)); } /// Agenten anlegen, entfernen, Werkzeuge zulassen und einstellen. public AgentAdministration AgentAdmin => _agentAdmin.Value; /// Dienste der Instanz anlegen, entfernen, umschalten. public ServiceAdministration ServiceAdmin => _serviceAdmin.Value; /// Geplante Läufe anlegen, ändern, entfernen, abschalten. public JobAdministration JobAdmin => _jobAdmin.Value; /// „Jetzt ausführen" samt Verlauf. public RunCoordinator Runs => _runs.Value; /// Was der Aufrufer beim Start erfragen muss. public sealed class Callbacks { /// /// Wählt die Instanz. Gibt null zurück, wenn der Nutzer abbricht. /// Ein Dienst liefert hier den fest eingestellten Pfad, ohne zu fragen. /// public required Func> SelectInstance { get; init; } /// Wie der Lizenz-Torwächter mit dem Benutzer spricht. Null im geführten Modus. public ILicensePrompt? License { get; init; } /// /// Die Behauptung, vom Instanzmanager geführt zu werden — der Schalter /// --managed. /// /// Eine Behauptung, kein Befund. Ob sie stimmt, entscheidet allein /// die Unterschrift des Managers (); dieser Schalter /// sagt nur, dass überhaupt jemand den Anspruch erhebt. Ohne gültige Unterschrift /// prüft die Instanz ihre Lizenz selbst. /// public bool IsManaged { get; init; } /// /// 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. /// public Func>? TelegramLogin { get; init; } public Func>? Telegram2FA { get; init; } } /// /// Ergebnis des Aufbaus. 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. /// public readonly record struct StartupResult(AppHost? Host, string? Error); /// /// Wohin diese Instanz protokolliert. /// /// Die Regeln stehen in — 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. /// public static string ResolveLogDirectory(InstanceConfig instance, string instancePath) => InstanceLayout.ResolveLogDirectory(instance, instancePath); /// public static string ResolveBackupDirectory(InstanceConfig instance, string instancePath) => InstanceLayout.ResolveBackupDirectory(instance, instancePath); public static async Task 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( 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.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 stagingGate = new StagingGate(new StagingPolicy(), stagingRepository); var usage = new SqliteUsageRepository(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, MaxDelegationDepth = instance.MaxDelegationDepth, DelegationGateTimeout = TimeSpan.FromSeconds(instance.DelegationGateTimeoutSeconds) }; engine.SetAgentConfigProvider( () => instance.Agents, instance.InstanceId, agentId => { var agent = instance.Agents.FirstOrDefault(a => a.AgentId == agentId); return string.IsNullOrWhiteSpace(agent?.AgentDir) ? null : agent.AgentDir; }); engine.LoadPersistedChats(); var (scanner, staging, board) = BuildTaskboard( instance, taskRepository, engine, tools, stateStore, audit, stagingRepository, 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, 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; }); logger.LogInformation("AgentEngine und Scanner erstellt, Chat-Verläufe geladen"); return host; } /// /// Taskboard und Scanner. Der Scanner ist der einzige periodische Treiber (A1) — /// geplante Agentenläufe wie Tool-Job-Polls sind Tasks. /// /// /// Sichtet beim Start die Werkzeugaufrufe, die beim letzten Ende offen blieben (#554), /// und schreibt das Ergebnis ins Protokoll. /// /// 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. /// 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 = agent.Tools.TryGetValue(toolName, out var cfg) ? cfg.AsReadOnly() : new Dictionary().AsReadOnly(); return new AgentToolContext( agent.AgentId, instance.InstanceId, toolConfig, stateStore, loggerFactory.CreateLogger($"ClawdDotNet.Tools.{toolName}.Probe"), CancellationToken.None, agent.WorkspacePath, agent.SharedWorkspacePath); } 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?) BuildTaskboard( InstanceConfig instance, SqliteTaskRepository taskRepository, AgentEngine engine, ToolRegistry tools, SqliteStateStore stateStore, SqliteAuditRepository audit, SqliteStagingRepository stagingRepository, 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); var board = new TaskboardService(taskRepository, Path.Combine(sharedWorkspace, "tasks")); var staging = new StagingService(stagingRepository, engine, board, loggerFactory, audit); var dispatcher = new EngineTaskDispatcher( engine, () => instance.Agents, instance.InstanceId, tools, stateStore, loggerFactory); var scanner = new TaskScanner(taskRepository, dispatcher, loggerFactory); // 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); } 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"); } } /// /// Anbindung ans Deploymentcenter: Instanz-Heartbeat, Fehler-Stream, Bugtracker und /// die einmalige Update-Prüfung. /// /// Jede laufende Instanz meldet sich als eigener Monitor — der Server führt /// sie über source + instance. Stürzt eine von mehreren ab, fällt /// genau deren Monitor, und der Evaluator schlägt nur dafür Alarm. /// 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); } /// /// Die Version, die nach draußen geht: Aktivierungsliste, Heartbeat, /// Fehlermeldungen, Versionsvergleich. Kommt aus <Version> in /// Directory.Build.props und wird zur Übersetzungszeit eingebettet /// (Deploymentcenter.BuildInfo.targets) — zusammen mit Commit und Build-Datum. /// /// Nicht zu verwechseln mit ClawdDotNet.Core.BuildInfo.Build: das ist /// ein von Hand geführter Zähler mit Änderungstext, keine Versionsangabe. /// public static string AppVersion => ReleaseInfo.Version; /// Version, Commit, Build-Datum und Kanal in einer Zeile — für Anzeigen. 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(); } }