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 <noreply@anthropic.com>
This commit is contained in:
mika kuns
2026-10-01 12:08:43 +02:00
co-authored by Claude Sonnet 5.5
parent 488c28a9d5
commit 2dbec543df
18 changed files with 539 additions and 18 deletions
@@ -37,6 +37,8 @@ public abstract class StubWorkerClient : IWorkerClient
public event Action<string>? RefineStartedEvent;
public event Action<string, bool, string?>? RefineFinishedEvent;
public event Action<string, string, int, int>? OperationProgressEvent;
public event Action<RunningOperationDto>? OperationStartedEvent;
public event Action<string>? OperationEndedEvent;
public event Action<string, string>? PlanningMergeStartedEvent;
public event Action<string, string>? PlanningSubtaskMergedEvent;
public event Action<string, string, IReadOnlyList<string>, bool>? PlanningMergeConflictEvent;
@@ -67,6 +69,8 @@ public abstract class StubWorkerClient : IWorkerClient
public void RaisePlanningMergeConflict(string planningTaskId, string subtaskId, IReadOnlyList<string> 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<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct = default) => Task.FromResult(0);
public virtual Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId) => Task.FromResult<MergeTargetsDto?>(null);
public virtual Task<IReadOnlyList<RunningOperationDto>> GetActiveOperationsAsync() => Task.FromResult<IReadOnlyList<RunningOperationDto>>(Array.Empty<RunningOperationDto>());
public virtual Task<IReadOnlyList<WorkerLogEntry>> GetRecentLogsAsync() => Task.FromResult<IReadOnlyList<WorkerLogEntry>>(Array.Empty<WorkerLogEntry>());
public virtual Task<IReadOnlyList<SubtaskDiffDto>> GetPlanningAggregateAsync(string planningTaskId)
=> Task.FromResult<IReadOnlyList<SubtaskDiffDto>>(Array.Empty<SubtaskDiffDto>());
@@ -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<ClaudeDoDbContext>().UseSqlite($"Data Source={_dbPath}").Options);
private sealed class FakeWorker : StubWorkerClient
{
public IReadOnlyList<RunningOperationDto> Snapshot { get; set; } = Array.Empty<RunningOperationDto>();
public override Task<IReadOnlyList<RunningOperationDto>> 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;
}
}
@@ -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<InvalidOperationException>(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]); });
}
}
@@ -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<TaskMergeService>.Instance);
NullLogger<TaskMergeService>.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<RunningOperationDto>? 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<RunningOperationDto>? 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<VerifyCommandResult> 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)