From 2dbec543df0e7e908de31dee8a4599ed9d65109f Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 1 Oct 2026 12:08:43 +0200 Subject: [PATCH] feat(mission-control): show running Worker operations in a Running now section Registry of active verify/merge/rebase/worktree-creation operations in the Worker, GetActiveOperations snapshot + OperationStarted/OperationEnded hub events, and a Running now section in Mission Control with ticking elapsed time. Co-Authored-By: Claude Sonnet 5.5 --- src/ClaudeDo.Data/Wire.cs | 15 +++ src/ClaudeDo.Localization/locales/de.json | 7 ++ src/ClaudeDo.Localization/locales/en.json | 7 ++ .../Services/Interfaces/IWorkerClient.cs | 8 ++ src/ClaudeDo.Ui/Services/WorkerClient.cs | 15 +++ .../ViewModels/MissionControlViewModel.cs | 101 ++++++++++++++++- .../MissionControl/MissionControlView.axaml | 41 ++++++- .../MissionControlView.axaml.cs | 22 +++- src/ClaudeDo.Worker/Hub/HubBroadcaster.cs | 8 ++ src/ClaudeDo.Worker/Hub/WorkerHub.cs | 9 +- .../Lifecycle/RunningOperationRegistry.cs | 49 ++++++++ .../Lifecycle/TaskMergeService.cs | 33 ++++-- src/ClaudeDo.Worker/Program.cs | 1 + src/ClaudeDo.Worker/Runner/WorktreeManager.cs | 7 +- tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs | 5 + .../MissionControlRunningOperationsTests.cs | 107 ++++++++++++++++++ .../RunningOperationRegistryTests.cs | 55 +++++++++ .../Services/TaskMergeServiceTests.cs | 67 ++++++++++- 18 files changed, 539 insertions(+), 18 deletions(-) create mode 100644 src/ClaudeDo.Worker/Lifecycle/RunningOperationRegistry.cs create mode 100644 tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlRunningOperationsTests.cs create mode 100644 tests/ClaudeDo.Worker.Tests/Lifecycle/RunningOperationRegistryTests.cs diff --git a/src/ClaudeDo.Data/Wire.cs b/src/ClaudeDo.Data/Wire.cs index fda7b5b5..f888fbf1 100644 --- a/src/ClaudeDo.Data/Wire.cs +++ b/src/ClaudeDo.Data/Wire.cs @@ -13,6 +13,21 @@ namespace ClaudeDo.Data.Wire; public record ActiveTaskDto(string Slot, string TaskId, DateTime StartedAt); +/// A Worker operation running right now (verify, merge, rebase, worktree creation). +/// is the task title, or the list name for a batch verify. +public record RunningOperationDto( + string Id, string Kind, string? TaskId, int? TaskNumber, string? ListId, string Title, DateTime StartedAt); + +/// Stable tokens, localized by the UI. +public static class OperationKinds +{ + public const string VerifyMerge = "verify-merge"; + public const string VerifyBatch = "verify-batch"; + public const string Merging = "merging"; + public const string Rebasing = "rebasing"; + public const string CreatingWorktree = "creating-worktree"; +} + public record AppSettingsDto( string DefaultClaudeInstructions, string DefaultModel, diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index c7d20b2a..c1b12a79 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -375,6 +375,13 @@ "empty": "Keine laufenden Aufgaben", "settings": "Einstellungen", "activity": "Aktivität", + "runningNow": "Läuft gerade", + "opVerifyMerge": "Verify (Merge)", + "opVerifyBatch": "Verify (Batch)", + "opMerging": "Merge", + "opRebasing": "Rebase", + "opCreatingWorktree": "Worktree wird erstellt", + "operationsRefreshFailed": "Laufende Operationen konnten nicht geladen werden: {0}", "blocked": "Blockiert", "running": "Läuft", "review": "Im Review", diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index 8753c160..8baa9cd1 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -375,6 +375,13 @@ "empty": "No running tasks", "settings": "Settings", "activity": "Activity", + "runningNow": "Running now", + "opVerifyMerge": "Verify (merge)", + "opVerifyBatch": "Verify (batch)", + "opMerging": "Merging", + "opRebasing": "Rebasing", + "opCreatingWorktree": "Creating worktree", + "operationsRefreshFailed": "Couldn't load running operations: {0}", "blocked": "Blocked", "running": "Running", "review": "In review", diff --git a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs index a7f046ff..dd77295a 100644 --- a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs @@ -59,6 +59,11 @@ public interface IWorkerClient : INotifyPropertyChanged /// total. event Action? OperationProgressEvent; + /// A Worker operation (verify, merge, rebase, worktree creation) began. + event Action? OperationStartedEvent; + /// The operation with this id finished, failed or threw. + event Action? OperationEndedEvent; + event Action? PlanningMergeStartedEvent; event Action? PlanningSubtaskMergedEvent; /// (planningTaskId, subtaskId, conflictedFiles, externallyDriven). externallyDriven @@ -208,6 +213,9 @@ public interface IWorkerClient : INotifyPropertyChanged Task GetLastPrepLogAsync(); Task> GetRecentLogsAsync(); + /// Operations running on the Worker right now — the state to start from before applying started/ended events. + Task> GetActiveOperationsAsync(); + Task> GetPrimeSchedulesAsync(); Task UpsertPrimeScheduleAsync(PrimeScheduleDto dto); Task DeletePrimeScheduleAsync(Guid id); diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs index 819f6b85..b3028a58 100644 --- a/src/ClaudeDo.Ui/Services/WorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs @@ -69,6 +69,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC public event Action? UsageUpdatedEvent; public event Action? OperationProgressEvent; + public event Action? OperationStartedEvent; + public event Action? OperationEndedEvent; public event Action? PlanningMergeStartedEvent; public event Action? PlanningSubtaskMergedEvent; @@ -203,6 +205,16 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC Dispatcher.UIThread.Post(() => OperationProgressEvent?.Invoke(opKey, phase, current, total)); }); + _hub.On("OperationStarted", op => + { + Dispatcher.UIThread.Post(() => OperationStartedEvent?.Invoke(op)); + }); + + _hub.On("OperationEnded", id => + { + Dispatcher.UIThread.Post(() => OperationEndedEvent?.Invoke(id)); + }); + _hub.On("PlanningMergeStarted", (planningTaskId, targetBranch) => { Dispatcher.UIThread.Post(() => PlanningMergeStartedEvent?.Invoke(planningTaskId, targetBranch)); @@ -530,6 +542,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC public async Task GetLastPrepLogAsync() => await TryInvokeAsync("GetLastPrepLog") ?? string.Empty; + public async Task> GetActiveOperationsAsync() + => await TryInvokeAsync>("GetActiveOperations") ?? new List(); + public async Task> GetRecentLogsAsync() => await TryInvokeAsync>("GetRecentLogs") ?? new List(); diff --git a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs index 167da9d9..0c4d324e 100644 --- a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs @@ -23,6 +23,8 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable private readonly Action _onTaskFinished; private readonly Action _onTaskUpdated; private readonly Action _onConnectionRestored; + private readonly Action _onOperationStarted; + private readonly Action _onOperationEnded; private readonly Action, string> _onHandoffRequested; private readonly Action> _onListHandlerStartRequested; private readonly Action _onHandlerUsageWarning; @@ -78,6 +80,13 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable public ObservableCollection Queued { get; } = new(); public bool HasQueued => Queued.Count > 0; + /// Worker operations running right now (verify, merge, rebase, worktree creation), oldest first. + public ObservableCollection RunningOperations { get; } = new(); + public bool HasRunningOperations => RunningOperations.Count > 0; + + /// The activity strip is shown while it has queue rows or running operations. + public bool HasActivity => HasQueued || HasRunningOperations; + // "New session" targets: one entry per list with a working dir, plus a browse fallback, so a // fresh session is two clicks instead of navigating a folder picker. public ObservableCollection SessionTargets { get; } = new(); @@ -118,9 +127,14 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable _onTaskUpdated = taskId => _ = RefreshQueueAsync(); _worker.TaskUpdatedEvent += _onTaskUpdated; - _onConnectionRestored = () => { _ = RefreshQueueAsync(); }; + _onConnectionRestored = () => { _ = RefreshQueueAsync(); _ = RefreshOperationsAsync(); }; _worker.ConnectionRestoredEvent += _onConnectionRestored; + _onOperationStarted = AddOperation; + _worker.OperationStartedEvent += _onOperationStarted; + _onOperationEnded = RemoveOperation; + _worker.OperationEndedEvent += _onOperationEnded; + _onHandoffRequested = (taskId, survivingTaskIds, nextPhase) => { _ = OpenMergeHelperHandoffConPtySessionAsync(taskId, survivingTaskIds, nextPhase); }; _worker.HandoffRequestedEvent += _onHandoffRequested; _worker.SessionHandoffRequestedEvent += OpenSessionHandoff; @@ -133,9 +147,52 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable _worker.HandlerHandoffDeferredEvent += _onHandlerHandoffDeferred; _ = RefreshQueueAsync(); + _ = RefreshOperationsAsync(); _ = RefreshSessionTargetsAsync(); } + /// Replaces the running-operations list with the Worker's snapshot (open and reconnect). + internal async System.Threading.Tasks.Task RefreshOperationsAsync() + { + try + { + var snapshot = await _worker.GetActiveOperationsAsync(); + RunningOperations.Clear(); + foreach (var op in snapshot.OrderBy(o => o.StartedAt)) + RunningOperations.Add(new RunningOperationViewModel(op)); + NotifyOperationsChanged(); + } + catch (Exception ex) { ErrorReported?.Invoke(Loc.T("missionControl.operationsRefreshFailed", ex.Message)); } + } + + private void AddOperation(RunningOperationDto op) + { + if (RunningOperations.Any(r => r.Id == op.Id)) return; + RunningOperations.Add(new RunningOperationViewModel(op)); + NotifyOperationsChanged(); + } + + private void RemoveOperation(string id) + { + var row = RunningOperations.FirstOrDefault(r => r.Id == id); + if (row is null) return; + RunningOperations.Remove(row); + NotifyOperationsChanged(); + } + + private void NotifyOperationsChanged() + { + OnPropertyChanged(nameof(HasRunningOperations)); + OnPropertyChanged(nameof(HasActivity)); + } + + /// Refreshes every row's elapsed text; the view calls this once a second while open. + public void TickOperations(DateTime? nowUtc = null) + { + var now = nowUtc ?? DateTime.UtcNow; + foreach (var r in RunningOperations) r.Tick(now); + } + internal async System.Threading.Tasks.Task RefreshQueueAsync() { try @@ -174,6 +231,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable }); } OnPropertyChanged(nameof(HasQueued)); + OnPropertyChanged(nameof(HasActivity)); } catch (Exception ex) { ErrorReported?.Invoke(Loc.T("missionControl.queueRefreshFailed", ex.Message)); } } @@ -777,6 +835,8 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable _worker.TaskFinishedEvent -= _onTaskFinished; _worker.TaskUpdatedEvent -= _onTaskUpdated; _worker.ConnectionRestoredEvent -= _onConnectionRestored; + _worker.OperationStartedEvent -= _onOperationStarted; + _worker.OperationEndedEvent -= _onOperationEnded; _worker.HandoffRequestedEvent -= _onHandoffRequested; _worker.SessionHandoffRequestedEvent -= OpenSessionHandoff; _worker.ListHandlerStartRequestedEvent -= _onListHandlerStartRequested; @@ -820,6 +880,45 @@ public sealed class QueuedTaskViewModel public IRelayCommand? OpenSessionCommand { get; init; } } +/// One row in the "Running now" section: a Worker operation with a ticking elapsed time. +public sealed partial class RunningOperationViewModel : ObservableObject +{ + private readonly DateTime _startedAtUtc; + + public string Id { get; } + /// Localized operation kind ("Verify (merge)", "Rebasing", ...). + public string KindLabel { get; } + /// #Number Title for a task operation, the list name for a batch verify. + public string Subject { get; } + + [ObservableProperty] private string _elapsed; + + public RunningOperationViewModel(RunningOperationDto op) + { + Id = op.Id; + _startedAtUtc = op.StartedAt; + KindLabel = op.Kind switch + { + OperationKinds.VerifyMerge => Loc.T("missionControl.opVerifyMerge"), + OperationKinds.VerifyBatch => Loc.T("missionControl.opVerifyBatch"), + OperationKinds.Merging => Loc.T("missionControl.opMerging"), + OperationKinds.Rebasing => Loc.T("missionControl.opRebasing"), + OperationKinds.CreatingWorktree => Loc.T("missionControl.opCreatingWorktree"), + _ => op.Kind, + }; + Subject = op.TaskNumber is > 0 ? $"#{op.TaskNumber} {op.Title}" : op.Title; + _elapsed = Format(DateTime.UtcNow - _startedAtUtc); + } + + public void Tick(DateTime nowUtc) => Elapsed = Format(nowUtc - _startedAtUtc); + + private static string Format(TimeSpan t) + { + if (t < TimeSpan.Zero) t = TimeSpan.Zero; + return t.TotalHours >= 1 ? t.ToString(@"h\:mm\:ss") : t.ToString(@"mm\:ss"); + } +} + /// One row in the "Recent sessions" flyout. public sealed class RecentSessionViewModel { diff --git a/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml b/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml index 462f0807..15074049 100644 --- a/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml +++ b/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml @@ -185,7 +185,7 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml.cs b/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml.cs index cd9cd5e5..30879c75 100644 --- a/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml.cs +++ b/src/ClaudeDo.Ui/Views/MissionControl/MissionControlView.axaml.cs @@ -40,6 +40,24 @@ public partial class MissionControlView : UserControl OverviewGrid.AddHandler(PointerReleasedEvent, OnTilePointerReleased); } + // Ticks the "Running now" elapsed times while the view is on screen. + private Avalonia.Threading.DispatcherTimer? _elapsedTimer; + + protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnAttachedToVisualTree(e); + _elapsedTimer = new Avalonia.Threading.DispatcherTimer { Interval = TimeSpan.FromSeconds(1) }; + _elapsedTimer.Tick += (_, _) => _vm?.TickOperations(); + _elapsedTimer.Start(); + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + _elapsedTimer?.Stop(); + _elapsedTimer = null; + base.OnDetachedFromVisualTree(e); + } + private const double DragThreshold = 4; /// Tile presenter whose header was pressed; null when no tile drag is pending. @@ -199,7 +217,7 @@ public partial class MissionControlView : UserControl { if (e.PropertyName == nameof(MissionControlViewModel.ColumnCount)) RebuildOverviewGrid(); - else if (e.PropertyName == nameof(MissionControlViewModel.HasQueued)) + else if (e.PropertyName == nameof(MissionControlViewModel.HasActivity)) UpdateQueueColumn(); else if (e.PropertyName == nameof(MissionControlViewModel.IsFocusMode)) // The now-visible layout's hosts must re-steal their pane views from the hidden @@ -221,7 +239,7 @@ public partial class MissionControlView : UserControl private void UpdateQueueColumn() { var queueColumn = BodyGrid.ColumnDefinitions[2]; - if (_vm?.HasQueued == true) + if (_vm?.HasActivity == true) { queueColumn.MinWidth = MinQueueWidth; queueColumn.Width = _queueWidth; diff --git a/src/ClaudeDo.Worker/Hub/HubBroadcaster.cs b/src/ClaudeDo.Worker/Hub/HubBroadcaster.cs index e17ed39e..ee391a02 100644 --- a/src/ClaudeDo.Worker/Hub/HubBroadcaster.cs +++ b/src/ClaudeDo.Worker/Hub/HubBroadcaster.cs @@ -78,6 +78,14 @@ public sealed class HubBroadcaster : IPrimeBroadcaster, IRefineBroadcaster public Task OperationProgress(string opKey, string phase, int current, int total) => _hub.Clients.All.SendAsync("OperationProgress", opKey, phase, current, total); + /// A tracked Worker operation (verify, merge, rebase, worktree creation) began. + public Task OperationStarted(RunningOperationDto operation) => + _hub.Clients.All.SendAsync("OperationStarted", operation); + + /// The tracked operation with this id finished, failed or threw. + public Task OperationEnded(string operationId) => + _hub.Clients.All.SendAsync("OperationEnded", operationId); + public Task PlanningMergeStarted(string planningTaskId, string targetBranch) => _hub.Clients.All.SendAsync("PlanningMergeStarted", planningTaskId, targetBranch); diff --git a/src/ClaudeDo.Worker/Hub/WorkerHub.cs b/src/ClaudeDo.Worker/Hub/WorkerHub.cs index 54c319dd..197f1995 100644 --- a/src/ClaudeDo.Worker/Hub/WorkerHub.cs +++ b/src/ClaudeDo.Worker/Hub/WorkerHub.cs @@ -75,6 +75,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub private readonly HandlerRestartCoordinator? _handlerRestart; private readonly HandlerRoundState? _roundState; private readonly ConnectedClientTracker? _clientTracker; + private readonly RunningOperationRegistry? _operations; public WorkerHub( QueueService queue, @@ -116,7 +117,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub TicketImportService ticketImport = null!, HandlerRestartCoordinator? handlerRestart = null, HandlerRoundState? roundState = null, - ConnectedClientTracker? clientTracker = null) + ConnectedClientTracker? clientTracker = null, + RunningOperationRegistry? operations = null) { _queue = queue; _waker = waker; @@ -158,6 +160,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub _handlerRestart = handlerRestart; _roundState = roundState; _clientTracker = clientTracker; + _operations = operations; } // Tracks connected UI clients so an out-of-band caller (external MCP's start_list_handler) @@ -203,6 +206,10 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub public IReadOnlyList GetRecentLogs() => _logBuffer?.Snapshot() ?? Array.Empty(); + /// Operations running right now — lets a connecting or reconnecting UI recover state before the started/ended events. + public IReadOnlyList GetActiveOperations() => + _operations?.Snapshot() ?? Array.Empty(); + // Maps the two exceptions service methods throw into client-facing HubExceptions: // KeyNotFoundException -> notFoundMessage, InvalidOperationException -> its own message. private static async Task HubGuard(Func action, string notFoundMessage = "task not found") diff --git a/src/ClaudeDo.Worker/Lifecycle/RunningOperationRegistry.cs b/src/ClaudeDo.Worker/Lifecycle/RunningOperationRegistry.cs new file mode 100644 index 00000000..8f3ce0fd --- /dev/null +++ b/src/ClaudeDo.Worker/Lifecycle/RunningOperationRegistry.cs @@ -0,0 +1,49 @@ +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)); + } + } +} diff --git a/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs b/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs index 00481abb..bde7abac 100644 --- a/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs +++ b/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs @@ -118,6 +118,7 @@ public sealed class TaskMergeService private readonly IVerifyCommandRunner _verify; private readonly ILogger _logger; private readonly UnitBranchService? _unitBranch; + private readonly RunningOperationRegistry _operations; public TaskMergeService( IDbContextFactory dbFactory, @@ -126,7 +127,8 @@ public sealed class TaskMergeService ITaskStateService state, IVerifyCommandRunner verify, ILogger logger, - UnitBranchService? unitBranch = null) + UnitBranchService? unitBranch = null, + RunningOperationRegistry? operations = null) { _dbFactory = dbFactory; _git = git; @@ -135,8 +137,12 @@ public sealed class TaskMergeService _verify = verify; _logger = logger; _unitBranch = unitBranch; + _operations = operations ?? new RunningOperationRegistry(); } + private IDisposable TrackTask(string kind, TaskEntity task) => + _operations.Begin(kind, task.Title, task.Id, task.Number, task.ListId); + private async Task<(TaskEntity Task, ListEntity List, WorktreeEntity? Worktree, string? VerifyCommand)> LoadMergeContextAsync( string taskId, CancellationToken ct) { @@ -159,7 +165,7 @@ public sealed class TaskMergeService /// CLAUDE.md — only the Done transition is withheld). /// private async Task RunVerifyGateAsync( - string? verifyCommand, string workingDir, CancellationToken ct, + string? verifyCommand, string workingDir, Func track, CancellationToken ct, IProgress? progress = null, Action? onTick = null) { @@ -168,8 +174,9 @@ public sealed class TaskMergeService VerifyCommandResult result; try { - result = await ProgressReporter.RunAsync( - _verify.RunAsync(workingDir, verifyCommand, VerifyTimeout, ct), ProgressReportInterval, progress, "verify gate running", onTick); + using (track()) + result = await ProgressReporter.RunAsync( + _verify.RunAsync(workingDir, verifyCommand, VerifyTimeout, ct), ProgressReportInterval, progress, "verify gate running", onTick); } catch (Exception ex) { @@ -320,6 +327,8 @@ public sealed class TaskMergeService } if (candidates.Count == 0) return; + using var rebasing = TrackTask(OperationKinds.Rebasing, mergedTask); + // Runs where the stalled "Merging…" phase would otherwise sit unchanged in the UI while // this loop does real (if best-effort) work — see the correction note in the task spec. for (var i = 0; i < candidates.Count; i++) @@ -399,6 +408,7 @@ public sealed class TaskMergeService // Announced before the gate wait: another merge holding the repo is itself a reason the // caller sees nothing happen, and a UI waiting on this call needs a phase to show at once. + using var merging = TrackTask(OperationKinds.Merging, task); await _broadcaster.OperationProgress(taskId, PhaseMerging, 0, 0); var gate = GetMergeGate(list.WorkingDir); @@ -482,6 +492,7 @@ public sealed class TaskMergeService } await MarkWorktreeMergedAsync(taskId, mergeSha, ct); + merging.Dispose(); await RebaseOthersAfterMergeAsync(task, list, targetBranch, oldTargetTip, mergeSha, ct); // The merge itself is instant; the verify gate is what makes this call take minutes. @@ -506,7 +517,7 @@ public sealed class TaskMergeService } var verifyFailure = await RunVerifyGateAsync( - verifyCommand, list.WorkingDir, ct, progress, + verifyCommand, list.WorkingDir, () => TrackTask(OperationKinds.VerifyMerge, task), ct, progress, elapsed => _ = _broadcaster.OperationProgress(taskId, PhaseVerifying, (int)elapsed.TotalSeconds, 0)); if (verifyFailure is not null) { @@ -641,7 +652,8 @@ public sealed class TaskMergeService return new MergeResult(StatusMergedVerifyPending, Array.Empty(), null); } - var verifyFailure = await RunVerifyGateAsync(verifyCommand, list.WorkingDir, ct, progress); + var verifyFailure = await RunVerifyGateAsync( + verifyCommand, list.WorkingDir, () => TrackTask(OperationKinds.VerifyMerge, task), ct, progress); if (verifyFailure is not null) { _logger.LogWarning("Verify command failed after continuing merge of task {TaskId}: {Reason}", taskId, verifyFailure.ErrorMessage); @@ -1101,7 +1113,7 @@ public sealed class TaskMergeService { // Same reason as the post-merge gate: this holds the approve call for minutes. var failed = await RunVerifyGateAsync( - verifyCommand, list.WorkingDir!, ct, progress, + verifyCommand, list.WorkingDir!, () => TrackTask(OperationKinds.VerifyMerge, task), ct, progress, elapsed => _ = _broadcaster.OperationProgress(taskId, PhaseVerifying, (int)elapsed.TotalSeconds, 0)); if (failed is not null) return failed; } @@ -1141,6 +1153,7 @@ public sealed class TaskMergeService { var taskId = task.Id; var workingDir = list.WorkingDir!; + using var merging = TrackTask(OperationKinds.Merging, task); await _broadcaster.OperationProgress(taskId, PhaseMerging, 0, 0); var gate = GetMergeGate(workingDir); @@ -1195,6 +1208,7 @@ public sealed class TaskMergeService } await MarkWorktreeMergedAsync(taskId, mergeSha, ct); + merging.Dispose(); await RebaseOthersAfterMergeAsync(task, list, targetBranch, oldTargetTip, mergeSha, ct); if (!string.IsNullOrWhiteSpace(verifyCommand) && skipVerify) @@ -1206,7 +1220,7 @@ public sealed class TaskMergeService } var verifyFailure = await RunVerifyGateAsync( - verifyCommand, workingDir, ct, progress, + verifyCommand, workingDir, () => TrackTask(OperationKinds.VerifyMerge, task), ct, progress, elapsed => _ = _broadcaster.OperationProgress(taskId, PhaseVerifying, (int)elapsed.TotalSeconds, 0)); if (verifyFailure is not null) { @@ -1284,7 +1298,8 @@ public sealed class TaskMergeService $"Batch verify running for {candidates.Count} merged task(s) in \"{list.Name}\"", WorkerLogLevel.Info, DateTime.UtcNow); failure = await RunVerifyGateAsync( - verifyCommand, list.WorkingDir, ct, progress, + verifyCommand, list.WorkingDir, + () => _operations.Begin(OperationKinds.VerifyBatch, list.Name, listId: listId), ct, progress, elapsed => _ = _broadcaster.OperationProgress(listId, PhaseVerifying, (int)elapsed.TotalSeconds, 0)); } finally { gate.Release(); } diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs index 74ee597a..17c232a7 100644 --- a/src/ClaudeDo.Worker/Program.cs +++ b/src/ClaudeDo.Worker/Program.cs @@ -110,6 +110,7 @@ builder.Services.AddSignalR(options => builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/ClaudeDo.Worker/Runner/WorktreeManager.cs b/src/ClaudeDo.Worker/Runner/WorktreeManager.cs index 420e1c25..62fef53f 100644 --- a/src/ClaudeDo.Worker/Runner/WorktreeManager.cs +++ b/src/ClaudeDo.Worker/Runner/WorktreeManager.cs @@ -5,6 +5,7 @@ using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Config; using ClaudeDo.Worker.Git; using ClaudeDo.Worker.Hub; +using ClaudeDo.Worker.Lifecycle; using ClaudeDo.Worker.Tickets; using Microsoft.EntityFrameworkCore; @@ -24,16 +25,19 @@ public sealed class WorktreeManager private readonly WorkerConfig _cfg; private readonly ILogger _logger; private readonly HubBroadcaster? _broadcaster; + private readonly RunningOperationRegistry _operations; public WorktreeManager( GitService git, IDbContextFactory dbFactory, WorkerConfig cfg, - ILogger logger, HubBroadcaster? broadcaster = null) + ILogger logger, HubBroadcaster? broadcaster = null, + RunningOperationRegistry? operations = null) { _git = git; _dbFactory = dbFactory; _cfg = cfg; _logger = logger; _broadcaster = broadcaster; + _operations = operations ?? new RunningOperationRegistry(); } private Task BroadcastCreating(string taskId) => @@ -115,6 +119,7 @@ public sealed class WorktreeManager // Create the worktree. If a stale branch from a previous run remains // (e.g. after force-remove), delete it and retry once. + using var creating = _operations.Begin(OperationKinds.CreatingWorktree, task.Title, task.Id, task.Number, task.ListId); await BroadcastCreating(task.Id); try { diff --git a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs index a8826097..777441c5 100644 --- a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs +++ b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs @@ -37,6 +37,8 @@ public abstract class StubWorkerClient : IWorkerClient public event Action? RefineStartedEvent; public event Action? RefineFinishedEvent; public event Action? OperationProgressEvent; + public event Action? OperationStartedEvent; + public event Action? OperationEndedEvent; public event Action? PlanningMergeStartedEvent; public event Action? PlanningSubtaskMergedEvent; public event Action, bool>? PlanningMergeConflictEvent; @@ -67,6 +69,8 @@ public abstract class StubWorkerClient : IWorkerClient public void RaisePlanningMergeConflict(string planningTaskId, string subtaskId, IReadOnlyList files, bool externallyDriven) => PlanningMergeConflictEvent?.Invoke(planningTaskId, subtaskId, files, externallyDriven); public void RaiseOperationProgress(string opKey, string phase, int current, int total) => OperationProgressEvent?.Invoke(opKey, phase, current, total); + public void RaiseOperationStarted(RunningOperationDto op) => OperationStartedEvent?.Invoke(op); + public void RaiseOperationEnded(string id) => OperationEndedEvent?.Invoke(id); public void RaisePlanningMergeStarted(string planningTaskId, string targetBranch) => PlanningMergeStartedEvent?.Invoke(planningTaskId, targetBranch); public void RaisePlanningMergeAborted(string planningTaskId, string? reason = null) => PlanningMergeAbortedEvent?.Invoke(planningTaskId, reason); public void RaisePlanningCompleted(string planningTaskId) => PlanningCompletedEvent?.Invoke(planningTaskId); @@ -156,6 +160,7 @@ public abstract class StubWorkerClient : IWorkerClient public virtual Task FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default) => Task.CompletedTask; public virtual Task GetPendingDraftCountAsync(string taskId, CancellationToken ct = default) => Task.FromResult(0); public virtual Task GetMergeTargetsAsync(string taskId) => Task.FromResult(null); + public virtual Task> GetActiveOperationsAsync() => Task.FromResult>(Array.Empty()); public virtual Task> GetRecentLogsAsync() => Task.FromResult>(Array.Empty()); public virtual Task> GetPlanningAggregateAsync(string planningTaskId) => Task.FromResult>(Array.Empty()); diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlRunningOperationsTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlRunningOperationsTests.cs new file mode 100644 index 00000000..cc82b408 --- /dev/null +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlRunningOperationsTests.cs @@ -0,0 +1,107 @@ +using ClaudeDo.Data; +using ClaudeDo.Ui.Services; +using ClaudeDo.Ui.ViewModels; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace ClaudeDo.Ui.Tests.ViewModels; + +public class MissionControlRunningOperationsTests : IDisposable +{ + private readonly string _dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_mcops_{Guid.NewGuid():N}.db"); + + public MissionControlRunningOperationsTests() + { + using var ctx = NewContext(); + ctx.Database.EnsureCreated(); + } + + public void Dispose() + { + foreach (var suffix in new[] { "", "-wal", "-shm" }) + try { File.Delete(_dbPath + suffix); } catch { } + } + + private ClaudeDoDbContext NewContext() => + new(new DbContextOptionsBuilder().UseSqlite($"Data Source={_dbPath}").Options); + + private sealed class FakeWorker : StubWorkerClient + { + public IReadOnlyList Snapshot { get; set; } = Array.Empty(); + public override Task> GetActiveOperationsAsync() => Task.FromResult(Snapshot); + } + + private static RunningOperationDto Op(string id, string kind, int? number, string title, string? listId = null) => + new(id, kind, number is null ? null : "t-" + id, number, listId, title, DateTime.UtcNow.AddSeconds(-5)); + + private MissionControlViewModel BuildVm(FakeWorker worker) => + new(new TestDbFactory(NewContext), worker, new UsagePillViewModel(worker)); + + [Fact] + public async Task Snapshot_LoadsRunningOperations_AndShowsActivity() + { + var worker = new FakeWorker + { + Snapshot = new[] + { + Op("a", OperationKinds.VerifyMerge, 12, "Fix login"), + Op("b", OperationKinds.VerifyBatch, null, "My list", listId: "l1"), + }, + }; + var vm = BuildVm(worker); + + await vm.RefreshOperationsAsync(); + + Assert.Equal(new[] { "#12 Fix login", "My list" }, vm.RunningOperations.Select(r => r.Subject)); + Assert.True(vm.HasRunningOperations); + Assert.True(vm.HasActivity); + } + + [Fact] + public async Task StartedAndEndedEvents_UpdateTheList() + { + var worker = new FakeWorker(); + var vm = BuildVm(worker); + await vm.RefreshOperationsAsync(); + Assert.False(vm.HasRunningOperations); + + worker.RaiseOperationStarted(Op("a", OperationKinds.Merging, 3, "Task")); + worker.RaiseOperationStarted(Op("a", OperationKinds.Merging, 3, "Task")); // duplicate ignored + worker.RaiseOperationStarted(Op("b", OperationKinds.Rebasing, 4, "Other")); + Assert.Equal(2, vm.RunningOperations.Count); + + worker.RaiseOperationEnded("a"); + Assert.Equal("b", Assert.Single(vm.RunningOperations).Id); + + worker.RaiseOperationEnded("unknown"); + worker.RaiseOperationEnded("b"); + Assert.False(vm.HasRunningOperations); + } + + [Fact] + public async Task ConnectionRestored_ReplacesListWithFreshSnapshot() + { + var worker = new FakeWorker { Snapshot = new[] { Op("a", OperationKinds.Merging, 1, "Old") } }; + var vm = BuildVm(worker); + await vm.RefreshOperationsAsync(); + + worker.Snapshot = new[] { Op("c", OperationKinds.CreatingWorktree, 2, "New") }; + await vm.RefreshOperationsAsync(); + + Assert.Equal("c", Assert.Single(vm.RunningOperations).Id); + } + + [Fact] + public async Task TickOperations_UpdatesElapsed() + { + var worker = new FakeWorker(); + var vm = BuildVm(worker); + var started = new DateTime(2026, 10, 1, 8, 0, 0, DateTimeKind.Utc); + worker.RaiseOperationStarted(new RunningOperationDto("a", OperationKinds.VerifyMerge, "t", 1, null, "T", started)); + + vm.TickOperations(started.AddSeconds(75)); + + Assert.Equal("01:15", vm.RunningOperations.Single().Elapsed); + await Task.CompletedTask; + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Lifecycle/RunningOperationRegistryTests.cs b/tests/ClaudeDo.Worker.Tests/Lifecycle/RunningOperationRegistryTests.cs new file mode 100644 index 00000000..6ff1e18f --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Lifecycle/RunningOperationRegistryTests.cs @@ -0,0 +1,55 @@ +using ClaudeDo.Worker.Hub; +using ClaudeDo.Worker.Lifecycle; +using ClaudeDo.Worker.Tests.Services; + +namespace ClaudeDo.Worker.Tests.Lifecycle; + +public class RunningOperationRegistryTests +{ + [Fact] + public void Begin_AddsEntry_DisposeRemovesIt_AndIsIdempotent() + { + var ops = new RunningOperationRegistry(); + + var scope = ops.Begin(OperationKinds.Merging, "title", taskId: "t1", taskNumber: 7); + var op = Assert.Single(ops.Snapshot()); + Assert.Equal(OperationKinds.Merging, op.Kind); + Assert.Equal("t1", op.TaskId); + Assert.Equal(7, op.TaskNumber); + + scope.Dispose(); + scope.Dispose(); + Assert.Empty(ops.Snapshot()); + } + + [Fact] + public void Entry_IsRemovedWhenTheOperationThrows() + { + var ops = new RunningOperationRegistry(); + + Action act = () => + { + using var _ = ops.Begin(OperationKinds.Rebasing, "title", taskId: "t1"); + Assert.Single(ops.Snapshot()); + throw new InvalidOperationException("boom"); + }; + Assert.Throws(act); + + Assert.Empty(ops.Snapshot()); + } + + [Fact] + public void BeginAndDispose_BroadcastStartedAndEnded() + { + var hub = new MergeRecordingHubContext(); + var ops = new RunningOperationRegistry(new HubBroadcaster(hub)); + + var scope = ops.Begin(OperationKinds.CreatingWorktree, "title", taskId: "t1"); + var id = ops.Snapshot().Single().Id; + scope.Dispose(); + + Assert.Collection(hub.Proxy.Calls, + c => { Assert.Equal("OperationStarted", c.Method); Assert.Equal(id, ((RunningOperationDto)c.Args[0]!).Id); }, + c => { Assert.Equal("OperationEnded", c.Method); Assert.Equal(id, c.Args[0]); }); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs index 966e9842..c1f72d67 100644 --- a/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs @@ -31,7 +31,7 @@ public class TaskMergeServiceTests : IDisposable } private static (TaskMergeService svc, MergeRecordingClientProxy proxy) BuildService( - DbFixture db, IVerifyCommandRunner? verify = null) + DbFixture db, IVerifyCommandRunner? verify = null, RunningOperationRegistry? operations = null) { var fakeHub = new MergeRecordingHubContext(); var broadcaster = new HubBroadcaster(fakeHub); @@ -42,7 +42,8 @@ public class TaskMergeServiceTests : IDisposable broadcaster, state, verify ?? new VerifyCommandRunner(), - NullLogger.Instance); + NullLogger.Instance, + operations: operations); return (svc, fakeHub.Proxy); } @@ -1335,6 +1336,62 @@ public class TaskMergeServiceTests : IDisposable Assert.Equal(TaskStatus.WaitingForReview, updated!.Status); } + [Fact] + public async Task RunningOperations_VerifyMerge_PresentDuringRunAndRemovedAfterSuccess() + { + var db = NewDb(); + var (list, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.WaitingForReview); + await SeedVerifyCommand(db, list.Id, "dotnet test"); + var ops = new RunningOperationRegistry(); + IReadOnlyList? during = null; + var fakeVerify = new FakeVerifyCommandRunner { OnRun = () => during = ops.Snapshot() }; + var (svc, _) = BuildService(db, fakeVerify, ops); + + await svc.ApproveAndMergeAsync(task.Id, "main", CancellationToken.None); + + var op = Assert.Single(during!); + Assert.Equal(OperationKinds.VerifyMerge, op.Kind); + Assert.Equal(task.Id, op.TaskId); + Assert.Equal("merge-task", op.Title); + Assert.Empty(ops.Snapshot()); + } + + [Fact] + public async Task RunningOperations_VerifyMerge_RemovedWhenRunnerThrows() + { + var db = NewDb(); + var (list, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.WaitingForReview); + await SeedVerifyCommand(db, list.Id, "dotnet test"); + var ops = new RunningOperationRegistry(); + var fakeVerify = new FakeVerifyCommandRunner { ThrowOnRun = new InvalidOperationException("boom") }; + var (svc, _) = BuildService(db, fakeVerify, ops); + + var result = await svc.ApproveAndMergeAsync(task.Id, "main", CancellationToken.None); + + Assert.Equal(TaskMergeService.StatusVerifyFailed, result.Status); + Assert.Empty(ops.Snapshot()); + } + + [Fact] + public async Task RunningOperations_VerifyBatch_PresentDuringRunAndRemovedAfter() + { + var db = NewDb(); + var (list, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.WaitingForReview); + await SeedVerifyCommand(db, list.Id, "dotnet test"); + var ops = new RunningOperationRegistry(); + IReadOnlyList? during = null; + var fakeVerify = new FakeVerifyCommandRunner { OnRun = () => during = ops.Snapshot() }; + var (svc, _) = BuildService(db, fakeVerify, ops); + + await svc.RunListVerifyAsync(list.Id, new[] { task.Id }, CancellationToken.None); + + var op = Assert.Single(during!); + Assert.Equal(OperationKinds.VerifyBatch, op.Kind); + Assert.Equal(list.Id, op.ListId); + Assert.Equal("merge-test", op.Title); + Assert.Empty(ops.Snapshot()); + } + [Fact] public async Task ApproveAndMergeAsync_NoWorktreeAndVerifyPasses_MarksDone() { @@ -2208,9 +2265,15 @@ internal sealed class FakeVerifyCommandRunner : IVerifyCommandRunner // report without the real verify command ever taking that long. public TimeSpan? Delay { get; set; } + // Invoked at call time -- lets a test observe state while the verify run is "in flight". + public Action? OnRun { get; set; } + public Exception? ThrowOnRun { get; set; } + public async Task RunAsync(string workingDir, string command, TimeSpan timeout, CancellationToken ct) { CallCount++; + OnRun?.Invoke(); + if (ThrowOnRun is not null) throw ThrowOnRun; CapturedWorkingDir = workingDir; CapturedCommand = command; if (FileToCheck is not null)