Compare commits
2
Commits
8a7212c240
...
621a83de43
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
621a83de43 | ||
|
|
79a3a7854f |
@@ -598,6 +598,9 @@ public sealed class AppHost : IAsyncDisposable
|
|||||||
foreach (var user in tools.GetAll().OfType<Core.Sandbox.ISandboxUser>())
|
foreach (var user in tools.GetAll().OfType<Core.Sandbox.ISandboxUser>())
|
||||||
user.Sandbox = sandbox;
|
user.Sandbox = sandbox;
|
||||||
|
|
||||||
|
// #720: which version of a shared file each agent read — a write cannot silently lose another.
|
||||||
|
engine.Files = new Core.Files.FileLedger(storage);
|
||||||
|
|
||||||
// #712: Foreign runtimes. The application itself is the launcher inside the sandbox
|
// #712: Foreign runtimes. The application itself is the launcher inside the sandbox
|
||||||
// (network through the proxy, secrets from a file) and the MCP bridge back to us.
|
// (network through the proxy, secrets from a file) and the MCP bridge back to us.
|
||||||
var mcpGrants = new Core.Mcp.McpGrants(storage);
|
var mcpGrants = new Core.Mcp.McpGrants(storage);
|
||||||
|
|||||||
@@ -67,7 +67,8 @@ public sealed record TeamSnapshot(
|
|||||||
IReadOnlyList<TeamMember> Members,
|
IReadOnlyList<TeamMember> Members,
|
||||||
IReadOnlyList<AgentLink> RecentLinks,
|
IReadOnlyList<AgentLink> RecentLinks,
|
||||||
DateTime AtUtc,
|
DateTime AtUtc,
|
||||||
IReadOnlyList<TeamUnit>? Units = null);
|
IReadOnlyList<TeamUnit>? Units = null,
|
||||||
|
IReadOnlyList<Core.Files.FileConflictHotspot>? ConflictHotspots = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// #711: The team overview — who is working, who reached whom, where something is stuck.
|
/// #711: The team overview — who is working, who reached whom, where something is stuck.
|
||||||
@@ -105,6 +106,12 @@ public sealed class TeamOverview(
|
|||||||
/// <summary>Without history, a run counts as stuck after this long.</summary>
|
/// <summary>Without history, a run counts as stuck after this long.</summary>
|
||||||
public static readonly TimeSpan StuckWithoutHistory = TimeSpan.FromMinutes(15);
|
public static readonly TimeSpan StuckWithoutHistory = TimeSpan.FromMinutes(15);
|
||||||
|
|
||||||
|
/// <summary>#720: How far back conflicts count for a hotspot…</summary>
|
||||||
|
public static readonly TimeSpan HotspotWindow = TimeSpan.FromDays(7);
|
||||||
|
|
||||||
|
/// <summary>…and how many make one. One conflict is an accident; three in a week are a pattern.</summary>
|
||||||
|
public const int HotspotMinimum = 3;
|
||||||
|
|
||||||
private readonly TimeProvider _clock = clock ?? TimeProvider.System;
|
private readonly TimeProvider _clock = clock ?? TimeProvider.System;
|
||||||
|
|
||||||
public async Task<TeamSnapshot> SnapshotAsync(CancellationToken ct)
|
public async Task<TeamSnapshot> SnapshotAsync(CancellationToken ct)
|
||||||
@@ -151,7 +158,30 @@ public sealed class TeamOverview(
|
|||||||
org.UnitOf(agent.AgentId)?.Id, org.LedBy(agent.AgentId) is not null));
|
org.UnitOf(agent.AgentId)?.Id, org.LedBy(agent.AgentId) is not null));
|
||||||
}
|
}
|
||||||
|
|
||||||
return new TeamSnapshot(members, links, now, RollUp(org, members));
|
// #720: files where agents keep getting in each other's way — for a human to decide
|
||||||
|
// whether they should be split, appended to, or become a table.
|
||||||
|
var hotspots = engine?.Files is { } files
|
||||||
|
? (await files.HotspotsAsync(now - HotspotWindow, HotspotMinimum, 10, ct))
|
||||||
|
.Select(h => h with { Path = Readable(h.Path, configured) }).ToList()
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return new TeamSnapshot(members, links, now, RollUp(org, members), hotspots);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #720: A file path as a human finds it — relative to the instance's <c>Agents</c> folder,
|
||||||
|
/// where the shared workspaces live. A path elsewhere stays as it is.
|
||||||
|
/// </summary>
|
||||||
|
public static string Readable(string path, IReadOnlyList<AgentConfig> agents)
|
||||||
|
{
|
||||||
|
var shared = agents.Select(a => a.SharedWorkspacePath).FirstOrDefault(p => !string.IsNullOrWhiteSpace(p));
|
||||||
|
if (shared is null
|
||||||
|
|| Path.GetDirectoryName(Path.TrimEndingDirectorySeparator(Path.GetFullPath(shared))) is not { } agentsDir)
|
||||||
|
return path;
|
||||||
|
var relative = Path.GetRelativePath(agentsDir, path);
|
||||||
|
return relative.StartsWith("..", StringComparison.Ordinal) || Path.IsPathRooted(relative)
|
||||||
|
? path
|
||||||
|
: relative.Replace(Path.DirectorySeparatorChar, '/');
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -37,7 +37,14 @@ public enum AuditStatus
|
|||||||
/// <see cref="Started"/>-Eintrag offen, und jeder gewöhnliche Abbruch sähe beim
|
/// <see cref="Started"/>-Eintrag offen, und jeder gewöhnliche Abbruch sähe beim
|
||||||
/// nächsten Start aus wie ein Absturz — bis niemand mehr hinsieht.</para>
|
/// nächsten Start aus wie ein Absturz — bis niemand mehr hinsieht.</para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Cancelled
|
Cancelled,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #720: The write was refused because the file changed since the agent read it, or
|
||||||
|
/// another agent wrote it and this one never read it. Its own status, so the conflicts
|
||||||
|
/// can be counted — frequent ones say the file should be stored differently.
|
||||||
|
/// </summary>
|
||||||
|
Conflict
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public Budget.ISpecialBudgetRepository? SpecialBudgets { get; set; }
|
public Budget.ISpecialBudgetRepository? SpecialBudgets { get; set; }
|
||||||
|
|
||||||
|
/// <summary>#720: Which version of a shared file each agent read — so a write cannot silently lose another.</summary>
|
||||||
|
public Files.FileLedger? Files { get; set; }
|
||||||
|
|
||||||
/// <summary>#654: The sandbox tools run code in.</summary>
|
/// <summary>#654: The sandbox tools run code in.</summary>
|
||||||
public Sandbox.ISandboxRunner? Sandbox { get; set; }
|
public Sandbox.ISandboxRunner? Sandbox { get; set; }
|
||||||
|
|
||||||
@@ -1665,7 +1668,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
|||||||
|
|
||||||
if (!result.Success)
|
if (!result.Success)
|
||||||
{
|
{
|
||||||
await Audit(AuditStatus.Error, result.ErrorMessage ?? "");
|
await Audit(result.IsConflict ? AuditStatus.Conflict : AuditStatus.Error, result.ErrorMessage ?? "");
|
||||||
return JsonSerializer.Serialize(new { error = result.ErrorMessage });
|
return JsonSerializer.Serialize(new { error = result.ErrorMessage });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1856,6 +1859,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
|||||||
Cases = CaseDesk,
|
Cases = CaseDesk,
|
||||||
SpecialBudgets = SpecialBudgets,
|
SpecialBudgets = SpecialBudgets,
|
||||||
Sandbox = Sandbox,
|
Sandbox = Sandbox,
|
||||||
|
Files = Files,
|
||||||
// #697: Where something an agent hands on came from — known to the engine, not claimed by the agent.
|
// #697: Where something an agent hands on came from — known to the engine, not claimed by the agent.
|
||||||
RunId = runId,
|
RunId = runId,
|
||||||
RunTaskId = runId is null ? null : Activity.Find(runId)?.TaskId
|
RunTaskId = runId is null ? null : Activity.Find(runId)?.TaskId
|
||||||
@@ -1890,7 +1894,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
|||||||
var context = BuildToolContext(config, _instanceId, tool, ct, runId: runId) with { FreigabeGeprueft = true };
|
var context = BuildToolContext(config, _instanceId, tool, ct, runId: runId) with { FreigabeGeprueft = true };
|
||||||
var result = await agentTool.ExecuteAsync(input, context, ct);
|
var result = await agentTool.ExecuteAsync(input, context, ct);
|
||||||
|
|
||||||
var status = result.Success ? AuditStatus.Ok : AuditStatus.Error;
|
var status = result.Success ? AuditStatus.Ok : result.IsConflict ? AuditStatus.Conflict : AuditStatus.Error;
|
||||||
await RecordAuditAsync(runId, config, AuditSource.Approval, tool, argumentsJson,
|
await RecordAuditAsync(runId, config, AuditSource.Approval, tool, argumentsJson,
|
||||||
status, result.Success ? "Freigegeben ausgeführt" : (result.ErrorMessage ?? ""), sw.ElapsedMilliseconds);
|
status, result.Success ? "Freigegeben ausgeführt" : (result.ErrorMessage ?? ""), sw.ElapsedMilliseconds);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using ClawdDotNet.Core.Storage;
|
||||||
|
|
||||||
|
namespace ClawdDotNet.Core.Files;
|
||||||
|
|
||||||
|
/// <summary>#720: A write that was refused because it would have lost someone's change.</summary>
|
||||||
|
public sealed record FileConflict(
|
||||||
|
string Path,
|
||||||
|
string Workspace,
|
||||||
|
string AgentId,
|
||||||
|
string? RunId,
|
||||||
|
string? OtherAgentId,
|
||||||
|
string? OtherRunId,
|
||||||
|
string Kind,
|
||||||
|
DateTime OccurredAtUtc)
|
||||||
|
{
|
||||||
|
/// <summary>The file changed after this agent read it.</summary>
|
||||||
|
public const string ChangedSinceRead = "changed-since-read";
|
||||||
|
|
||||||
|
/// <summary>Another agent wrote the file, and this one never read it.</summary>
|
||||||
|
public const string NotRead = "not-read";
|
||||||
|
|
||||||
|
/// <summary>What the agent is told — what happened, and what to do instead.</summary>
|
||||||
|
public string Message(string displayPath)
|
||||||
|
{
|
||||||
|
var who = OtherAgentId is null ? "someone else" : $"agent '{OtherAgentId}'";
|
||||||
|
return Kind == NotRead
|
||||||
|
? $"Conflict: {displayPath} was last written by {who}, and you have not read it. " +
|
||||||
|
"Read it first and make your change on its current content — or use 'append' if you only add to it."
|
||||||
|
: $"Conflict: {displayPath} was changed by {who} since you read it. Your write would overwrite that change. " +
|
||||||
|
"Read it again and make your change on the current content — or use 'append' if you only add to it.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>#720: A file that keeps running into conflicts — the candidate for a different kind of storage.</summary>
|
||||||
|
public sealed record FileConflictHotspot(string Path, string Workspace, int Conflicts, DateTime LastAtUtc, IReadOnlyList<string> Agents);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #720: Lost updates in shared workspaces, found instead of waited for.
|
||||||
|
///
|
||||||
|
/// <para><b>Optimistic, not locked.</b> A lock held across model steps lasts minutes, is left
|
||||||
|
/// behind when a run breaks off, and makes the waiting agents burn steps. Instead the
|
||||||
|
/// instance remembers which version of a file each agent read. A write that replaces a file
|
||||||
|
/// compares the file as it is now with that version; if someone changed it in between, the
|
||||||
|
/// write is refused and the agent reads again. The agent needs no parameter for this, so it
|
||||||
|
/// holds for a cheap model that would forget one.</para>
|
||||||
|
///
|
||||||
|
/// <para><b>What is remembered where.</b> Reads and last writers live in memory: after a
|
||||||
|
/// restart there is no run that could build on an earlier read, and a file whose writer is
|
||||||
|
/// unknown may be overwritten as before. Conflicts are kept in the database — they are what
|
||||||
|
/// shows which files should become something else (split per agent, append-only, a table).</para>
|
||||||
|
///
|
||||||
|
/// <para>Appending is not checked: it loses nothing. It is noted, though — whoever read the
|
||||||
|
/// file before the append does not know the appended part, and a later replace by them is a
|
||||||
|
/// conflict. That is the point.</para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FileLedger
|
||||||
|
{
|
||||||
|
private sealed record Mark(string Hash, string? RunId, DateTime AtUtc, string AgentId);
|
||||||
|
|
||||||
|
private readonly ConcurrentDictionary<(string Path, string Agent), Mark> _reads = new();
|
||||||
|
private readonly ConcurrentDictionary<string, Mark> _writers = new();
|
||||||
|
private readonly SqliteStorage? _storage;
|
||||||
|
private readonly TimeProvider _clock;
|
||||||
|
|
||||||
|
public FileLedger(SqliteStorage? storage = null, TimeProvider? clock = null)
|
||||||
|
{
|
||||||
|
_storage = storage;
|
||||||
|
_clock = clock ?? TimeProvider.System;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The SHA-256 of a file's bytes; <c>null</c> if it does not exist.</summary>
|
||||||
|
public static string? HashOf(string path)
|
||||||
|
{
|
||||||
|
if (!File.Exists(path))
|
||||||
|
return null;
|
||||||
|
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
|
||||||
|
return Convert.ToHexStringLower(SHA256.HashData(stream));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>An agent read the file in this version.</summary>
|
||||||
|
public void NoteRead(string agentId, string path, string? hash)
|
||||||
|
{
|
||||||
|
if (hash is null)
|
||||||
|
return;
|
||||||
|
_reads[(Key(path), Agent(agentId))] = new Mark(hash, null, Now, agentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether an agent may replace the file, given what it is now. <c>null</c>: yes. Pure
|
||||||
|
/// apart from the lookups — the caller holds the file's lock while it checks and writes.
|
||||||
|
/// </summary>
|
||||||
|
public FileConflict? CheckReplace(string agentId, string? runId, string path, string workspace, string? currentHash)
|
||||||
|
{
|
||||||
|
if (currentHash is null)
|
||||||
|
return null; // a new file replaces nothing
|
||||||
|
|
||||||
|
var key = Key(path);
|
||||||
|
_writers.TryGetValue(key, out var writer);
|
||||||
|
// The writer we know of is the one who made the file as it is now — otherwise
|
||||||
|
// someone we do not know of (a human, a tool without the ledger) changed it since.
|
||||||
|
var knownWriter = writer is not null && writer.Hash == currentHash ? writer : null;
|
||||||
|
|
||||||
|
if (_reads.TryGetValue((key, Agent(agentId)), out var read))
|
||||||
|
{
|
||||||
|
return read.Hash == currentHash
|
||||||
|
? null
|
||||||
|
: Conflict(FileConflict.ChangedSinceRead);
|
||||||
|
}
|
||||||
|
|
||||||
|
return knownWriter is not null && !SameAgent(knownWriter.AgentId, agentId)
|
||||||
|
? Conflict(FileConflict.NotRead)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
FileConflict Conflict(string kind) => new(
|
||||||
|
path, workspace, agentId, runId,
|
||||||
|
knownWriter is not null && !SameAgent(knownWriter.AgentId, agentId) ? knownWriter.AgentId : null,
|
||||||
|
knownWriter is not null && !SameAgent(knownWriter.AgentId, agentId) ? knownWriter.RunId : null,
|
||||||
|
kind, Now);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The agent wrote the whole file: it is the last writer and knows what it wrote.</summary>
|
||||||
|
public void NoteReplace(string agentId, string? runId, string path, string? newHash)
|
||||||
|
{
|
||||||
|
if (newHash is null)
|
||||||
|
return;
|
||||||
|
var key = Key(path);
|
||||||
|
var mark = new Mark(newHash, runId, Now, agentId);
|
||||||
|
_writers[key] = mark;
|
||||||
|
_reads[(key, Agent(agentId))] = mark;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The agent appended. It is the last writer; its own read stays valid only if it knew
|
||||||
|
/// the file as it was before — everyone else's read is now of an older version.
|
||||||
|
/// </summary>
|
||||||
|
public void NoteAppend(string agentId, string? runId, string path, string? hashBefore, string? hashAfter)
|
||||||
|
{
|
||||||
|
if (hashAfter is null)
|
||||||
|
return;
|
||||||
|
var key = Key(path);
|
||||||
|
var mark = new Mark(hashAfter, runId, Now, agentId);
|
||||||
|
_writers[key] = mark;
|
||||||
|
var own = (key, Agent(agentId));
|
||||||
|
if (hashBefore is null || (_reads.TryGetValue(own, out var read) && read.Hash == hashBefore))
|
||||||
|
_reads[own] = mark;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Keeps the conflict for the analysis. Best effort — the refusal itself does not depend on it.</summary>
|
||||||
|
public async Task RecordAsync(FileConflict conflict, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (_storage is null)
|
||||||
|
return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _storage.WriteAsync(async conn =>
|
||||||
|
{
|
||||||
|
using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = """
|
||||||
|
INSERT INTO FileConflicts (Path, Workspace, AgentId, RunId, OtherAgentId, OtherRunId, Kind, OccurredAt)
|
||||||
|
VALUES (@path, @workspace, @agent, @run, @other, @otherRun, @kind, @at)
|
||||||
|
""";
|
||||||
|
cmd.Parameters.AddWithValue("@path", conflict.Path);
|
||||||
|
cmd.Parameters.AddWithValue("@workspace", conflict.Workspace);
|
||||||
|
cmd.Parameters.AddWithValue("@agent", conflict.AgentId);
|
||||||
|
cmd.Parameters.AddWithValue("@run", (object?)conflict.RunId ?? DBNull.Value);
|
||||||
|
cmd.Parameters.AddWithValue("@other", (object?)conflict.OtherAgentId ?? DBNull.Value);
|
||||||
|
cmd.Parameters.AddWithValue("@otherRun", (object?)conflict.OtherRunId ?? DBNull.Value);
|
||||||
|
cmd.Parameters.AddWithValue("@kind", conflict.Kind);
|
||||||
|
cmd.Parameters.AddWithValue("@at", conflict.OccurredAtUtc.ToString("O", CultureInfo.InvariantCulture));
|
||||||
|
await cmd.ExecuteNonQueryAsync(ct);
|
||||||
|
}, ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
// The conflict was refused all the same; only the count is missing one.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The files with at least <paramref name="minimum"/> conflicts since <paramref name="sinceUtc"/>, most first.</summary>
|
||||||
|
public async Task<IReadOnlyList<FileConflictHotspot>> HotspotsAsync(DateTime sinceUtc, int minimum, int limit, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (_storage is null)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
await using var conn = await _storage.OpenConnectionAsync(ct);
|
||||||
|
using var cmd = conn.CreateCommand();
|
||||||
|
cmd.CommandText = """
|
||||||
|
SELECT Path, Workspace, AgentId, OtherAgentId, OccurredAt
|
||||||
|
FROM FileConflicts
|
||||||
|
WHERE OccurredAt >= @since
|
||||||
|
""";
|
||||||
|
cmd.Parameters.AddWithValue("@since", sinceUtc.ToString("O", CultureInfo.InvariantCulture));
|
||||||
|
|
||||||
|
var rows = new List<(string Path, string Workspace, string Agent, string? Other, DateTime At)>();
|
||||||
|
await using (var reader = await cmd.ExecuteReaderAsync(ct))
|
||||||
|
{
|
||||||
|
while (await reader.ReadAsync(ct))
|
||||||
|
{
|
||||||
|
rows.Add((reader.GetString(0), reader.GetString(1), reader.GetString(2),
|
||||||
|
reader.IsDBNull(3) ? null : reader.GetString(3),
|
||||||
|
DateTime.Parse(reader.GetString(4), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows
|
||||||
|
.GroupBy(r => (r.Path, r.Workspace))
|
||||||
|
.Select(g => new FileConflictHotspot(
|
||||||
|
g.Key.Path, g.Key.Workspace, g.Count(), g.Max(r => r.At),
|
||||||
|
g.SelectMany(r => r.Other is null ? new[] { r.Agent } : new[] { r.Agent, r.Other })
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase).Order(StringComparer.OrdinalIgnoreCase).ToList()))
|
||||||
|
.Where(h => h.Conflicts >= minimum)
|
||||||
|
.OrderByDescending(h => h.Conflicts).ThenByDescending(h => h.LastAtUtc)
|
||||||
|
.Take(limit)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private DateTime Now => _clock.GetUtcNow().UtcDateTime;
|
||||||
|
|
||||||
|
private static bool SameAgent(string a, string b) => string.Equals(a, b, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private static string Agent(string agentId) => agentId.ToLowerInvariant();
|
||||||
|
|
||||||
|
/// <summary>One key per file, however the path was written; case-insensitive where the file system is.</summary>
|
||||||
|
private static string Key(string path)
|
||||||
|
{
|
||||||
|
var full = Path.GetFullPath(path);
|
||||||
|
return OperatingSystem.IsWindows() || OperatingSystem.IsMacOS() ? full.ToLowerInvariant() : full;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -374,6 +374,20 @@ public sealed class SqliteStorage
|
|||||||
Json TEXT NOT NULL
|
Json TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- #720: Writes refused because they would have lost someone's change.
|
||||||
|
CREATE TABLE IF NOT EXISTS FileConflicts (
|
||||||
|
Id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
Path TEXT NOT NULL,
|
||||||
|
Workspace TEXT NOT NULL,
|
||||||
|
AgentId TEXT NOT NULL,
|
||||||
|
RunId TEXT NULL,
|
||||||
|
OtherAgentId TEXT NULL,
|
||||||
|
OtherRunId TEXT NULL,
|
||||||
|
Kind TEXT NOT NULL,
|
||||||
|
OccurredAt TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS IX_FileConflicts_OccurredAt ON FileConflicts (OccurredAt);
|
||||||
|
|
||||||
-- #713: Personal tokens for the MCP server — only the hash is kept.
|
-- #713: Personal tokens for the MCP server — only the hash is kept.
|
||||||
CREATE TABLE IF NOT EXISTS McpTokens (
|
CREATE TABLE IF NOT EXISTS McpTokens (
|
||||||
Id TEXT PRIMARY KEY,
|
Id TEXT PRIMARY KEY,
|
||||||
|
|||||||
@@ -55,6 +55,12 @@ public sealed record AgentToolContext(
|
|||||||
/// <summary>#717: Special budgets — set up by an approved request_budget.</summary>
|
/// <summary>#717: Special budgets — set up by an approved request_budget.</summary>
|
||||||
public Budget.ISpecialBudgetRepository? SpecialBudgets { get; init; }
|
public Budget.ISpecialBudgetRepository? SpecialBudgets { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #720: Which version of a shared file each agent read. Tools that replace files in a
|
||||||
|
/// shared workspace check it first. <c>null</c> in tests and outside the engine: no check.
|
||||||
|
/// </summary>
|
||||||
|
public Files.FileLedger? Files { get; init; }
|
||||||
|
|
||||||
/// <summary>#697: The run this call belongs to — for stamping where something came from.</summary>
|
/// <summary>#697: The run this call belongs to — for stamping where something came from.</summary>
|
||||||
public string? RunId { get; init; }
|
public string? RunId { get; init; }
|
||||||
|
|
||||||
|
|||||||
@@ -9,4 +9,9 @@ public sealed record ToolResult(
|
|||||||
public static ToolResult Ok(string content) => new(true, content);
|
public static ToolResult Ok(string content) => new(true, content);
|
||||||
|
|
||||||
public static ToolResult Fail(string error) => new(false, "", error);
|
public static ToolResult Fail(string error) => new(false, "", error);
|
||||||
|
|
||||||
|
/// <summary>#720: Refused because it would have lost another write — audited as a conflict, not as an error.</summary>
|
||||||
|
public bool IsConflict { get; init; }
|
||||||
|
|
||||||
|
public static ToolResult Conflict(string error) => new(false, "", error) { IsConflict = true };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -869,6 +869,32 @@ button.klein {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* #720: files with repeated write conflicts, above the team canvas */
|
||||||
|
.team-konflikte {
|
||||||
|
border: 1px solid var(--divider);
|
||||||
|
border-left: 4px solid var(--gefahr, #c0392b);
|
||||||
|
background: var(--surface);
|
||||||
|
padding: 10px 14px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-konflikte[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-konflikte p {
|
||||||
|
margin: 4px 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-konflikte ul {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-konflikte code {
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
/* #712: the runtime card */
|
/* #712: the runtime card */
|
||||||
.laufzeit-feld {
|
.laufzeit-feld {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -982,5 +982,9 @@
|
|||||||
"runtime.oauthToken": "Abo-Token",
|
"runtime.oauthToken": "Abo-Token",
|
||||||
"runtime.oauthTokenHint": "Erzeugt mit 'claude setup-token' auf einem Rechner, an dem du angemeldet bist.",
|
"runtime.oauthTokenHint": "Erzeugt mit 'claude setup-token' auf einem Rechner, an dem du angemeldet bist.",
|
||||||
"runtime.keepStored": "hinterlegt – leer lassen zum Behalten",
|
"runtime.keepStored": "hinterlegt – leer lassen zum Behalten",
|
||||||
"runtime.accountSaved": "Claude-Code-Konto gespeichert."
|
"runtime.accountSaved": "Claude-Code-Konto gespeichert.",
|
||||||
|
|
||||||
|
"team.conflicts.title": "Dateien, in denen sich Agenten gegenseitig überschreiben",
|
||||||
|
"team.conflicts.hint": "Hier wurden Schreibvorgänge abgelehnt, weil sie die Änderung eines anderen Agenten verloren hätten. Passiert das oft, sollte die Ablage anders werden: eine Datei je Agent oder Tag, Anhängen statt Ersetzen oder eine Tabelle.",
|
||||||
|
"team.conflicts.count": "{n} Konflikte in 7 Tagen, zwischen {agents}; zuletzt {time}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -982,5 +982,9 @@
|
|||||||
"runtime.oauthToken": "Subscription token",
|
"runtime.oauthToken": "Subscription token",
|
||||||
"runtime.oauthTokenHint": "Created with 'claude setup-token' on a machine where you are signed in.",
|
"runtime.oauthTokenHint": "Created with 'claude setup-token' on a machine where you are signed in.",
|
||||||
"runtime.keepStored": "stored — leave empty to keep",
|
"runtime.keepStored": "stored — leave empty to keep",
|
||||||
"runtime.accountSaved": "Claude Code account saved."
|
"runtime.accountSaved": "Claude Code account saved.",
|
||||||
|
|
||||||
|
"team.conflicts.title": "Files agents keep overwriting each other in",
|
||||||
|
"team.conflicts.hint": "Writes were refused here because they would have lost another agent's change. If it keeps happening, change how the file is kept: one file per agent or day, appending instead of replacing, or a table.",
|
||||||
|
"team.conflicts.count": "{n} conflicts in 7 days, between {agents}; last {time}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ let matchInfo = null;
|
|||||||
let zoomValue = null;
|
let zoomValue = null;
|
||||||
let panel = null;
|
let panel = null;
|
||||||
let summary = null;
|
let summary = null;
|
||||||
|
let hotspots = null;
|
||||||
|
|
||||||
let unsubscribe = null;
|
let unsubscribe = null;
|
||||||
let refreshTimer = null;
|
let refreshTimer = null;
|
||||||
@@ -151,8 +152,12 @@ export async function renderTeam() {
|
|||||||
panel = erstelleElement("aside", { klasse: "team-seitenleiste" });
|
panel = erstelleElement("aside", { klasse: "team-seitenleiste" });
|
||||||
panel.hidden = true;
|
panel.hidden = true;
|
||||||
|
|
||||||
|
// #720: files agents keep overwriting each other in — hidden while there are none.
|
||||||
|
hotspots = erstelleElement("div", { klasse: "team-konflikte", role: "status" });
|
||||||
|
hotspots.hidden = true;
|
||||||
|
|
||||||
const area = erstelleElement("div", { klasse: "team-arbeitsflaeche" }, buildTree(), canvas, panel);
|
const area = erstelleElement("div", { klasse: "team-arbeitsflaeche" }, buildTree(), canvas, panel);
|
||||||
container.replaceChildren(erstelleElement("div", { klasse: "team-ansicht" }, head, area, buildLegend()));
|
container.replaceChildren(erstelleElement("div", { klasse: "team-ansicht" }, head, hotspots, area, buildLegend()));
|
||||||
|
|
||||||
if (!holeAktiveInstanz()) {
|
if (!holeAktiveInstanz()) {
|
||||||
canvas.replaceChildren(erstelleElement("div", { klasse: "hinweis-zentriert", text: t("team.noInstance") }));
|
canvas.replaceChildren(erstelleElement("div", { klasse: "hinweis-zentriert", text: t("team.noInstance") }));
|
||||||
@@ -1068,6 +1073,28 @@ function renderSummary() {
|
|||||||
summary.replaceChildren(
|
summary.replaceChildren(
|
||||||
...(parts.length ? parts : [erstelleElement("span", { klasse: "gedaempft", text: t("team.empty") })])
|
...(parts.length ? parts : [erstelleElement("span", { klasse: "gedaempft", text: t("team.empty") })])
|
||||||
);
|
);
|
||||||
|
renderHotspots();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #720: Files with repeated write conflicts in the last week. Detecting is code; what to do
|
||||||
|
* about it — split the file, append instead, make it a table — is a human's call.
|
||||||
|
*/
|
||||||
|
function renderHotspots() {
|
||||||
|
if (!hotspots || !snapshot) return;
|
||||||
|
const list = snapshot.conflictHotspots ?? [];
|
||||||
|
hotspots.hidden = list.length === 0;
|
||||||
|
if (list.length === 0) {
|
||||||
|
hotspots.replaceChildren();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hotspots.replaceChildren(
|
||||||
|
erstelleElement("strong", { text: `⚠ ${t("team.conflicts.title")}` }),
|
||||||
|
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("team.conflicts.hint") }),
|
||||||
|
erstelleElement("ul", {},
|
||||||
|
...list.map((h) => erstelleElement("li", {},
|
||||||
|
erstelleElement("code", { text: h.path }),
|
||||||
|
` — ${t("team.conflicts.count", { n: h.conflicts, agents: h.agents.join(", "), time: formatiereDatumZeit(h.lastAtUtc) })}`))));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The card tones by the names the badges use. */
|
/** The card tones by the names the badges use. */
|
||||||
|
|||||||
@@ -317,10 +317,20 @@ public sealed class FTPTool : IAgentTool
|
|||||||
{
|
{
|
||||||
var (localFull, wsLabel) = ResolveLocalPath(localRel, context, write: true);
|
var (localFull, wsLabel) = ResolveLocalPath(localRel, context, write: true);
|
||||||
|
|
||||||
|
// #720: a download over a shared file replaces it — not over a change this agent has not seen.
|
||||||
|
var guarded = wsLabel != WorkspaceLayout.Personal && context.Files is not null;
|
||||||
|
if (guarded && context.Files!.CheckReplace(context.AgentId, context.RunId, localFull, wsLabel, Core.Files.FileLedger.HashOf(localFull)) is { } conflict)
|
||||||
|
{
|
||||||
|
await context.Files.RecordAsync(conflict, ct);
|
||||||
|
return ToolResult.Conflict(conflict.Message(localRel));
|
||||||
|
}
|
||||||
|
|
||||||
var localDir = Path.GetDirectoryName(localFull);
|
var localDir = Path.GetDirectoryName(localFull);
|
||||||
if (localDir != null && !Directory.Exists(localDir)) Directory.CreateDirectory(localDir);
|
if (localDir != null && !Directory.Exists(localDir)) Directory.CreateDirectory(localDir);
|
||||||
|
|
||||||
var status = await client.DownloadFile(localFull, remote, FtpLocalExists.Overwrite, FtpVerify.None, null, ct);
|
var status = await client.DownloadFile(localFull, remote, FtpLocalExists.Overwrite, FtpVerify.None, null, ct);
|
||||||
|
if (guarded && status == FtpStatus.Success)
|
||||||
|
context.Files!.NoteReplace(context.AgentId, context.RunId, localFull, Core.Files.FileLedger.HashOf(localFull));
|
||||||
return status == FtpStatus.Success
|
return status == FtpStatus.Success
|
||||||
? ToolResult.Ok($"[{wsLabel} Workspace] File successfully downloaded: {remote} → {localRel}")
|
? ToolResult.Ok($"[{wsLabel} Workspace] File successfully downloaded: {remote} → {localRel}")
|
||||||
: ToolResult.Fail($"Download failed: {status}");
|
: ToolResult.Fail($"Download failed: {status}");
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using ClawdDotNet.Core.Files;
|
||||||
using ClawdDotNet.Core.Config;
|
using ClawdDotNet.Core.Config;
|
||||||
using ClawdDotNet.Core.Organisation;
|
using ClawdDotNet.Core.Organisation;
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
@@ -310,6 +311,41 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
|||||||
|| relative.StartsWith(TasksFolder + "/", StringComparison.OrdinalIgnoreCase);
|
|| relative.StartsWith(TasksFolder + "/", StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Lost updates (#720) ───
|
||||||
|
|
||||||
|
/// <summary>Only workspaces more than one agent can write to — never the personal one.</summary>
|
||||||
|
private static bool Guarded(Area area, AgentToolContext context) => !area.IsPersonal && context.Files is not null;
|
||||||
|
|
||||||
|
private static void NoteRead(string path, Area area, AgentToolContext context)
|
||||||
|
{
|
||||||
|
if (Guarded(area, context))
|
||||||
|
context.Files!.NoteRead(context.AgentId, path, FileLedger.HashOf(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void NoteReplace(string path, Area area, AgentToolContext context)
|
||||||
|
{
|
||||||
|
if (Guarded(area, context))
|
||||||
|
context.Files!.NoteReplace(context.AgentId, context.RunId, path, FileLedger.HashOf(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The refusal when replacing the file would lose a change this agent has not seen —
|
||||||
|
/// kept for the analysis, audited as a conflict. <c>null</c>: go ahead. Called under the
|
||||||
|
/// file's lock, so nothing can change between the check and the write.
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<ToolResult?> RefuseLostUpdateAsync(
|
||||||
|
string path, string displayPath, Area area, AgentToolContext context, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!Guarded(area, context))
|
||||||
|
return null;
|
||||||
|
if (context.Files!.CheckReplace(context.AgentId, context.RunId, path, area.Name, FileLedger.HashOf(path)) is not { } conflict)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
context.Logger.LogInformation("Write refused, lost update: {Path} by {Agent} ({Kind})", displayPath, context.AgentId, conflict.Kind);
|
||||||
|
await context.Files.RecordAsync(conflict, ct);
|
||||||
|
return ToolResult.Conflict(conflict.Message(displayPath));
|
||||||
|
}
|
||||||
|
|
||||||
private static List<string> NormaliseProtectedPaths(IEnumerable<string> paths)
|
private static List<string> NormaliseProtectedPaths(IEnumerable<string> paths)
|
||||||
=> paths
|
=> paths
|
||||||
.Select(p => p.Replace('\\', '/').Trim().Trim('/'))
|
.Select(p => p.Replace('\\', '/').Trim().Trim('/'))
|
||||||
@@ -504,6 +540,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var content = await File.ReadAllTextAsync(path, ct);
|
var content = await File.ReadAllTextAsync(path, ct);
|
||||||
|
NoteRead(path, area, context);
|
||||||
var relativePath = Path.GetRelativePath(rootPath, path);
|
var relativePath = Path.GetRelativePath(rootPath, path);
|
||||||
return ToolResult.Ok($"[{area.Name}:/{relativePath}]\n{content}");
|
return ToolResult.Ok($"[{area.Name}:/{relativePath}]\n{content}");
|
||||||
}
|
}
|
||||||
@@ -561,7 +598,11 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
|||||||
var sperre = GetFileLock(pfad);
|
var sperre = GetFileLock(pfad);
|
||||||
await sperre.WaitAsync(ct);
|
await sperre.WaitAsync(ct);
|
||||||
string inhalt;
|
string inhalt;
|
||||||
try { inhalt = await File.ReadAllTextAsync(pfad, ct); }
|
try
|
||||||
|
{
|
||||||
|
inhalt = await File.ReadAllTextAsync(pfad, ct);
|
||||||
|
NoteRead(pfad, area, context);
|
||||||
|
}
|
||||||
finally { sperre.Release(); }
|
finally { sperre.Release(); }
|
||||||
|
|
||||||
text.Append($"\n--- {area.Name}:/{relativ} ---\n").Append(inhalt.TrimEnd()).Append('\n');
|
text.Append($"\n--- {area.Name}:/{relativ} ---\n").Append(inhalt.TrimEnd()).Append('\n');
|
||||||
@@ -665,6 +706,10 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
|||||||
$"Tip: Use 'stock_add' for structured data points.");
|
$"Tip: Use 'stock_add' for structured data points.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #720: not over a change this agent has not seen.
|
||||||
|
if (await RefuseLostUpdateAsync(path, $"{area.Name}:/{Path.GetRelativePath(rootPath, path).Replace('\\', '/')}", area, context, ct) is { } conflict)
|
||||||
|
return conflict;
|
||||||
|
|
||||||
var dir = Path.GetDirectoryName(path);
|
var dir = Path.GetDirectoryName(path);
|
||||||
if (dir != null && !Directory.Exists(dir)) Directory.CreateDirectory(dir);
|
if (dir != null && !Directory.Exists(dir)) Directory.CreateDirectory(dir);
|
||||||
|
|
||||||
@@ -674,6 +719,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
|||||||
// schützte also ihre eigenen Konfigurationsdateien, die Arbeitsergebnisse der
|
// schützte also ihre eigenen Konfigurationsdateien, die Arbeitsergebnisse der
|
||||||
// Agenten aber nicht.
|
// Agenten aber nicht.
|
||||||
await AtomicFile.WriteAllTextAsync(path, content, new UTF8Encoding(false), ct);
|
await AtomicFile.WriteAllTextAsync(path, content, new UTF8Encoding(false), ct);
|
||||||
|
NoteReplace(path, area, context);
|
||||||
return ToolResult.Ok($"[{area.Name} Workspace] File successfully written: {area.Name}:/{Path.GetRelativePath(rootPath, path)}");
|
return ToolResult.Ok($"[{area.Name} Workspace] File successfully written: {area.Name}:/{Path.GetRelativePath(rootPath, path)}");
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
@@ -695,10 +741,17 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
|||||||
// die ganze Datei neu geschrieben wird — bei einer großen Protokolldatei wäre
|
// die ganze Datei neu geschrieben wird — bei einer großen Protokolldatei wäre
|
||||||
// das der falsche Handel. Hier bleibt ein Riss möglich; genau dafür gibt es
|
// das der falsche Handel. Hier bleibt ein Riss möglich; genau dafür gibt es
|
||||||
// die Zustandsprüfung in #555.
|
// die Zustandsprüfung in #555.
|
||||||
|
// #720: appending loses nothing and is not checked — but noted, so whoever read
|
||||||
|
// the file before does not replace it without the appended part.
|
||||||
|
var before = Guarded(area, context) ? FileLedger.HashOf(path) : null;
|
||||||
|
|
||||||
// #575: Neu entstehende Dateien gehoeren nur dem Besitzer (0600).
|
// #575: Neu entstehende Dateien gehoeren nur dem Besitzer (0600).
|
||||||
await using (var stream = ClawdDotNet.Core.Storage.OwnerOnlyFile.OpenAppend(path))
|
await using (var stream = ClawdDotNet.Core.Storage.OwnerOnlyFile.OpenAppend(path))
|
||||||
await using (var writer = new StreamWriter(stream, new UTF8Encoding(false)))
|
await using (var writer = new StreamWriter(stream, new UTF8Encoding(false)))
|
||||||
await writer.WriteAsync(content.AsMemory(), ct);
|
await writer.WriteAsync(content.AsMemory(), ct);
|
||||||
|
|
||||||
|
if (Guarded(area, context))
|
||||||
|
context.Files!.NoteAppend(context.AgentId, context.RunId, path, before, FileLedger.HashOf(path));
|
||||||
return ToolResult.Ok($"[{area.Name} Workspace] Content successfully appended to: {area.Name}:/{Path.GetRelativePath(rootPath, path)}");
|
return ToolResult.Ok($"[{area.Name} Workspace] Content successfully appended to: {area.Name}:/{Path.GetRelativePath(rootPath, path)}");
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
@@ -909,10 +962,15 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|
|||||||
await second.WaitAsync(ct);
|
await second.WaitAsync(ct);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
// #720: copying over a file replaces it — the same check as a write.
|
||||||
|
if (await RefuseLostUpdateAsync(destPath, $"{destArea.Name}:/{Path.GetRelativePath(destRootPath, destPath).Replace('\\', '/')}", destArea, context, ct) is { } conflict)
|
||||||
|
return conflict;
|
||||||
|
|
||||||
var destDir = Path.GetDirectoryName(destPath);
|
var destDir = Path.GetDirectoryName(destPath);
|
||||||
if (destDir != null && !Directory.Exists(destDir)) Directory.CreateDirectory(destDir);
|
if (destDir != null && !Directory.Exists(destDir)) Directory.CreateDirectory(destDir);
|
||||||
|
|
||||||
await Task.Run(() => File.Copy(sourcePath, destPath, overwrite: !IsPathProtected(destPath, destRootPath, destArea)), ct);
|
await Task.Run(() => File.Copy(sourcePath, destPath, overwrite: !IsPathProtected(destPath, destRootPath, destArea)), ct);
|
||||||
|
NoteReplace(destPath, destArea, context);
|
||||||
|
|
||||||
var srcLabel = $"{sourceArea.Name}:/{Path.GetRelativePath(sourceRootPath, sourcePath)}";
|
var srcLabel = $"{sourceArea.Name}:/{Path.GetRelativePath(sourceRootPath, sourcePath)}";
|
||||||
var dstLabel = $"{destArea.Name}:/{Path.GetRelativePath(destRootPath, destPath)}";
|
var dstLabel = $"{destArea.Name}:/{Path.GetRelativePath(destRootPath, destPath)}";
|
||||||
|
|||||||
@@ -255,9 +255,19 @@ public sealed class RocketChatTool : IAgentTool, IToolJobProvider
|
|||||||
if (file is null)
|
if (file is null)
|
||||||
return ToolResult.Fail(pathError!);
|
return ToolResult.Fail(pathError!);
|
||||||
|
|
||||||
|
// #720: a download over a shared file replaces it — not over a change this agent has not seen.
|
||||||
|
var guarded = file.Label != WorkspaceLayout.Personal && context.Files is not null;
|
||||||
|
if (guarded && context.Files!.CheckReplace(context.AgentId, context.RunId, file.FullPath, file.Label, Core.Files.FileLedger.HashOf(file.FullPath)) is { } conflict)
|
||||||
|
{
|
||||||
|
await context.Files.RecordAsync(conflict, ct);
|
||||||
|
return ToolResult.Conflict(conflict.Message($"{file.Label}:{targetName}"));
|
||||||
|
}
|
||||||
|
|
||||||
Directory.CreateDirectory(Path.GetDirectoryName(file.FullPath)!);
|
Directory.CreateDirectory(Path.GetDirectoryName(file.FullPath)!);
|
||||||
|
|
||||||
var bytes = await client.DownloadToFileAsync(fileId, file.FullPath, config.MaxDownloadBytes, ct);
|
var bytes = await client.DownloadToFileAsync(fileId, file.FullPath, config.MaxDownloadBytes, ct);
|
||||||
|
if (guarded)
|
||||||
|
context.Files!.NoteReplace(context.AgentId, context.RunId, file.FullPath, Core.Files.FileLedger.HashOf(file.FullPath));
|
||||||
|
|
||||||
return ToolResult.Ok(JsonSerializer.Serialize(new
|
return ToolResult.Ok(JsonSerializer.Serialize(new
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -189,7 +189,11 @@ public sealed record TeamSnapshotDto(
|
|||||||
IReadOnlyList<TeamMemberDto> Members,
|
IReadOnlyList<TeamMemberDto> Members,
|
||||||
IReadOnlyList<AgentLinkDto> Links,
|
IReadOnlyList<AgentLinkDto> Links,
|
||||||
DateTime AtUtc,
|
DateTime AtUtc,
|
||||||
IReadOnlyList<TeamUnitDto> Units);
|
IReadOnlyList<TeamUnitDto> Units,
|
||||||
|
IReadOnlyList<ConflictHotspotDto> ConflictHotspots);
|
||||||
|
|
||||||
|
/// <summary>#720: A file agents keep overwriting each other in — shown with a warning. The path is relative to the instance's Agents folder.</summary>
|
||||||
|
public sealed record ConflictHotspotDto(string Workspace, string Path, int Conflicts, DateTime LastAtUtc, IReadOnlyList<string> Agents);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// #711, stage 1: A unit with the states of everyone in it and below, counted by the
|
/// #711, stage 1: A unit with the states of everyone in it and below, counted by the
|
||||||
|
|||||||
@@ -101,7 +101,9 @@ internal static class Mapping
|
|||||||
(snapshot.Units ?? []).Select(u => new TeamUnitDto(
|
(snapshot.Units ?? []).Select(u => new TeamUnitDto(
|
||||||
u.Id, u.Name, u.ParentId, u.Path, u.LeaderAgentId, u.Goal, u.Agents,
|
u.Id, u.Name, u.ParentId, u.Path, u.LeaderAgentId, u.Goal, u.Agents,
|
||||||
u.States.ToDictionary(s => s.Key.ToString(), s => s.Value),
|
u.States.ToDictionary(s => s.Key.ToString(), s => s.Value),
|
||||||
u.PendingApprovals, u.Worst.ToString())).ToList());
|
u.PendingApprovals, u.Worst.ToString())).ToList(),
|
||||||
|
(snapshot.ConflictHotspots ?? []).Select(h => new ConflictHotspotDto(
|
||||||
|
h.Workspace, h.Path, h.Conflicts, h.LastAtUtc, h.Agents)).ToList());
|
||||||
|
|
||||||
public static ActiveRunDto ToDto(this Core.Activity.ActiveRun run)
|
public static ActiveRunDto ToDto(this Core.Activity.ActiveRun run)
|
||||||
=> new(run.RunId, run.AgentId, run.Source, run.TaskId, run.CalledBy, run.StartedAtUtc);
|
=> new(run.RunId, run.AgentId, run.Source, run.TaskId, run.CalledBy, run.StartedAtUtc);
|
||||||
|
|||||||
@@ -162,4 +162,28 @@ public sealed class TeamOverviewTests
|
|||||||
public void Without_units_there_is_nothing_to_roll_up()
|
public void Without_units_there_is_nothing_to_roll_up()
|
||||||
=> TeamOverview.RollUp(new OrgChart([], [new AgentConfig { AgentId = "a" }]), [Member("a", null, TeamState.Problem)])
|
=> TeamOverview.RollUp(new OrgChart([], [new AgentConfig { AgentId = "a" }]), [Member("a", null, TeamState.Problem)])
|
||||||
.ShouldBeEmpty();
|
.ShouldBeEmpty();
|
||||||
|
|
||||||
|
// ─── #720: conflict hotspots ───
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_hotspot_is_shown_relative_to_the_agents_folder()
|
||||||
|
{
|
||||||
|
var agents = Path.Combine(Path.GetTempPath(), "inst", "Agents");
|
||||||
|
var configured = new List<AgentConfig> { new() { AgentId = "a", SharedWorkspacePath = Path.Combine(agents, "SharedWorkspace") } };
|
||||||
|
|
||||||
|
TeamOverview.Readable(Path.Combine(agents, "SharedWorkspaces", "articles", "list.csv"), configured)
|
||||||
|
.ShouldBe("SharedWorkspaces/articles/list.csv");
|
||||||
|
TeamOverview.Readable(Path.Combine(agents, "SharedWorkspace", "plan.md"), configured)
|
||||||
|
.ShouldBe("SharedWorkspace/plan.md");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void A_path_elsewhere_stays_as_it_is()
|
||||||
|
{
|
||||||
|
var configured = new List<AgentConfig> { new() { AgentId = "a", SharedWorkspacePath = Path.Combine(Path.GetTempPath(), "inst", "Agents", "SharedWorkspace") } };
|
||||||
|
var elsewhere = Path.Combine(Path.GetTempPath(), "other", "x.md");
|
||||||
|
|
||||||
|
TeamOverview.Readable(elsewhere, configured).ShouldBe(elsewhere);
|
||||||
|
TeamOverview.Readable(elsewhere, []).ShouldBe(elsewhere);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,6 +78,14 @@ internal sealed class FakeTool : IAgentTool
|
|||||||
public static FakeTool Failing(string error, string name = "TestTool")
|
public static FakeTool Failing(string error, string name = "TestTool")
|
||||||
=> new(name, (_, _, _) => Task.FromResult(ToolResult.Fail(error)));
|
=> new(name, (_, _, _) => Task.FromResult(ToolResult.Fail(error)));
|
||||||
|
|
||||||
|
/// <summary>#720: Refuses with a write conflict.</summary>
|
||||||
|
public static FakeTool Conflicting(string error, string name = "TestTool")
|
||||||
|
=> new(name, (_, _, _) => Task.FromResult(ToolResult.Conflict(error)));
|
||||||
|
|
||||||
|
/// <summary>Hands the context it was called with to <paramref name="seen"/>.</summary>
|
||||||
|
public static FakeTool Observing(Action<AgentToolContext> seen, string name = "TestTool")
|
||||||
|
=> new(name, (_, context, _) => { seen(context); return Task.FromResult(ToolResult.Ok("ok")); });
|
||||||
|
|
||||||
public static FakeTool Throwing(Exception ex, string name = "TestTool")
|
public static FakeTool Throwing(Exception ex, string name = "TestTool")
|
||||||
=> new(name, (_, _, _) => throw ex);
|
=> new(name, (_, _, _) => throw ex);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user