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