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:
@@ -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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user