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>
142 lines
5.4 KiB
C#
142 lines
5.4 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// #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.
|
|
/// </summary>
|
|
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<ToolResult> 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<string, object?> { ["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<ToolResult> Read(string agent, string file = "plan.md") => Run(agent, new { action = "read", workspace = "shared", path = file });
|
|
private Task<ToolResult> 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();
|
|
}
|
|
}
|