chore(claude-do): merge [C3] Worktree-Anlage beim Task-Start an der Task-Zeile sicht

ClaudeDo-Task: 68254f28-13f4-40e8-94c0-e15d2fe58b37
This commit is contained in:
mika kuns
2026-08-21 13:45:24 +02:00
6 changed files with 156 additions and 1 deletions
@@ -58,6 +58,12 @@ public sealed partial class TaskRowViewModel : ViewModelBase
// Set by the custom drag while this row is being dragged — drives the "grabbed" row style.
[ObservableProperty] private bool _isDragging;
// Transient: set from HubBroadcaster's OperationProgress while the worker is still creating
// this task's worktree (the silent gap between Queued and the first agent output). Cleared
// by the next entity refresh — UpdateFromEntity always reflects a settled state, so there's
// nothing left to show past that point.
[ObservableProperty] private string? _creationPhase;
// True while a drag is hovering this row (i.e. it would show a drop-hint gap). Used to
// suppress the ordinary hover highlight/transitions so they don't fight the hint.
public bool IsDropTarget => DropHintAbove || DropHintBelow;
@@ -145,6 +151,16 @@ public sealed partial class TaskRowViewModel : ViewModelBase
? "1 roadblock reported during the run — see details"
: $"{RoadblockCount} roadblocks reported during the run — see details";
/// Mirrors WorktreeManager.PhaseCreatingWorktree — a hub payload token, not a display string.
private const string PhaseCreatingWorktree = "creating_worktree";
public bool HasCreationPhase => CreationPhase is not null;
public string? CreationPhaseLabel => CreationPhase switch
{
PhaseCreatingWorktree => Loc.T("ops.worker.creatingWorktree"),
_ => null,
};
// True for every Failed task, even one that predates this field — FailureReasonLabel then
// falls back to "unknown" instead of leaving the tooltip blank.
public bool HasFailureReason => Status == TaskStatus.Failed;
@@ -345,6 +361,11 @@ public sealed partial class TaskRowViewModel : ViewModelBase
partial void OnRoadblockCountChanged(int value) { OnPropertyChanged(nameof(HasRoadblock)); OnPropertyChanged(nameof(RoadblockTooltip)); }
partial void OnDropHintAboveChanged(bool value) => OnPropertyChanged(nameof(IsDropTarget));
partial void OnDropHintBelowChanged(bool value) => OnPropertyChanged(nameof(IsDropTarget));
partial void OnCreationPhaseChanged(string? value)
{
OnPropertyChanged(nameof(HasCreationPhase));
OnPropertyChanged(nameof(CreationPhaseLabel));
}
public void RefreshLocalized()
{
@@ -363,6 +384,9 @@ public sealed partial class TaskRowViewModel : ViewModelBase
public void UpdateFromEntity(TaskEntity t)
{
// Any entity-backed refresh reflects a settled state, so the transient creation-phase
// banner (set from the OperationProgress broadcast) has nothing left to announce.
CreationPhase = null;
var (add, del) = ParseDiffStat(t.Worktree?.DiffStat);
Number = t.Number;
Title = t.Title;
@@ -188,6 +188,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
_worker.ConnectionRestoredEvent += () => LoadForList(_currentList);
_worker.RefineStartedEvent += OnRefineStarted;
_worker.RefineFinishedEvent += OnRefineFinished;
_worker.OperationProgressEvent += OnWorkerOperationProgress;
}
_langChangedHandler = (_, _) => RefreshLocalizedText();
Loc.LanguageChanged += _langChangedHandler;
@@ -1563,6 +1564,18 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
if (row is not null) row.IsRefining = false;
}
// OperationProgress is a generic channel shared with merge phases (TaskMergeService), whose
// opKey is also the task id — filter on the phase token so a merge in flight can't clobber
// this row's creation-phase banner.
private const string CreationPhaseCreatingWorktree = "creating_worktree";
private void OnWorkerOperationProgress(string opKey, string phase, int current, int total)
{
if (phase != CreationPhaseCreatingWorktree) return;
var row = Items.FirstOrDefault(r => r.Id == opKey);
if (row is not null) row.CreationPhase = phase;
}
partial void OnSelectedTaskChanged(TaskRowViewModel? value)
{
foreach (var i in Items) i.IsSelected = ReferenceEquals(i, value);
@@ -143,6 +143,12 @@
<TextBlock Text="{Binding StatusLabel}"/>
</Border>
<!-- Transient creation-phase chip: fills the silent gap between Queued and the
first agent output while the worker creates this task's worktree. -->
<Border Classes="chip chip-tag" IsVisible="{Binding HasCreationPhase}">
<TextBlock Text="{Binding CreationPhaseLabel}"/>
</Border>
<!-- Dequeue button (visible when row is Queued, or planning parent has queued subtasks) -->
<Button Classes="icon-btn dequeue-btn"
IsVisible="{Binding CanRemoveFromQueue}"
+18 -1
View File
@@ -4,6 +4,7 @@ using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.Git;
using ClaudeDo.Worker.Hub;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Worker.Runner;
@@ -12,19 +13,31 @@ public sealed record WorktreeContext(string WorktreePath, string BranchName, str
public sealed class WorktreeManager
{
// Phase token for the OperationProgress broadcast while `git worktree add` (+ the
// self-heal retry) is in flight -- stable identifier, localized by the UI via the
// pre-existing "ops.worker.creatingWorktree" key. opKey is the task id.
public const string PhaseCreatingWorktree = "creating_worktree";
private readonly GitService _git;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly WorkerConfig _cfg;
private readonly ILogger<WorktreeManager> _logger;
private readonly HubBroadcaster? _broadcaster;
public WorktreeManager(GitService git, IDbContextFactory<ClaudeDoDbContext> dbFactory, WorkerConfig cfg, ILogger<WorktreeManager> logger)
public WorktreeManager(
GitService git, IDbContextFactory<ClaudeDoDbContext> dbFactory, WorkerConfig cfg,
ILogger<WorktreeManager> logger, HubBroadcaster? broadcaster = null)
{
_git = git;
_dbFactory = dbFactory;
_cfg = cfg;
_logger = logger;
_broadcaster = broadcaster;
}
private Task BroadcastCreating(string taskId) =>
_broadcaster?.OperationProgress(taskId, PhaseCreatingWorktree, 0, 0) ?? Task.CompletedTask;
public async Task<WorktreeContext> CreateAsync(TaskEntity task, ListEntity list, CancellationToken ct)
{
var workingDir = list.WorkingDir
@@ -63,6 +76,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.
await BroadcastCreating(task.Id);
try
{
await _git.WorktreeAddAsync(workingDir, branchName, worktreePath, baseCommit, ct);
@@ -70,6 +84,9 @@ public sealed class WorktreeManager
catch (InvalidOperationException ex) when (ex.Message.Contains("already exists", StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning("Branch {Branch} already exists; cleaning phantom worktrees and retrying", branchName);
// Self-heal is the slow section (list -> remove -> prune -> branch delete -> retry);
// report it as its own section rather than staying silent through the retry.
await BroadcastCreating(task.Id);
// Find and forcefully remove any existing worktree registered against this branch.
List<string> stalePaths;
@@ -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()
{
@@ -41,6 +41,7 @@ sealed class FakeWorkerClient : IWorkerClient
public void RaiseTaskUpdated(string taskId) => TaskUpdatedEvent?.Invoke(taskId);
public void RaiseWorktreeUpdated(string taskId) => WorktreeUpdatedEvent?.Invoke(taskId);
public void RaiseTaskMessage(string taskId, string line) => TaskMessageEvent?.Invoke(taskId, line);
public void RaiseOperationProgress(string opKey, string phase, int current, int total) => OperationProgressEvent?.Invoke(opKey, phase, current, total);
public Task RunNowAsync(string taskId) => Task.CompletedTask;
public Task ContinueTaskAsync(string taskId, string followUpPrompt) => Task.CompletedTask;
@@ -277,6 +278,32 @@ public class TasksIslandViewModelPlanningTests
Assert.Equal(("t1", false), raised);
}
[Fact]
public void OperationProgress_CreatingWorktreePhase_SetsOnlyMatchingRow()
{
var row1 = MakeRow("t1", TaskStatus.Running);
var row2 = MakeRow("t2", TaskStatus.Running);
var (_, worker) = VmFactory.Create([row1, row2]);
worker.RaiseOperationProgress("t1", "creating_worktree", 0, 0);
Assert.True(row1.HasCreationPhase);
Assert.False(row2.HasCreationPhase);
}
[Fact]
public void OperationProgress_UnrelatedPhase_DoesNotSetCreationPhase()
{
// Same channel carries TaskMergeService's merge phases (opKey is also the task id) --
// the row must only react to its own creation-phase token, not any phase for its id.
var row = MakeRow("t1", TaskStatus.Running);
var (_, worker) = VmFactory.Create([row]);
worker.RaiseOperationProgress("t1", "merging", 0, 0);
Assert.False(row.HasCreationPhase);
}
[Fact]
public void ToggleExpand_TogglesParentExpansion()
{