Wiederaufnahme nach Absturz, Nachschau und Auswertung (#553-#556)

#553 FileRW schreibt atomar. Die Vorkehrung lag seit #241 im selben
Repository und wurde ueberall benutzt - Einstellungen, Sicherungen,
Chat-Zustand, Identity.md - nur der Weg, ueber den Agenten ihre
Arbeitsergebnisse ablegen, ging daran vorbei. Damit stand das Verhaeltnis
auf dem Kopf: geschuetzt war das Eigene, ungeschuetzt das Ergebnis.
append bleibt bewusst nicht atomar; das ginge nur, indem die ganze Datei
neu geschrieben wird, und bei einer grossen Protokolldatei waere das der
falsche Handel.

#554 Wiederaufnahme. Neuer Eintrag AuditStatus.Started, geschrieben VOR
der Wirkung und als einziger nicht "best effort" - auf ihn stuetzt sich
alles Weitere, also laeuft der Aufruf gar nicht erst, wenn er nicht
durchkommt. Neue Kennzeichnung IsRepeatable, getrennt von IsParallelSafe:
Das eine heisst "stoert keinen gleichzeitigen Aufruf", das andere
"richtet zweimal keinen Schaden an". CrashRecovery sichtet beim Start,
was offen blieb, und stuft ein - fuehrt aber nichts aus. Es sichtet und
berichtet; entscheiden soll der Aufrufer.

Dabei kippte eine alte Regel: Bisher wurde ein Abbruch bewusst NICHT
protokolliert. Das war richtig, solange es keinen Eintrag vor dem Aufruf
gab. Jetzt saehe sonst jeder Benutzerabbruch beim naechsten Start aus wie
ein Absturz - bis niemand mehr hinsieht. Deshalb AuditStatus.Cancelled.

#555 Optionale Zustandspruefung IToolStateProbe, umgesetzt in FileRW.
Unknown ist eine vollwertige Antwort, kein Fehlschlag: Ob ein SMTP-Server
angenommen hat, laesst sich nicht feststellen, und ein Werkzeug, das hier
raet, waere schlimmer als eines, das schweigt. Partial gilt ausdruecklich
nicht als geklaert - einen halb angehaengten Text sollte niemand
automatisch geraderuecken.

#556 Auswertung. ListForAgentAsync und SummarizeToolUsageAsync. Die Dauer
wird nur ueber abgeschlossene Aufrufe gemittelt; zaehlten die
Started-Eintraege mit ihrer Dauer 0 mit, saehe jedes Werkzeug doppelt so
schnell aus, wie es ist.

Nebenbei: SqliteStorage kann jetzt Spalten nachruesten. CREATE TABLE IF
NOT EXISTS allein genuegte nicht - bei einer bestehenden Datenbank tut die
Anweisung nichts, und erst das naechste INSERT waere gescheitert, im
Betrieb beim Anwender.

Vier Audit-Tests angepasst: Ein ausgefuehrter Aufruf erzeugt jetzt zwei
Eintraege statt einem. Ein Aufruf, der gar nicht erst laeuft - verweigert,
unbekannt, vorgelegt - hat weiterhin genau einen; der Beginn wird erst
nach Rechtepruefung und Staging geschrieben.

23 neue Tests, 912 gesamt, keine Regression. Build ohne Warnungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-12 21:48:18 +02:00
co-authored by Claude Opus 5
parent fb4b218e45
commit b568a45de3
14 changed files with 1177 additions and 30 deletions
@@ -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.
///
/// <para>Seit #554 erzeugt ein ausgefuehrter Aufruf ZWEI Eintraege: erst
/// <see cref="AuditStatus.Started"/> vor der Wirkung, dann den Ausgang. Die Tests
/// hier filtern deshalb auf den Abschluss, wo es um den Ausgang geht — dass der
/// Beginn mitgeschrieben wird, pruefen die WiederaufnahmeTests.</para>
///
/// <para>Ein Aufruf, der gar nicht erst laeuft (verweigert, unbekannt, vorgelegt),
/// hat weiterhin genau einen Eintrag: Der Beginn wird erst nach Rechtepruefung und
/// Staging geschrieben.</para>
/// </summary>
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]
@@ -0,0 +1,262 @@
using ClawdDotNet.Core.Audit;
using ClawdDotNet.Core.Engine;
using ClawdDotNet.Core.Tests.Infrastructure;
using ClawdDotNet.Core.Tools;
namespace ClawdDotNet.Core.Tests.Audit;
/// <summary>
/// Wiederaufnahme nach hartem Absturz (#554) und die Auswertung des Protokolls (#556).
/// </summary>
public class WiederaufnahmeTests
{
// ─────────────── Der "angefangen"-Eintrag ───────────────
[Fact]
public async Task Vor_dem_Werkzeug_steht_ein_Eintrag_im_Protokoll()
{
var audit = new InMemoryAuditRepository();
var fixture = new EngineFixture(audit).WithTool(FakeTool.Returning("ok"));
var agent = fixture.AddAgent("a1", "TestTool");
fixture.Client
.RespondsWithToolCall("TestTool", id: "c1")
.RespondsWithText("Fertig.");
await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None);
var eintraege = audit.Entries.Where(e => e.CallId == "c1").ToList();
// Genau zwei: erst der Beginn, dann der Abschluss — in dieser Reihenfolge.
Assert.Equal(2, eintraege.Count);
Assert.Equal(AuditStatus.Started, eintraege[0].Status);
Assert.Equal(AuditStatus.Ok, eintraege[1].Status);
}
[Fact]
public async Task Ein_abgeschlossener_Aufruf_gilt_nicht_als_offen()
{
var audit = new InMemoryAuditRepository();
var fixture = new EngineFixture(audit).WithTool(FakeTool.Returning("ok"));
var agent = fixture.AddAgent("a1", "TestTool");
fixture.Client
.RespondsWithToolCall("TestTool", id: "c1")
.RespondsWithText("Fertig.");
await fixture.Engine.RunAsync(agent, "Los", "inst", CancellationToken.None);
Assert.Empty(await audit.ListOpenCallsAsync(CancellationToken.None));
}
[Fact]
public async Task Ein_Abbruch_laesst_den_Aufruf_nicht_offen_stehen()
{
// Sonst sähe jeder gewöhnliche Benutzerabbruch beim nächsten Start aus wie
// ein Absturz — und niemand sähe mehr hin.
var audit = new InMemoryAuditRepository();
var fixture = new EngineFixture(audit).WithTool(
FakeTool.Slow(TimeSpan.FromSeconds(5), name: "LangsamTool"));
var agent = fixture.AddAgent("a1", "LangsamTool");
fixture.Client
.RespondsWithToolCall("LangsamTool", id: "c1")
.AlwaysRespondsWithText("Fertig.");
var lauf = fixture.Engine.ChatAsync(agent, "Los", "inst", CancellationToken.None);
await Task.Delay(300);
fixture.Engine.AbortChat("a1");
await lauf;
Assert.Empty(await audit.ListOpenCallsAsync(CancellationToken.None));
var letzter = audit.Entries.Last(e => e.CallId == "c1");
Assert.Equal(AuditStatus.Cancelled, letzter.Status);
}
[Fact]
public async Task Ein_Aufruf_ohne_Abschluss_wird_als_offen_gefunden()
{
// Simuliert den harten Absturz: Der Beginn steht im Protokoll, mehr nicht.
var audit = new InMemoryAuditRepository();
await audit.AppendAsync(new AuditEntry
{
RunId = "r1", CallId = "c1", AgentId = "a1", Tool = "Mail",
Arguments = """{"to":"x@example.com"}""",
Status = AuditStatus.Started, OccurredAt = DateTime.Now
}, CancellationToken.None);
var offen = await audit.ListOpenCallsAsync(CancellationToken.None);
Assert.Single(offen);
Assert.Equal("Mail", offen[0].Tool);
}
[Fact]
public async Task Alteintraege_ohne_CallId_gelten_nicht_als_offen()
{
// Sonst legte der erste Start nach der Aktualisierung das ganze Altprotokoll
// zur Prüfung vor.
var audit = new InMemoryAuditRepository();
await audit.AppendAsync(new AuditEntry
{
RunId = "r1", CallId = "", AgentId = "a1", Tool = "Mail",
Status = AuditStatus.Started, OccurredAt = DateTime.Now
}, CancellationToken.None);
Assert.Empty(await audit.ListOpenCallsAsync(CancellationToken.None));
}
// ─────────────── Die Einstufung ───────────────
[Fact]
public async Task Ein_wiederholbares_Werkzeug_braucht_keine_Entscheidung()
{
var audit = new InMemoryAuditRepository();
var registry = new ToolRegistry();
registry.Register(new WiederholbaresTool());
await audit.AppendAsync(new AuditEntry
{
RunId = "r1", CallId = "c1", AgentId = "a1", Tool = "Abruf",
Arguments = "{}", Status = AuditStatus.Started, OccurredAt = DateTime.Now
}, CancellationToken.None);
var recovery = new CrashRecovery(audit, registry, TestLogging.Factory);
var faelle = await recovery.SichtenAsync(CancellationToken.None);
Assert.Single(faelle);
Assert.Equal(RecoveryVerdict.Repeatable, faelle[0].Verdict);
Assert.True(faelle[0].Erledigt);
}
[Fact]
public async Task Ein_nicht_wiederholbares_Werkzeug_wird_vorgelegt()
{
var audit = new InMemoryAuditRepository();
var registry = new ToolRegistry();
registry.Register(FakeTool.Returning("ok", "Versand")); // Vorgabe: nicht wiederholbar
await audit.AppendAsync(new AuditEntry
{
RunId = "r1", CallId = "c1", AgentId = "a1", Tool = "Versand",
Arguments = "{}", Status = AuditStatus.Started, OccurredAt = DateTime.Now
}, CancellationToken.None);
var recovery = new CrashRecovery(audit, registry, TestLogging.Factory);
var faelle = await recovery.SichtenAsync(CancellationToken.None);
Assert.Equal(RecoveryVerdict.NeedsReview, faelle[0].Verdict);
Assert.False(faelle[0].Erledigt);
}
[Fact]
public async Task Ein_verschwundenes_Werkzeug_wird_als_solches_gemeldet()
{
var audit = new InMemoryAuditRepository();
await audit.AppendAsync(new AuditEntry
{
RunId = "r1", CallId = "c1", AgentId = "a1", Tool = "GibtsNichtMehr",
Arguments = "{}", Status = AuditStatus.Started, OccurredAt = DateTime.Now
}, CancellationToken.None);
var recovery = new CrashRecovery(audit, new ToolRegistry(), TestLogging.Factory);
var faelle = await recovery.SichtenAsync(CancellationToken.None);
Assert.Equal(RecoveryVerdict.ToolUnknown, faelle[0].Verdict);
Assert.False(faelle[0].Erledigt);
}
[Fact]
public async Task Nach_geordnetem_Ende_ist_nichts_zu_sichten()
{
var recovery = new CrashRecovery(
new InMemoryAuditRepository(), new ToolRegistry(), TestLogging.Factory);
Assert.Empty(await recovery.SichtenAsync(CancellationToken.None));
}
// ─────────────── Die Auswertung (#556) ───────────────
[Fact]
public async Task Die_Kennzahlen_zaehlen_nur_abgeschlossene_Aufrufe()
{
var audit = new InMemoryAuditRepository();
var jetzt = DateTime.Now;
// Zwei vollständige Aufrufe à 100 ms, einer davon gescheitert,
// plus ein dritter, der nie endete.
foreach (var (call, status, dauer) in new[]
{
("c1", AuditStatus.Ok, 100L),
("c2", AuditStatus.Error, 100L),
})
{
await audit.AppendAsync(new AuditEntry
{
CallId = call, AgentId = "a1", Tool = "T", Status = AuditStatus.Started,
DurationMs = 0, OccurredAt = jetzt
}, CancellationToken.None);
await audit.AppendAsync(new AuditEntry
{
CallId = call, AgentId = "a1", Tool = "T", Status = status,
DurationMs = dauer, OccurredAt = jetzt
}, CancellationToken.None);
}
await audit.AppendAsync(new AuditEntry
{
CallId = "c3", AgentId = "a1", Tool = "T", Status = AuditStatus.Started,
DurationMs = 0, OccurredAt = jetzt
}, CancellationToken.None);
var stats = await audit.SummarizeToolUsageAsync("a1", null, CancellationToken.None);
var t = stats.Single(s => s.Tool == "T");
Assert.Equal(2, t.Calls);
Assert.Equal(1, t.Failed);
Assert.Equal(1, t.Unfinished);
// 200 ms auf zwei abgeschlossene Aufrufe. Zählten die drei Started-Einträge
// mit ihrer Dauer 0 mit, stünde hier 40 — das Werkzeug sähe doppelt so
// schnell aus, wie es ist.
Assert.Equal(100d, t.AverageDurationMs);
Assert.Equal(0.5d, t.FailureRate);
}
[Fact]
public async Task Die_Abfrage_je_Agent_trennt_die_Agenten()
{
var audit = new InMemoryAuditRepository();
foreach (var agent in new[] { "a1", "a1", "a2" })
{
await audit.AppendAsync(new AuditEntry
{
CallId = Guid.NewGuid().ToString("N"), AgentId = agent, Tool = "T",
Status = AuditStatus.Ok, OccurredAt = DateTime.Now
}, CancellationToken.None);
}
var fuerA1 = await audit.ListForAgentAsync("a1", null, 100, CancellationToken.None);
Assert.Equal(2, fuerA1.Count);
Assert.All(fuerA1, e => Assert.Equal("a1", e.AgentId));
}
}
/// <summary>Ein Werkzeug, das sich als wiederholbar ausgibt.</summary>
internal sealed class WiederholbaresTool : IAgentTool
{
public string Name => "Abruf";
public string Description => "Liest nur.";
public bool IsRepeatable => true;
public System.Text.Json.JsonElement InputSchema { get; } =
System.Text.Json.JsonDocument.Parse("""{"type":"object"}""").RootElement.Clone();
public Task<ToolResult> ExecuteAsync(
System.Text.Json.JsonElement input, AgentToolContext context, CancellationToken ct)
=> Task.FromResult(ToolResult.Ok("ok"));
}