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:
co-authored by
Claude Opus 5.5
parent
8a7212c240
commit
79a3a7854f
@@ -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)}";
|
||||
|
||||
Reference in New Issue
Block a user