using ClawdDotNet.Core.Audit; using ClawdDotNet.Core.Files; using ClawdDotNet.Core.Storage; using ClawdDotNet.Core.Tests.Infrastructure; using Shouldly; namespace ClawdDotNet.Core.Tests.Files; /// /// #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. /// 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"); } }