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
+232
View File
@@ -0,0 +1,232 @@
using System.Collections.Concurrent;
using System.Globalization;
using System.Security.Cryptography;
using ClawdDotNet.Core.Storage;
namespace ClawdDotNet.Core.Files;
/// <summary>#720: A write that was refused because it would have lost someone's change.</summary>
public sealed record FileConflict(
string Path,
string Workspace,
string AgentId,
string? RunId,
string? OtherAgentId,
string? OtherRunId,
string Kind,
DateTime OccurredAtUtc)
{
/// <summary>The file changed after this agent read it.</summary>
public const string ChangedSinceRead = "changed-since-read";
/// <summary>Another agent wrote the file, and this one never read it.</summary>
public const string NotRead = "not-read";
/// <summary>What the agent is told — what happened, and what to do instead.</summary>
public string Message(string displayPath)
{
var who = OtherAgentId is null ? "someone else" : $"agent '{OtherAgentId}'";
return Kind == NotRead
? $"Conflict: {displayPath} was last written by {who}, and you have not read it. " +
"Read it first and make your change on its current content — or use 'append' if you only add to it."
: $"Conflict: {displayPath} was changed by {who} since you read it. Your write would overwrite that change. " +
"Read it again and make your change on the current content — or use 'append' if you only add to it.";
}
}
/// <summary>#720: A file that keeps running into conflicts — the candidate for a different kind of storage.</summary>
public sealed record FileConflictHotspot(string Path, string Workspace, int Conflicts, DateTime LastAtUtc, IReadOnlyList<string> Agents);
/// <summary>
/// #720: Lost updates in shared workspaces, found instead of waited for.
///
/// <para><b>Optimistic, not locked.</b> A lock held across model steps lasts minutes, is left
/// behind when a run breaks off, and makes the waiting agents burn steps. Instead the
/// instance remembers which version of a file each agent read. A write that replaces a file
/// compares the file as it is now with that version; if someone changed it in between, the
/// write is refused and the agent reads again. The agent needs no parameter for this, so it
/// holds for a cheap model that would forget one.</para>
///
/// <para><b>What is remembered where.</b> Reads and last writers live in memory: after a
/// restart there is no run that could build on an earlier read, and a file whose writer is
/// unknown may be overwritten as before. Conflicts are kept in the database — they are what
/// shows which files should become something else (split per agent, append-only, a table).</para>
///
/// <para>Appending is not checked: it loses nothing. It is noted, though — whoever read the
/// file before the append does not know the appended part, and a later replace by them is a
/// conflict. That is the point.</para>
/// </summary>
public sealed class FileLedger
{
private sealed record Mark(string Hash, string? RunId, DateTime AtUtc, string AgentId);
private readonly ConcurrentDictionary<(string Path, string Agent), Mark> _reads = new();
private readonly ConcurrentDictionary<string, Mark> _writers = new();
private readonly SqliteStorage? _storage;
private readonly TimeProvider _clock;
public FileLedger(SqliteStorage? storage = null, TimeProvider? clock = null)
{
_storage = storage;
_clock = clock ?? TimeProvider.System;
}
/// <summary>The SHA-256 of a file's bytes; <c>null</c> if it does not exist.</summary>
public static string? HashOf(string path)
{
if (!File.Exists(path))
return null;
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
return Convert.ToHexStringLower(SHA256.HashData(stream));
}
/// <summary>An agent read the file in this version.</summary>
public void NoteRead(string agentId, string path, string? hash)
{
if (hash is null)
return;
_reads[(Key(path), Agent(agentId))] = new Mark(hash, null, Now, agentId);
}
/// <summary>
/// Whether an agent may replace the file, given what it is now. <c>null</c>: yes. Pure
/// apart from the lookups — the caller holds the file's lock while it checks and writes.
/// </summary>
public FileConflict? CheckReplace(string agentId, string? runId, string path, string workspace, string? currentHash)
{
if (currentHash is null)
return null; // a new file replaces nothing
var key = Key(path);
_writers.TryGetValue(key, out var writer);
// The writer we know of is the one who made the file as it is now — otherwise
// someone we do not know of (a human, a tool without the ledger) changed it since.
var knownWriter = writer is not null && writer.Hash == currentHash ? writer : null;
if (_reads.TryGetValue((key, Agent(agentId)), out var read))
{
return read.Hash == currentHash
? null
: Conflict(FileConflict.ChangedSinceRead);
}
return knownWriter is not null && !SameAgent(knownWriter.AgentId, agentId)
? Conflict(FileConflict.NotRead)
: null;
FileConflict Conflict(string kind) => new(
path, workspace, agentId, runId,
knownWriter is not null && !SameAgent(knownWriter.AgentId, agentId) ? knownWriter.AgentId : null,
knownWriter is not null && !SameAgent(knownWriter.AgentId, agentId) ? knownWriter.RunId : null,
kind, Now);
}
/// <summary>The agent wrote the whole file: it is the last writer and knows what it wrote.</summary>
public void NoteReplace(string agentId, string? runId, string path, string? newHash)
{
if (newHash is null)
return;
var key = Key(path);
var mark = new Mark(newHash, runId, Now, agentId);
_writers[key] = mark;
_reads[(key, Agent(agentId))] = mark;
}
/// <summary>
/// The agent appended. It is the last writer; its own read stays valid only if it knew
/// the file as it was before — everyone else's read is now of an older version.
/// </summary>
public void NoteAppend(string agentId, string? runId, string path, string? hashBefore, string? hashAfter)
{
if (hashAfter is null)
return;
var key = Key(path);
var mark = new Mark(hashAfter, runId, Now, agentId);
_writers[key] = mark;
var own = (key, Agent(agentId));
if (hashBefore is null || (_reads.TryGetValue(own, out var read) && read.Hash == hashBefore))
_reads[own] = mark;
}
/// <summary>Keeps the conflict for the analysis. Best effort — the refusal itself does not depend on it.</summary>
public async Task RecordAsync(FileConflict conflict, CancellationToken ct)
{
if (_storage is null)
return;
try
{
await _storage.WriteAsync(async conn =>
{
using var cmd = conn.CreateCommand();
cmd.CommandText = """
INSERT INTO FileConflicts (Path, Workspace, AgentId, RunId, OtherAgentId, OtherRunId, Kind, OccurredAt)
VALUES (@path, @workspace, @agent, @run, @other, @otherRun, @kind, @at)
""";
cmd.Parameters.AddWithValue("@path", conflict.Path);
cmd.Parameters.AddWithValue("@workspace", conflict.Workspace);
cmd.Parameters.AddWithValue("@agent", conflict.AgentId);
cmd.Parameters.AddWithValue("@run", (object?)conflict.RunId ?? DBNull.Value);
cmd.Parameters.AddWithValue("@other", (object?)conflict.OtherAgentId ?? DBNull.Value);
cmd.Parameters.AddWithValue("@otherRun", (object?)conflict.OtherRunId ?? DBNull.Value);
cmd.Parameters.AddWithValue("@kind", conflict.Kind);
cmd.Parameters.AddWithValue("@at", conflict.OccurredAtUtc.ToString("O", CultureInfo.InvariantCulture));
await cmd.ExecuteNonQueryAsync(ct);
}, ct);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
// The conflict was refused all the same; only the count is missing one.
}
}
/// <summary>The files with at least <paramref name="minimum"/> conflicts since <paramref name="sinceUtc"/>, most first.</summary>
public async Task<IReadOnlyList<FileConflictHotspot>> HotspotsAsync(DateTime sinceUtc, int minimum, int limit, CancellationToken ct)
{
if (_storage is null)
return [];
await using var conn = await _storage.OpenConnectionAsync(ct);
using var cmd = conn.CreateCommand();
cmd.CommandText = """
SELECT Path, Workspace, AgentId, OtherAgentId, OccurredAt
FROM FileConflicts
WHERE OccurredAt >= @since
""";
cmd.Parameters.AddWithValue("@since", sinceUtc.ToString("O", CultureInfo.InvariantCulture));
var rows = new List<(string Path, string Workspace, string Agent, string? Other, DateTime At)>();
await using (var reader = await cmd.ExecuteReaderAsync(ct))
{
while (await reader.ReadAsync(ct))
{
rows.Add((reader.GetString(0), reader.GetString(1), reader.GetString(2),
reader.IsDBNull(3) ? null : reader.GetString(3),
DateTime.Parse(reader.GetString(4), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind)));
}
}
return rows
.GroupBy(r => (r.Path, r.Workspace))
.Select(g => new FileConflictHotspot(
g.Key.Path, g.Key.Workspace, g.Count(), g.Max(r => r.At),
g.SelectMany(r => r.Other is null ? new[] { r.Agent } : new[] { r.Agent, r.Other })
.Distinct(StringComparer.OrdinalIgnoreCase).Order(StringComparer.OrdinalIgnoreCase).ToList()))
.Where(h => h.Conflicts >= minimum)
.OrderByDescending(h => h.Conflicts).ThenByDescending(h => h.LastAtUtc)
.Take(limit)
.ToList();
}
private DateTime Now => _clock.GetUtcNow().UtcDateTime;
private static bool SameAgent(string a, string b) => string.Equals(a, b, StringComparison.OrdinalIgnoreCase);
private static string Agent(string agentId) => agentId.ToLowerInvariant();
/// <summary>One key per file, however the path was written; case-insensitive where the file system is.</summary>
private static string Key(string path)
{
var full = Path.GetFullPath(path);
return OperatingSystem.IsWindows() || OperatingSystem.IsMacOS() ? full.ToLowerInvariant() : full;
}
}