Wiederaufnahme nach Absturz, Nachschau und Auswertung (#553-#556)

#553 FileRW schreibt atomar. Die Vorkehrung lag seit #241 im selben
Repository und wurde ueberall benutzt - Einstellungen, Sicherungen,
Chat-Zustand, Identity.md - nur der Weg, ueber den Agenten ihre
Arbeitsergebnisse ablegen, ging daran vorbei. Damit stand das Verhaeltnis
auf dem Kopf: geschuetzt war das Eigene, ungeschuetzt das Ergebnis.
append bleibt bewusst nicht atomar; das ginge nur, indem die ganze Datei
neu geschrieben wird, und bei einer grossen Protokolldatei waere das der
falsche Handel.

#554 Wiederaufnahme. Neuer Eintrag AuditStatus.Started, geschrieben VOR
der Wirkung und als einziger nicht "best effort" - auf ihn stuetzt sich
alles Weitere, also laeuft der Aufruf gar nicht erst, wenn er nicht
durchkommt. Neue Kennzeichnung IsRepeatable, getrennt von IsParallelSafe:
Das eine heisst "stoert keinen gleichzeitigen Aufruf", das andere
"richtet zweimal keinen Schaden an". CrashRecovery sichtet beim Start,
was offen blieb, und stuft ein - fuehrt aber nichts aus. Es sichtet und
berichtet; entscheiden soll der Aufrufer.

Dabei kippte eine alte Regel: Bisher wurde ein Abbruch bewusst NICHT
protokolliert. Das war richtig, solange es keinen Eintrag vor dem Aufruf
gab. Jetzt saehe sonst jeder Benutzerabbruch beim naechsten Start aus wie
ein Absturz - bis niemand mehr hinsieht. Deshalb AuditStatus.Cancelled.

#555 Optionale Zustandspruefung IToolStateProbe, umgesetzt in FileRW.
Unknown ist eine vollwertige Antwort, kein Fehlschlag: Ob ein SMTP-Server
angenommen hat, laesst sich nicht feststellen, und ein Werkzeug, das hier
raet, waere schlimmer als eines, das schweigt. Partial gilt ausdruecklich
nicht als geklaert - einen halb angehaengten Text sollte niemand
automatisch geraderuecken.

#556 Auswertung. ListForAgentAsync und SummarizeToolUsageAsync. Die Dauer
wird nur ueber abgeschlossene Aufrufe gemittelt; zaehlten die
Started-Eintraege mit ihrer Dauer 0 mit, saehe jedes Werkzeug doppelt so
schnell aus, wie es ist.

Nebenbei: SqliteStorage kann jetzt Spalten nachruesten. CREATE TABLE IF
NOT EXISTS allein genuegte nicht - bei einer bestehenden Datenbank tut die
Anweisung nichts, und erst das naechste INSERT waere gescheitert, im
Betrieb beim Anwender.

Vier Audit-Tests angepasst: Ein ausgefuehrter Aufruf erzeugt jetzt zwei
Eintraege statt einem. Ein Aufruf, der gar nicht erst laeuft - verweigert,
unbekannt, vorgelegt - hat weiterhin genau einen; der Beginn wird erst
nach Rechtepruefung und Staging geschrieben.

23 neue Tests, 912 gesamt, keine Regression. Build ohne Warnungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-12 21:48:18 +02:00
co-authored by Claude Opus 5
parent fb4b218e45
commit b568a45de3
14 changed files with 1177 additions and 30 deletions
+51 -17
View File
@@ -1230,7 +1230,8 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
// Engine, nie aus dem Tool-Ergebnis. Best effort: ein Audit-Fehler darf den Lauf
// nicht scheitern lassen.
Task Audit(AuditStatus status, string summary)
=> RecordAuditAsync(runId, agentConfig, source, toolName, arguments, status, summary, sw.ElapsedMilliseconds);
=> RecordAuditAsync(runId, agentConfig, source, toolName, arguments, status, summary,
sw.ElapsedMilliseconds, toolCall.Id);
try
{
@@ -1271,6 +1272,13 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
logger.LogDebug("Executing tool {Tool} for agent {AgentId}", toolName, agentConfig.AgentId);
// #554: Vor der Wirkung vermerken, nicht danach. Stirbt die Anwendung
// mitten im Aufruf, ist das hier die einzige Spur, die davon übrig bleibt —
// ein Eintrag nach der Ausführung wäre genau dann nie geschrieben worden.
// Steht bewusst nach Rechteprüfung und Staging: Was gar nicht erst läuft,
// hinterlässt auch nichts aufzuräumen.
await Audit(AuditStatus.Started, "");
var result = await tool.ExecuteAsync(input, context, ct);
logger.LogDebug("Tool {Tool} completed: success={Success}", toolName, result.Success);
@@ -1301,8 +1309,19 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
// Nicht als Tool-Fehler zurückgeben: Sonst läuft die Schleife noch einen
// Schritt weiter und der Abbruch greift erst verzögert. Auch kein Audit —
// der Aufruf kam nicht zum Abschluss.
// Schritt weiter und der Abbruch greift erst verzögert.
//
// Sehr wohl aber ins Protokoll (#554). Früher stand hier "auch kein Audit",
// und das war richtig, solange es keinen Eintrag vor dem Aufruf gab. Jetzt
// gibt es ihn: Bliebe er ohne Abschluss, sähe jeder gewöhnliche Abbruch beim
// nächsten Start aus wie ein Absturz. Die Wirkung bleibt in beiden Fällen
// ungewiss — nur ist sie hier wenigstens erklärt.
//
// CancellationToken.None, weil der reguläre Token gerade abgebrochen wurde
// und das Schreiben sonst selbst mit abbräche.
await RecordAuditAsync(runId, agentConfig, source, toolName, arguments,
AuditStatus.Cancelled, "Unterbrochen, während der Aufruf lief.",
sw.ElapsedMilliseconds, toolCall.Id);
throw;
}
catch (Exception ex)
@@ -1457,26 +1476,41 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
/// </summary>
private async Task RecordAuditAsync(
string runId, AgentConfig agentConfig, string? source, string tool,
string arguments, AuditStatus status, string summary, long durationMs)
string arguments, AuditStatus status, string summary, long durationMs,
string callId = "")
{
if (_auditRepository is null)
return;
var entry = new AuditEntry
{
RunId = runId,
CallId = callId,
AgentId = agentConfig.AgentId,
Model = agentConfig.Model,
Source = AuditSource.Normalize(source),
Tool = tool,
Arguments = Cap(arguments, 4_000),
Status = status,
Summary = Cap(summary, 500),
DurationMs = durationMs,
OccurredAt = DateTime.Now
};
// Der "angefangen"-Eintrag ist der einzige, dessen Fehlschlag zählt (#554):
// Auf ihn stützt sich die Wiederaufnahme nach einem Absturz. Kommt er nicht
// durch, liefe der Aufruf ohne jede Spur — dann lieber gar nicht erst laufen
// lassen. Alle übrigen Einträge bleiben "best effort": Ein Protokollfehler soll
// einen bereits erfolgreichen Aufruf nicht nachträglich zum Scheitern bringen.
if (status == AuditStatus.Started)
{
await _auditRepository.AppendAsync(entry, CancellationToken.None);
return;
}
try
{
await _auditRepository.AppendAsync(new AuditEntry
{
RunId = runId,
AgentId = agentConfig.AgentId,
Model = agentConfig.Model,
Source = AuditSource.Normalize(source),
Tool = tool,
Arguments = Cap(arguments, 4_000),
Status = status,
Summary = Cap(summary, 500),
DurationMs = durationMs,
OccurredAt = DateTime.Now
}, CancellationToken.None);
await _auditRepository.AppendAsync(entry, CancellationToken.None);
}
catch (Exception ex)
{