using System.Collections.Concurrent; using ClaudeDo.Worker.Hub; namespace ClaudeDo.Worker.Lifecycle; /// /// In-memory registry of Worker operations running right now (verify, merge, rebase, worktree /// creation). registers and broadcasts; disposing the returned scope removes /// the entry, so a failed or thrown operation is always cleaned up by its caller's using/finally. /// public sealed class RunningOperationRegistry { private readonly ConcurrentDictionary _active = new(); private readonly HubBroadcaster? _broadcaster; public RunningOperationRegistry(HubBroadcaster? broadcaster = null) => _broadcaster = broadcaster; /// The operations running right now, oldest first. public IReadOnlyList Snapshot() => _active.Values.OrderBy(o => o.StartedAt).ToList(); /// Registers an operation; dispose the result when it ends (idempotent). public IDisposable Begin(string kind, string title, string? taskId = null, int? taskNumber = null, string? listId = null) { var op = new RunningOperationDto(Guid.NewGuid().ToString("N"), kind, taskId, taskNumber, listId, title, DateTime.UtcNow); _active[op.Id] = op; _ = Broadcast(_broadcaster?.OperationStarted(op)); return new Scope(this, op.Id); } // A broadcast failure must never fail the operation it describes. private static async Task Broadcast(Task? send) { if (send is null) return; try { await send; } catch { /* no clients / hub down */ } } private sealed class Scope(RunningOperationRegistry owner, string id) : IDisposable { private int _disposed; public void Dispose() { if (Interlocked.Exchange(ref _disposed, 1) != 0) return; owner._active.TryRemove(id, out _); _ = Broadcast(owner._broadcaster?.OperationEnded(id)); } } }