feat(worker): report worktree-creation phase on OperationProgress

Fills the silent gap between Queued and the first agent output: WorktreeManager
now broadcasts a "creating_worktree" phase (before the initial git worktree add
and again for the self-heal retry section) through the existing OperationProgress
channel, and the task row shows it via the pre-existing but unused
ops.worker.creatingWorktree locale key until the next entity refresh clears it.

Confirmed the 2026-08-07 triage finding still holds: TaskRunner already
broadcasts WorktreeUpdated right after WorktreeManager.CreateAsync
(TaskRunner.cs:342-346, :501) -- no second broadcast added there.
This commit is contained in:
mika kuns
2026-08-21 13:35:26 +02:00
parent dcda067b48
commit eba0d842b5
6 changed files with 156 additions and 1 deletions
@@ -3,6 +3,7 @@ using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.Extensions.Logging.Abstractions;
@@ -76,6 +77,73 @@ public class WorktreeManagerTests : IDisposable
Assert.Null(row.HeadCommit);
}
[Fact]
public async Task CreateAsync_BroadcastsCreatingWorktreePhase()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = CreateRepo();
var (task, list) = MakeEntities(repo.RepoDir);
var db = new DbFixture();
_dbFixtures.Add(db);
using (var seedCtx = db.CreateContext())
{
await new ListRepository(seedCtx).AddAsync(list);
await new TaskRepository(seedCtx).AddAsync(task);
}
var fakeHub = new CapturingHubContext();
var broadcaster = new HubBroadcaster(fakeHub);
var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" };
var mgr = new WorktreeManager(
new GitService(), db.CreateFactory(), cfg, NullLogger<WorktreeManager>.Instance, broadcaster);
var ctx = await mgr.CreateAsync(task, list, CancellationToken.None);
_worktreeCleanups.Add((repo.RepoDir, ctx.WorktreePath));
var progressCalls = fakeHub.Proxy.Calls.Where(c => c.Method == "OperationProgress").ToList();
Assert.Single(progressCalls);
Assert.Equal(task.Id, (string?)progressCalls[0].Args[0]);
Assert.Equal(WorktreeManager.PhaseCreatingWorktree, (string?)progressCalls[0].Args[1]);
}
[Fact]
public async Task CreateAsync_SelfHealPath_BroadcastsCreatingWorktreePhaseForEachSection()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = CreateRepo();
var (task, list) = MakeEntities(repo.RepoDir);
var db = new DbFixture();
_dbFixtures.Add(db);
using (var seedCtx = db.CreateContext())
{
await new ListRepository(seedCtx).AddAsync(list);
await new TaskRepository(seedCtx).AddAsync(task);
}
// Pre-create the branch so `git worktree add -b` hits the "already exists" self-heal path.
var branchName = $"claudedo/{task.Id.Replace("-", "")}";
GitRepoFixture.RunGit(repo.RepoDir, "branch", branchName, repo.BaseCommit);
var fakeHub = new CapturingHubContext();
var broadcaster = new HubBroadcaster(fakeHub);
var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" };
var mgr = new WorktreeManager(
new GitService(), db.CreateFactory(), cfg, NullLogger<WorktreeManager>.Instance, broadcaster);
var ctx = await mgr.CreateAsync(task, list, CancellationToken.None);
_worktreeCleanups.Add((repo.RepoDir, ctx.WorktreePath));
// One message for the initial attempt, one for the self-heal retry section -- not a tick stream.
var progressCalls = fakeHub.Proxy.Calls
.Where(c => c.Method == "OperationProgress"
&& (string?)c.Args[0] == task.Id
&& (string?)c.Args[1] == WorktreeManager.PhaseCreatingWorktree)
.ToList();
Assert.Equal(2, progressCalls.Count);
}
[Fact]
public async Task CommitIfChangedAsync_NoChanges_HeadCommitStaysNull()
{