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
@@ -0,0 +1,188 @@
using ClawdDotNet.Core.Audit;
using ClawdDotNet.Core.Files;
using ClawdDotNet.Core.Storage;
using ClawdDotNet.Core.Tests.Infrastructure;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Files;
/// <summary>
/// #720: The lost update — A reads, B reads, A writes, B writes, and A's change is gone
/// without a word. The ledger refuses B's write instead and keeps the conflict for the count.
/// </summary>
public sealed class FileLedgerTests : IDisposable
{
private readonly string _dir = Path.Combine(Path.GetTempPath(), "clawd-tests", "ledger-" + Guid.NewGuid().ToString("N")[..8]);
private readonly string _file;
private readonly FileLedger _ledger;
public FileLedgerTests()
{
Directory.CreateDirectory(_dir);
_file = Path.Combine(_dir, "plan.md");
File.WriteAllText(_file, "v1");
_ledger = new FileLedger(new SqliteStorage(Path.Combine(_dir, "state.db")));
}
public void Dispose()
{
SqliteStorage.ReleasePooledConnections(_dir);
try { Directory.Delete(_dir, recursive: true); }
catch { /* cleanup is secondary */ }
}
private string? Now => FileLedger.HashOf(_file);
private void Write(string agent, string content)
{
File.WriteAllText(_file, content);
_ledger.NoteReplace(agent, "run-" + agent, _file, Now);
}
[Fact]
public void The_second_writer_of_the_same_read_is_refused()
{
_ledger.NoteRead("a", _file, Now);
_ledger.NoteRead("b", _file, Now);
_ledger.CheckReplace("a", "run-a", _file, "shared", Now).ShouldBeNull();
Write("a", "v2 by a");
var conflict = _ledger.CheckReplace("b", "run-b", _file, "shared", Now);
conflict.ShouldNotBeNull();
conflict.Kind.ShouldBe(FileConflict.ChangedSinceRead);
conflict.OtherAgentId.ShouldBe("a");
conflict.OtherRunId.ShouldBe("run-a");
}
[Fact]
public void After_reading_again_the_write_goes_through()
{
_ledger.NoteRead("b", _file, Now);
Write("a", "v2 by a");
_ledger.CheckReplace("b", null, _file, "shared", Now).ShouldNotBeNull();
_ledger.NoteRead("b", _file, Now);
_ledger.CheckReplace("b", null, _file, "shared", Now).ShouldBeNull();
}
[Fact]
public void Writing_twice_in_a_row_is_not_a_conflict_with_oneself()
{
_ledger.NoteRead("a", _file, Now);
Write("a", "v2");
_ledger.CheckReplace("a", null, _file, "shared", Now).ShouldBeNull();
}
[Fact]
public void Another_agents_file_is_not_overwritten_unread()
{
Write("a", "a's work");
var conflict = _ledger.CheckReplace("b", null, _file, "shared", Now);
conflict.ShouldNotBeNull();
conflict.Kind.ShouldBe(FileConflict.NotRead);
conflict.Message("shared:/plan.md").ShouldContain("have not read it");
}
[Fact]
public void The_own_file_and_a_file_of_unknown_origin_may_be_overwritten_unread()
{
Write("a", "a's work");
_ledger.CheckReplace("a", null, _file, "shared", Now).ShouldBeNull();
// A file nobody wrote through the ledger — from before, or by a human.
File.WriteAllText(_file, "put there by hand");
_ledger.CheckReplace("b", null, _file, "shared", Now).ShouldBeNull();
}
[Fact]
public void A_new_file_replaces_nothing()
=> _ledger.CheckReplace("b", null, Path.Combine(_dir, "new.md"), "shared", null).ShouldBeNull();
[Fact]
public void An_append_by_another_makes_an_older_read_stale()
{
_ledger.NoteRead("a", _file, Now);
var before = Now;
File.AppendAllText(_file, "\n+ b's line");
_ledger.NoteAppend("b", "run-b", _file, before, Now);
var conflict = _ledger.CheckReplace("a", null, _file, "shared", Now);
conflict.ShouldNotBeNull("a's replace would drop b's appended line");
conflict.OtherAgentId.ShouldBe("b");
}
[Fact]
public void An_own_append_on_a_known_file_keeps_the_read_valid()
{
_ledger.NoteRead("a", _file, Now);
var before = Now;
File.AppendAllText(_file, "\n+ a's line");
_ledger.NoteAppend("a", null, _file, before, Now);
_ledger.CheckReplace("a", null, _file, "shared", Now).ShouldBeNull();
}
[Fact]
public void A_change_by_hand_after_the_read_is_a_conflict_too()
{
_ledger.NoteRead("a", _file, Now);
File.WriteAllText(_file, "edited by a human");
var conflict = _ledger.CheckReplace("a", null, _file, "shared", Now);
conflict.ShouldNotBeNull();
conflict.OtherAgentId.ShouldBeNull();
conflict.Message("shared:/plan.md").ShouldContain("someone else");
}
[Fact]
public async Task Hotspots_are_files_with_repeated_conflicts()
{
var other = Path.Combine(_dir, "once.md");
for (var i = 0; i < 3; i++)
await _ledger.RecordAsync(new FileConflict(_file, "shared", "b", null, "a", null, FileConflict.ChangedSinceRead, DateTime.UtcNow), default);
await _ledger.RecordAsync(new FileConflict(other, "shared", "c", null, null, null, FileConflict.NotRead, DateTime.UtcNow), default);
await _ledger.RecordAsync(new FileConflict(_file, "shared", "b", null, "a", null, FileConflict.ChangedSinceRead, DateTime.UtcNow.AddDays(-30)), default);
var hotspots = await _ledger.HotspotsAsync(DateTime.UtcNow.AddDays(-7), minimum: 3, limit: 10, default);
hotspots.Count.ShouldBe(1);
hotspots[0].Path.ShouldBe(_file);
hotspots[0].Conflicts.ShouldBe(3, "the one from a month ago is outside the window");
hotspots[0].Agents.ShouldBe(["a", "b"]);
}
[Fact]
public async Task The_engine_audits_a_refused_write_as_a_conflict_not_an_error()
{
var audit = new InMemoryAuditRepository();
var fixture = new EngineFixture(audit).WithTool(FakeTool.Conflicting("Conflict: plan.md was changed"));
var agent = fixture.AddAgent("agent-a", "TestTool");
fixture.Client.RespondsWithToolCall("TestTool").RespondsWithText("done");
await fixture.Engine.RunAsync(agent, "go", "test-instance", default);
audit.Entries.Single(e => e.Status != AuditStatus.Started).Status.ShouldBe(AuditStatus.Conflict);
}
[Fact]
public async Task Tools_find_the_ledger_in_their_context()
{
Core.Tools.AgentToolContext? seen = null;
var fixture = new EngineFixture().WithTool(FakeTool.Observing(c => seen = c));
fixture.Engine.Files = _ledger;
var agent = fixture.AddAgent("agent-a", "TestTool");
fixture.Client.RespondsWithToolCall("TestTool").RespondsWithText("done");
await fixture.Engine.RunAsync(agent, "go", "test-instance", default);
seen!.Files.ShouldBeSameAs(_ledger);
seen.RunId.ShouldNotBeNullOrWhiteSpace("a conflict names the run");
}
}