diff --git a/src/ClawdDotNet.Core/Audit/AuditModels.cs b/src/ClawdDotNet.Core/Audit/AuditModels.cs index 86cb984..1982187 100644 --- a/src/ClawdDotNet.Core/Audit/AuditModels.cs +++ b/src/ClawdDotNet.Core/Audit/AuditModels.cs @@ -3,6 +3,16 @@ namespace ClawdDotNet.Core.Audit; /// Ausgang eines Tool-Aufrufs, von der Engine festgestellt. public enum AuditStatus { + /// + /// Der Aufruf beginnt jetzt (#554). Der einzige Eintrag, der vor der Wirkung + /// geschrieben wird — und der einzige, der deshalb einen harten Absturz überlebt. + /// + /// 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. + /// + Started, + /// Tool lief und lieferte ein Ergebnis. Ok, @@ -16,7 +26,18 @@ public enum AuditStatus NotFound, /// Der Aufruf wurde zur Freigabe vorgelegt (A2), nicht ausgeführt. - Staged + Staged, + + /// + /// Der Aufruf wurde unterbrochen, während er lief (#554) — Benutzerabbruch, + /// Zeitüberschreitung oder geordnetes Ende der Instanz. + /// + /// Wie beim Absturz ist die Wirkung ungewiss. Der Unterschied ist, dass wir + /// hier wissen, dass unterbrochen wurde. Ohne diesen Eintrag bliebe der + /// -Eintrag offen, und jeder gewöhnliche Abbruch sähe beim + /// nächsten Start aus wie ein Absturz — bis niemand mehr hinsieht. + /// + Cancelled } /// @@ -34,6 +55,15 @@ public sealed record AuditEntry /// Korrelations-Id des Laufs — bündelt alle Aufrufe eines Laufs. public string RunId { get; init; } = ""; + /// + /// Id des einzelnen Werkzeugaufrufs (die tool_call_id des Modells). Paart + /// den -Eintrag mit seinem Abschluss (#554). + /// + /// Leer bei Einträgen aus der Zeit vor #554 und bei solchen, die keinem + /// einzelnen Aufruf zuzuordnen sind. + /// + public string CallId { get; init; } = ""; + public string AgentId { get; init; } = ""; /// Worker-Typ: das Modell/die Engine. Getrennt von der Session (Source). @@ -93,6 +123,37 @@ public sealed record RunReceipt public DateTime OccurredAt { get; init; } } +/// +/// Kennzahlen eines Werkzeugs bei einem Agenten (#556). Aus dem Audit-Log gerechnet, +/// ohne eine einzige zusätzliche Messung. +/// +public sealed record ToolUsageStat +{ + public string AgentId { get; init; } = ""; + public string Tool { get; init; } = ""; + + /// Abgeschlossene Aufrufe — zählt nicht mit. + public int Calls { get; init; } + + public int Failed { get; init; } + public int Denied { get; init; } + + /// + /// Aufrufe, die begannen und nie endeten. Nicht dasselbe wie gescheitert: Hier fehlt + /// die Auskunft, statt dass sie negativ wäre. + /// + public int Unfinished { get; init; } + + public long TotalDurationMs { get; init; } + public long MaxDurationMs { get; init; } + + /// Mittlere Dauer. 0, wenn nichts abgeschlossen wurde. + public double AverageDurationMs => Calls == 0 ? 0 : (double)TotalDurationMs / Calls; + + /// Anteil der gescheiterten an den abgeschlossenen Aufrufen, 0 bis 1. + public double FailureRate => Calls == 0 ? 0 : (double)Failed / Calls; +} + /// Bekannte Session-/Kanal-Bezeichner für die Herkunft. Deckt sich mit /// ChatSource; steht für „nicht bekannt", nicht für geraten. public static class AuditSource diff --git a/src/ClawdDotNet.Core/Audit/CrashRecovery.cs b/src/ClawdDotNet.Core/Audit/CrashRecovery.cs new file mode 100644 index 0000000..cd4cac0 --- /dev/null +++ b/src/ClawdDotNet.Core/Audit/CrashRecovery.cs @@ -0,0 +1,192 @@ +using System.Text.Json; +using ClawdDotNet.Core.Tools; +using Microsoft.Extensions.Logging; + +namespace ClawdDotNet.Core.Audit; + +/// +/// Wie mit einem Aufruf zu verfahren ist, der beim Absturz lief. +/// +public enum RecoveryVerdict +{ + /// + /// Das Werkzeug gibt sich als wiederholbar aus. Ein zweiter, gleicher Aufruf + /// führt zum selben Zustand — ob der erste wirkte, muss deshalb niemand wissen. + /// + Repeatable, + + /// + /// Die Wirkung ist ungewiss und eine Wiederholung nicht unbedenklich. Gehört einem + /// Menschen vorgelegt, nicht automatisch entschieden. + /// + NeedsReview, + + /// + /// Das Werkzeug gibt es nicht mehr — umbenannt, entfernt, oder dem Agenten + /// entzogen. Wiederholen ginge gar nicht; hier bleibt nur der Hinweis. + /// + ToolUnknown +} + +/// +/// Ein Aufruf, der beim Absturz lief, mit seiner Einstufung — und, falls das Werkzeug +/// nachsehen konnte, mit dem Befund der Nachschau (#555). +/// +public sealed record RecoveryCase( + AuditEntry Entry, + RecoveryVerdict Verdict, + string Reason, + ProbeFinding? Probe = null) +{ + /// + /// Ob dieser Fall ohne menschliche Entscheidung erledigt ist. + /// + /// Zwei Wege führen dahin: Das Werkzeug ist wiederholbar, oder die Nachschau + /// hat die Ungewissheit aufgelöst. Ein heißt, + /// die Wirkung ist eingetreten — nichts zu tun. Ein + /// heißt, sie ist es nicht — der Aufruf kann gefahrlos wiederholt werden. + /// + /// gehört ausdrücklich nicht dazu. + /// Ein halb angehängter Text ist genau der Fall, den niemand automatisch + /// geraderücken sollte. + /// + public bool Erledigt => + Verdict == RecoveryVerdict.Repeatable || + Probe?.Outcome is ProbeOutcome.Complete or ProbeOutcome.NotStarted; +} + +/// +/// Sichtet nach dem Start, was beim letzten Mal offen geblieben ist (#554). +/// +/// Was dieser Dienst kann und was nicht: Er findet Aufrufe, die begonnen +/// und nie geendet haben, und stuft sie ein. Er kann nicht 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. +/// +/// Er führt deshalb auch nichts aus. Er sichtet und berichtet; was geschieht, +/// entscheidet der Aufrufer. +/// +public sealed class CrashRecovery +{ + private readonly IAuditRepository _audit; + private readonly ToolRegistry _tools; + private readonly ILogger _logger; + + /// + /// 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. + /// + private readonly Func? _kontextFuer; + + public CrashRecovery( + IAuditRepository audit, + ToolRegistry tools, + ILoggerFactory loggerFactory, + Func? kontextFuer = null) + { + _audit = audit; + _tools = tools; + _logger = loggerFactory.CreateLogger("ClawdDotNet.Core.Audit.CrashRecovery"); + _kontextFuer = kontextFuer; + } + + /// + /// Sichtet die offenen Aufrufe. Leere Liste heißt: Das letzte Ende war geordnet. + /// + public async Task> 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(); + 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; + } + + /// + /// Fragt das Werkzeug, was von dem Aufruf übrig ist — sofern es das beantworten kann + /// (#555). Ein Werkzeug ohne Nachschau ist der Normalfall, kein Mangel. + /// + private async Task 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."); + } +} diff --git a/src/ClawdDotNet.Core/Audit/IAuditRepository.cs b/src/ClawdDotNet.Core/Audit/IAuditRepository.cs index 508f258..d4fe01e 100644 --- a/src/ClawdDotNet.Core/Audit/IAuditRepository.cs +++ b/src/ClawdDotNet.Core/Audit/IAuditRepository.cs @@ -26,4 +26,25 @@ public interface IAuditRepository /// Zahl der Log-Einträge insgesamt. Task CountAsync(CancellationToken ct); + + /// + /// Aufrufe, die begonnen haben und nie abgeschlossen wurden (#554) — also + /// ohne späteren Eintrag mit derselben + /// . + /// + /// 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. + /// + Task> ListOpenCallsAsync(CancellationToken ct); + + /// Die jüngsten Aufrufe eines Agenten, optional ab einem Zeitpunkt (#556). + Task> ListForAgentAsync( + string agentId, DateTime? seit, int limit, CancellationToken ct); + + /// + /// Kennzahlen je Agent und Werkzeug (#556): wie oft, wie oft vergeblich, wie lange. + /// + Task> SummarizeToolUsageAsync( + string? agentId, DateTime? seit, CancellationToken ct); } diff --git a/src/ClawdDotNet.Core/Audit/SqliteAuditRepository.cs b/src/ClawdDotNet.Core/Audit/SqliteAuditRepository.cs index fda88f0..1dc9011 100644 --- a/src/ClawdDotNet.Core/Audit/SqliteAuditRepository.cs +++ b/src/ClawdDotNet.Core/Audit/SqliteAuditRepository.cs @@ -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)); } + /// + /// Offene Aufrufe (#554): ein Started, zu dem kein späterer Eintrag mit + /// derselben CallId existiert. + /// + /// 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. + /// + public async Task> 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> 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); + } + + /// + /// Kennzahlen je Agent und Werkzeug (#556). + /// + /// Die Dauer wird nur über abgeschlossene Aufrufe gemittelt. Ein + /// Started-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. + /// + public async Task> 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(); + 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(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() diff --git a/src/ClawdDotNet.Core/Engine/AgentEngine.cs b/src/ClawdDotNet.Core/Engine/AgentEngine.cs index 9b3de91..ff56fc7 100644 --- a/src/ClawdDotNet.Core/Engine/AgentEngine.cs +++ b/src/ClawdDotNet.Core/Engine/AgentEngine.cs @@ -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 /// 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) { diff --git a/src/ClawdDotNet.Core/Storage/SqliteStorage.cs b/src/ClawdDotNet.Core/Storage/SqliteStorage.cs index 582943f..2738853 100644 --- a/src/ClawdDotNet.Core/Storage/SqliteStorage.cs +++ b/src/ClawdDotNet.Core/Storage/SqliteStorage.cs @@ -202,7 +202,10 @@ public sealed class SqliteStorage Status TEXT NOT NULL, Summary TEXT NOT NULL DEFAULT '', 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 @@ -261,5 +264,55 @@ public sealed class SqliteStorage """; cmd.ExecuteNonQuery(); + + Nachruesten(connection); + } + + /// + /// Spalten, die nach der ersten Fassung einer Tabelle dazugekommen sind. + /// + /// CREATE TABLE IF NOT EXISTS allein genügt dafür nicht: Bei einer + /// bestehenden Datenbank tut die Anweisung gar nichts, die neue Spalte fehlt, und + /// erst das nächste INSERT, das sie benennt, scheitert — im Betrieb, beim + /// Anwender, nicht beim Entwickler mit der frischen Datei. + /// + 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(); + } + + /// + /// Ergänzt eine Spalte, falls sie fehlt. Ist sie da, geschieht nichts. + /// + /// SQLite kennt kein ADD COLUMN IF NOT EXISTS, deshalb wird erst + /// nachgesehen. Die Vorgabe ist Pflicht: Eine NOT NULL-Spalte ohne + /// Vorgabewert lässt sich einer gefüllten Tabelle nicht hinzufügen. + /// + 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(); } } diff --git a/src/ClawdDotNet.Core/Tools/IAgentTool.cs b/src/ClawdDotNet.Core/Tools/IAgentTool.cs index a49fe4b..2995470 100644 --- a/src/ClawdDotNet.Core/Tools/IAgentTool.cs +++ b/src/ClawdDotNet.Core/Tools/IAgentTool.cs @@ -23,6 +23,25 @@ public interface IAgentTool /// bool IsParallelSafe => false; + /// + /// Ob ein zweiter, gleicher Aufruf schadlos bleibt (#554). + /// + /// 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. + /// + /// Nicht zu verwechseln mit . 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. + /// + /// Vorgabe false, aus demselben Grund wie oben: Wer sich irrt, versendet + /// die Mail zweimal. + /// + bool IsRepeatable => false; + Task ExecuteAsync( JsonElement input, AgentToolContext context, diff --git a/src/ClawdDotNet.Core/Tools/IToolStateProbe.cs b/src/ClawdDotNet.Core/Tools/IToolStateProbe.cs new file mode 100644 index 0000000..cbbd20b --- /dev/null +++ b/src/ClawdDotNet.Core/Tools/IToolStateProbe.cs @@ -0,0 +1,62 @@ +using System.Text.Json; + +namespace ClawdDotNet.Core.Tools; + +/// Was die Nachschau über einen unterbrochenen Aufruf ergeben hat. +public enum ProbeOutcome +{ + /// + /// Nicht feststellbar — und das ist eine vollwertige Antwort, kein Fehlschlag. + /// + /// 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. + /// + Unknown, + + /// Nachweislich nichts geschehen — der Aufruf kam nicht bis zur Wirkung. + NotStarted, + + /// Die Wirkung ist eingetreten, aber unvollständig. + Partial, + + /// Die Wirkung ist vollständig eingetreten. Wiederholen wäre überflüssig. + Complete +} + +/// +/// Ein Befund der Nachschau. sagt, woran es erkannt +/// wurde — der Befund ist Beleg, nicht Urteil, und wer ihn liest, soll ihn beurteilen +/// können statt ihm glauben zu müssen. +/// +public sealed record ProbeFinding(ProbeOutcome Outcome, string Evidence) +{ + public static ProbeFinding Unknown(string grund) => new(ProbeOutcome.Unknown, grund); +} + +/// +/// Optional von einem Werkzeug zu bedienen (#555): Nach einem Absturz die Welt befragen, +/// wie weit ein unterbrochener Aufruf gekommen ist. +/// +/// 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. +/// +/// Was hier nicht hingehört: 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 finish_reason (#548). +/// +public interface IToolStateProbe +{ + /// + /// Sieht nach, was der Aufruf mit diesen Argumenten hinterlassen hat. + /// + /// Darf nichts verändern. Wirft nicht: Was sich nicht feststellen lässt, wird + /// als gemeldet, mit Begründung. + /// + Task ProbeAsync(JsonElement arguments, AgentToolContext context, CancellationToken ct); +} diff --git a/src/ClawdDotNet.Tools.FileRW/FileRWTool.cs b/src/ClawdDotNet.Tools.FileRW/FileRWTool.cs index b9662ad..ccd7b9c 100644 --- a/src/ClawdDotNet.Tools.FileRW/FileRWTool.cs +++ b/src/ClawdDotNet.Tools.FileRW/FileRWTool.cs @@ -1,12 +1,13 @@ using System.Collections.Concurrent; using System.Text; using System.Text.Json; +using ClawdDotNet.Core.Storage; using ClawdDotNet.Core.Tools; using Microsoft.Extensions.Logging; namespace ClawdDotNet.Tools.FileRW; -public sealed class FileRWTool : IAgentTool +public sealed class FileRWTool : IAgentTool, IToolStateProbe { /// /// Per-Datei Locking: Verhindert Race Conditions wenn mehrere Agenten @@ -200,6 +201,88 @@ public sealed class FileRWTool : IAgentTool return []; } + /// + /// Nachschau nach einem Absturz (#555). Sieht nach, was von einem unterbrochenen + /// Aufruf übrig ist — verändert nichts. + /// + /// Wie weit die Auskunft trägt, hängt an der Aktion: + /// + /// write — 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. + /// append — 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. + /// alles andere — . Lieber schweigen als + /// raten. + /// + /// + public async Task 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) { var relativePath = input.TryGetProperty("path", out var p) ? p.GetString() : ""; @@ -268,7 +351,12 @@ public sealed class FileRWTool : IAgentTool var dir = Path.GetDirectoryName(path); 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)}"); } finally @@ -286,6 +374,10 @@ public sealed class FileRWTool : IAgentTool await fileLock.WaitAsync(ct); 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); return ToolResult.Ok($"[{workspace} Workspace] Inhalt erfolgreich angehängt an: {workspace}:/{Path.GetRelativePath(rootPath, path)}"); } diff --git a/src/ClawdDotNet.Tools.WebFetch/WebFetchTool.cs b/src/ClawdDotNet.Tools.WebFetch/WebFetchTool.cs index 6f54581..7c35f56 100644 --- a/src/ClawdDotNet.Tools.WebFetch/WebFetchTool.cs +++ b/src/ClawdDotNet.Tools.WebFetch/WebFetchTool.cs @@ -29,6 +29,13 @@ public sealed class WebFetchTool : IAgentTool /// public bool IsParallelSafe => true; + /// + /// 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. + /// + public bool IsRepeatable => true; + public JsonElement InputSchema { get; } = JsonDocument.Parse(""" { "type": "object", diff --git a/tests/ClawdDotNet.Core.Tests/Audit/EngineAuditTests.cs b/tests/ClawdDotNet.Core.Tests/Audit/EngineAuditTests.cs index 4fd1aeb..6a98e53 100644 --- a/tests/ClawdDotNet.Core.Tests/Audit/EngineAuditTests.cs +++ b/tests/ClawdDotNet.Core.Tests/Audit/EngineAuditTests.cs @@ -8,6 +8,15 @@ namespace ClawdDotNet.Core.Tests.Audit; /// 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 /// ihrem eigenen Wissen, nicht aus dem Tool-Ergebnis. +/// +/// Seit #554 erzeugt ein ausgefuehrter Aufruf ZWEI Eintraege: erst +/// 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. +/// +/// Ein Aufruf, der gar nicht erst laeuft (verweigert, unbekannt, vorgelegt), +/// hat weiterhin genau einen Eintrag: Der Beginn wird erst nach Rechtepruefung und +/// Staging geschrieben. /// public sealed class EngineAuditTests { @@ -27,9 +36,17 @@ public sealed class EngineAuditTests await fixture.Engine.RunAsync(agent, "los", "test-instance", default); 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.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] @@ -42,7 +59,7 @@ public sealed class EngineAuditTests 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.Model.ShouldBe("test/model"); entry.Source.ShouldBe("task"); @@ -74,7 +91,7 @@ public sealed class EngineAuditTests 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.Summary.ShouldContain("kaputt"); } @@ -108,7 +125,7 @@ public sealed class EngineAuditTests await fixture.Engine.RunAsync(agent, "los", "test-instance", default); 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] diff --git a/tests/ClawdDotNet.Core.Tests/Audit/WiederaufnahmeTests.cs b/tests/ClawdDotNet.Core.Tests/Audit/WiederaufnahmeTests.cs new file mode 100644 index 0000000..951d435 --- /dev/null +++ b/tests/ClawdDotNet.Core.Tests/Audit/WiederaufnahmeTests.cs @@ -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; + +/// +/// Wiederaufnahme nach hartem Absturz (#554) und die Auswertung des Protokolls (#556). +/// +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)); + } +} + +/// Ein Werkzeug, das sich als wiederholbar ausgibt. +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 ExecuteAsync( + System.Text.Json.JsonElement input, AgentToolContext context, CancellationToken ct) + => Task.FromResult(ToolResult.Ok("ok")); +} diff --git a/tests/ClawdDotNet.Core.Tests/Infrastructure/InMemoryAuditRepository.cs b/tests/ClawdDotNet.Core.Tests/Infrastructure/InMemoryAuditRepository.cs index 48dc4e4..84e60a9 100644 --- a/tests/ClawdDotNet.Core.Tests/Infrastructure/InMemoryAuditRepository.cs +++ b/tests/ClawdDotNet.Core.Tests/Infrastructure/InMemoryAuditRepository.cs @@ -37,4 +37,58 @@ internal sealed class InMemoryAuditRepository : IAuditRepository => Task.FromResult>(Receipts.Where(r => r.TaskId == taskId).ToList()); public Task CountAsync(CancellationToken ct) => Task.FromResult(Entries.Count); + + /// Spiegelt die SQL-Regel: Started ohne späteren Abschluss derselben CallId. + public Task> 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>(offen); + } + + public Task> ListForAgentAsync( + string agentId, DateTime? seit, int limit, CancellationToken ct) + => Task.FromResult>( + Entries.Where(e => e.AgentId == agentId) + .Where(e => seit is null || e.OccurredAt >= seit) + .Reverse() + .Take(limit) + .ToList()); + + public Task> SummarizeToolUsageAsync( + string? agentId, DateTime? seit, CancellationToken ct) + => Task.FromResult>( + 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()); } diff --git a/tests/ClawdDotNet.Tools.Tests/FileRW/NachschauTests.cs b/tests/ClawdDotNet.Tools.Tests/FileRW/NachschauTests.cs new file mode 100644 index 0000000..eea5097 --- /dev/null +++ b/tests/ClawdDotNet.Tools.Tests/FileRW/NachschauTests.cs @@ -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; + +/// +/// 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. +/// +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(), + new NullStateStore(), + NullLogger.Instance, + CancellationToken.None, + WorkspacePath: _root); + + private Task Probe(string json) + => _tool.ProbeAsync(JsonDocument.Parse(json).RootElement, Context(), CancellationToken.None); + + private Task 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 GetAsync(string key, CancellationToken ct) => Task.FromResult(null); + public Task SetAsync(string key, string value, CancellationToken ct) => Task.CompletedTask; + public Task DeleteAsync(string key, CancellationToken ct) => Task.CompletedTask; +}