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
+62 -1
View File
@@ -3,6 +3,16 @@ namespace ClawdDotNet.Core.Audit;
/// <summary>Ausgang eines Tool-Aufrufs, von der Engine festgestellt.</summary> /// <summary>Ausgang eines Tool-Aufrufs, von der Engine festgestellt.</summary>
public enum AuditStatus public enum AuditStatus
{ {
/// <summary>
/// Der Aufruf beginnt jetzt (#554). Der einzige Eintrag, der <b>vor</b> der Wirkung
/// geschrieben wird — und der einzige, der deshalb einen harten Absturz überlebt.
///
/// <para>Bleibt er ohne Abschluss, lief der Aufruf, als die Anwendung starb. Was er
/// dabei bewirkt hat, sagt er nicht: Der Absturz kann vor oder nach der Wirkung
/// liegen. Er grenzt die Frage ein, er beantwortet sie nicht.</para>
/// </summary>
Started,
/// <summary>Tool lief und lieferte ein Ergebnis.</summary> /// <summary>Tool lief und lieferte ein Ergebnis.</summary>
Ok, Ok,
@@ -16,7 +26,18 @@ public enum AuditStatus
NotFound, NotFound,
/// <summary>Der Aufruf wurde zur Freigabe vorgelegt (A2), nicht ausgeführt.</summary> /// <summary>Der Aufruf wurde zur Freigabe vorgelegt (A2), nicht ausgeführt.</summary>
Staged Staged,
/// <summary>
/// Der Aufruf wurde unterbrochen, während er lief (#554) — Benutzerabbruch,
/// Zeitüberschreitung oder geordnetes Ende der Instanz.
///
/// <para>Wie beim Absturz ist die Wirkung ungewiss. Der Unterschied ist, dass wir
/// hier <b>wissen</b>, dass unterbrochen wurde. Ohne diesen Eintrag bliebe der
/// <see cref="Started"/>-Eintrag offen, und jeder gewöhnliche Abbruch sähe beim
/// nächsten Start aus wie ein Absturz — bis niemand mehr hinsieht.</para>
/// </summary>
Cancelled
} }
/// <summary> /// <summary>
@@ -34,6 +55,15 @@ public sealed record AuditEntry
/// <summary>Korrelations-Id des Laufs — bündelt alle Aufrufe eines Laufs.</summary> /// <summary>Korrelations-Id des Laufs — bündelt alle Aufrufe eines Laufs.</summary>
public string RunId { get; init; } = ""; public string RunId { get; init; } = "";
/// <summary>
/// Id des einzelnen Werkzeugaufrufs (die <c>tool_call_id</c> des Modells). Paart
/// den <see cref="AuditStatus.Started"/>-Eintrag mit seinem Abschluss (#554).
///
/// <para>Leer bei Einträgen aus der Zeit vor #554 und bei solchen, die keinem
/// einzelnen Aufruf zuzuordnen sind.</para>
/// </summary>
public string CallId { get; init; } = "";
public string AgentId { get; init; } = ""; public string AgentId { get; init; } = "";
/// <summary>Worker-Typ: das Modell/die Engine. Getrennt von der Session (Source).</summary> /// <summary>Worker-Typ: das Modell/die Engine. Getrennt von der Session (Source).</summary>
@@ -93,6 +123,37 @@ public sealed record RunReceipt
public DateTime OccurredAt { get; init; } public DateTime OccurredAt { get; init; }
} }
/// <summary>
/// Kennzahlen eines Werkzeugs bei einem Agenten (#556). Aus dem Audit-Log gerechnet,
/// ohne eine einzige zusätzliche Messung.
/// </summary>
public sealed record ToolUsageStat
{
public string AgentId { get; init; } = "";
public string Tool { get; init; } = "";
/// <summary>Abgeschlossene Aufrufe — <see cref="AuditStatus.Started"/> zählt nicht mit.</summary>
public int Calls { get; init; }
public int Failed { get; init; }
public int Denied { get; init; }
/// <summary>
/// Aufrufe, die begannen und nie endeten. Nicht dasselbe wie gescheitert: Hier fehlt
/// die Auskunft, statt dass sie negativ wäre.
/// </summary>
public int Unfinished { get; init; }
public long TotalDurationMs { get; init; }
public long MaxDurationMs { get; init; }
/// <summary>Mittlere Dauer. 0, wenn nichts abgeschlossen wurde.</summary>
public double AverageDurationMs => Calls == 0 ? 0 : (double)TotalDurationMs / Calls;
/// <summary>Anteil der gescheiterten an den abgeschlossenen Aufrufen, 0 bis 1.</summary>
public double FailureRate => Calls == 0 ? 0 : (double)Failed / Calls;
}
/// <summary>Bekannte Session-/Kanal-Bezeichner für die Herkunft. Deckt sich mit /// <summary>Bekannte Session-/Kanal-Bezeichner für die Herkunft. Deckt sich mit
/// <c>ChatSource</c>; <see cref="Unknown"/> steht für „nicht bekannt", nicht für geraten.</summary> /// <c>ChatSource</c>; <see cref="Unknown"/> steht für „nicht bekannt", nicht für geraten.</summary>
public static class AuditSource public static class AuditSource
+192
View File
@@ -0,0 +1,192 @@
using System.Text.Json;
using ClawdDotNet.Core.Tools;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Core.Audit;
/// <summary>
/// Wie mit einem Aufruf zu verfahren ist, der beim Absturz lief.
/// </summary>
public enum RecoveryVerdict
{
/// <summary>
/// Das Werkzeug gibt sich als wiederholbar aus. Ein zweiter, gleicher Aufruf
/// führt zum selben Zustand — ob der erste wirkte, muss deshalb niemand wissen.
/// </summary>
Repeatable,
/// <summary>
/// Die Wirkung ist ungewiss und eine Wiederholung nicht unbedenklich. Gehört einem
/// Menschen vorgelegt, nicht automatisch entschieden.
/// </summary>
NeedsReview,
/// <summary>
/// Das Werkzeug gibt es nicht mehr — umbenannt, entfernt, oder dem Agenten
/// entzogen. Wiederholen ginge gar nicht; hier bleibt nur der Hinweis.
/// </summary>
ToolUnknown
}
/// <summary>
/// Ein Aufruf, der beim Absturz lief, mit seiner Einstufung — und, falls das Werkzeug
/// nachsehen konnte, mit dem Befund der Nachschau (#555).
/// </summary>
public sealed record RecoveryCase(
AuditEntry Entry,
RecoveryVerdict Verdict,
string Reason,
ProbeFinding? Probe = null)
{
/// <summary>
/// Ob dieser Fall ohne menschliche Entscheidung erledigt ist.
///
/// <para>Zwei Wege führen dahin: Das Werkzeug ist wiederholbar, oder die Nachschau
/// hat die Ungewissheit aufgelöst. Ein <see cref="ProbeOutcome.Complete"/> heißt,
/// die Wirkung ist eingetreten — nichts zu tun. Ein <see cref="ProbeOutcome.NotStarted"/>
/// heißt, sie ist es nicht — der Aufruf kann gefahrlos wiederholt werden.</para>
///
/// <para><see cref="ProbeOutcome.Partial"/> gehört ausdrücklich <b>nicht</b> dazu.
/// Ein halb angehängter Text ist genau der Fall, den niemand automatisch
/// geraderücken sollte.</para>
/// </summary>
public bool Erledigt =>
Verdict == RecoveryVerdict.Repeatable ||
Probe?.Outcome is ProbeOutcome.Complete or ProbeOutcome.NotStarted;
}
/// <summary>
/// Sichtet nach dem Start, was beim letzten Mal offen geblieben ist (#554).
///
/// <para><b>Was dieser Dienst kann und was nicht:</b> Er findet Aufrufe, die begonnen
/// und nie geendet haben, und stuft sie ein. Er kann <i>nicht</i> sagen, ob ihre Wirkung
/// eingetreten ist — der Absturz kann vor oder nach dem Versenden einer Mail liegen, und
/// im Protokoll steht beides Mal dasselbe. Er verkleinert die Frage von „was ist in zehn
/// Minuten passiert?" auf „diese zwei Aufrufe waren in der Luft". Mehr nicht, und das
/// absichtlich: Ein Dienst, der hier eine Gewissheit vortäuschte, führte zu doppelt
/// versendeten Mails.</para>
///
/// <para>Er führt deshalb auch nichts aus. Er sichtet und berichtet; was geschieht,
/// entscheidet der Aufrufer.</para>
/// </summary>
public sealed class CrashRecovery
{
private readonly IAuditRepository _audit;
private readonly ToolRegistry _tools;
private readonly ILogger _logger;
/// <summary>
/// Baut den Kontext, den eine Nachschau braucht — Arbeitsverzeichnisse, Einstellungen.
/// Null heißt: keine Nachschau, nur Einstufung. Die Engine kennt diese Angaben, der
/// Wiederaufnahmedienst nicht; deshalb werden sie hereingereicht statt nachgebaut.
/// </summary>
private readonly Func<string, string, AgentToolContext?>? _kontextFuer;
public CrashRecovery(
IAuditRepository audit,
ToolRegistry tools,
ILoggerFactory loggerFactory,
Func<string, string, AgentToolContext?>? kontextFuer = null)
{
_audit = audit;
_tools = tools;
_logger = loggerFactory.CreateLogger("ClawdDotNet.Core.Audit.CrashRecovery");
_kontextFuer = kontextFuer;
}
/// <summary>
/// Sichtet die offenen Aufrufe. Leere Liste heißt: Das letzte Ende war geordnet.
/// </summary>
public async Task<IReadOnlyList<RecoveryCase>> SichtenAsync(CancellationToken ct)
{
var offen = await _audit.ListOpenCallsAsync(ct);
if (offen.Count == 0)
{
_logger.LogDebug("Keine offenen Werkzeugaufrufe — das letzte Ende war geordnet.");
return [];
}
var faelle = new List<RecoveryCase>();
foreach (var eintrag in offen)
faelle.Add(await NachsehenAsync(Einstufen(eintrag), ct));
var zurPruefung = faelle.Count(f => !f.Erledigt);
_logger.LogWarning(
"{Gesamt} Werkzeugaufruf(e) liefen beim letzten Ende und wurden nie abgeschlossen. " +
"{Erledigt} davon sind geklärt, {Pruefung} brauchen eine Entscheidung.",
faelle.Count, faelle.Count - zurPruefung, zurPruefung);
foreach (var fall in faelle)
{
_logger.LogWarning(
"Offen: {Tool} von {Agent} (Lauf {RunId}, begonnen {Zeit:yyyy-MM-dd HH:mm:ss}) — " +
"{Urteil}: {Grund}{Nachschau}",
fall.Entry.Tool, fall.Entry.AgentId, fall.Entry.RunId,
fall.Entry.OccurredAt, fall.Verdict, fall.Reason,
fall.Probe is { } p ? $" Nachschau: {p.Outcome} — {p.Evidence}" : "");
}
return faelle;
}
/// <summary>
/// Fragt das Werkzeug, was von dem Aufruf übrig ist — sofern es das beantworten kann
/// (#555). Ein Werkzeug ohne Nachschau ist der Normalfall, kein Mangel.
/// </summary>
private async Task<RecoveryCase> NachsehenAsync(RecoveryCase fall, CancellationToken ct)
{
if (_tools.Get(fall.Entry.Tool) is not IToolStateProbe probe || _kontextFuer is null)
return fall;
var kontext = _kontextFuer(fall.Entry.AgentId, fall.Entry.Tool);
if (kontext is null)
return fall;
try
{
// Die Argumente werden gekappt gespeichert. Ein abgeschnittener Eintrag ist
// kein gültiges JSON — dann gibt es nichts nachzusehen, und das ist eine
// Aussage über unser Protokoll, nicht über die Welt.
using var argumente = JsonDocument.Parse(fall.Entry.Arguments);
var befund = await probe.ProbeAsync(argumente.RootElement, kontext, ct);
return fall with { Probe = befund };
}
catch (JsonException)
{
return fall with
{
Probe = ProbeFinding.Unknown(
"Die protokollierten Argumente sind unvollständig (gekappt) — " +
"der Aufruf lässt sich nicht nachstellen.")
};
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Nachschau für {Tool} scheiterte.", fall.Entry.Tool);
return fall with { Probe = ProbeFinding.Unknown($"Nachschau scheiterte: {ex.Message}") };
}
}
private RecoveryCase Einstufen(AuditEntry entry)
{
var tool = _tools.Get(entry.Tool);
if (tool is null)
{
return new RecoveryCase(entry, RecoveryVerdict.ToolUnknown,
$"Werkzeug '{entry.Tool}' ist nicht mehr registriert.");
}
if (tool.IsRepeatable)
{
return new RecoveryCase(entry, RecoveryVerdict.Repeatable,
"Das Werkzeug gibt sich als wiederholbar aus — ein zweiter Aufruf führt zum selben Zustand.");
}
return new RecoveryCase(entry, RecoveryVerdict.NeedsReview,
"Wirkung ungewiss und Wiederholung nicht als unbedenklich gekennzeichnet.");
}
}
@@ -26,4 +26,25 @@ public interface IAuditRepository
/// <summary>Zahl der Log-Einträge insgesamt.</summary> /// <summary>Zahl der Log-Einträge insgesamt.</summary>
Task<int> CountAsync(CancellationToken ct); Task<int> CountAsync(CancellationToken ct);
/// <summary>
/// Aufrufe, die begonnen haben und nie abgeschlossen wurden (#554) — also
/// <see cref="AuditStatus.Started"/> ohne späteren Eintrag mit derselben
/// <see cref="AuditEntry.CallId"/>.
///
/// <para>Nach einem geordneten Ende ist die Liste leer. Ist sie es nicht, ist die
/// Anwendung gestorben, während diese Aufrufe liefen. Was sie bewirkt haben, sagt
/// die Liste nicht — das ist die Frage, die danach zu klären ist.</para>
/// </summary>
Task<IReadOnlyList<AuditEntry>> ListOpenCallsAsync(CancellationToken ct);
/// <summary>Die jüngsten Aufrufe eines Agenten, optional ab einem Zeitpunkt (#556).</summary>
Task<IReadOnlyList<AuditEntry>> ListForAgentAsync(
string agentId, DateTime? seit, int limit, CancellationToken ct);
/// <summary>
/// Kennzahlen je Agent und Werkzeug (#556): wie oft, wie oft vergeblich, wie lange.
/// </summary>
Task<IReadOnlyList<ToolUsageStat>> SummarizeToolUsageAsync(
string? agentId, DateTime? seit, CancellationToken ct);
} }
@@ -20,11 +20,12 @@ public sealed class SqliteAuditRepository : IAuditRepository
using var cmd = conn.CreateCommand(); using var cmd = conn.CreateCommand();
cmd.CommandText = """ cmd.CommandText = """
INSERT INTO AuditLog 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 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("@runId", entry.RunId);
cmd.Parameters.AddWithValue("@callId", entry.CallId);
cmd.Parameters.AddWithValue("@agentId", entry.AgentId); cmd.Parameters.AddWithValue("@agentId", entry.AgentId);
cmd.Parameters.AddWithValue("@model", entry.Model); cmd.Parameters.AddWithValue("@model", entry.Model);
cmd.Parameters.AddWithValue("@source", AuditSource.Normalize(entry.Source)); cmd.Parameters.AddWithValue("@source", AuditSource.Normalize(entry.Source));
@@ -120,10 +121,113 @@ public sealed class SqliteAuditRepository : IAuditRepository
return Convert.ToInt32(await cmd.ExecuteScalarAsync(ct)); 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 ─── // ─── Hilfsfunktionen ───
private const string AuditColumns = 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 = private const string ReceiptColumns =
"Id, RunId, AgentId, Model, Source, TaskId, Status, StepCount, PromptTokens, " + "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, Status = Enum.TryParse<AuditStatus>(r.GetString(7), out var s) ? s : AuditStatus.Ok,
Summary = r.GetString(8), Summary = r.GetString(8),
DurationMs = r.GetInt64(9), 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() private static RunReceipt ReadReceipt(SqliteDataReader r) => new()
+42 -8
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 // Engine, nie aus dem Tool-Ergebnis. Best effort: ein Audit-Fehler darf den Lauf
// nicht scheitern lassen. // nicht scheitern lassen.
Task Audit(AuditStatus status, string summary) 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 try
{ {
@@ -1271,6 +1272,13 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
logger.LogDebug("Executing tool {Tool} for agent {AgentId}", toolName, agentConfig.AgentId); 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); var result = await tool.ExecuteAsync(input, context, ct);
logger.LogDebug("Tool {Tool} completed: success={Success}", toolName, result.Success); 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) catch (OperationCanceledException) when (ct.IsCancellationRequested)
{ {
// Nicht als Tool-Fehler zurückgeben: Sonst läuft die Schleife noch einen // 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 — // Schritt weiter und der Abbruch greift erst verzögert.
// der Aufruf kam nicht zum Abschluss. //
// 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; throw;
} }
catch (Exception ex) catch (Exception ex)
@@ -1457,16 +1476,16 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
/// </summary> /// </summary>
private async Task RecordAuditAsync( private async Task RecordAuditAsync(
string runId, AgentConfig agentConfig, string? source, string tool, 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) if (_auditRepository is null)
return; return;
try var entry = new AuditEntry
{
await _auditRepository.AppendAsync(new AuditEntry
{ {
RunId = runId, RunId = runId,
CallId = callId,
AgentId = agentConfig.AgentId, AgentId = agentConfig.AgentId,
Model = agentConfig.Model, Model = agentConfig.Model,
Source = AuditSource.Normalize(source), Source = AuditSource.Normalize(source),
@@ -1476,7 +1495,22 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
Summary = Cap(summary, 500), Summary = Cap(summary, 500),
DurationMs = durationMs, DurationMs = durationMs,
OccurredAt = DateTime.Now OccurredAt = DateTime.Now
}, CancellationToken.None); };
// 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(entry, CancellationToken.None);
} }
catch (Exception ex) catch (Exception ex)
{ {
+54 -1
View File
@@ -202,7 +202,10 @@ public sealed class SqliteStorage
Status TEXT NOT NULL, Status TEXT NOT NULL,
Summary TEXT NOT NULL DEFAULT '', Summary TEXT NOT NULL DEFAULT '',
DurationMs INTEGER NOT NULL DEFAULT 0, DurationMs INTEGER NOT NULL DEFAULT 0,
OccurredAt TEXT NOT NULL OccurredAt TEXT NOT NULL,
-- Paart den "angefangen"-Eintrag mit seinem Abschluss (#554).
-- Wird fuer bestehende Datenbanken in Nachruesten() ergaenzt.
CallId TEXT NOT NULL DEFAULT ''
); );
CREATE INDEX IF NOT EXISTS IX_AuditLog_Run CREATE INDEX IF NOT EXISTS IX_AuditLog_Run
@@ -261,5 +264,55 @@ public sealed class SqliteStorage
"""; """;
cmd.ExecuteNonQuery(); cmd.ExecuteNonQuery();
Nachruesten(connection);
}
/// <summary>
/// Spalten, die nach der ersten Fassung einer Tabelle dazugekommen sind.
///
/// <para><c>CREATE TABLE IF NOT EXISTS</c> allein genügt dafür nicht: Bei einer
/// bestehenden Datenbank tut die Anweisung gar nichts, die neue Spalte fehlt, und
/// erst das nächste <c>INSERT</c>, das sie benennt, scheitert — im Betrieb, beim
/// Anwender, nicht beim Entwickler mit der frischen Datei.</para>
/// </summary>
private static void Nachruesten(SqliteConnection connection)
{
// #554: Paart den "angefangen"-Eintrag mit seinem Abschluss.
SpalteErgaenzen(connection, "AuditLog", "CallId", "TEXT NOT NULL DEFAULT ''");
// Findet offene Aufrufe beim Start, ohne die ganze Tabelle zu lesen.
using var index = connection.CreateCommand();
index.CommandText = """
CREATE INDEX IF NOT EXISTS IX_AuditLog_Call
ON AuditLog (CallId, Id);
CREATE INDEX IF NOT EXISTS IX_AuditLog_Agent
ON AuditLog (AgentId, Id DESC);
""";
index.ExecuteNonQuery();
}
/// <summary>
/// Ergänzt eine Spalte, falls sie fehlt. Ist sie da, geschieht nichts.
///
/// <para>SQLite kennt kein <c>ADD COLUMN IF NOT EXISTS</c>, deshalb wird erst
/// nachgesehen. Die Vorgabe ist Pflicht: Eine <c>NOT NULL</c>-Spalte ohne
/// Vorgabewert lässt sich einer gefüllten Tabelle nicht hinzufügen.</para>
/// </summary>
private static void SpalteErgaenzen(SqliteConnection connection, string tabelle, string spalte, string definition)
{
using (var pruefen = connection.CreateCommand())
{
pruefen.CommandText = $"SELECT COUNT(*) FROM pragma_table_info('{tabelle}') WHERE name = @spalte";
pruefen.Parameters.AddWithValue("@spalte", spalte);
if (Convert.ToInt32(pruefen.ExecuteScalar()) > 0)
return;
}
using var ergaenzen = connection.CreateCommand();
ergaenzen.CommandText = $"ALTER TABLE {tabelle} ADD COLUMN {spalte} {definition}";
ergaenzen.ExecuteNonQuery();
} }
} }
+19
View File
@@ -23,6 +23,25 @@ public interface IAgentTool
/// </summary> /// </summary>
bool IsParallelSafe => false; bool IsParallelSafe => false;
/// <summary>
/// Ob ein zweiter, gleicher Aufruf schadlos bleibt (#554).
///
/// <para>Gebraucht wird das nach einem harten Absturz: Im Protokoll steht, dass ein
/// Aufruf begann, nicht ob er wirkte. Bei einem wiederholbaren Werkzeug muss man das
/// auch nicht wissen — man ruft es einfach noch einmal. Bei allen anderen bleibt nur,
/// den Fall vorzulegen statt zu raten.</para>
///
/// <para>Nicht zu verwechseln mit <see cref="IsParallelSafe"/>. Das heißt „stört
/// keinen gleichzeitigen Aufruf", dies hier heißt „richtet zweimal keinen Schaden an".
/// Ein Werkzeug kann das eine sein und das andere nicht: Eine Datei zu überschreiben
/// ist wiederholbar — zweimal derselbe Inhalt ergibt denselben Zustand —, verträgt
/// aber keinen zweiten Schreiber zur selben Zeit.</para>
///
/// <para>Vorgabe <c>false</c>, aus demselben Grund wie oben: Wer sich irrt, versendet
/// die Mail zweimal.</para>
/// </summary>
bool IsRepeatable => false;
Task<ToolResult> ExecuteAsync( Task<ToolResult> ExecuteAsync(
JsonElement input, JsonElement input,
AgentToolContext context, AgentToolContext context,
@@ -0,0 +1,62 @@
using System.Text.Json;
namespace ClawdDotNet.Core.Tools;
/// <summary>Was die Nachschau über einen unterbrochenen Aufruf ergeben hat.</summary>
public enum ProbeOutcome
{
/// <summary>
/// Nicht feststellbar — und das ist eine vollwertige Antwort, kein Fehlschlag.
///
/// <para>Die meisten Werkzeuge landen hier, und zwar zu Recht: Ob ein SMTP-Server
/// eine Nachricht angenommen hat, lässt sich im Nachhinein nicht feststellen, wenn
/// die Gegenseite keine Kennung führt. Ein Werkzeug, das in so einem Fall
/// „vermutlich nicht" meldete, wäre schlimmer als eines, das schweigt.</para>
/// </summary>
Unknown,
/// <summary>Nachweislich nichts geschehen — der Aufruf kam nicht bis zur Wirkung.</summary>
NotStarted,
/// <summary>Die Wirkung ist eingetreten, aber unvollständig.</summary>
Partial,
/// <summary>Die Wirkung ist vollständig eingetreten. Wiederholen wäre überflüssig.</summary>
Complete
}
/// <summary>
/// Ein Befund der Nachschau. <paramref name="Evidence"/> sagt, <b>woran</b> es erkannt
/// wurde — der Befund ist Beleg, nicht Urteil, und wer ihn liest, soll ihn beurteilen
/// können statt ihm glauben zu müssen.
/// </summary>
public sealed record ProbeFinding(ProbeOutcome Outcome, string Evidence)
{
public static ProbeFinding Unknown(string grund) => new(ProbeOutcome.Unknown, grund);
}
/// <summary>
/// Optional von einem Werkzeug zu bedienen (#555): Nach einem Absturz die Welt befragen,
/// wie weit ein unterbrochener Aufruf gekommen ist.
///
/// <para>Das Protokoll sagt, dass ein Aufruf begann, nicht ob er wirkte. Manche Werkzeuge
/// können diese Lücke selbst schließen, weil ihr Wirkungsort befragbar ist — ein
/// Dateisystem ist es, ein fremder Mailserver nicht. Deshalb ist die Schnittstelle
/// optional, und ihr Fehlen ist eine Aussage: Bei diesem Werkzeug bleibt es ungewiss.</para>
///
/// <para><b>Was hier nicht hingehört:</b> die Frage, ob ein Inhalt „mitten im Satz"
/// endet, weil das Modell an seine Ausgabegrenze stieß. Die Datei ist dann fehlerfrei
/// geschrieben — nur der Inhalt ist halb. Vom Wirkungsort aus lässt sich eine kurze
/// Notiz nicht von einem abgeschnittenen Bericht unterscheiden. Diese Auskunft gibt es
/// zuverlässig an der Quelle, über <c>finish_reason</c> (#548).</para>
/// </summary>
public interface IToolStateProbe
{
/// <summary>
/// Sieht nach, was der Aufruf mit diesen Argumenten hinterlassen hat.
///
/// <para>Darf nichts verändern. Wirft nicht: Was sich nicht feststellen lässt, wird
/// als <see cref="ProbeOutcome.Unknown"/> gemeldet, mit Begründung.</para>
/// </summary>
Task<ProbeFinding> ProbeAsync(JsonElement arguments, AgentToolContext context, CancellationToken ct);
}
+94 -2
View File
@@ -1,12 +1,13 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Text; using System.Text;
using System.Text.Json; using System.Text.Json;
using ClawdDotNet.Core.Storage;
using ClawdDotNet.Core.Tools; using ClawdDotNet.Core.Tools;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Tools.FileRW; namespace ClawdDotNet.Tools.FileRW;
public sealed class FileRWTool : IAgentTool public sealed class FileRWTool : IAgentTool, IToolStateProbe
{ {
/// <summary> /// <summary>
/// Per-Datei Locking: Verhindert Race Conditions wenn mehrere Agenten /// Per-Datei Locking: Verhindert Race Conditions wenn mehrere Agenten
@@ -200,6 +201,88 @@ public sealed class FileRWTool : IAgentTool
return []; return [];
} }
/// <summary>
/// Nachschau nach einem Absturz (#555). Sieht nach, was von einem unterbrochenen
/// Aufruf übrig ist — verändert nichts.
///
/// <para>Wie weit die Auskunft trägt, hängt an der Aktion:</para>
/// <list type="bullet">
/// <item><b>write</b> — seit #553 atomar. Entweder die alte oder die neue Fassung,
/// nie eine halbe. Ein Vergleich mit dem beabsichtigten Inhalt sagt deshalb
/// zuverlässig, ob der Aufruf durchkam.</item>
/// <item><b>append</b> — nicht atomar und nicht atomar zu machen. Hier kann wirklich
/// ein Riss entstehen, und hier lohnt die Nachschau: Steht der beabsichtigte Text
/// vollständig am Ende, kam der Aufruf durch; steht ein Anfang davon da, riss es
/// mittendrin.</item>
/// <item>alles andere — <see cref="ProbeOutcome.Unknown"/>. Lieber schweigen als
/// raten.</item>
/// </list>
/// </summary>
public async Task<ProbeFinding> ProbeAsync(JsonElement arguments, AgentToolContext context, CancellationToken ct)
{
try
{
var action = arguments.TryGetProperty("action", out var a) ? a.GetString() : null;
if (action is not ("write" or "append"))
return ProbeFinding.Unknown($"Für die Aktion '{action ?? "?"}' gibt es nichts nachzusehen.");
var workspace = (arguments.TryGetProperty("workspace", out var w) ? w.GetString() : null) ?? "personal";
var rootPath = workspace == "shared" ? context.SharedWorkspacePath : context.WorkspacePath;
if (string.IsNullOrWhiteSpace(rootPath))
return ProbeFinding.Unknown("Workspace-Pfad ist nicht bekannt.");
var path = GetAndValidatePath(arguments, Path.GetFullPath(rootPath), workspace, true, context);
var erwartet = arguments.TryGetProperty("content", out var c) ? c.GetString() ?? "" : "";
if (!File.Exists(path))
{
return new ProbeFinding(ProbeOutcome.NotStarted,
"Die Datei existiert nicht — geschrieben wurde nichts.");
}
var vorhanden = await File.ReadAllTextAsync(path, ct);
if (action == "write")
{
return vorhanden == erwartet
? new ProbeFinding(ProbeOutcome.Complete,
$"Der Inhalt entspricht genau dem beabsichtigten ({erwartet.Length} Zeichen).")
: new ProbeFinding(ProbeOutcome.NotStarted,
"Die Datei trägt noch den alten Inhalt. Seit #553 wird atomar " +
"geschrieben — eine halbe Fassung kann es nicht geben.");
}
// append
if (vorhanden.EndsWith(erwartet, StringComparison.Ordinal))
{
return new ProbeFinding(ProbeOutcome.Complete,
$"Der beabsichtigte Text steht vollständig am Ende ({erwartet.Length} Zeichen).");
}
// Wie viel vom Erwarteten steht am Ende? Das ist die Rissstelle.
for (var laenge = Math.Min(erwartet.Length - 1, vorhanden.Length); laenge > 0; laenge--)
{
if (vorhanden.EndsWith(erwartet[..laenge], StringComparison.Ordinal))
{
return new ProbeFinding(ProbeOutcome.Partial,
$"Angehängt wurden {laenge} von {erwartet.Length} Zeichen — " +
"der Aufruf riss mittendrin ab.");
}
}
return new ProbeFinding(ProbeOutcome.NotStarted,
"Vom beabsichtigten Text steht nichts am Ende der Datei.");
}
catch (Exception ex)
{
// Nie werfen: Eine gescheiterte Nachschau ist keine Aussage über die Welt,
// sondern eine über die Nachschau.
return ProbeFinding.Unknown($"Nachschau nicht möglich: {ex.Message}");
}
}
private string GetAndValidatePath(JsonElement input, string rootPath, string workspace, bool checkExtension, AgentToolContext context) private string GetAndValidatePath(JsonElement input, string rootPath, string workspace, bool checkExtension, AgentToolContext context)
{ {
var relativePath = input.TryGetProperty("path", out var p) ? p.GetString() : ""; var relativePath = input.TryGetProperty("path", out var p) ? p.GetString() : "";
@@ -268,7 +351,12 @@ public sealed class FileRWTool : IAgentTool
var dir = Path.GetDirectoryName(path); var dir = Path.GetDirectoryName(path);
if (dir != null && !Directory.Exists(dir)) Directory.CreateDirectory(dir); if (dir != null && !Directory.Exists(dir)) Directory.CreateDirectory(dir);
await File.WriteAllTextAsync(path, content, new UTF8Encoding(false), ct); // Atomar schreiben (#553): erst in eine Nebendatei, dann ersetzen. Ein Absturz
// mitten im Vorgang hinterlässt damit entweder die alte oder die neue Fassung,
// nie eine halbe. Zuvor stand hier File.WriteAllTextAsync — die Anwendung
// schützte also ihre eigenen Konfigurationsdateien, die Arbeitsergebnisse der
// Agenten aber nicht.
await AtomicFile.WriteAllTextAsync(path, content, new UTF8Encoding(false), ct);
return ToolResult.Ok($"[{workspace} Workspace] Datei erfolgreich geschrieben: {workspace}:/{Path.GetRelativePath(rootPath, path)}"); return ToolResult.Ok($"[{workspace} Workspace] Datei erfolgreich geschrieben: {workspace}:/{Path.GetRelativePath(rootPath, path)}");
} }
finally finally
@@ -286,6 +374,10 @@ public sealed class FileRWTool : IAgentTool
await fileLock.WaitAsync(ct); await fileLock.WaitAsync(ct);
try try
{ {
// Bewusst NICHT atomar (#553): Anhängen ließe sich nur atomar machen, indem
// die ganze Datei neu geschrieben wird — bei einer großen Protokolldatei wäre
// das der falsche Handel. Hier bleibt ein Riss möglich; genau dafür gibt es
// die Zustandsprüfung in #555.
await File.AppendAllTextAsync(path, content, new UTF8Encoding(false), ct); await File.AppendAllTextAsync(path, content, new UTF8Encoding(false), ct);
return ToolResult.Ok($"[{workspace} Workspace] Inhalt erfolgreich angehängt an: {workspace}:/{Path.GetRelativePath(rootPath, path)}"); return ToolResult.Ok($"[{workspace} Workspace] Inhalt erfolgreich angehängt an: {workspace}:/{Path.GetRelativePath(rootPath, path)}");
} }
@@ -29,6 +29,13 @@ public sealed class WebFetchTool : IAgentTool
/// </summary> /// </summary>
public bool IsParallelSafe => true; public bool IsParallelSafe => true;
/// <summary>
/// Wiederholbar (#554): Ein zweiter Abruf derselben Adresse ändert nichts an der
/// Welt. Die Antwort kann inzwischen eine andere sein — das ist aber kein Schaden,
/// sondern der Zweck eines Abrufs.
/// </summary>
public bool IsRepeatable => true;
public JsonElement InputSchema { get; } = JsonDocument.Parse(""" public JsonElement InputSchema { get; } = JsonDocument.Parse("""
{ {
"type": "object", "type": "object",
@@ -8,6 +8,15 @@ namespace ClawdDotNet.Core.Tests.Audit;
/// A3 an der Engine: Jeder Tool-Aufruf wird protokolliert, jeder Lauf bekommt einen Beleg. /// A3 an der Engine: Jeder Tool-Aufruf wird protokolliert, jeder Lauf bekommt einen Beleg.
/// Entscheidend ist die Provenienz — die Engine stempelt Agent, Modell und Herkunft aus /// Entscheidend ist die Provenienz — die Engine stempelt Agent, Modell und Herkunft aus
/// ihrem eigenen Wissen, nicht aus dem Tool-Ergebnis. /// ihrem eigenen Wissen, nicht aus dem Tool-Ergebnis.
///
/// <para>Seit #554 erzeugt ein ausgefuehrter Aufruf ZWEI Eintraege: erst
/// <see cref="AuditStatus.Started"/> vor der Wirkung, dann den Ausgang. Die Tests
/// hier filtern deshalb auf den Abschluss, wo es um den Ausgang geht — dass der
/// Beginn mitgeschrieben wird, pruefen die WiederaufnahmeTests.</para>
///
/// <para>Ein Aufruf, der gar nicht erst laeuft (verweigert, unbekannt, vorgelegt),
/// hat weiterhin genau einen Eintrag: Der Beginn wird erst nach Rechtepruefung und
/// Staging geschrieben.</para>
/// </summary> /// </summary>
public sealed class EngineAuditTests public sealed class EngineAuditTests
{ {
@@ -27,9 +36,17 @@ public sealed class EngineAuditTests
await fixture.Engine.RunAsync(agent, "los", "test-instance", default); await fixture.Engine.RunAsync(agent, "los", "test-instance", default);
var entries = audit.Entries.ToList(); var entries = audit.Entries.ToList();
entries.Count.ShouldBe(2);
// Zwei Aufrufe, je ein Beginn und ein Abschluss.
entries.Count.ShouldBe(4);
entries.ShouldAllBe(e => e.Tool == "TestTool"); entries.ShouldAllBe(e => e.Tool == "TestTool");
entries.ShouldAllBe(e => e.Status == AuditStatus.Ok); entries.Count(e => e.Status == AuditStatus.Started).ShouldBe(2);
entries.Count(e => e.Status == AuditStatus.Ok).ShouldBe(2);
// Jeder Beginn gehoert zu genau einem Abschluss.
entries.Where(e => e.Status == AuditStatus.Started).Select(e => e.CallId)
.ShouldBe(entries.Where(e => e.Status == AuditStatus.Ok).Select(e => e.CallId),
ignoreOrder: true);
} }
[Fact] [Fact]
@@ -42,7 +59,7 @@ public sealed class EngineAuditTests
await fixture.Engine.RunAsync(agent, "los", "test-instance", default, source: "task", taskId: "t-7"); await fixture.Engine.RunAsync(agent, "los", "test-instance", default, source: "task", taskId: "t-7");
var entry = audit.Entries.Single(); var entry = audit.Entries.Single(e => e.Status == AuditStatus.Ok);
entry.AgentId.ShouldBe("agent-a"); entry.AgentId.ShouldBe("agent-a");
entry.Model.ShouldBe("test/model"); entry.Model.ShouldBe("test/model");
entry.Source.ShouldBe("task"); entry.Source.ShouldBe("task");
@@ -74,7 +91,7 @@ public sealed class EngineAuditTests
await fixture.Engine.RunAsync(agent, "los", "test-instance", default); await fixture.Engine.RunAsync(agent, "los", "test-instance", default);
var entry = audit.Entries.Single(); var entry = audit.Entries.Single(e => e.Status != AuditStatus.Started);
entry.Status.ShouldBe(AuditStatus.Error); entry.Status.ShouldBe(AuditStatus.Error);
entry.Summary.ShouldContain("kaputt"); entry.Summary.ShouldContain("kaputt");
} }
@@ -108,7 +125,7 @@ public sealed class EngineAuditTests
await fixture.Engine.RunAsync(agent, "los", "test-instance", default); await fixture.Engine.RunAsync(agent, "los", "test-instance", default);
var runId = audit.Receipts.Single().RunId; var runId = audit.Receipts.Single().RunId;
audit.Entries.Single().RunId.ShouldBe(runId, "alle Aufrufe eines Laufs tragen dieselbe RunId"); audit.Entries.ShouldAllBe(e => e.RunId == runId);
} }
[Fact] [Fact]
@@ -0,0 +1,262 @@
using ClawdDotNet.Core.Audit;
using ClawdDotNet.Core.Engine;
using ClawdDotNet.Core.Tests.Infrastructure;
using ClawdDotNet.Core.Tools;
namespace ClawdDotNet.Core.Tests.Audit;
/// <summary>
/// Wiederaufnahme nach hartem Absturz (#554) und die Auswertung des Protokolls (#556).
/// </summary>
public class WiederaufnahmeTests
{
// ─────────────── Der "angefangen"-Eintrag ───────────────
[Fact]
public async Task Vor_dem_Werkzeug_steht_ein_Eintrag_im_Protokoll()
{
var audit = new InMemoryAuditRepository();
var fixture = new EngineFixture(audit).WithTool(FakeTool.Returning("ok"));
var agent = fixture.AddAgent("a1", "TestTool");
fixture.Client
.RespondsWithToolCall("TestTool", id: "c1")
.RespondsWithText("Fertig.");
await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None);
var eintraege = audit.Entries.Where(e => e.CallId == "c1").ToList();
// Genau zwei: erst der Beginn, dann der Abschluss — in dieser Reihenfolge.
Assert.Equal(2, eintraege.Count);
Assert.Equal(AuditStatus.Started, eintraege[0].Status);
Assert.Equal(AuditStatus.Ok, eintraege[1].Status);
}
[Fact]
public async Task Ein_abgeschlossener_Aufruf_gilt_nicht_als_offen()
{
var audit = new InMemoryAuditRepository();
var fixture = new EngineFixture(audit).WithTool(FakeTool.Returning("ok"));
var agent = fixture.AddAgent("a1", "TestTool");
fixture.Client
.RespondsWithToolCall("TestTool", id: "c1")
.RespondsWithText("Fertig.");
await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None);
Assert.Empty(await audit.ListOpenCallsAsync(CancellationToken.None));
}
[Fact]
public async Task Ein_Abbruch_laesst_den_Aufruf_nicht_offen_stehen()
{
// Sonst sähe jeder gewöhnliche Benutzerabbruch beim nächsten Start aus wie
// ein Absturz — und niemand sähe mehr hin.
var audit = new InMemoryAuditRepository();
var fixture = new EngineFixture(audit).WithTool(
FakeTool.Slow(TimeSpan.FromSeconds(5), name: "LangsamTool"));
var agent = fixture.AddAgent("a1", "LangsamTool");
fixture.Client
.RespondsWithToolCall("LangsamTool", id: "c1")
.AlwaysRespondsWithText("Fertig.");
var lauf = fixture.Engine.ChatAsync(agent, "Los", "inst", CancellationToken.None);
await Task.Delay(300);
fixture.Engine.AbortChat("a1");
await lauf;
Assert.Empty(await audit.ListOpenCallsAsync(CancellationToken.None));
var letzter = audit.Entries.Last(e => e.CallId == "c1");
Assert.Equal(AuditStatus.Cancelled, letzter.Status);
}
[Fact]
public async Task Ein_Aufruf_ohne_Abschluss_wird_als_offen_gefunden()
{
// Simuliert den harten Absturz: Der Beginn steht im Protokoll, mehr nicht.
var audit = new InMemoryAuditRepository();
await audit.AppendAsync(new AuditEntry
{
RunId = "r1", CallId = "c1", AgentId = "a1", Tool = "Mail",
Arguments = """{"to":"x@example.com"}""",
Status = AuditStatus.Started, OccurredAt = DateTime.Now
}, CancellationToken.None);
var offen = await audit.ListOpenCallsAsync(CancellationToken.None);
Assert.Single(offen);
Assert.Equal("Mail", offen[0].Tool);
}
[Fact]
public async Task Alteintraege_ohne_CallId_gelten_nicht_als_offen()
{
// Sonst legte der erste Start nach der Aktualisierung das ganze Altprotokoll
// zur Prüfung vor.
var audit = new InMemoryAuditRepository();
await audit.AppendAsync(new AuditEntry
{
RunId = "r1", CallId = "", AgentId = "a1", Tool = "Mail",
Status = AuditStatus.Started, OccurredAt = DateTime.Now
}, CancellationToken.None);
Assert.Empty(await audit.ListOpenCallsAsync(CancellationToken.None));
}
// ─────────────── Die Einstufung ───────────────
[Fact]
public async Task Ein_wiederholbares_Werkzeug_braucht_keine_Entscheidung()
{
var audit = new InMemoryAuditRepository();
var registry = new ToolRegistry();
registry.Register(new WiederholbaresTool());
await audit.AppendAsync(new AuditEntry
{
RunId = "r1", CallId = "c1", AgentId = "a1", Tool = "Abruf",
Arguments = "{}", Status = AuditStatus.Started, OccurredAt = DateTime.Now
}, CancellationToken.None);
var recovery = new CrashRecovery(audit, registry, TestLogging.Factory);
var faelle = await recovery.SichtenAsync(CancellationToken.None);
Assert.Single(faelle);
Assert.Equal(RecoveryVerdict.Repeatable, faelle[0].Verdict);
Assert.True(faelle[0].Erledigt);
}
[Fact]
public async Task Ein_nicht_wiederholbares_Werkzeug_wird_vorgelegt()
{
var audit = new InMemoryAuditRepository();
var registry = new ToolRegistry();
registry.Register(FakeTool.Returning("ok", "Versand")); // Vorgabe: nicht wiederholbar
await audit.AppendAsync(new AuditEntry
{
RunId = "r1", CallId = "c1", AgentId = "a1", Tool = "Versand",
Arguments = "{}", Status = AuditStatus.Started, OccurredAt = DateTime.Now
}, CancellationToken.None);
var recovery = new CrashRecovery(audit, registry, TestLogging.Factory);
var faelle = await recovery.SichtenAsync(CancellationToken.None);
Assert.Equal(RecoveryVerdict.NeedsReview, faelle[0].Verdict);
Assert.False(faelle[0].Erledigt);
}
[Fact]
public async Task Ein_verschwundenes_Werkzeug_wird_als_solches_gemeldet()
{
var audit = new InMemoryAuditRepository();
await audit.AppendAsync(new AuditEntry
{
RunId = "r1", CallId = "c1", AgentId = "a1", Tool = "GibtsNichtMehr",
Arguments = "{}", Status = AuditStatus.Started, OccurredAt = DateTime.Now
}, CancellationToken.None);
var recovery = new CrashRecovery(audit, new ToolRegistry(), TestLogging.Factory);
var faelle = await recovery.SichtenAsync(CancellationToken.None);
Assert.Equal(RecoveryVerdict.ToolUnknown, faelle[0].Verdict);
Assert.False(faelle[0].Erledigt);
}
[Fact]
public async Task Nach_geordnetem_Ende_ist_nichts_zu_sichten()
{
var recovery = new CrashRecovery(
new InMemoryAuditRepository(), new ToolRegistry(), TestLogging.Factory);
Assert.Empty(await recovery.SichtenAsync(CancellationToken.None));
}
// ─────────────── Die Auswertung (#556) ───────────────
[Fact]
public async Task Die_Kennzahlen_zaehlen_nur_abgeschlossene_Aufrufe()
{
var audit = new InMemoryAuditRepository();
var jetzt = DateTime.Now;
// Zwei vollständige Aufrufe à 100 ms, einer davon gescheitert,
// plus ein dritter, der nie endete.
foreach (var (call, status, dauer) in new[]
{
("c1", AuditStatus.Ok, 100L),
("c2", AuditStatus.Error, 100L),
})
{
await audit.AppendAsync(new AuditEntry
{
CallId = call, AgentId = "a1", Tool = "T", Status = AuditStatus.Started,
DurationMs = 0, OccurredAt = jetzt
}, CancellationToken.None);
await audit.AppendAsync(new AuditEntry
{
CallId = call, AgentId = "a1", Tool = "T", Status = status,
DurationMs = dauer, OccurredAt = jetzt
}, CancellationToken.None);
}
await audit.AppendAsync(new AuditEntry
{
CallId = "c3", AgentId = "a1", Tool = "T", Status = AuditStatus.Started,
DurationMs = 0, OccurredAt = jetzt
}, CancellationToken.None);
var stats = await audit.SummarizeToolUsageAsync("a1", null, CancellationToken.None);
var t = stats.Single(s => s.Tool == "T");
Assert.Equal(2, t.Calls);
Assert.Equal(1, t.Failed);
Assert.Equal(1, t.Unfinished);
// 200 ms auf zwei abgeschlossene Aufrufe. Zählten die drei Started-Einträge
// mit ihrer Dauer 0 mit, stünde hier 40 — das Werkzeug sähe doppelt so
// schnell aus, wie es ist.
Assert.Equal(100d, t.AverageDurationMs);
Assert.Equal(0.5d, t.FailureRate);
}
[Fact]
public async Task Die_Abfrage_je_Agent_trennt_die_Agenten()
{
var audit = new InMemoryAuditRepository();
foreach (var agent in new[] { "a1", "a1", "a2" })
{
await audit.AppendAsync(new AuditEntry
{
CallId = Guid.NewGuid().ToString("N"), AgentId = agent, Tool = "T",
Status = AuditStatus.Ok, OccurredAt = DateTime.Now
}, CancellationToken.None);
}
var fuerA1 = await audit.ListForAgentAsync("a1", null, 100, CancellationToken.None);
Assert.Equal(2, fuerA1.Count);
Assert.All(fuerA1, e => Assert.Equal("a1", e.AgentId));
}
}
/// <summary>Ein Werkzeug, das sich als wiederholbar ausgibt.</summary>
internal sealed class WiederholbaresTool : IAgentTool
{
public string Name => "Abruf";
public string Description => "Liest nur.";
public bool IsRepeatable => true;
public System.Text.Json.JsonElement InputSchema { get; } =
System.Text.Json.JsonDocument.Parse("""{"type":"object"}""").RootElement.Clone();
public Task<ToolResult> ExecuteAsync(
System.Text.Json.JsonElement input, AgentToolContext context, CancellationToken ct)
=> Task.FromResult(ToolResult.Ok("ok"));
}
@@ -37,4 +37,58 @@ internal sealed class InMemoryAuditRepository : IAuditRepository
=> Task.FromResult<IReadOnlyList<RunReceipt>>(Receipts.Where(r => r.TaskId == taskId).ToList()); => Task.FromResult<IReadOnlyList<RunReceipt>>(Receipts.Where(r => r.TaskId == taskId).ToList());
public Task<int> CountAsync(CancellationToken ct) => Task.FromResult(Entries.Count); public Task<int> CountAsync(CancellationToken ct) => Task.FromResult(Entries.Count);
/// <summary>Spiegelt die SQL-Regel: Started ohne späteren Abschluss derselben CallId.</summary>
public Task<IReadOnlyList<AuditEntry>> ListOpenCallsAsync(CancellationToken ct)
{
var alle = Entries.ToList();
var abgeschlossen = alle
.Where(e => e.Status != AuditStatus.Started && e.CallId.Length > 0)
.Select(e => e.CallId)
.ToHashSet();
var offen = alle
.Where(e => e.Status == AuditStatus.Started
&& e.CallId.Length > 0
&& !abgeschlossen.Contains(e.CallId))
.ToList();
return Task.FromResult<IReadOnlyList<AuditEntry>>(offen);
}
public Task<IReadOnlyList<AuditEntry>> ListForAgentAsync(
string agentId, DateTime? seit, int limit, CancellationToken ct)
=> Task.FromResult<IReadOnlyList<AuditEntry>>(
Entries.Where(e => e.AgentId == agentId)
.Where(e => seit is null || e.OccurredAt >= seit)
.Reverse()
.Take(limit)
.ToList());
public Task<IReadOnlyList<ToolUsageStat>> SummarizeToolUsageAsync(
string? agentId, DateTime? seit, CancellationToken ct)
=> Task.FromResult<IReadOnlyList<ToolUsageStat>>(
Entries.Where(e => agentId is null || e.AgentId == agentId)
.Where(e => seit is null || e.OccurredAt >= seit)
.GroupBy(e => (e.AgentId, e.Tool))
.Select(g =>
{
var abgeschlossen = g.Where(e => e.Status != AuditStatus.Started).ToList();
var begonnen = g.Count(e => e.Status == AuditStatus.Started);
return new ToolUsageStat
{
AgentId = g.Key.AgentId,
Tool = g.Key.Tool,
Calls = abgeschlossen.Count,
Failed = abgeschlossen.Count(e => e.Status == AuditStatus.Error),
Denied = abgeschlossen.Count(e => e.Status == AuditStatus.Denied),
Unfinished = Math.Max(0, begonnen - abgeschlossen.Count),
TotalDurationMs = abgeschlossen.Sum(e => e.DurationMs),
MaxDurationMs = g.Max(e => e.DurationMs)
};
})
.OrderByDescending(s => s.Calls)
.ToList());
} }
@@ -0,0 +1,168 @@
using System.Text.Json;
using ClawdDotNet.Core.State;
using ClawdDotNet.Core.Tools;
using ClawdDotNet.Tools.FileRW;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
namespace ClawdDotNet.Tools.Tests.FileRW;
/// <summary>
/// Die Nachschau nach einem Absturz (#555) und das atomare Schreiben (#553).
///
/// Das Dateisystem wird auch hier nicht abstrahiert — es geht gerade darum, was auf der
/// Platte steht, nachdem ein Aufruf unterbrochen wurde.
/// </summary>
public sealed class NachschauTests : IDisposable
{
private readonly string _root;
private readonly FileRWTool _tool = new();
public NachschauTests()
{
_root = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"), "Workspace");
Directory.CreateDirectory(_root);
}
public void Dispose()
{
try { Directory.Delete(Path.GetDirectoryName(_root)!, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}
private AgentToolContext Context() => new(
"a1", "inst",
new Dictionary<string, object?>(),
new NullStateStore(),
NullLogger.Instance,
CancellationToken.None,
WorkspacePath: _root);
private Task<ProbeFinding> Probe(string json)
=> _tool.ProbeAsync(JsonDocument.Parse(json).RootElement, Context(), CancellationToken.None);
private Task<ToolResult> Run(string json)
=> _tool.ExecuteAsync(JsonDocument.Parse(json).RootElement, Context(), CancellationToken.None);
// ═══════════════════ #553: atomar schreiben ═══════════════════
[Fact]
public async Task Schreiben_hinterlaesst_keine_Nebendateien()
{
var result = await Run("""{"action":"write","path":"a.txt","content":"Inhalt"}""");
result.Success.ShouldBeTrue();
// Das atomare Schreiben geht über eine Nebendatei — die muss danach weg sein.
Directory.GetFiles(_root).ShouldHaveSingleItem();
(await File.ReadAllTextAsync(Path.Combine(_root, "a.txt"))).ShouldBe("Inhalt");
}
[Fact]
public async Task Ueberschreiben_ersetzt_den_Inhalt_vollstaendig()
{
await Run("""{"action":"write","path":"a.txt","content":"ein sehr langer alter Inhalt"}""");
await Run("""{"action":"write","path":"a.txt","content":"kurz"}""");
// Beim Ersetzen darf kein Rest der alten, längeren Fassung stehenbleiben.
(await File.ReadAllTextAsync(Path.Combine(_root, "a.txt"))).ShouldBe("kurz");
}
// ═══════════════════ #555: Nachschau bei write ═══════════════════
[Fact]
public async Task Fehlende_Datei_heisst_nichts_geschehen()
{
var befund = await Probe("""{"action":"write","path":"nie.txt","content":"x"}""");
befund.Outcome.ShouldBe(ProbeOutcome.NotStarted);
}
[Fact]
public async Task Der_beabsichtigte_Inhalt_heisst_durchgekommen()
{
await Run("""{"action":"write","path":"a.txt","content":"fertig"}""");
var befund = await Probe("""{"action":"write","path":"a.txt","content":"fertig"}""");
befund.Outcome.ShouldBe(ProbeOutcome.Complete);
}
[Fact]
public async Task Der_alte_Inhalt_heisst_nicht_durchgekommen()
{
await Run("""{"action":"write","path":"a.txt","content":"alt"}""");
// So sähe es aus, wenn der Absturz vor dem Ersetzen kam.
var befund = await Probe("""{"action":"write","path":"a.txt","content":"neu"}""");
befund.Outcome.ShouldBe(ProbeOutcome.NotStarted);
}
// ═══════════════════ #555: Nachschau bei append ═══════════════════
[Fact]
public async Task Vollstaendig_angehaengter_Text_wird_erkannt()
{
await Run("""{"action":"write","path":"log.txt","content":"Zeile 1\n"}""");
await Run("""{"action":"append","path":"log.txt","content":"Zeile 2\n"}""");
var befund = await Probe("""{"action":"append","path":"log.txt","content":"Zeile 2\n"}""");
befund.Outcome.ShouldBe(ProbeOutcome.Complete);
}
[Fact]
public async Task Ein_halb_angehaengter_Text_wird_als_Riss_erkannt()
{
// Anhängen ist nicht atomar — genau dieser Zustand kann wirklich entstehen.
// Hier wird er von Hand hergestellt, weil ein echter Riss nicht reproduzierbar ist.
var pfad = Path.Combine(_root, "log.txt");
await File.WriteAllTextAsync(pfad, "Zeile 1\nZeile 2 ist nur halb ang");
var befund = await Probe(
"""{"action":"append","path":"log.txt","content":"Zeile 2 ist nur halb angekommen\n"}""");
befund.Outcome.ShouldBe(ProbeOutcome.Partial);
befund.Evidence.ShouldContain("von");
}
[Fact]
public async Task Fehlt_der_Text_ganz_gilt_der_Aufruf_als_nicht_geschehen()
{
await Run("""{"action":"write","path":"log.txt","content":"Zeile 1\n"}""");
var befund = await Probe("""{"action":"append","path":"log.txt","content":"Zeile 2\n"}""");
befund.Outcome.ShouldBe(ProbeOutcome.NotStarted);
}
// ═══════════════════ Die Grenzen der Nachschau ═══════════════════
[Theory]
[InlineData("read")]
[InlineData("delete")]
[InlineData("list")]
public async Task Fuer_andere_Aktionen_wird_nichts_behauptet(string aktion)
{
var befund = await Probe($$"""{"action":"{{aktion}}","path":"a.txt"}""");
befund.Outcome.ShouldBe(ProbeOutcome.Unknown);
}
[Fact]
public async Task Ein_unbrauchbarer_Pfad_ergibt_Unbekannt_statt_einer_Ausnahme()
{
// Die Nachschau darf nie werfen: Ein Fehlschlag der Nachschau ist eine Aussage
// über die Nachschau, nicht über die Welt.
var befund = await Probe("""{"action":"write","path":"../../ausserhalb.txt","content":"x"}""");
befund.Outcome.ShouldBe(ProbeOutcome.Unknown);
}
}
internal sealed class NullStateStore : IStateStore
{
public Task<string?> GetAsync(string key, CancellationToken ct) => Task.FromResult<string?>(null);
public Task SetAsync(string key, string value, CancellationToken ct) => Task.CompletedTask;
public Task DeleteAsync(string key, CancellationToken ct) => Task.CompletedTask;
}