An instance without n8n has no use for workflow proposals. New instance
setting automation.enabled ("this instance works with n8n"), off by default:
- off: automation_find/automation_propose refuse, no candidates are put to
agents, and the escalation tool does not mention proposals at all;
- the switch sits in the settings card for external MCP servers; the
approvals view says proposals are off instead of standing empty.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1111 lines
51 KiB
C#
1111 lines
51 KiB
C#
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>#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;
|
||
|
||
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));
|
||
}
|
||
|
||
/// <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>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) = 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);
|
||
|
||
// #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?) 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);
|
||
|
||
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;
|
||
|
||
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),
|
||
// #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);
|
||
}
|
||
|
||
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><Version></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();
|
||
}
|
||
}
|