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).
This commit is contained in:
Mika Kuns
2026-08-13 08:31:23 +02:00
parent db32e5307f
commit b8caa27027
8 changed files with 39 additions and 17 deletions
+3 -1
View File
@@ -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`
+7 -4
View File
@@ -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:<taskId>"). 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);
@@ -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 <paramref name="work"/> while reporting MCP progress every
/// <see cref="ProgressReportInterval"/> so a caller waiting on a long verify run doesn't hit
/// the MCP client's own idle-silence abort. <paramref name="onTick"/> 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.
/// </summary>
private static async Task<T> RunReportingProgressAsync<T>(
@@ -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(); }