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
@@ -20,11 +20,12 @@ public sealed class SqliteAuditRepository : IAuditRepository
using var cmd = conn.CreateCommand();
cmd.CommandText = """
INSERT INTO AuditLog
(RunId, AgentId, Model, Source, Tool, Arguments, Status, Summary, DurationMs, OccurredAt)
(RunId, CallId, AgentId, Model, Source, Tool, Arguments, Status, Summary, DurationMs, OccurredAt)
VALUES
(@runId, @agentId, @model, @source, @tool, @arguments, @status, @summary, @durationMs, @occurredAt)
(@runId, @callId, @agentId, @model, @source, @tool, @arguments, @status, @summary, @durationMs, @occurredAt)
""";
cmd.Parameters.AddWithValue("@runId", entry.RunId);
cmd.Parameters.AddWithValue("@callId", entry.CallId);
cmd.Parameters.AddWithValue("@agentId", entry.AgentId);
cmd.Parameters.AddWithValue("@model", entry.Model);
cmd.Parameters.AddWithValue("@source", AuditSource.Normalize(entry.Source));
@@ -120,10 +121,113 @@ public sealed class SqliteAuditRepository : IAuditRepository
return Convert.ToInt32(await cmd.ExecuteScalarAsync(ct));
}
/// <summary>
/// Offene Aufrufe (#554): ein <c>Started</c>, zu dem kein späterer Eintrag mit
/// derselben CallId existiert.
///
/// <para>Einträge ohne CallId bleiben außen vor — sie stammen aus der Zeit vor #554
/// und ließen sich nicht paaren. Sie alle als offen zu melden, wäre schlimmer als
/// sie zu übergehen: Der erste Start nach der Aktualisierung legte sonst das ganze
/// Altprotokoll zur Prüfung vor.</para>
/// </summary>
public async Task<IReadOnlyList<AuditEntry>> ListOpenCallsAsync(CancellationToken ct)
{
await using var conn = await _storage.OpenConnectionAsync(ct);
using var cmd = conn.CreateCommand();
cmd.CommandText = $"""
SELECT {AuditColumns} FROM AuditLog a
WHERE a.Status = 'Started'
AND a.CallId <> ''
AND NOT EXISTS (
SELECT 1 FROM AuditLog b
WHERE b.CallId = a.CallId
AND b.Id > a.Id
AND b.Status <> 'Started'
)
ORDER BY a.Id
""";
return await ReadEntriesAsync(cmd, ct);
}
public async Task<IReadOnlyList<AuditEntry>> ListForAgentAsync(
string agentId, DateTime? seit, int limit, CancellationToken ct)
{
await using var conn = await _storage.OpenConnectionAsync(ct);
using var cmd = conn.CreateCommand();
cmd.CommandText = $"""
SELECT {AuditColumns} FROM AuditLog
WHERE AgentId = @agentId
AND (@seit IS NULL OR OccurredAt >= @seit)
ORDER BY Id DESC
LIMIT @limit
""";
cmd.Parameters.AddWithValue("@agentId", agentId);
cmd.Parameters.AddWithValue("@seit", seit is { } s ? Format(s) : DBNull.Value);
cmd.Parameters.AddWithValue("@limit", limit);
return await ReadEntriesAsync(cmd, ct);
}
/// <summary>
/// Kennzahlen je Agent und Werkzeug (#556).
///
/// <para>Die Dauer wird nur über abgeschlossene Aufrufe gemittelt. Ein
/// <c>Started</c>-Eintrag trägt die Dauer 0, weil zum Zeitpunkt des Schreibens noch
/// nichts gelaufen war — würde er mitgezählt, sähe jedes Werkzeug doppelt so schnell
/// aus, wie es ist.</para>
/// </summary>
public async Task<IReadOnlyList<ToolUsageStat>> SummarizeToolUsageAsync(
string? agentId, DateTime? seit, CancellationToken ct)
{
await using var conn = await _storage.OpenConnectionAsync(ct);
using var cmd = conn.CreateCommand();
cmd.CommandText = """
SELECT AgentId, Tool,
SUM(CASE WHEN Status <> 'Started' THEN 1 ELSE 0 END) AS Calls,
SUM(CASE WHEN Status = 'Error' THEN 1 ELSE 0 END) AS Failed,
SUM(CASE WHEN Status = 'Denied' THEN 1 ELSE 0 END) AS Denied,
SUM(CASE WHEN Status = 'Started' THEN 1 ELSE 0 END) AS Begonnen,
SUM(CASE WHEN Status <> 'Started' THEN DurationMs ELSE 0 END) AS GesamtMs,
MAX(DurationMs) AS MaxMs
FROM AuditLog
WHERE (@agentId IS NULL OR AgentId = @agentId)
AND (@seit IS NULL OR OccurredAt >= @seit)
GROUP BY AgentId, Tool
ORDER BY Calls DESC
""";
cmd.Parameters.AddWithValue("@agentId", (object?)agentId ?? DBNull.Value);
cmd.Parameters.AddWithValue("@seit", seit is { } s ? Format(s) : DBNull.Value);
var results = new List<ToolUsageStat>();
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
{
var abgeschlossen = reader.GetInt32(2);
var begonnen = reader.GetInt32(5);
results.Add(new ToolUsageStat
{
AgentId = reader.GetString(0),
Tool = reader.GetString(1),
Calls = abgeschlossen,
Failed = reader.GetInt32(3),
Denied = reader.GetInt32(4),
// Begonnene minus abgeschlossene: Was übrig bleibt, hat nie geendet.
// Nie negativ — ein Abschluss ohne Start kann es nicht geben, aber
// Alteinträge ohne Started-Zeile gibt es sehr wohl.
Unfinished = Math.Max(0, begonnen - abgeschlossen),
TotalDurationMs = reader.GetInt64(6),
MaxDurationMs = reader.GetInt64(7)
});
}
return results;
}
// ─── Hilfsfunktionen ───
private const string AuditColumns =
"Id, RunId, AgentId, Model, Source, Tool, Arguments, Status, Summary, DurationMs, OccurredAt";
"Id, RunId, AgentId, Model, Source, Tool, Arguments, Status, Summary, DurationMs, OccurredAt, CallId";
private const string ReceiptColumns =
"Id, RunId, AgentId, Model, Source, TaskId, Status, StepCount, PromptTokens, " +
@@ -150,7 +254,8 @@ public sealed class SqliteAuditRepository : IAuditRepository
Status = Enum.TryParse<AuditStatus>(r.GetString(7), out var s) ? s : AuditStatus.Ok,
Summary = r.GetString(8),
DurationMs = r.GetInt64(9),
OccurredAt = Parse(r.GetString(10))
OccurredAt = Parse(r.GetString(10)),
CallId = r.GetString(11)
};
private static RunReceipt ReadReceipt(SqliteDataReader r) => new()