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)