Sichtung offener Werkzeugaufrufe beim Start verdrahtet (#554)

AppHost.StartAsync ruft jetzt CrashRecovery. Ein geordnetes Ende
hinterlaesst nichts; ist etwas da, ist die Instanz gestorben, waehrend
Werkzeuge liefen.

Bewusst nur sichten und protokollieren, nicht ausfuehren. Ob die Wirkung
eines Aufrufs eintrat, weiss auch dieser Dienst nicht - ein automatisches
"dann eben nochmal" versendete im Zweifel die Mail ein zweites Mal.

Scheitert die Sichtung selbst, haelt sie den Start nicht auf. Eine
Instanz, die wegen einer misslungenen Nachschau nicht hochkommt, waere
schlimmer als eine, die mit einer Wissensluecke startet.

Der Aufruf sitzt in StartAsync statt in BuildCore: BuildCore ist synchron,
und die Signatur zu aendern haette durch alle Aufrufer gezogen. Blockendes
Warten in einer Fensteranwendung waere die schlechtere Loesung gewesen.

Build ohne Warnungen, 912 Tests, keine Regression.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-12 21:51:46 +02:00
co-authored by Claude Opus 5
parent b568a45de3
commit e2567a466f
+85
View File
@@ -60,6 +60,15 @@ public sealed class AppHost : IAsyncDisposable
public required ILoggerFactory LoggerFactory { get; init; }
public required ToolRegistry Tools { 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).
///
@@ -389,6 +398,15 @@ public sealed class AppHost : IAsyncDisposable
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;
@@ -539,6 +557,8 @@ public sealed class AppHost : IAsyncDisposable
LogDirectory = logDirectory,
LoggerFactory = loggerFactory,
Tools = tools,
StateStore = stateStore,
AuditRepository = audit,
Engine = engine,
Scanner = scanner,
Staging = staging,
@@ -569,6 +589,71 @@ public sealed class AppHost : IAsyncDisposable
/// 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 = agent.Tools.TryGetValue(toolName, out var cfg)
? cfg.AsReadOnly()
: new Dictionary<string, object?>().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,