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;
+}