#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>
169 lines
6.3 KiB
C#
169 lines
6.3 KiB
C#
using System.Text.Json;
|
|
using ClawdDotNet.Core.State;
|
|
using ClawdDotNet.Core.Tools;
|
|
using ClawdDotNet.Tools.FileRW;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Shouldly;
|
|
|
|
namespace ClawdDotNet.Tools.Tests.FileRW;
|
|
|
|
/// <summary>
|
|
/// Die Nachschau nach einem Absturz (#555) und das atomare Schreiben (#553).
|
|
///
|
|
/// Das Dateisystem wird auch hier nicht abstrahiert — es geht gerade darum, was auf der
|
|
/// Platte steht, nachdem ein Aufruf unterbrochen wurde.
|
|
/// </summary>
|
|
public sealed class NachschauTests : IDisposable
|
|
{
|
|
private readonly string _root;
|
|
private readonly FileRWTool _tool = new();
|
|
|
|
public NachschauTests()
|
|
{
|
|
_root = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"), "Workspace");
|
|
Directory.CreateDirectory(_root);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
try { Directory.Delete(Path.GetDirectoryName(_root)!, recursive: true); }
|
|
catch { /* Aufräumen ist Nebensache */ }
|
|
}
|
|
|
|
private AgentToolContext Context() => new(
|
|
"a1", "inst",
|
|
new Dictionary<string, object?>(),
|
|
new NullStateStore(),
|
|
NullLogger.Instance,
|
|
CancellationToken.None,
|
|
WorkspacePath: _root);
|
|
|
|
private Task<ProbeFinding> Probe(string json)
|
|
=> _tool.ProbeAsync(JsonDocument.Parse(json).RootElement, Context(), CancellationToken.None);
|
|
|
|
private Task<ToolResult> Run(string json)
|
|
=> _tool.ExecuteAsync(JsonDocument.Parse(json).RootElement, Context(), CancellationToken.None);
|
|
|
|
// ═══════════════════ #553: atomar schreiben ═══════════════════
|
|
|
|
[Fact]
|
|
public async Task Schreiben_hinterlaesst_keine_Nebendateien()
|
|
{
|
|
var result = await Run("""{"action":"write","path":"a.txt","content":"Inhalt"}""");
|
|
result.Success.ShouldBeTrue();
|
|
|
|
// Das atomare Schreiben geht über eine Nebendatei — die muss danach weg sein.
|
|
Directory.GetFiles(_root).ShouldHaveSingleItem();
|
|
(await File.ReadAllTextAsync(Path.Combine(_root, "a.txt"))).ShouldBe("Inhalt");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Ueberschreiben_ersetzt_den_Inhalt_vollstaendig()
|
|
{
|
|
await Run("""{"action":"write","path":"a.txt","content":"ein sehr langer alter Inhalt"}""");
|
|
await Run("""{"action":"write","path":"a.txt","content":"kurz"}""");
|
|
|
|
// Beim Ersetzen darf kein Rest der alten, längeren Fassung stehenbleiben.
|
|
(await File.ReadAllTextAsync(Path.Combine(_root, "a.txt"))).ShouldBe("kurz");
|
|
}
|
|
|
|
// ═══════════════════ #555: Nachschau bei write ═══════════════════
|
|
|
|
[Fact]
|
|
public async Task Fehlende_Datei_heisst_nichts_geschehen()
|
|
{
|
|
var befund = await Probe("""{"action":"write","path":"nie.txt","content":"x"}""");
|
|
|
|
befund.Outcome.ShouldBe(ProbeOutcome.NotStarted);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Der_beabsichtigte_Inhalt_heisst_durchgekommen()
|
|
{
|
|
await Run("""{"action":"write","path":"a.txt","content":"fertig"}""");
|
|
|
|
var befund = await Probe("""{"action":"write","path":"a.txt","content":"fertig"}""");
|
|
|
|
befund.Outcome.ShouldBe(ProbeOutcome.Complete);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Der_alte_Inhalt_heisst_nicht_durchgekommen()
|
|
{
|
|
await Run("""{"action":"write","path":"a.txt","content":"alt"}""");
|
|
|
|
// So sähe es aus, wenn der Absturz vor dem Ersetzen kam.
|
|
var befund = await Probe("""{"action":"write","path":"a.txt","content":"neu"}""");
|
|
|
|
befund.Outcome.ShouldBe(ProbeOutcome.NotStarted);
|
|
}
|
|
|
|
// ═══════════════════ #555: Nachschau bei append ═══════════════════
|
|
|
|
[Fact]
|
|
public async Task Vollstaendig_angehaengter_Text_wird_erkannt()
|
|
{
|
|
await Run("""{"action":"write","path":"log.txt","content":"Zeile 1\n"}""");
|
|
await Run("""{"action":"append","path":"log.txt","content":"Zeile 2\n"}""");
|
|
|
|
var befund = await Probe("""{"action":"append","path":"log.txt","content":"Zeile 2\n"}""");
|
|
|
|
befund.Outcome.ShouldBe(ProbeOutcome.Complete);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Ein_halb_angehaengter_Text_wird_als_Riss_erkannt()
|
|
{
|
|
// Anhängen ist nicht atomar — genau dieser Zustand kann wirklich entstehen.
|
|
// Hier wird er von Hand hergestellt, weil ein echter Riss nicht reproduzierbar ist.
|
|
var pfad = Path.Combine(_root, "log.txt");
|
|
await File.WriteAllTextAsync(pfad, "Zeile 1\nZeile 2 ist nur halb ang");
|
|
|
|
var befund = await Probe(
|
|
"""{"action":"append","path":"log.txt","content":"Zeile 2 ist nur halb angekommen\n"}""");
|
|
|
|
befund.Outcome.ShouldBe(ProbeOutcome.Partial);
|
|
befund.Evidence.ShouldContain("von");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Fehlt_der_Text_ganz_gilt_der_Aufruf_als_nicht_geschehen()
|
|
{
|
|
await Run("""{"action":"write","path":"log.txt","content":"Zeile 1\n"}""");
|
|
|
|
var befund = await Probe("""{"action":"append","path":"log.txt","content":"Zeile 2\n"}""");
|
|
|
|
befund.Outcome.ShouldBe(ProbeOutcome.NotStarted);
|
|
}
|
|
|
|
// ═══════════════════ Die Grenzen der Nachschau ═══════════════════
|
|
|
|
[Theory]
|
|
[InlineData("read")]
|
|
[InlineData("delete")]
|
|
[InlineData("list")]
|
|
public async Task Fuer_andere_Aktionen_wird_nichts_behauptet(string aktion)
|
|
{
|
|
var befund = await Probe($$"""{"action":"{{aktion}}","path":"a.txt"}""");
|
|
|
|
befund.Outcome.ShouldBe(ProbeOutcome.Unknown);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Ein_unbrauchbarer_Pfad_ergibt_Unbekannt_statt_einer_Ausnahme()
|
|
{
|
|
// Die Nachschau darf nie werfen: Ein Fehlschlag der Nachschau ist eine Aussage
|
|
// über die Nachschau, nicht über die Welt.
|
|
var befund = await Probe("""{"action":"write","path":"../../ausserhalb.txt","content":"x"}""");
|
|
|
|
befund.Outcome.ShouldBe(ProbeOutcome.Unknown);
|
|
}
|
|
}
|
|
|
|
internal sealed class NullStateStore : IStateStore
|
|
{
|
|
public Task<string?> GetAsync(string key, CancellationToken ct) => Task.FromResult<string?>(null);
|
|
public Task SetAsync(string key, string value, CancellationToken ct) => Task.CompletedTask;
|
|
public Task DeleteAsync(string key, CancellationToken ct) => Task.CompletedTask;
|
|
}
|