Verlorene Updates erkennen statt warten: optimistische Pruefung beim Ersetzen, Konflikte im Audit und im Lagebild (#720)

FileLedger merkt je Agent die gelesene Fassung einer Datei in gemeinsamen
Bereichen. write, copy und Downloads (FTP, RocketChat) ersetzen eine Datei nur,
wenn sie seit dem Lesen unveraendert ist; eine fremde Datei nicht ungelesen.
append bleibt ungeprueft, macht aeltere Lesestaende aber ungueltig.
Abgelehnte Schreibvorgaenge: eigener Audit-Status Conflict, Tabelle
FileConflicts, Hotspots (>= 3 in 7 Tagen) in der Teamuebersicht.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-28 10:45:46 +02:00
co-authored by Claude Opus 5.5
parent 8a7212c240
commit 79a3a7854f
21 changed files with 818 additions and 11 deletions
+59 -1
View File
@@ -1,3 +1,4 @@
using ClawdDotNet.Core.Files;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Organisation;
using System.Collections.Concurrent;
@@ -310,6 +311,41 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|| relative.StartsWith(TasksFolder + "/", StringComparison.OrdinalIgnoreCase);
}
// ─── Lost updates (#720) ───
/// <summary>Only workspaces more than one agent can write to — never the personal one.</summary>
private static bool Guarded(Area area, AgentToolContext context) => !area.IsPersonal && context.Files is not null;
private static void NoteRead(string path, Area area, AgentToolContext context)
{
if (Guarded(area, context))
context.Files!.NoteRead(context.AgentId, path, FileLedger.HashOf(path));
}
private static void NoteReplace(string path, Area area, AgentToolContext context)
{
if (Guarded(area, context))
context.Files!.NoteReplace(context.AgentId, context.RunId, path, FileLedger.HashOf(path));
}
/// <summary>
/// The refusal when replacing the file would lose a change this agent has not seen —
/// kept for the analysis, audited as a conflict. <c>null</c>: go ahead. Called under the
/// file's lock, so nothing can change between the check and the write.
/// </summary>
private static async Task<ToolResult?> RefuseLostUpdateAsync(
string path, string displayPath, Area area, AgentToolContext context, CancellationToken ct)
{
if (!Guarded(area, context))
return null;
if (context.Files!.CheckReplace(context.AgentId, context.RunId, path, area.Name, FileLedger.HashOf(path)) is not { } conflict)
return null;
context.Logger.LogInformation("Write refused, lost update: {Path} by {Agent} ({Kind})", displayPath, context.AgentId, conflict.Kind);
await context.Files.RecordAsync(conflict, ct);
return ToolResult.Conflict(conflict.Message(displayPath));
}
private static List<string> NormaliseProtectedPaths(IEnumerable<string> paths)
=> paths
.Select(p => p.Replace('\\', '/').Trim().Trim('/'))
@@ -504,6 +540,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
try
{
var content = await File.ReadAllTextAsync(path, ct);
NoteRead(path, area, context);
var relativePath = Path.GetRelativePath(rootPath, path);
return ToolResult.Ok($"[{area.Name}:/{relativePath}]\n{content}");
}
@@ -561,7 +598,11 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
var sperre = GetFileLock(pfad);
await sperre.WaitAsync(ct);
string inhalt;
try { inhalt = await File.ReadAllTextAsync(pfad, ct); }
try
{
inhalt = await File.ReadAllTextAsync(pfad, ct);
NoteRead(pfad, area, context);
}
finally { sperre.Release(); }
text.Append($"\n--- {area.Name}:/{relativ} ---\n").Append(inhalt.TrimEnd()).Append('\n');
@@ -665,6 +706,10 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
$"Tip: Use 'stock_add' for structured data points.");
}
// #720: not over a change this agent has not seen.
if (await RefuseLostUpdateAsync(path, $"{area.Name}:/{Path.GetRelativePath(rootPath, path).Replace('\\', '/')}", area, context, ct) is { } conflict)
return conflict;
var dir = Path.GetDirectoryName(path);
if (dir != null && !Directory.Exists(dir)) Directory.CreateDirectory(dir);
@@ -674,6 +719,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
// schützte also ihre eigenen Konfigurationsdateien, die Arbeitsergebnisse der
// Agenten aber nicht.
await AtomicFile.WriteAllTextAsync(path, content, new UTF8Encoding(false), ct);
NoteReplace(path, area, context);
return ToolResult.Ok($"[{area.Name} Workspace] File successfully written: {area.Name}:/{Path.GetRelativePath(rootPath, path)}");
}
finally
@@ -695,10 +741,17 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
// 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.
// #720: appending loses nothing and is not checked — but noted, so whoever read
// the file before does not replace it without the appended part.
var before = Guarded(area, context) ? FileLedger.HashOf(path) : null;
// #575: Neu entstehende Dateien gehoeren nur dem Besitzer (0600).
await using (var stream = ClawdDotNet.Core.Storage.OwnerOnlyFile.OpenAppend(path))
await using (var writer = new StreamWriter(stream, new UTF8Encoding(false)))
await writer.WriteAsync(content.AsMemory(), ct);
if (Guarded(area, context))
context.Files!.NoteAppend(context.AgentId, context.RunId, path, before, FileLedger.HashOf(path));
return ToolResult.Ok($"[{area.Name} Workspace] Content successfully appended to: {area.Name}:/{Path.GetRelativePath(rootPath, path)}");
}
finally
@@ -909,10 +962,15 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
await second.WaitAsync(ct);
try
{
// #720: copying over a file replaces it — the same check as a write.
if (await RefuseLostUpdateAsync(destPath, $"{destArea.Name}:/{Path.GetRelativePath(destRootPath, destPath).Replace('\\', '/')}", destArea, context, ct) is { } conflict)
return conflict;
var destDir = Path.GetDirectoryName(destPath);
if (destDir != null && !Directory.Exists(destDir)) Directory.CreateDirectory(destDir);
await Task.Run(() => File.Copy(sourcePath, destPath, overwrite: !IsPathProtected(destPath, destRootPath, destArea)), ct);
NoteReplace(destPath, destArea, context);
var srcLabel = $"{sourceArea.Name}:/{Path.GetRelativePath(sourceRootPath, sourcePath)}";
var dstLabel = $"{destArea.Name}:/{Path.GetRelativePath(destRootPath, destPath)}";