using System.Collections.Concurrent; using System.Globalization; using System.Security.Cryptography; using ClawdDotNet.Core.Storage; namespace ClawdDotNet.Core.Files; /// #720: A write that was refused because it would have lost someone's change. public sealed record FileConflict( string Path, string Workspace, string AgentId, string? RunId, string? OtherAgentId, string? OtherRunId, string Kind, DateTime OccurredAtUtc) { /// The file changed after this agent read it. public const string ChangedSinceRead = "changed-since-read"; /// Another agent wrote the file, and this one never read it. public const string NotRead = "not-read"; /// What the agent is told — what happened, and what to do instead. 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."; } } /// #720: A file that keeps running into conflicts — the candidate for a different kind of storage. public sealed record FileConflictHotspot(string Path, string Workspace, int Conflicts, DateTime LastAtUtc, IReadOnlyList Agents); /// /// #720: Lost updates in shared workspaces, found instead of waited for. /// /// Optimistic, not locked. 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. /// /// What is remembered where. 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). /// /// 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. /// 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 _writers = new(); private readonly SqliteStorage? _storage; private readonly TimeProvider _clock; public FileLedger(SqliteStorage? storage = null, TimeProvider? clock = null) { _storage = storage; _clock = clock ?? TimeProvider.System; } /// The SHA-256 of a file's bytes; null if it does not exist. 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)); } /// An agent read the file in this version. 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); } /// /// Whether an agent may replace the file, given what it is now. null: yes. Pure /// apart from the lookups — the caller holds the file's lock while it checks and writes. /// 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); } /// The agent wrote the whole file: it is the last writer and knows what it wrote. 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; } /// /// 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. /// 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; } /// Keeps the conflict for the analysis. Best effort — the refusal itself does not depend on it. 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. } } /// The files with at least conflicts since , most first. public async Task> 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(); /// One key per file, however the path was written; case-insensitive where the file system is. private static string Key(string path) { var full = Path.GetFullPath(path); return OperatingSystem.IsWindows() || OperatingSystem.IsMacOS() ? full.ToLowerInvariant() : full; } }