Verlorene Updates erkennen statt warten: optimistische Pruefung beim Ersetzen, Konflikte im Audit und im Lagebild (#720)

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>
This commit is contained in:
Richard
2026-09-28 10:45:46 +02:00
co-authored by Claude Opus 5.5
parent 8a7212c240
commit 79a3a7854f
21 changed files with 818 additions and 11 deletions
+3
View File
@@ -598,6 +598,9 @@ public sealed class AppHost : IAsyncDisposable
foreach (var user in tools.GetAll().OfType<Core.Sandbox.ISandboxUser>())
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
// (network through the proxy, secrets from a file) and the MCP bridge back to us.
var mcpGrants = new Core.Mcp.McpGrants(storage);
+32 -2
View File
@@ -67,7 +67,8 @@ public sealed record TeamSnapshot(
IReadOnlyList<TeamMember> Members,
IReadOnlyList<AgentLink> RecentLinks,
DateTime AtUtc,
IReadOnlyList<TeamUnit>? Units = null);
IReadOnlyList<TeamUnit>? Units = null,
IReadOnlyList<Core.Files.FileConflictHotspot>? ConflictHotspots = null);
/// <summary>
/// #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>
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;
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));
}
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>
+8 -1
View File
@@ -37,7 +37,14 @@ public enum AuditStatus
/// <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>
/// </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>
+6 -2
View File
@@ -60,6 +60,9 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
/// </summary>
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>
public Sandbox.ISandboxRunner? Sandbox { get; set; }
@@ -1665,7 +1668,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
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 });
}
@@ -1856,6 +1859,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
Cases = CaseDesk,
SpecialBudgets = SpecialBudgets,
Sandbox = Sandbox,
Files = Files,
// #697: Where something an agent hands on came from — known to the engine, not claimed by the agent.
RunId = runId,
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 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,
status, result.Success ? "Freigegeben ausgeführt" : (result.ErrorMessage ?? ""), sw.ElapsedMilliseconds);
+232
View File
@@ -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
);
-- #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.
CREATE TABLE IF NOT EXISTS McpTokens (
Id TEXT PRIMARY KEY,
@@ -55,6 +55,12 @@ public sealed record AgentToolContext(
/// <summary>#717: Special budgets — set up by an approved request_budget.</summary>
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>
public string? RunId { get; init; }
+5
View File
@@ -9,4 +9,9 @@ public sealed record ToolResult(
public static ToolResult Ok(string content) => new(true, content);
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;
}
/* #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 */
.laufzeit-feld {
display: grid;
@@ -982,5 +982,9 @@
"runtime.oauthToken": "Abo-Token",
"runtime.oauthTokenHint": "Erzeugt mit 'claude setup-token' auf einem Rechner, an dem du angemeldet bist.",
"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.oauthTokenHint": "Created with 'claude setup-token' on a machine where you are signed in.",
"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 panel = null;
let summary = null;
let hotspots = null;
let unsubscribe = null;
let refreshTimer = null;
@@ -151,8 +152,12 @@ export async function renderTeam() {
panel = erstelleElement("aside", { klasse: "team-seitenleiste" });
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);
container.replaceChildren(erstelleElement("div", { klasse: "team-ansicht" }, head, area, buildLegend()));
container.replaceChildren(erstelleElement("div", { klasse: "team-ansicht" }, head, hotspots, area, buildLegend()));
if (!holeAktiveInstanz()) {
canvas.replaceChildren(erstelleElement("div", { klasse: "hinweis-zentriert", text: t("team.noInstance") }));
@@ -1068,6 +1073,28 @@ function renderSummary() {
summary.replaceChildren(
...(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. */
+10
View File
@@ -317,10 +317,20 @@ public sealed class FTPTool : IAgentTool
{
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);
if (localDir != null && !Directory.Exists(localDir)) Directory.CreateDirectory(localDir);
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
? ToolResult.Ok($"[{wsLabel} Workspace] File successfully downloaded: {remote} → {localRel}")
: ToolResult.Fail($"Download failed: {status}");
+59 -1
View File
@@ -1,3 +1,4 @@
using ClawdDotNet.Core.Files;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Organisation;
using System.Collections.Concurrent;
@@ -310,6 +311,41 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
|| 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)
=> paths
.Select(p => p.Replace('\\', '/').Trim().Trim('/'))
@@ -504,6 +540,7 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
try
{
var content = await File.ReadAllTextAsync(path, ct);
NoteRead(path, area, context);
var relativePath = Path.GetRelativePath(rootPath, path);
return ToolResult.Ok($"[{area.Name}:/{relativePath}]\n{content}");
}
@@ -561,7 +598,11 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
var sperre = GetFileLock(pfad);
await sperre.WaitAsync(ct);
string inhalt;
try { inhalt = await File.ReadAllTextAsync(pfad, ct); }
try
{
inhalt = await File.ReadAllTextAsync(pfad, ct);
NoteRead(pfad, area, context);
}
finally { sperre.Release(); }
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.");
}
// #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);
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
// Agenten aber nicht.
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)}");
}
finally
@@ -695,10 +741,17 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
// 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
// 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).
await using (var stream = ClawdDotNet.Core.Storage.OwnerOnlyFile.OpenAppend(path))
await using (var writer = new StreamWriter(stream, new UTF8Encoding(false)))
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)}");
}
finally
@@ -909,10 +962,15 @@ public sealed class FileRWTool : IAgentTool, IToolStateProbe
await second.WaitAsync(ct);
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);
if (destDir != null && !Directory.Exists(destDir)) Directory.CreateDirectory(destDir);
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 dstLabel = $"{destArea.Name}:/{Path.GetRelativePath(destRootPath, destPath)}";
@@ -255,9 +255,19 @@ public sealed class RocketChatTool : IAgentTool, IToolJobProvider
if (file is null)
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)!);
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
{
+5 -1
View File
@@ -189,7 +189,11 @@ public sealed record TeamSnapshotDto(
IReadOnlyList<TeamMemberDto> Members,
IReadOnlyList<AgentLinkDto> Links,
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>
/// #711, stage 1: A unit with the states of everyone in it and below, counted by the
+3 -1
View File
@@ -101,7 +101,9 @@ internal static class Mapping
(snapshot.Units ?? []).Select(u => new TeamUnitDto(
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.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)
=> new(run.RunId, run.AgentId, run.Source, run.TaskId, run.CalledBy, run.StartedAtUtc);