From b8caa2702780eb00491ca0181fbae2a866cc7b10 Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Thu, 13 Aug 2026 08:31:23 +0200 Subject: [PATCH] feat(worker): add generic OperationProgress channel, port MergeProgress onto it Merge/verify phases now broadcast over a generic (opKey, phase, current, total) Hub event instead of a merge-specific one, so future producers (worktree cleanup, startup recovery, planning integration) can reuse it. IWorkerClient.MergeProgressEvent stays as a thin forwarder for existing consumers (elapsed seconds riding in the generic "current" slot). --- .../Services/Interfaces/IWorkerClient.cs | 11 ++++++++++- src/ClaudeDo.Ui/Services/WorkerClient.cs | 9 +++++++-- src/ClaudeDo.Worker/CLAUDE.md | 4 +++- src/ClaudeDo.Worker/Hub/HubBroadcaster.cs | 11 +++++++---- src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs | 14 +++++++------- tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs | 2 ++ .../Services/TaskMergeServiceTests.cs | 4 ++-- .../UiVm/TasksIslandViewModelPlanningTests.cs | 1 + 8 files changed, 39 insertions(+), 17 deletions(-) diff --git a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs index 03afb38b..4d0f9535 100644 --- a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs @@ -34,9 +34,18 @@ public interface IWorkerClient : INotifyPropertyChanged event Action? PrepLineEvent; event Action? PrepFinishedEvent; + /// (opKey, phase, current, total) — generic progress channel for long-running worker + /// operations (merge phases, worktree cleanup, startup recovery, planning integration, ...). + /// opKey is the TaskId for a task-bound operation, otherwise a stable string. current/total + /// is meant to be shown as text, never a progress bar — most operations have no meaningful + /// total. + event Action? OperationProgressEvent; + /// (taskId, phase, elapsedSeconds) — phase of an in-flight single-task merge /// ("merging" | "verifying"). Fires while the MergeTask call itself is still pending, so the - /// waiting UI can show what it's blocked on; the verify phase re-fires every 30 s. + /// waiting UI can show what it's blocked on; the verify phase re-fires every 30 s. A thin + /// forwarder over kept for existing consumers (elapsed + /// seconds riding in the generic "current" slot). event Action? MergeProgressEvent; event Action? PlanningMergeStartedEvent; diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs index cf7206b4..62d99a2a 100644 --- a/src/ClaudeDo.Ui/Services/WorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs @@ -64,6 +64,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC public event Action? UsageUpdatedEvent; + public event Action? OperationProgressEvent; public event Action? MergeProgressEvent; public event Action? PlanningMergeStartedEvent; @@ -174,9 +175,13 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC WorkerLogReceivedEvent?.Invoke(new WorkerLogEntry(message, level, timestampUtc))); }); - _hub.On("MergeProgress", (taskId, phase, elapsedSeconds) => + _hub.On("OperationProgress", (opKey, phase, current, total) => { - Dispatcher.UIThread.Post(() => MergeProgressEvent?.Invoke(taskId, phase, elapsedSeconds)); + Dispatcher.UIThread.Post(() => + { + OperationProgressEvent?.Invoke(opKey, phase, current, total); + MergeProgressEvent?.Invoke(opKey, phase, current); + }); }); _hub.On("PlanningMergeStarted", (planningTaskId, targetBranch) => diff --git a/src/ClaudeDo.Worker/CLAUDE.md b/src/ClaudeDo.Worker/CLAUDE.md index 307bfaee..9d40c302 100644 --- a/src/ClaudeDo.Worker/CLAUDE.md +++ b/src/ClaudeDo.Worker/CLAUDE.md @@ -168,7 +168,9 @@ launch specs · worktrees · agents/settings/lists · reports/notes/prep · diag - `PrepStarted` - `PrepLine` - `PrepFinished` -- `MergeProgress` +- `OperationProgress` (generic `(opKey, phase, current, total)` channel; merge phases are its + first producer — `IWorkerClient.MergeProgressEvent` on the Ui side is a thin forwarder over it + for existing consumers, elapsed seconds riding in the `current` slot) - `PlanningMergeStarted` - `PlanningSubtaskMerged` - `PlanningMergeConflict` diff --git a/src/ClaudeDo.Worker/Hub/HubBroadcaster.cs b/src/ClaudeDo.Worker/Hub/HubBroadcaster.cs index 1084ba87..872cf56f 100644 --- a/src/ClaudeDo.Worker/Hub/HubBroadcaster.cs +++ b/src/ClaudeDo.Worker/Hub/HubBroadcaster.cs @@ -46,10 +46,13 @@ public sealed class HubBroadcaster : IPrimeBroadcaster, IRefineBroadcaster public Task WorkerLog(string message, WorkerLogLevel level, DateTime timestampUtc) => _hub.Clients.All.SendAsync("WorkerLog", message, level, timestampUtc); - // Phase of an in-flight single-task merge (see TaskMergeService.Phase*), so a client waiting - // on the MergeTask call can show what it is waiting for instead of a frozen button. - public Task MergeProgress(string taskId, string phase, int elapsedSeconds) => - _hub.Clients.All.SendAsync("MergeProgress", taskId, phase, elapsedSeconds); + // Generic progress channel for long-running worker operations (merge phases, worktree + // cleanup, startup recovery, planning integration, ...). opKey is the TaskId for a + // task-bound operation, otherwise a stable string ("worktree-cleanup", "startup-recovery", + // "planning-integration:"). current/total is shown as text by the UI, never a + // progress bar -- most operations have no meaningful total. + public Task OperationProgress(string opKey, string phase, int current, int total) => + _hub.Clients.All.SendAsync("OperationProgress", opKey, phase, current, total); public Task PlanningMergeStarted(string planningTaskId, string targetBranch) => _hub.Clients.All.SendAsync("PlanningMergeStarted", planningTaskId, targetBranch); diff --git a/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs b/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs index 689964c9..b29ca7cc 100644 --- a/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs +++ b/src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs @@ -61,7 +61,7 @@ public sealed class TaskMergeService public const string StatusReverted = "reverted"; public const string StatusConflictAborted = "conflict_aborted"; - // Phase tokens for the MergeProgress broadcast — stable identifiers, localized by the UI. + // Phase tokens for the OperationProgress broadcast — stable identifiers, localized by the UI. public const string PhaseMerging = "merging"; public const string PhaseVerifying = "verifying"; @@ -168,7 +168,7 @@ public sealed class TaskMergeService /// Awaits while reporting MCP progress every /// so a caller waiting on a long verify run doesn't hit /// the MCP client's own idle-silence abort. rides the same cadence - /// for non-MCP callers (the Hub, which turns it into a MergeProgress broadcast). No-op + /// for non-MCP callers (the Hub, which turns it into an OperationProgress broadcast). No-op /// passthrough when both are null. /// private static async Task RunReportingProgressAsync( @@ -392,7 +392,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. - await _broadcaster.MergeProgress(taskId, PhaseMerging, 0); + await _broadcaster.OperationProgress(taskId, PhaseMerging, 0, 0); var gate = GetMergeGate(list.WorkingDir); await gate.WaitAsync(ct); @@ -479,7 +479,7 @@ public sealed class TaskMergeService // silence here is what makes a working merge look like a dead button. if (!string.IsNullOrWhiteSpace(verifyCommand)) { - await _broadcaster.MergeProgress(taskId, PhaseVerifying, 0); + await _broadcaster.OperationProgress(taskId, PhaseVerifying, 0, 0); await _broadcaster.WorkerLog( $"Verify command running after merging #{task.Number} \"{task.Title}\" into {targetBranch}", WorkerLogLevel.Info, DateTime.UtcNow); @@ -487,7 +487,7 @@ public sealed class TaskMergeService var verifyFailure = await RunVerifyGateAsync( verifyCommand, list.WorkingDir, ct, progress, - elapsed => _ = _broadcaster.MergeProgress(taskId, PhaseVerifying, (int)elapsed.TotalSeconds)); + elapsed => _ = _broadcaster.OperationProgress(taskId, PhaseVerifying, (int)elapsed.TotalSeconds, 0)); if (verifyFailure is not null) { _logger.LogWarning("Verify command failed after merging task {TaskId}: {Reason}", taskId, verifyFailure.ErrorMessage); @@ -897,14 +897,14 @@ public sealed class TaskMergeService if (!string.IsNullOrWhiteSpace(verifyCommand) && !string.IsNullOrWhiteSpace(list.WorkingDir)) { var verifyGate = GetMergeGate(list.WorkingDir!); - await _broadcaster.MergeProgress(taskId, PhaseVerifying, 0); + await _broadcaster.OperationProgress(taskId, PhaseVerifying, 0, 0); await verifyGate.WaitAsync(ct); try { // Same reason as the post-merge gate: this holds the approve call for minutes. var failed = await RunVerifyGateAsync( verifyCommand, list.WorkingDir!, ct, progress, - elapsed => _ = _broadcaster.MergeProgress(taskId, PhaseVerifying, (int)elapsed.TotalSeconds)); + elapsed => _ = _broadcaster.OperationProgress(taskId, PhaseVerifying, (int)elapsed.TotalSeconds, 0)); if (failed is not null) return failed; } finally { verifyGate.Release(); } diff --git a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs index 35ae5a30..2cc8bc53 100644 --- a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs +++ b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs @@ -31,6 +31,7 @@ public abstract class StubWorkerClient : IWorkerClient public event Action? PrepFinishedEvent; public event Action? RefineStartedEvent; public event Action? RefineFinishedEvent; + public event Action? OperationProgressEvent; public event Action? MergeProgressEvent; public event Action? PlanningMergeStartedEvent; public event Action? PlanningSubtaskMergedEvent; @@ -56,6 +57,7 @@ public abstract class StubWorkerClient : IWorkerClient public void RaiseHandoffRequested(string taskId, IReadOnlyList survivingTaskIds, string nextPhase = "wait") => HandoffRequestedEvent?.Invoke(taskId, survivingTaskIds, nextPhase); 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 RaiseMergeProgress(string taskId, string phase, int elapsedSeconds) => MergeProgressEvent?.Invoke(taskId, phase, elapsedSeconds); public void RaisePlanningMergeStarted(string planningTaskId, string targetBranch) => PlanningMergeStartedEvent?.Invoke(planningTaskId, targetBranch); public void RaisePlanningMergeAborted(string planningTaskId) => PlanningMergeAbortedEvent?.Invoke(planningTaskId); diff --git a/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs index 42f94d33..cf83cfcc 100644 --- a/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs @@ -335,9 +335,9 @@ public class TaskMergeServiceTests : IDisposable commitMessage: "", ct: CancellationToken.None); Assert.Equal("merged", result.Status); - Assert.Contains(proxy.Calls, c => c.Method == "MergeProgress" + Assert.Contains(proxy.Calls, c => c.Method == "OperationProgress" && (string?)c.Args[0] == task.Id && (string?)c.Args[1] == TaskMergeService.PhaseMerging); - Assert.Contains(proxy.Calls, c => c.Method == "MergeProgress" + Assert.Contains(proxy.Calls, c => c.Method == "OperationProgress" && (string?)c.Args[0] == task.Id && (string?)c.Args[1] == TaskMergeService.PhaseVerifying); Assert.Contains(proxy.Calls, c => c.Method == "WorkerLog" && c.Args[0] is string s && s.Contains("Verify command running")); diff --git a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs index cb207b5a..5b9b38b7 100644 --- a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs +++ b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs @@ -113,6 +113,7 @@ sealed class FakeWorkerClient : IWorkerClient public event Action? PrepFinishedEvent; public event Action? RefineStartedEvent; public event Action? RefineFinishedEvent; + public event Action? OperationProgressEvent; public event Action? MergeProgressEvent; public event Action? PlanningMergeStartedEvent; public event Action? PlanningSubtaskMergedEvent;