diff --git a/Services/InstanceDirectoryManager.cs b/Services/InstanceDirectoryManager.cs index 14f6d2b..e93fba9 100644 --- a/Services/InstanceDirectoryManager.cs +++ b/Services/InstanceDirectoryManager.cs @@ -1,6 +1,7 @@ using System.Text.Json; using ClawdDotNet.Core.Config; using ClawdDotNet.Core.Security; +using ClawdDotNet.Core.Storage; using ClawdDotNet.Models; namespace ClawdDotNet.Services; @@ -229,7 +230,7 @@ public sealed class InstanceDirectoryManager SaveAgentSettings(agentDir, agentConfig); // Identity.md - File.WriteAllText(Path.Combine(agentDir, "Identity.md"), + AtomicFile.WriteAllText(Path.Combine(agentDir, "Identity.md"), $""" # Identity: {agentName} @@ -246,7 +247,7 @@ public sealed class InstanceDirectoryManager """); // Soul.md - File.WriteAllText(Path.Combine(agentDir, "Soul.md"), + AtomicFile.WriteAllText(Path.Combine(agentDir, "Soul.md"), $""" # Soul: {agentName} @@ -337,12 +338,12 @@ public sealed class InstanceDirectoryManager public void SaveAgentIdentity(string agentDir, string identity) { - File.WriteAllText(Path.Combine(agentDir, "Identity.md"), identity); + AtomicFile.WriteAllText(Path.Combine(agentDir, "Identity.md"), identity); } public void SaveAgentSoul(string agentDir, string soul) { - File.WriteAllText(Path.Combine(agentDir, "Soul.md"), soul); + AtomicFile.WriteAllText(Path.Combine(agentDir, "Soul.md"), soul); } public void RemoveAgent(string instanceDir, string agentFolderName) @@ -431,17 +432,15 @@ public sealed class InstanceDirectoryManager private static void SaveJson(string path, T obj) { - var dir = Path.GetDirectoryName(path); - if (!string.IsNullOrEmpty(dir)) - Directory.CreateDirectory(dir); - - var json = JsonSerializer.Serialize(obj, JsonOpts); - File.WriteAllText(path, json); + // Atomar: Ein Absturz mitten im Schreiben soll keine halbe Datei hinterlassen. + // Genau das ist bereits passiert (TokenUsage.json.corrupt_…). + AtomicFile.WriteAllText(path, JsonSerializer.Serialize(obj, JsonOpts)); } private static T? LoadJson(string path) { - var json = File.ReadAllText(path); + // Lesen ohne den Schreiber zu blockieren — siehe AtomicFile.ReadAllText. + var json = AtomicFile.ReadAllText(path); return JsonSerializer.Deserialize(json, JsonOpts); } diff --git a/Services/SettingsManager.cs b/Services/SettingsManager.cs index 9533358..8d67edb 100644 --- a/Services/SettingsManager.cs +++ b/Services/SettingsManager.cs @@ -50,12 +50,8 @@ public sealed class SettingsManager { try { - var dir = Path.GetDirectoryName(_settingsPath); - if (!string.IsNullOrEmpty(dir)) - Directory.CreateDirectory(dir); - var json = JsonSerializer.Serialize(AppSettings, JsonOptions); - File.WriteAllText(_settingsPath, json); + ClawdDotNet.Core.Storage.AtomicFile.WriteAllText(_settingsPath, json); } catch (Exception ex) { diff --git a/docs/Konzepte-Backup-Finanz-Analyse.md b/docs/Konzepte-Backup-Finanz-Analyse.md new file mode 100644 index 0000000..691d8a8 --- /dev/null +++ b/docs/Konzepte-Backup-Finanz-Analyse.md @@ -0,0 +1,292 @@ +# Drei Konzepte: Backup, Finanzumfeld, Leistungsanalyse + +Diskussionsgrundlage, noch nicht umgesetzt. + +--- + +# 1. Backup und Wiederherstellung + +## Was überhaupt schützenswert ist + +Nicht alles im Instanzverzeichnis ist gleich wertvoll. Entscheidend ist, was sich +**nicht** wiederherstellen lässt: + +| Was | Wert | Bemerkung | +|---|---|---| +| `Identity.md`, `Soul.md` | **hoch** | Die eigentliche Arbeit an einem Agenten | +| `AgentSettings.json`, `InstanceSettings.json` | **hoch** | Tool-Zuweisungen, Budgets, Zugangsdaten | +| `state.db` → Tabelle `Memories` | **hoch** | Das Langzeitgedächtnis — über Monate gewachsen | +| `Workspace/`, `SharedWorkspace/` | hoch | Berichte, Wissensdatenbank | +| `ChatHistory.json`, `ChatContext.json` | mittel | Laufender Arbeitsstand | +| `state.db` → `RunUsage` | mittel | Kostenhistorie, Grundlage der Auswertung | +| Telegram-Session | **hoch** | Ohne sie ist ein erneuter Login mit Code nötig | +| `Logs/` | gering | Nachvollziehbarkeit, groß | +| `bin/` | keiner | Wird gebaut | + +## Problem 1: Verschlüsselte Zugangsdaten überleben den Rechner nicht ⚠️ + +Das ist eine direkte Folge von S7 und der wichtigste Punkt hier. + +DPAPI verschlüsselt im Benutzerkontext — entschlüsseln kann nur derselbe +Windows-Benutzer auf demselben Rechner. Ein Backup, das genau dann gebraucht wird, +wenn der Rechner defekt ist, enthält damit **unbrauchbare Zugangsdaten**. + +Ein Backup, das sich nicht auf einem anderen Rechner wiederherstellen lässt, erfüllt +seinen Zweck nicht. + +**Lösung:** Beim Backup werden Secrets umgeschlüsselt — von DPAPI auf eine +Passphrase (PBKDF2 zur Schlüsselableitung, AES-GCM zur Verschlüsselung). Beim +Wiederherstellen wird die Passphrase abgefragt und auf DPAPI des Zielrechners +zurückgeschlüsselt. + +Alternativ als bewusste Option: **Backup ohne Zugangsdaten**. Dann ist der Restore +unvollständig, aber die Datei ist gefahrlos ablegbar — auch auf einem NAS oder in +einer Cloud. Beide Varianten sollten anwählbar sein, mit deutlicher Kennzeichnung +im Manifest. + +## Problem 2: SQLite darf nicht einfach kopiert werden + +Mit WAL (seit dem Speicher-Fundament aktiv) stehen die jüngsten Änderungen in +`state.db-wal`, nicht in `state.db`. Wer nur die `.db` kopiert, sichert einen +veralteten und womöglich inkonsistenten Stand. + +**Richtig:** `VACUUM INTO 'ziel.db'` — erzeugt im laufenden Betrieb eine konsistente, +in sich geschlossene Kopie. Ein einzelnes SQL-Kommando, keine zusätzliche +Abhängigkeit. + +## Problem 3: JSON-Dateien werden nicht atomar geschrieben ⚠️ + +Alle Schreibvorgänge laufen über `File.WriteAllText` +([InstanceDirectoryManager.cs:439](../Services/InstanceDirectoryManager.cs#L439), +[AgentEngine.cs:783](../src/ClawdDotNet.Core/Engine/AgentEngine.cs#L783)). Ein Absturz +oder Stromausfall mitten im Schreiben hinterlässt eine abgeschnittene Datei. + +**Das ist bereits passiert:** In der Instanz `TradingTeam` liegt eine +`TokenUsage.json.corrupt_2026…` — die Fehlerbehandlung hat sie gesichert und neu +angefangen. Der Verbrauch bis dahin war weg. + +**Lösung:** In eine temporäre Datei daneben schreiben, dann `File.Replace` — das ist +auf NTFS atomar. Gehört unabhängig vom Backup repariert. + +## Vorschlag + +Ein `BackupService`, der ein ZIP mit Manifest erzeugt: + +``` +backup_Instance-TradingTeam_2026-07-28_1400.zip +├── manifest.json ← Version, Zeitpunkt, Instanz, Prüfsummen, +│ ob Zugangsdaten enthalten sind +├── state.db ← via VACUUM INTO, konsistent +├── InstanceSettings.json +└── Agents/… +``` + +Eigenschaften: + +- **Planbar** über den vorhandenen ToolJob-Mechanismus (Cron) — kein neuer Scheduler. +- **Rotation**: die letzten N behalten, plus je ein wöchentliches/monatliches. +- **Restore mit Vorschau**: erst anzeigen, was überschrieben würde, dann bestätigen. +- **Prüfsummen im Manifest**, damit ein beschädigtes Archiv beim Wiederherstellen + auffällt und nicht erst danach. + +**Der einzige Test, der zählt:** Backup erzeugen → in ein leeres Verzeichnis +wiederherstellen → vergleichen. Ein ungeprüftes Restore ist kein Backup, sondern eine +Vermutung. Dazu ein Test für den Rechnerwechsel: Backup mit Passphrase, DPAPI-Kontext +simuliert anders, Restore muss funktionieren. + +--- + +# 2. Was für das Finanzumfeld noch fehlt + +Vorhanden: `DirectAPI` (Kurse, Krypto, Forex), `WebFetch`, `WebMonitor`, +`SocialMediaManager` (X, YouTube-Transkripte), `Telegram`, `Mail`, `Database`, +`FileRW` mit `stock_add`, seit neuem `Memory`. + +Nach Wirkung sortiert: + +## 2.1 Marktkalender — spart sofort Geld ★★★ + +Agenten wissen nicht, ob die Börse offen ist. Ein `*/30`-Cron läuft auch Sonntag um +3 Uhr, ruft Kurse ab, analysiert Freitagsdaten und schreibt einen Bericht. Das kostet +Tokens und erzeugt Scheinaktivität. + +Zwei Bausteine: + +- **Scheduler-Erweiterung** `onlyWhenMarketOpen: "NYSE"` bzw. `"XETRA"` — der Lauf + wird schlicht übersprungen. Wirkt ohne Zutun des Modells. +- **Tool `MarketCalendar`** für Fragen des Agenten: Ist heute Handelstag? Wann + öffnet/schließt? Vor-/Nachbörse? Nächster Feiertag? + +Handelskalender ändern sich selten und lassen sich als Datei pflegen — keine externe +Abhängigkeit nötig. + +## 2.2 Deterministische Berechnung ★★★ + +Sprachmodelle rechnen unzuverlässig. Indikatoren vom Modell schätzen zu lassen ist +gleich doppelt schlecht: Das Ergebnis stimmt oft nicht, und die Zahlenkolonnen +müssen dafür durch den Kontext. + +Ein Tool `Indicators`, das im Code rechnet: gleitende Durchschnitte, RSI, ATR, +Volatilität, prozentuale Veränderung, Korrelation, Drawdown, Positionsgröße nach +Risiko. Der Agent bekommt Ergebnisse statt Rohdaten. + +Spart Tokens **und** verbessert die Qualität — die seltene Kombination. + +## 2.3 Datenaktualität erzwingen ★★ + +`DirectAPI` liefert brav `dataAsOf` mit, aber nichts wertet es aus. Ein Agent kann +ungehindert auf drei Tage alten Kursen argumentieren. + +Vorschlag: `maxAgeSeconds` in der Tool-Konfiguration. Überschrittene Daten werden +entweder abgelehnt oder mit einem unübersehbaren Hinweis geliefert — nicht +stillschweigend durchgereicht. + +## 2.4 Termine und Fundamentaldaten ★★ + +Für „Finanznachrichten" ist der Kalender oft wichtiger als der Kurs: Was steht diese +Woche an? Aktuell gibt es dazu nichts. + +- Earnings-Termine, Dividenden, Splits +- SEC EDGAR: Filings (8-K, 10-Q, 13F) — frei zugänglich, gut strukturiert +- Wirtschaftstermine (Zinsentscheide, Inflationsdaten) + +## 2.5 Bestandsregister ★★ + +`stock_add` ist eine Wissenssammlung, kein Bestand. Aussagen wie „Wie ist mein Risiko +verteilt?" oder „Wie lief die Position seit Einstieg?" sind damit nicht möglich. + +Eine eigene Tabelle mit Positionen (Symbol, Menge, Einstand, Datum, Notiz) — auch +rein zur Beobachtung, ohne Handelsanbindung. Sie ist zugleich die Grundlage für die +Leistungsmessung aus Teil 3. + +## 2.6 Nachrichten-Entdopplung ★★ + +Dieselbe Meldung läuft über zehn Quellen. Ohne Abgleich zahlt man zehnmal, und der +Agent hält es für zehn unabhängige Signale — was die Einschätzung systematisch +verzerrt. + +Eine `SeenItems`-Tabelle mit Prüfsumme über den normalisierten Titel plus +Ähnlichkeitsabgleich. Passt gut zum vorhandenen Speicher-Fundament. + +## 2.7 Prompt-Injection ist hier keine Theorie ★★★ + +Finanzinhalte auf X und in Newslettern sind genau der Ort, an dem gezielt manipuliert +wird. Ein präparierter Beitrag kann einen Agenten steuern, der Mail versenden und +posten darf. K2 aus der Bestandsaufnahme ist in diesem Umfeld die dringlichste +Konzeptlücke. + +Konkret: Tool-Ergebnisse als Daten rahmen, im System-Prompt verankern, dass daraus +keine Anweisungen befolgt werden, und irreversible Aktionen an eine Freigabe koppeln. + +## Abgrenzung + +Was hier beschrieben ist, sind Recherche- und Analysewerkzeuge. Automatische +**Orderausführung** wäre eine andere Kategorie mit eigenen Anforderungen (Broker-API, +Fehlerbehandlung bei Teilausführungen, Nachvollziehbarkeit, rechtlicher Rahmen). Das +wäre eine bewusste Entscheidung und kein Nebenprodukt der Analyse-Agenten. + +--- + +# 3. Kosten und Leistung auswerten + +## Der Kern des Problems + +Kosten sind seit K5 sauber erfasst. Leistung ist ungleich schwerer — und der ehrliche +Grund ist: + +> **Leistung ist nur messbar, wenn der Agent sich auf etwas Falsifizierbares festlegt.** + +Ein Agent, der „interessante Beobachtungen" liefert, lässt sich nicht bewerten. Einer, +der sagt „NVDA über 5 Handelstage +3 %, Konfidenz 0,7", schon. + +Das Finanzumfeld ist dafür ein Glücksfall: Aussagen werden von der Realität +beantwortet, ohne dass jemand sie bewerten muss. + +## Stufe 1 — Betriebsmetriken (sofort möglich) + +Aus vorhandenen Daten, ohne neues Konzept: + +| Metrik | Quelle | Was sie verrät | +|---|---|---| +| Kosten je Agent/Tag/Modell | `RunUsage` | vorhanden | +| Cache-Trefferquote | `CachedTokens / PromptTokens` | ob T1 wirkt | +| Fehlerquote | Status `Failed`/`LoopLimitExceeded` | instabile Agenten | +| **Leerlaufquote** | Läufe ohne Ergebnis | siehe unten | +| Tool-Fehlerquote | braucht Audit-Log (S4) | kaputte Tool-Konfiguration | +| Schritte je Lauf | `StepCount` | umständliche Arbeitsweise | + +Die **Leerlaufquote** ist die wirksamste einfache Kennzahl: Ein Agent, der 40 % seiner +Läufe ohne greifbares Ergebnis beendet, hat meist ein Zeitplan-Problem — genau das, +was der Marktkalender aus Teil 2 löst. Kosten ohne Gegenwert, sofort abstellbar. + +## Stufe 2 — Ergebnisregister + +Bisher wird nirgends festgehalten, **was** ein Lauf hervorgebracht hat. + +Eine Tabelle `AgentOutput`, verknüpft mit dem Lauf: Art (Bericht, Signal, Nachricht, +Gedächtniseintrag), Betreff, Verweis. Damit wird aus „Kosten pro Lauf" die deutlich +nützlichere Größe **„Kosten pro Ergebnis"**. + +## Stufe 3 — Falsifizierbare Aussagen + +Das eigentliche Leistungsmaß. Ein Agent hält eine Aussage fest: + +``` +Subjekt: NVDA +Aussage: Kurs steigt +Horizont: 5 Handelstage +Konfidenz: 0.7 +Begründung: … +``` + +Ein Auflösungs-Job prüft nach Ablauf gegen die tatsächlichen Kurse — `DirectAPI` hat +sie bereits. Kein Mensch muss bewerten. + +Daraus fällt ab: + +- **Trefferquote** je Agent, je Kategorie, je Horizont +- **Brier-Score** — misst nicht nur, ob die Richtung stimmte, sondern ob die + Konfidenz ehrlich war. Ein Agent, der bei 0,9 nur in 60 % der Fälle recht hat, ist + überheblich; das bleibt bei reiner Trefferquote unsichtbar. +- **Kosten je richtiger Aussage** +- **Vergleich gegen eine Nulllinie** — etwa „der Index steigt immer" oder + „Zufallsentscheidung". Ohne Nulllinie ist eine Trefferquote von 55 % nicht + einzuordnen. + +## Die vorgeschlagene Kennzahl + +Keine einzelne Zahl, sondern ein Quotient mit Bezugspunkt: + +``` +Nutzen = Brier-Skill-Score gegenüber Nulllinie +Wert = Nutzen / Kosten pro Tag +``` + +Die Betriebsmetriken aus Stufe 1 dienen der Diagnose: *warum* ist ein Agent teuer — +zu viele Schritte, zu große Tool-Ergebnisse, Leerläufe, kein Cache-Treffer? + +## Eine Warnung zur Ehrlichkeit + +Bei 20 Aussagen sagt eine Trefferquote von 60 % statistisch nichts. Die Auswertung +muss Fallzahl und Unsicherheitsbereich mit ausweisen, sonst optimiert man Rauschen — +und schaltet einen guten Agenten ab, weil er eine schlechte Woche hatte. + +Faustregel für die Anzeige: unter 30 aufgelösten Aussagen keine Rangliste, nur +Rohzahlen. + +--- + +# Vorgeschlagene Reihenfolge + +| # | Was | Warum zuerst | +|---|---|---| +| 1 | Atomares Schreiben | Datenverlust ist bereits eingetreten | +| 2 | Backup + Restore mit Test | Schützt alles Folgende | +| 3 | Marktkalender | Spart sofort Kosten, verbessert Datenlage | +| 4 | `Indicators` | Qualität hoch, Tokens runter | +| 5 | Ergebnisregister (Stufe 2) | Grundlage jeder Bewertung | +| 6 | Aussagen + Auflösung (Stufe 3) | Das eigentliche Leistungsmaß | +| 7 | S4 + K2 | Voraussetzung für unbeaufsichtigten Betrieb | + +Punkte 1 und 2 gehören zusammen: Ein Backup nicht-atomar geschriebener Dateien kann +eine bereits beschädigte Datei sichern. diff --git a/src/ClawdDotNet.Core/Engine/AgentEngine.cs b/src/ClawdDotNet.Core/Engine/AgentEngine.cs index 74926b4..1934c8c 100644 --- a/src/ClawdDotNet.Core/Engine/AgentEngine.cs +++ b/src/ClawdDotNet.Core/Engine/AgentEngine.cs @@ -9,6 +9,7 @@ using ClawdDotNet.Core.Security; using ClawdDotNet.Core.Accounting; using ClawdDotNet.Core.Tools; using ClawdDotNet.Core.State; +using ClawdDotNet.Core.Storage; using Microsoft.Extensions.Logging; namespace ClawdDotNet.Core.Engine; @@ -613,7 +614,7 @@ public sealed class AgentEngine : IAgentMessageRouter if (File.Exists(historyPath)) { var history = JsonSerializer.Deserialize>( - File.ReadAllText(historyPath), _jsonOpts); + AtomicFile.ReadAllText(historyPath), _jsonOpts); if (history is { Count: > 0 }) { lock (_lock) @@ -624,7 +625,7 @@ public sealed class AgentEngine : IAgentMessageRouter var contextPath = Path.Combine(dir, "ChatContext.json"); if (File.Exists(contextPath)) { - var raw = File.ReadAllText(contextPath); + var raw = AtomicFile.ReadAllText(contextPath); List? context = null; // Versuche zuerst als Array (direktes List) @@ -779,13 +780,15 @@ public sealed class AgentEngine : IAgentMessageRouter _chatContexts.TryGetValue(agentId, out context); } + // Atomar schreiben: Ein Absturz mitten im Vorgang würde sonst den + // bisherigen Verlauf löschen und einen halben zurücklassen. if (history is not null) - File.WriteAllText( + AtomicFile.WriteAllText( Path.Combine(dir, "ChatHistory.json"), JsonSerializer.Serialize(history, _jsonOpts)); if (context is not null) - File.WriteAllText( + AtomicFile.WriteAllText( Path.Combine(dir, "ChatContext.json"), JsonSerializer.Serialize(context, _jsonOpts)); } diff --git a/src/ClawdDotNet.Core/Storage/AtomicFile.cs b/src/ClawdDotNet.Core/Storage/AtomicFile.cs new file mode 100644 index 0000000..d3d612b --- /dev/null +++ b/src/ClawdDotNet.Core/Storage/AtomicFile.cs @@ -0,0 +1,200 @@ +using System.Collections.Concurrent; +using System.Text; + +namespace ClawdDotNet.Core.Storage; + +/// +/// Schreibt Dateien so, dass ein Absturz keine halbe Datei hinterlässt. +/// +/// Hintergrund: Alle Konfigurations- und Zustandsdateien wurden mit +/// File.WriteAllText geschrieben. Das kürzt die Zieldatei zuerst auf null und +/// füllt sie dann — bricht der Vorgang dazwischen ab, ist der alte Inhalt weg und der +/// neue unvollständig. +/// +/// Das ist bereits eingetreten: In einer Instanz lag eine +/// TokenUsage.json.corrupt_…, die die Fehlerbehandlung beiseitegelegt hatte. +/// +/// Ablauf hier: in eine Nebendatei schreiben, auf die Platte zwingen, dann durch +/// Umbenennen ersetzen. Das Ersetzen ist auf NTFS atomar — es gibt keinen Zeitpunkt, +/// zu dem die Zieldatei halb beschrieben wäre. +/// +public static class AtomicFile +{ + private static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false); + + /// + /// Ein Schloss je Zieldatei. + /// + /// Zwei gleichzeitige Schreibvorgänge auf dieselbe Datei sind ohnehin ein Rennen — + /// einer gewinnt. Ohne Serialisierung scheitern sie aber zusätzlich: Windows lehnt + /// zwei gleichzeitige Ersetzungen desselben Ziels mit "Zugriff verweigert" ab. + /// Das Anstellen kostet nichts und macht das Ergebnis vorhersagbar. + /// + private static readonly ConcurrentDictionary PathLocks = new(); + + private static SemaphoreSlim LockFor(string fullPath) + => PathLocks.GetOrAdd(fullPath.ToLowerInvariant(), _ => new SemaphoreSlim(1, 1)); + + /// + /// Liest eine Datei, ohne einen gleichzeitigen Schreibvorgang zu blockieren. + /// + /// File.ReadAllText öffnet ohne Freigabe zum Löschen — solange der Lesevorgang + /// läuft, lässt Windows die Datei nicht ersetzen. Ein Leser könnte damit einen + /// Schreiber scheitern lassen. + /// + /// Wird die Datei während des Lesens ersetzt, liefert der bereits geöffnete Griff + /// weiterhin den alten Inhalt — vollständig und in sich stimmig. Genau das ist + /// gewünscht: nie ein halber Stand. + /// + public static string ReadAllText(string path, Encoding? encoding = null) + { + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete); + using var reader = new StreamReader(stream, encoding ?? Utf8NoBom, detectEncodingFromByteOrderMarks: true); + return reader.ReadToEnd(); + } + + public static void WriteAllText(string path, string content, Encoding? encoding = null) + => WriteAllBytes(path, (encoding ?? Utf8NoBom).GetBytes(content)); + + public static async Task WriteAllTextAsync( + string path, string content, Encoding? encoding = null, CancellationToken ct = default) + => await WriteAllBytesAsync(path, (encoding ?? Utf8NoBom).GetBytes(content), ct); + + public static void WriteAllBytes(string path, byte[] bytes) + { + var (fullPath, tempPath) = PreparePaths(path); + var gate = LockFor(fullPath); + + gate.Wait(); + try + { + using (var stream = CreateTempStream(tempPath)) + { + stream.Write(bytes, 0, bytes.Length); + // Ohne dieses Flush lägen die Daten nur im Schreibcache des + // Betriebssystems — bei Stromausfall wäre die Umbenennung erfolgt, + // der Inhalt aber nicht auf der Platte. + stream.Flush(flushToDisk: true); + } + + Commit(tempPath, fullPath); + } + catch + { + TryDelete(tempPath); + throw; + } + finally + { + gate.Release(); + } + } + + public static async Task WriteAllBytesAsync(string path, byte[] bytes, CancellationToken ct = default) + { + var (fullPath, tempPath) = PreparePaths(path); + var gate = LockFor(fullPath); + + await gate.WaitAsync(ct); + try + { + await using (var stream = CreateTempStream(tempPath)) + { + await stream.WriteAsync(bytes, ct); + stream.Flush(flushToDisk: true); + } + + Commit(tempPath, fullPath); + } + catch + { + TryDelete(tempPath); + throw; + } + finally + { + gate.Release(); + } + } + + // ─── Bausteine ─── + + private static (string FullPath, string TempPath) PreparePaths(string path) + { + var fullPath = Path.GetFullPath(path); + + var directory = Path.GetDirectoryName(fullPath); + if (!string.IsNullOrEmpty(directory)) + Directory.CreateDirectory(directory); + + // Eindeutiger Name, damit parallele Schreibvorgänge sich nicht die + // Nebendatei streitig machen. + var tempPath = fullPath + ".tmp_" + Guid.NewGuid().ToString("N")[..8]; + + return (fullPath, tempPath); + } + + private static FileStream CreateTempStream(string tempPath) + => new(tempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None); + + /// Versuche für das Ersetzen — siehe . + private const int CommitAttempts = 20; + + /// Obergrenze der Wartezeit zwischen zwei Versuchen. + private static readonly TimeSpan MaxCommitBackoff = TimeSpan.FromMilliseconds(200); + + /// + /// Ersetzt die Zieldatei durch die Nebendatei. + /// + /// Bewusst File.Replace und nicht File.Move(overwrite: true). Gemessen + /// auf Windows, mit einem Leser, der die Zieldatei geöffnet hält: + /// + /// + /// Freigabe des Lesers File.Move File.Replace + /// Read scheitert scheitert + /// ReadWrite scheitert scheitert + /// ReadWrite | Delete scheitert funktioniert + /// + /// + /// File.Move verlangt die Zieldatei exklusiv und scheitert deshalb immer, + /// sobald jemand sie geöffnet hat. File.Replace kommt damit zurecht, sofern + /// der Leser das Löschen freigibt — dafür gibt es . + /// + /// Wiederholt wird trotzdem: Fremde Leser wie Virenscanner oder Sicherungsläufe + /// öffnen ohne diese Freigabe. Solche Sperren sind kurzlebig. + /// + private static void Commit(string tempPath, string fullPath) + { + for (var attempt = 1; ; attempt++) + { + try + { + // File.Replace verlangt eine vorhandene Zieldatei. + if (File.Exists(fullPath)) + { + File.Replace(tempPath, fullPath, + destinationBackupFileName: null, ignoreMetadataErrors: true); + } + else + { + File.Move(tempPath, fullPath); + } + + return; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException + && attempt < CommitAttempts) + { + var wait = Math.Min(attempt * 20, (int)MaxCommitBackoff.TotalMilliseconds); + Thread.Sleep(wait); + } + } + } + + private static void TryDelete(string path) + { + try { if (File.Exists(path)) File.Delete(path); } + catch { /* Aufräumen darf den eigentlichen Fehler nicht verdecken */ } + } +} diff --git a/src/ClawdDotNet.Tools.FileRW/FileRWTool.cs b/src/ClawdDotNet.Tools.FileRW/FileRWTool.cs index 01c64de..bc94126 100644 --- a/src/ClawdDotNet.Tools.FileRW/FileRWTool.cs +++ b/src/ClawdDotNet.Tools.FileRW/FileRWTool.cs @@ -548,7 +548,10 @@ public sealed class FileRWTool : IAgentTool } index.Add(indexEntry); - await File.WriteAllTextAsync(indexPath, JsonSerializer.Serialize(index, StockJsonOpts), new UTF8Encoding(false), ct); + // Atomar: Der Index ist die einzige Übersicht über die Datenpunkte — + // eine halb geschriebene Datei wäre nicht wiederherstellbar. + await ClawdDotNet.Core.Storage.AtomicFile.WriteAllTextAsync( + indexPath, JsonSerializer.Serialize(index, StockJsonOpts), ct: ct); } finally { diff --git a/tests/ClawdDotNet.Core.Tests/Storage/AtomicFileTests.cs b/tests/ClawdDotNet.Core.Tests/Storage/AtomicFileTests.cs new file mode 100644 index 0000000..349cbe4 --- /dev/null +++ b/tests/ClawdDotNet.Core.Tests/Storage/AtomicFileTests.cs @@ -0,0 +1,210 @@ +using System.Text; +using System.Text.Json; +using ClawdDotNet.Core.Storage; +using Shouldly; + +namespace ClawdDotNet.Core.Tests.Storage; + +/// +/// Alle Konfigurations- und Zustandsdateien wurden mit File.WriteAllText geschrieben. +/// Das kürzt die Zieldatei zuerst auf null und füllt sie dann — bricht der Vorgang +/// dazwischen ab, ist der alte Inhalt weg und der neue unvollständig. +/// +/// Das ist im Betrieb bereits eingetreten: In einer Instanz lag eine +/// TokenUsage.json.corrupt_… +/// +public sealed class AtomicFileTests : IDisposable +{ + private readonly string _directory; + + public AtomicFileTests() + { + _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_directory); + } + + public void Dispose() + { + try { Directory.Delete(_directory, recursive: true); } + catch { /* Aufräumen ist Nebensache */ } + } + + private string Path_(string name) => Path.Combine(_directory, name); + + // ═══════════════════════════════════════════════════════════ + // Grundverhalten + // ═══════════════════════════════════════════════════════════ + + [Fact] + public void Inhalt_wird_geschrieben() + { + var path = Path_("datei.json"); + + AtomicFile.WriteAllText(path, """{"a":1}"""); + + File.ReadAllText(path).ShouldBe("""{"a":1}"""); + } + + [Fact] + public void Eine_bestehende_Datei_wird_ersetzt() + { + var path = Path_("datei.json"); + File.WriteAllText(path, "alter Inhalt der deutlich laenger ist"); + + AtomicFile.WriteAllText(path, "neu"); + + File.ReadAllText(path).ShouldBe("neu"); + } + + [Fact] + public void Fehlende_Verzeichnisse_werden_angelegt() + { + var path = Path_(Path.Combine("a", "b", "c", "datei.json")); + + AtomicFile.WriteAllText(path, "inhalt"); + + File.Exists(path).ShouldBeTrue(); + } + + [Fact] + public void Es_bleiben_keine_Nebendateien_zurueck() + { + var path = Path_("datei.json"); + + AtomicFile.WriteAllText(path, "eins"); + AtomicFile.WriteAllText(path, "zwei"); + + Directory.GetFiles(_directory).ShouldHaveSingleItem(); + } + + [Fact] + public void Umlaute_und_Emoji_werden_als_UTF8_ohne_BOM_geschrieben() + { + var path = Path_("datei.txt"); + const string content = "Grüße aus München 🦀"; + + AtomicFile.WriteAllText(path, content); + + var bytes = File.ReadAllBytes(path); + bytes[0].ShouldNotBe((byte)0xEF, "kein BOM am Dateianfang"); + new UTF8Encoding(false).GetString(bytes).ShouldBe(content); + } + + [Fact] + public async Task Die_asynchrone_Fassung_verhaelt_sich_gleich() + { + var path = Path_("datei.json"); + + await AtomicFile.WriteAllTextAsync(path, "inhalt"); + + File.ReadAllText(path).ShouldBe("inhalt"); + Directory.GetFiles(_directory).ShouldHaveSingleItem(); + } + + // ═══════════════════════════════════════════════════════════ + // Die eigentliche Zusage: nie ein halber Inhalt + // ═══════════════════════════════════════════════════════════ + + /// + /// Während geschrieben wird, wird parallel gelesen. Beim alten Verfahren erwischt + /// man dabei zwangsläufig abgeschnittene Zwischenstände — hier darf jeder Lesevorgang + /// nur ein vollständiges Dokument sehen. + /// + [Fact] + public async Task Waehrend_des_Schreibens_wird_nie_ein_halber_Inhalt_sichtbar() + { + var path = Path_("gross.json"); + var klein = JsonSerializer.Serialize(Enumerable.Range(0, 50).Select(i => $"eintrag-{i}")); + var gross = JsonSerializer.Serialize(Enumerable.Range(0, 20_000).Select(i => $"eintrag-{i}")); + + AtomicFile.WriteAllText(path, klein); + + // Harte Zeitgrenze, damit ein Fehler im Schreiber den Test nicht hängen lässt. + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(20)); + + var schreiber = Task.Run(async () => + { + try + { + for (var i = 0; i < 40; i++) + { + AtomicFile.WriteAllText(path, i % 2 == 0 ? gross : klein); + await Task.Delay(1); + } + } + finally + { + await cts.CancelAsync(); + } + }); + + var fehler = 0; + var gelesen = 0; + + var leser = Task.Run(async () => + { + while (!cts.IsCancellationRequested) + { + try + { + // AtomicFile.ReadAllText, damit der Leser den Schreiber nicht + // blockiert — genau dafür gibt es den Helfer. + var inhalt = AtomicFile.ReadAllText(path); + gelesen++; + + // Muss immer ein vollständiges JSON-Array sein. + JsonSerializer.Deserialize(inhalt); + } + catch (JsonException) + { + Interlocked.Increment(ref fehler); + } + catch (IOException) + { + // Kurzzeitig nicht zu öffnen ist kein Datenfehler. + } + await Task.Delay(5); + } + }); + + await Task.WhenAll(schreiber, leser); + + gelesen.ShouldBeGreaterThan(5, "der Test muss tatsächlich gelesen haben"); + fehler.ShouldBe(0, "es darf nie ein unvollständiges Dokument sichtbar sein"); + } + + /// + /// Der nicht offensichtliche Teil: Ein Leser, der die Datei geöffnet hält, würde + /// das Ersetzen blockieren — es sei denn, er gibt das Löschen frei. Genau das tut + /// . + /// + /// Gemessen auf Windows: File.Move scheitert in jedem Fall an einem offenen Leser, + /// File.Replace kommt mit einem freigebenden Leser zurecht. Deshalb Replace. + /// + [Fact] + public void Ein_offener_Leser_blockiert_das_Schreiben_nicht() + { + var path = Path_("gehalten.json"); + AtomicFile.WriteAllText(path, """{"stand":"alt"}"""); + + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, + FileShare.ReadWrite | FileShare.Delete); + + Should.NotThrow(() => AtomicFile.WriteAllText(path, """{"stand":"neu"}""")); + + AtomicFile.ReadAllText(path).ShouldBe("""{"stand":"neu"}"""); + } + + [Fact] + public void Parallele_Schreibvorgaenge_hinterlassen_eine_gueltige_Datei() + { + var path = Path_("parallel.json"); + + Parallel.For(0, 40, i => + AtomicFile.WriteAllText(path, JsonSerializer.Serialize(new { lauf = i }))); + + // Welcher Lauf gewinnt, ist offen — die Datei muss aber lesbar sein. + Should.NotThrow(() => JsonDocument.Parse(File.ReadAllText(path))); + Directory.GetFiles(_directory).ShouldHaveSingleItem(); + } +}