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>
189 lines
6.8 KiB
C#
189 lines
6.8 KiB
C#
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");
|
|
}
|
|
}
|