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:
co-authored by
Claude Opus 5.5
parent
8a7212c240
commit
79a3a7854f
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user