Konfig- und Zustandsdateien atomar schreiben

File.WriteAllText kuerzt die Zieldatei zuerst auf null und fuellt sie dann. Bricht
der Vorgang dazwischen ab, ist der alte Inhalt weg und der neue unvollstaendig.

Das ist im Betrieb bereits eingetreten: In der Instanz TradingTeam lag eine
TokenUsage.json.corrupt_..., die die Fehlerbehandlung beiseitegelegt hatte. Der
Verbrauch bis dahin war verloren.

AtomicFile schreibt in eine Nebendatei, erzwingt das Schreiben auf die Platte und
ersetzt dann. Umgestellt sind ChatHistory, ChatContext, alle Instanz- und
Agentenkonfigurationen, Identity und Soul, die App-Einstellungen sowie der
Stock-Index.

Zum Ersetzen wurde das Windows-Verhalten gemessen statt vermutet. Mit einem Leser,
der die Zieldatei geoeffnet haelt:

  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 geoeffnet hat. Daher File.Replace — und ein Lesehelfer
AtomicFile.ReadAllText, der das Loeschen freigibt, damit unsere eigenen Leser
keinen Schreiber blockieren. Die Leser in InstanceDirectoryManager und beim Laden
der Chatverlaeufe nutzen ihn jetzt.

Zusaetzlich ein Schloss je Zieldatei: Zwei gleichzeitige Schreibvorgaenge auf
dieselbe Datei sind ohnehin ein Rennen, ohne Serialisierung scheitern sie aber
zusaetzlich mit "Zugriff verweigert". Fuer fremde Leser wie Virenscanner bleibt
eine Wiederholung mit Wartezeit.

366 Tests gruen (218 Core, 148 Tools).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-07-29 09:47:38 +02:00
co-authored by Claude Opus 4.8
parent ef3e519f6c
commit 1157d28588
7 changed files with 724 additions and 21 deletions
+10 -11
View File
@@ -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<T>(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<T>(string path)
{
var json = File.ReadAllText(path);
// Lesen ohne den Schreiber zu blockieren — siehe AtomicFile.ReadAllText.
var json = AtomicFile.ReadAllText(path);
return JsonSerializer.Deserialize<T>(json, JsonOpts);
}
+1 -5
View File
@@ -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)
{
+292
View File
@@ -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.
+7 -4
View File
@@ -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<List<ChatEntry>>(
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<ChatMessage>? context = null;
// Versuche zuerst als Array (direktes List<ChatMessage>)
@@ -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));
}
+200
View File
@@ -0,0 +1,200 @@
using System.Collections.Concurrent;
using System.Text;
namespace ClawdDotNet.Core.Storage;
/// <summary>
/// Schreibt Dateien so, dass ein Absturz keine halbe Datei hinterlässt.
///
/// Hintergrund: Alle Konfigurations- und Zustandsdateien wurden mit
/// <c>File.WriteAllText</c> 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
/// <c>TokenUsage.json.corrupt_…</c>, 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.
/// </summary>
public static class AtomicFile
{
private static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false);
/// <summary>
/// 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.
/// </summary>
private static readonly ConcurrentDictionary<string, SemaphoreSlim> PathLocks = new();
private static SemaphoreSlim LockFor(string fullPath)
=> PathLocks.GetOrAdd(fullPath.ToLowerInvariant(), _ => new SemaphoreSlim(1, 1));
/// <summary>
/// Liest eine Datei, ohne einen gleichzeitigen Schreibvorgang zu blockieren.
///
/// <c>File.ReadAllText</c> ö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.
/// </summary>
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);
/// <summary>Versuche für das Ersetzen — siehe <see cref="Commit"/>.</summary>
private const int CommitAttempts = 20;
/// <summary>Obergrenze der Wartezeit zwischen zwei Versuchen.</summary>
private static readonly TimeSpan MaxCommitBackoff = TimeSpan.FromMilliseconds(200);
/// <summary>
/// Ersetzt die Zieldatei durch die Nebendatei.
///
/// Bewusst <c>File.Replace</c> und nicht <c>File.Move(overwrite: true)</c>. Gemessen
/// auf Windows, mit einem Leser, der die Zieldatei geöffnet hält:
///
/// <code>
/// Freigabe des Lesers File.Move File.Replace
/// Read scheitert scheitert
/// ReadWrite scheitert scheitert
/// ReadWrite | Delete scheitert funktioniert
/// </code>
///
/// <c>File.Move</c> verlangt die Zieldatei exklusiv und scheitert deshalb immer,
/// sobald jemand sie geöffnet hat. <c>File.Replace</c> kommt damit zurecht, sofern
/// der Leser das Löschen freigibt — dafür gibt es <see cref="ReadAllText"/>.
///
/// Wiederholt wird trotzdem: Fremde Leser wie Virenscanner oder Sicherungsläufe
/// öffnen ohne diese Freigabe. Solche Sperren sind kurzlebig.
/// </summary>
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 */ }
}
}
+4 -1
View File
@@ -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
{
@@ -0,0 +1,210 @@
using System.Text;
using System.Text.Json;
using ClawdDotNet.Core.Storage;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Storage;
/// <summary>
/// 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_…
/// </summary>
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
// ═══════════════════════════════════════════════════════════
/// <summary>
/// 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.
/// </summary>
[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<string[]>(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");
}
/// <summary>
/// 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
/// <see cref="AtomicFile.ReadAllText"/>.
///
/// Gemessen auf Windows: File.Move scheitert in jedem Fall an einem offenen Leser,
/// File.Replace kommt mit einem freigebenden Leser zurecht. Deshalb Replace.
/// </summary>
[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();
}
}