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:
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Per-Datei Locking: Verhindert Race Conditions wenn mehrere Agenten
|
||||
@@ -200,6 +201,88 @@ public sealed class FileRWTool : IAgentTool
|
||||
return [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Nachschau nach einem Absturz (#555). Sieht nach, was von einem unterbrochenen
|
||||
/// Aufruf übrig ist — verändert nichts.
|
||||
///
|
||||
/// <para>Wie weit die Auskunft trägt, hängt an der Aktion:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item><b>write</b> — seit #553 atomar. Entweder die alte oder die neue Fassung,
|
||||
/// nie eine halbe. Ein Vergleich mit dem beabsichtigten Inhalt sagt deshalb
|
||||
/// zuverlässig, ob der Aufruf durchkam.</item>
|
||||
/// <item><b>append</b> — nicht atomar und nicht atomar zu machen. Hier kann wirklich
|
||||
/// ein Riss entstehen, und hier lohnt die Nachschau: Steht der beabsichtigte Text
|
||||
/// vollständig am Ende, kam der Aufruf durch; steht ein Anfang davon da, riss es
|
||||
/// mittendrin.</item>
|
||||
/// <item>alles andere — <see cref="ProbeOutcome.Unknown"/>. Lieber schweigen als
|
||||
/// raten.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public async Task<ProbeFinding> ProbeAsync(JsonElement arguments, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var action = arguments.TryGetProperty("action", out var a) ? a.GetString() : null;
|
||||
|
||||
if (action is not ("write" or "append"))
|
||||
return ProbeFinding.Unknown($"Für die Aktion '{action ?? "?"}' gibt es nichts nachzusehen.");
|
||||
|
||||
var workspace = (arguments.TryGetProperty("workspace", out var w) ? w.GetString() : null) ?? "personal";
|
||||
var rootPath = workspace == "shared" ? context.SharedWorkspacePath : context.WorkspacePath;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(rootPath))
|
||||
return ProbeFinding.Unknown("Workspace-Pfad ist nicht bekannt.");
|
||||
|
||||
var path = GetAndValidatePath(arguments, Path.GetFullPath(rootPath), workspace, true, context);
|
||||
var erwartet = arguments.TryGetProperty("content", out var c) ? c.GetString() ?? "" : "";
|
||||
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return new ProbeFinding(ProbeOutcome.NotStarted,
|
||||
"Die Datei existiert nicht — geschrieben wurde nichts.");
|
||||
}
|
||||
|
||||
var vorhanden = await File.ReadAllTextAsync(path, ct);
|
||||
|
||||
if (action == "write")
|
||||
{
|
||||
return vorhanden == erwartet
|
||||
? new ProbeFinding(ProbeOutcome.Complete,
|
||||
$"Der Inhalt entspricht genau dem beabsichtigten ({erwartet.Length} Zeichen).")
|
||||
: new ProbeFinding(ProbeOutcome.NotStarted,
|
||||
"Die Datei trägt noch den alten Inhalt. Seit #553 wird atomar " +
|
||||
"geschrieben — eine halbe Fassung kann es nicht geben.");
|
||||
}
|
||||
|
||||
// append
|
||||
if (vorhanden.EndsWith(erwartet, StringComparison.Ordinal))
|
||||
{
|
||||
return new ProbeFinding(ProbeOutcome.Complete,
|
||||
$"Der beabsichtigte Text steht vollständig am Ende ({erwartet.Length} Zeichen).");
|
||||
}
|
||||
|
||||
// Wie viel vom Erwarteten steht am Ende? Das ist die Rissstelle.
|
||||
for (var laenge = Math.Min(erwartet.Length - 1, vorhanden.Length); laenge > 0; laenge--)
|
||||
{
|
||||
if (vorhanden.EndsWith(erwartet[..laenge], StringComparison.Ordinal))
|
||||
{
|
||||
return new ProbeFinding(ProbeOutcome.Partial,
|
||||
$"Angehängt wurden {laenge} von {erwartet.Length} Zeichen — " +
|
||||
"der Aufruf riss mittendrin ab.");
|
||||
}
|
||||
}
|
||||
|
||||
return new ProbeFinding(ProbeOutcome.NotStarted,
|
||||
"Vom beabsichtigten Text steht nichts am Ende der Datei.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Nie werfen: Eine gescheiterte Nachschau ist keine Aussage über die Welt,
|
||||
// sondern eine über die Nachschau.
|
||||
return ProbeFinding.Unknown($"Nachschau nicht möglich: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private string GetAndValidatePath(JsonElement input, string rootPath, string workspace, bool checkExtension, AgentToolContext context)
|
||||
{
|
||||
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)}");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user