using System.Text.Json; using ClawdDotNet.Core.Files; using ClawdDotNet.Core.Tools; using ClawdDotNet.Tools.FileRW; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; namespace ClawdDotNet.Tools.Tests.FileRW; /// /// #720: Two agents on the same file in the shared workspace. Nothing locks across steps; /// the write that would lose the other's change is refused, and the agent reads again. /// public sealed class LostUpdateTests : IDisposable { private readonly string _agents = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"), "Agents"); private readonly FileRWTool _tool = new(); private readonly FileLedger _ledger = new(); public LostUpdateTests() => Directory.CreateDirectory(Path.Combine(_agents, "SharedWorkspace")); public void Dispose() { try { Directory.Delete(Path.GetDirectoryName(_agents)!, recursive: true); } catch { /* cleaning up is secondary */ } } private string Shared(string file) => Path.Combine(_agents, "SharedWorkspace", file); private Task Run(string agentId, object input, bool withLedger = true) { var personal = Path.Combine(_agents, agentId, "Workspace"); Directory.CreateDirectory(personal); var context = new AgentToolContext(agentId, "inst", new Dictionary { ["sharedAccessLevel"] = "ReadWrite" }, new NullStateStore(), NullLogger.Instance, CancellationToken.None, WorkspacePath: personal, SharedWorkspacePath: Path.Combine(_agents, "SharedWorkspace")) { Files = withLedger ? _ledger : null, RunId = "run-" + agentId }; return _tool.ExecuteAsync(JsonSerializer.SerializeToElement(input), context, CancellationToken.None); } private Task Read(string agent, string file = "plan.md") => Run(agent, new { action = "read", workspace = "shared", path = file }); private Task Write(string agent, string content, string file = "plan.md") => Run(agent, new { action = "write", workspace = "shared", path = file, content }); [Fact] public async Task The_second_write_of_the_same_read_is_refused_and_the_first_survives() { await Write("a", "v1"); await Read("a"); await Read("b"); (await Write("a", "v2 by a")).Success.ShouldBeTrue(); var b = await Write("b", "v2 by b"); b.Success.ShouldBeFalse(); b.IsConflict.ShouldBeTrue(); b.ErrorMessage!.ShouldContain("changed by agent 'a' since you read it"); File.ReadAllText(Shared("plan.md")).ShouldBe("v2 by a"); } [Fact] public async Task Reading_again_resolves_it() { await Write("a", "v1"); await Read("b"); await Write("a", "v2 by a"); (await Write("b", "v2 by b")).IsConflict.ShouldBeTrue(); (await Read("b")).Content.ShouldContain("v2 by a"); (await Write("b", "v3 by b on top of a")).Success.ShouldBeTrue(); } [Fact] public async Task Another_agents_file_is_not_overwritten_blind_but_the_own_is() { await Write("a", "a's report"); (await Write("a", "a's report, revised")).Success.ShouldBeTrue("the own file may be overwritten without reading"); var b = await Write("b", "b's version"); b.IsConflict.ShouldBeTrue(); b.ErrorMessage!.ShouldContain("have not read it"); } [Fact] public async Task Appending_is_never_refused_but_makes_older_reads_stale() { await Write("a", "log"); await Read("a"); (await Run("b", new { action = "append", workspace = "shared", path = "plan.md", content = "\nb was here" })).Success.ShouldBeTrue(); var a = await Write("a", "log, rewritten"); a.IsConflict.ShouldBeTrue("the replace would drop b's line"); File.ReadAllText(Shared("plan.md")).ShouldContain("b was here"); } [Fact] public async Task Copying_over_a_changed_file_is_a_write_like_any_other() { await Write("a", "v1"); await Read("b"); await Write("a", "v2 by a"); await Run("b", new { action = "write", workspace = "shared", path = "draft.md", content = "b's draft" }); var copy = await Run("b", new { action = "copy", workspace = "shared", path = "draft.md", destinationPath = "plan.md" }); copy.IsConflict.ShouldBeTrue(); File.ReadAllText(Shared("plan.md")).ShouldBe("v2 by a"); } [Fact] public async Task The_personal_workspace_is_not_checked() { await Run("a", new { action = "write", path = "mine.md", content = "v1" }); await Run("a", new { action = "read", path = "mine.md" }); File.WriteAllText(Path.Combine(_agents, "a", "Workspace", "mine.md"), "changed outside"); (await Run("a", new { action = "write", path = "mine.md", content = "v2" })).Success.ShouldBeTrue(); } [Fact] public async Task Without_the_ledger_everything_is_as_before() { await Write("a", "v1"); (await Run("b", new { action = "write", workspace = "shared", path = "plan.md", content = "b" }, withLedger: false)).Success.ShouldBeTrue(); } [Fact] public async Task Reading_many_counts_as_reading_each() { await Write("a", "one", "x/1.md"); await Write("a", "two", "x/2.md"); await Run("b", new { action = "read_many", workspace = "shared", path = "x" }); (await Write("b", "one by b", "x/1.md")).Success.ShouldBeTrue(); } }