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;
///
/// 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; }
/// #660: Prompt history - prompt and response of every run.
public Core.Transcripts.IRunTranscriptStore? Transcripts { get; private init; }
/// #660: Usage analysis, run details and prompt history for the user interfaces.
public RunInsights? Insights { get; private init; }
/// Modellpreise von OpenRouter - fuer Budget und Kostenprognose (#595). Laedt im Hintergrund.
public ModelPricingCatalog? Pricing { get; private init; }
/// Das Taskboard fuer die Oberflaechen (#594). Zustandslos, deshalb je Zugriff neu.
public TaskAdministration TaskAdmin => new(
Board, AuditRepository, Scanner, Usage, Pricing, () => Instance.Agents,
LoggerFactory.CreateLogger("ClawdDotNet.App.Tasks"));
/// #711: The team overview. Stateless, like .
public TeamOverview Team => new(Engine, Staging, AuditRepository, Usage, () => Instance.Agents,
organisation: () => Core.Organisation.OrgChart.From(Instance));
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;
/// #683: Units, moves, tool ceilings, unit-wide values. Stateless.
public OrgAdministration Organisation => new(this, LoggerFactory.CreateLogger("ClawdDotNet.App.Organisation"));
/// 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 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);
// 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)
};
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) = BuildTaskboard(
instance, taskRepository, engine, tools, stateStore, audit,
stagingRepository, dauerfreigaben, 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,
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);
// #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;
}
///
/// 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)
{
// #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?) BuildTaskboard(
InstanceConfig instance,
SqliteTaskRepository taskRepository,
AgentEngine engine,
ToolRegistry tools,
SqliteStateStore stateStore,
SqliteAuditRepository audit,
SqliteStagingRepository stagingRepository,
SqliteDauerfreigabeRepository dauerfreigaben,
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,
dauerfreigaben: dauerfreigaben);
var dispatcher = new EngineTaskDispatcher(
engine, () => instance.Agents, instance.InstanceId, tools, stateStore, loggerFactory);
var headroom = engine.BudgetGuard is { } guard
? new BudgetHeadroom(guard, () => instance.Agents, () => engine.InstanceBudget)
: null;
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);
// 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();
}
}