Merge claudedo/b8004a65aba245bfbce192d10d90bb36

This commit is contained in:
mika kuns
2026-08-11 13:54:17 +02:00
11 changed files with 361 additions and 38 deletions
@@ -190,7 +190,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
public string SessionLabel => "claude-session";
public string TaskIdBadge => Task != null ? $"#T{Task.Id[..Math.Min(3, Task.Id.Length)].ToUpperInvariant()}" : "";
public string TaskIdBadge => Task != null ? $"#{Task.Number}" : "";
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(ContinueCommand))]
@@ -8,6 +8,7 @@ namespace ClaudeDo.Ui.ViewModels.Islands;
public sealed partial class TaskRowViewModel : ViewModelBase
{
public required string Id { get; init; }
[ObservableProperty] private int _number;
[ObservableProperty] private string _title = "";
[ObservableProperty] private string _listName = "";
[ObservableProperty] private bool _done;
@@ -351,6 +352,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
public void UpdateFromEntity(TaskEntity t)
{
var (add, del) = ParseDiffStat(t.Worktree?.DiffStat);
Number = t.Number;
Title = t.Title;
ListName = t.List?.Name ?? "";
Done = t.Status == TaskStatus.Done;
@@ -66,14 +66,18 @@
<!-- Title + chip row + live tail -->
<StackPanel Grid.Column="3" Spacing="6" VerticalAlignment="Center">
<Grid ColumnDefinitions="*,Auto" VerticalAlignment="Center">
<TextBlock Grid.Column="0"
Classes="task-title"
Text="{Binding Title}" FontSize="{StaticResource FontSizeTaskTitle}"
Foreground="{DynamicResource TextBrush}"
TextWrapping="Wrap"
FontStyle="{Binding IsDraft, Converter={StaticResource BoolToItalic}}"
Opacity="{Binding IsDraft, Converter={StaticResource BoolToDraftOpacity}}"
TextDecorations="{Binding Done, Converter={StaticResource StrikeIfTrue}}"/>
<Grid Grid.Column="0" ColumnDefinitions="Auto,*" VerticalAlignment="Center">
<TextBlock Grid.Column="0" Classes="meta" Text="{Binding Number, StringFormat='#{0}'}"
VerticalAlignment="Center" Margin="0,0,6,0"/>
<TextBlock Grid.Column="1"
Classes="task-title"
Text="{Binding Title}" FontSize="{StaticResource FontSizeTaskTitle}"
Foreground="{DynamicResource TextBrush}"
TextWrapping="Wrap"
FontStyle="{Binding IsDraft, Converter={StaticResource BoolToItalic}}"
Opacity="{Binding IsDraft, Converter={StaticResource BoolToDraftOpacity}}"
TextDecorations="{Binding Done, Converter={StaticResource StrikeIfTrue}}"/>
</Grid>
<!-- Badges: DRAFT and planning session -->
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="4"
@@ -315,7 +315,7 @@ public sealed class TaskMergeService
"Auto-rebase of task {TaskId} branch {Branch} onto {Target} failed, left as-is: {Detail}",
candidate.Id, wt.BranchName, targetBranch, detail);
await _broadcaster.WorkerLog(
$"Auto-rebase failed for \"{candidate.Title}\" onto {targetBranch} — left as-is ({detail})",
$"Auto-rebase failed for #{candidate.Number} \"{candidate.Title}\" onto {targetBranch} — left as-is ({detail})",
WorkerLogLevel.Warn, DateTime.UtcNow);
return;
}
@@ -429,7 +429,7 @@ public sealed class TaskMergeService
if (verifyFailure is not null)
{
_logger.LogWarning("Verify command failed after merging task {TaskId}: {Reason}", taskId, verifyFailure.ErrorMessage);
await _broadcaster.WorkerLog($"Verify failed for \"{task.Title}\" after merge into {targetBranch}", WorkerLogLevel.Warn, DateTime.UtcNow);
await _broadcaster.WorkerLog($"Verify failed for #{task.Number} \"{task.Title}\" after merge into {targetBranch}", WorkerLogLevel.Warn, DateTime.UtcNow);
return verifyFailure;
}
@@ -438,7 +438,7 @@ public sealed class TaskMergeService
_logger.LogInformation(
"Merged task {TaskId} branch {Branch} into {Target} (remove worktree: {Remove})",
taskId, wt.BranchName, targetBranch, removeWorktree);
await _broadcaster.WorkerLog($"Merged \"{task.Title}\" into {targetBranch}", WorkerLogLevel.Success, DateTime.UtcNow);
await _broadcaster.WorkerLog($"Merged #{task.Number} \"{task.Title}\" into {targetBranch}", WorkerLogLevel.Success, DateTime.UtcNow);
return new MergeResult(StatusMerged, Array.Empty<string>(), cleanupWarning);
}
@@ -523,7 +523,7 @@ public sealed class TaskMergeService
if (verifyFailure is not null)
{
_logger.LogWarning("Verify command failed after continuing merge of task {TaskId}: {Reason}", taskId, verifyFailure.ErrorMessage);
await _broadcaster.WorkerLog($"Verify failed for \"{task.Title}\" after merge", WorkerLogLevel.Warn, DateTime.UtcNow);
await _broadcaster.WorkerLog($"Verify failed for #{task.Number} \"{task.Title}\" after merge", WorkerLogLevel.Warn, DateTime.UtcNow);
return verifyFailure;
}
@@ -625,7 +625,7 @@ public sealed class TaskMergeService
_logger.LogInformation(
"Reverted merge of task {TaskId} (merge commit {MergeSha}) via revert commit {RevertSha}",
taskId, wt.MergeCommit, revertSha);
await _broadcaster.WorkerLog($"Reverted merge of \"{task.Title}\"", WorkerLogLevel.Warn, DateTime.UtcNow);
await _broadcaster.WorkerLog($"Reverted merge of #{task.Number} \"{task.Title}\"", WorkerLogLevel.Warn, DateTime.UtcNow);
return new RevertResult(StatusReverted, revertSha, Array.Empty<string>(), null);
}
@@ -55,7 +55,7 @@ public sealed class TaskResetService
if (wt is not null && wt.State == WorktreeState.Active && list.WorkingDir is not null)
{
await _wtManager.DiscardAsync(wt, list.WorkingDir, ct);
await _broadcaster.WorkerLog($"Discarded worktree for \"{task.Title}\"", WorkerLogLevel.Warn, DateTime.UtcNow);
await _broadcaster.WorkerLog($"Discarded worktree for #{task.Number} \"{task.Title}\"", WorkerLogLevel.Warn, DateTime.UtcNow);
worktreeChanged = true;
}
@@ -66,6 +66,6 @@ public sealed class TaskResetService
await _broadcaster.WorktreeUpdated(taskId);
_logger.LogInformation("Reset task {TaskId} to Idle (worktree discarded: {Discarded})", taskId, worktreeChanged);
await _broadcaster.WorkerLog($"Reset \"{task.Title}\"", WorkerLogLevel.Warn, DateTime.UtcNow);
await _broadcaster.WorkerLog($"Reset #{task.Number} \"{task.Title}\"", WorkerLogLevel.Warn, DateTime.UtcNow);
}
}
+22 -22
View File
@@ -73,7 +73,7 @@ public sealed class TaskRunner
list = await listRepo.GetByIdAsync(task.ListId, ct);
if (list is null)
{
await MarkFailed(task.Id, task.Title, slot, "List not found.");
await MarkFailed(task.Id, task.Number, task.Title, slot, "List not found.");
return;
}
listConfig = await listRepo.GetConfigAsync(task.ListId, ct);
@@ -117,7 +117,7 @@ public sealed class TaskRunner
var prep = await PrepareRunDirectoryAsync(task, list, ct);
if (prep.FailureReason is not null)
{
await MarkFailed(task.Id, task.Title, slot, prep.FailureReason);
await MarkFailed(task.Id, task.Number, task.Title, slot, prep.FailureReason);
return;
}
var wtCtx = prep.WtCtx;
@@ -135,7 +135,7 @@ public sealed class TaskRunner
attachmentPaths);
// Run 1.
var result = await RunOnceAsync(task.Id, task.Title, slot, runDir, resolvedConfig, 1, false, prompt, ct);
var result = await RunOnceAsync(task.Id, task.Number, task.Title, slot, runDir, resolvedConfig, 1, false, prompt, ct);
if (result.IsSuccess)
{
@@ -150,7 +150,7 @@ public sealed class TaskRunner
var retryConfig = resolvedConfig with { ResumeSessionId = result.SessionId };
var retryPrompt = BuildRetryPrompt(result.ErrorMarkdown);
var retryResult = await RunOnceAsync(task.Id, task.Title, slot, runDir, retryConfig, 2, true, retryPrompt, ct);
var retryResult = await RunOnceAsync(task.Id, task.Number, task.Title, slot, runDir, retryConfig, 2, true, retryPrompt, ct);
if (retryResult.IsSuccess)
{
@@ -158,13 +158,13 @@ public sealed class TaskRunner
}
else
{
await MarkFailed(task.Id, task.Title, slot, retryResult.ErrorMarkdown, retryResult.TurnCount,
await MarkFailed(task.Id, task.Number, task.Title, slot, retryResult.ErrorMarkdown, retryResult.TurnCount,
retryConfig.MaxTurns, ClassifyFailureReason(retryResult.TerminalReason));
}
}
else
{
await MarkFailed(task.Id, task.Title, slot, result.ErrorMarkdown, result.TurnCount,
await MarkFailed(task.Id, task.Number, task.Title, slot, result.ErrorMarkdown, result.TurnCount,
resolvedConfig.MaxTurns, ClassifyFailureReason(result.TerminalReason));
}
}
@@ -174,12 +174,12 @@ public sealed class TaskRunner
catch (OperationCanceledException)
{
_logger.LogInformation("Task {TaskId} was cancelled", task.Id);
await MarkFailed(task.Id, task.Title, slot, "Task cancelled.", failureReason: "cancelled");
await MarkFailed(task.Id, task.Number, task.Title, slot, "Task cancelled.", failureReason: "cancelled");
}
catch (Exception ex)
{
_logger.LogError(ex, "Unhandled exception running task {TaskId}", task.Id);
await MarkFailed(task.Id, task.Title, slot, $"Unhandled error: {ex.Message}");
await MarkFailed(task.Id, task.Number, task.Title, slot, $"Unhandled error: {ex.Message}");
}
finally
{
@@ -264,7 +264,7 @@ public sealed class TaskRunner
try
{
var nextRunNumber = lastRun.RunNumber + 1;
var result = await RunOnceAsync(taskId, task.Title, slot, runDir, resolvedConfig, nextRunNumber, false, followUpPrompt, ct);
var result = await RunOnceAsync(taskId, task.Number, task.Title, slot, runDir, resolvedConfig, nextRunNumber, false, followUpPrompt, ct);
if (result.IsSuccess)
{
@@ -272,7 +272,7 @@ public sealed class TaskRunner
}
else
{
await MarkFailed(taskId, task.Title, slot, result.ErrorMarkdown, result.TurnCount,
await MarkFailed(taskId, task.Number, task.Title, slot, result.ErrorMarkdown, result.TurnCount,
resolvedConfig.MaxTurns, ClassifyFailureReason(result.TerminalReason));
}
@@ -281,12 +281,12 @@ public sealed class TaskRunner
catch (OperationCanceledException)
{
_logger.LogInformation("Task {TaskId} was cancelled during continue", taskId);
await MarkFailed(taskId, task.Title, slot, "Task cancelled.", failureReason: "cancelled");
await MarkFailed(taskId, task.Number, task.Title, slot, "Task cancelled.", failureReason: "cancelled");
}
catch (Exception ex)
{
_logger.LogError(ex, "Unhandled exception continuing task {TaskId}", taskId);
await MarkFailed(taskId, task.Title, slot, $"Unhandled error: {ex.Message}");
await MarkFailed(taskId, task.Number, task.Title, slot, $"Unhandled error: {ex.Message}");
}
finally
{
@@ -329,7 +329,7 @@ public sealed class TaskRunner
try
{
var wtCtx = await _wtManager.CreateAsync(task, list, ct);
await _broadcaster.WorkerLog($"Created worktree for \"{task.Title}\"", WorkerLogLevel.Info, DateTime.UtcNow);
await _broadcaster.WorkerLog($"Created worktree for #{task.Number} \"{task.Title}\"", WorkerLogLevel.Info, DateTime.UtcNow);
// The worktrees row was just inserted; without this the UI keeps showing the task
// as having no worktree until some unrelated event happens to refresh it.
await _broadcaster.WorktreeUpdated(task.Id);
@@ -348,7 +348,7 @@ public sealed class TaskRunner
}
private async Task<RunResult> RunOnceAsync(
string taskId, string taskTitle, string slot, string runDir, ClaudeRunConfig config,
string taskId, int taskNumber, string taskTitle, string slot, string runDir, ClaudeRunConfig config,
int runNumber, bool isRetry, string prompt, CancellationToken ct)
{
var runId = Guid.NewGuid().ToString();
@@ -383,7 +383,7 @@ public sealed class TaskRunner
try
{
await _broadcaster.WorkerLog($"Started Claude for \"{taskTitle}\"", WorkerLogLevel.Info, DateTime.UtcNow);
await _broadcaster.WorkerLog($"Started Claude for #{taskNumber} \"{taskTitle}\"", WorkerLogLevel.Info, DateTime.UtcNow);
var result = await _claude.RunAsync(
arguments,
prompt,
@@ -486,7 +486,7 @@ public sealed class TaskRunner
committed = await _wtManager.CommitIfChangedAsync(wtCtx, task, list, ct);
if (committed)
{
await _broadcaster.WorkerLog($"Committed changes in \"{task.Title}\"", WorkerLogLevel.Info, DateTime.UtcNow);
await _broadcaster.WorkerLog($"Committed changes in #{task.Number} \"{task.Title}\"", WorkerLogLevel.Info, DateTime.UtcNow);
await _broadcaster.WorktreeUpdated(task.Id);
}
}
@@ -505,7 +505,7 @@ public sealed class TaskRunner
if (wtCtx is not null && !committed && deniedWrites.Count > 0)
{
await MarkFailed(
task.Id, task.Title, slot,
task.Id, task.Number, task.Title, slot,
$"All edits were blocked by permission denials ({string.Join(", ", deniedWrites)}) and nothing " +
"was changed. Check the run's permission mode (get_effective_run_config).",
result.TurnCount);
@@ -542,20 +542,20 @@ public sealed class TaskRunner
foreach (var child in pendingChildren)
await _state.EnqueueAsync(child.Id, CancellationToken.None);
await _broadcaster.WorkerLog(
$"Finished \"{task.Title}\" (waiting on {pendingChildren.Count} improvement(s))",
$"Finished #{task.Number} \"{task.Title}\" (waiting on {pendingChildren.Count} improvement(s))",
WorkerLogLevel.Success, DateTime.UtcNow);
await _broadcaster.TaskFinished(slot, task.Id, "waiting_for_children", finishedAt);
}
else if (isStandalone)
{
await _state.SubmitForReviewAsync(task.Id, finishedAt, reviewResult, CancellationToken.None);
await _broadcaster.WorkerLog($"Finished \"{task.Title}\" (waiting for review)", WorkerLogLevel.Success, DateTime.UtcNow);
await _broadcaster.WorkerLog($"Finished #{task.Number} \"{task.Title}\" (waiting for review)", WorkerLogLevel.Success, DateTime.UtcNow);
await _broadcaster.TaskFinished(slot, task.Id, "waiting_for_review", finishedAt);
}
else
{
await _state.CompleteAsync(task.Id, finishedAt, reviewResult, CancellationToken.None);
await _broadcaster.WorkerLog($"Finished \"{task.Title}\" (done)", WorkerLogLevel.Success, DateTime.UtcNow);
await _broadcaster.WorkerLog($"Finished #{task.Number} \"{task.Title}\" (done)", WorkerLogLevel.Success, DateTime.UtcNow);
await _broadcaster.TaskFinished(slot, task.Id, "done", finishedAt);
}
_logger.LogInformation("Task {TaskId} completed (turns={Turns}, tokens_in={In}, tokens_out={Out})",
@@ -563,7 +563,7 @@ public sealed class TaskRunner
}
private async Task MarkFailed(
string taskId, string taskTitle, string slot, string? error, int turnCount = 0,
string taskId, int taskNumber, string taskTitle, string slot, string? error, int turnCount = 0,
int? maxTurns = null, string failureReason = "error")
{
// Terminal write for a failed task: never cancel (the status must always
@@ -573,7 +573,7 @@ public sealed class TaskRunner
var finishedAt = DateTime.UtcNow;
await _state.FailAsync(taskId, finishedAt, error, CancellationToken.None,
failureReason, turnCount > 0 ? turnCount : null, maxTurns);
await _broadcaster.WorkerLog($"Finished \"{taskTitle}\" (failed)", WorkerLogLevel.Error, DateTime.UtcNow);
await _broadcaster.WorkerLog($"Finished #{taskNumber} \"{taskTitle}\" (failed)", WorkerLogLevel.Error, DateTime.UtcNow);
await _broadcaster.TaskFinished(slot, taskId, "failed", finishedAt);
_logger.LogWarning("Task {TaskId} failed (turns={Turns}): {Error}", taskId, turnCount, error);
}
@@ -0,0 +1,81 @@
using ClaudeDo.Data;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Islands;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Ui.Tests.ViewModels;
// Slice 4 of the task-numbers feature: the detail-pane header badge shows "#<Number>" instead of
// the old "#T<guid-prefix>" handle, which nobody could correlate back to a task.
public class DetailsIslandTaskIdBadgeTests : IDisposable
{
private readonly string _dbPath;
public DetailsIslandTaskIdBadgeTests()
{
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_badge_test_{Guid.NewGuid():N}.db");
using var ctx = NewContext();
ctx.Database.EnsureCreated();
}
public void Dispose()
{
try { File.Delete(_dbPath); } catch { }
try { File.Delete(_dbPath + "-wal"); } catch { }
try { File.Delete(_dbPath + "-shm"); } catch { }
}
private ClaudeDoDbContext NewContext()
{
var opts = new DbContextOptionsBuilder<ClaudeDoDbContext>()
.UseSqlite($"Data Source={_dbPath}")
.Options;
return new ClaudeDoDbContext(opts);
}
private sealed class TestDbFactory : IDbContextFactory<ClaudeDoDbContext>
{
private readonly Func<ClaudeDoDbContext> _create;
public TestDbFactory(Func<ClaudeDoDbContext> create) => _create = create;
public ClaudeDoDbContext CreateDbContext() => _create();
}
private sealed class StubNotesApi : ClaudeDo.Ui.Services.Interfaces.INotesApi
{
public Task<List<DailyNoteDto>> ListAsync(DateOnly day) => Task.FromResult(new List<DailyNoteDto>());
public Task<DailyNoteDto?> AddAsync(DateOnly day, string text) => Task.FromResult<DailyNoteDto?>(null);
public Task UpdateAsync(string id, string text) => Task.CompletedTask;
public Task DeleteAsync(string id) => Task.CompletedTask;
}
private sealed class NullServiceProvider : IServiceProvider
{
public object? GetService(Type serviceType) => null;
}
private sealed class DefaultStub : StubWorkerClient { }
private DetailsIslandViewModel NewVm()
{
var factory = new TestDbFactory(NewContext);
return new DetailsIslandViewModel(factory, new DefaultStub(), new NullServiceProvider(), new StubNotesApi(), new ClaudeDo.Ui.Services.MergeCoordinator());
}
[Fact]
public void TaskIdBadge_NoTaskBound_IsEmpty()
{
var vm = NewVm();
Assert.Equal("", vm.TaskIdBadge);
}
[Fact]
public void TaskIdBadge_BoundTask_IsHashNumber()
{
var vm = NewVm();
vm.Bind(new TaskRowViewModel { Id = Guid.NewGuid().ToString("N"), Number = 123 });
Assert.Equal("#123", vm.TaskIdBadge);
}
}
@@ -0,0 +1,85 @@
using ClaudeDo.Data;
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;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
using Xunit;
namespace ClaudeDo.Worker.Tests.Runner;
// Slice 4 of the task-numbers feature: the curated WorkerLog business events must say "#<Number>"
// so a user who knows a task by its number (not its GUID) can find it in the worker-log strip.
public sealed class WorkerLogTaskNumberTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly string _tempDir;
private readonly WorkerConfig _cfg;
public WorkerLogTaskNumberTests()
{
_tempDir = Path.Combine(Path.GetTempPath(), $"cd_wlnum_{Guid.NewGuid():N}");
Directory.CreateDirectory(_tempDir);
_cfg = new WorkerConfig { SandboxRoot = _tempDir, LogRoot = _tempDir };
}
public void Dispose() { _db.Dispose(); try { Directory.Delete(_tempDir, true); } catch { } }
[Fact]
public async Task RunAsync_Success_BroadcastsWorkerLog_ContainingTaskNumber()
{
var dbFactory = _db.CreateFactory();
using (var ctx = _db.CreateContext())
{
ctx.Lists.Add(new ListEntity { Id = "l1", Name = "L", WorkingDir = null, CreatedAt = DateTime.UtcNow });
ctx.Tasks.Add(new TaskEntity { Id = "solo", ListId = "l1", Title = "Solo", Number = 123,
Status = TaskStatus.Running, CreatedAt = DateTime.UtcNow });
await ctx.SaveChangesAsync();
}
var fake = new FakeClaudeProcess((_, _, _, _, _) =>
Task.FromResult(new RunResult { ExitCode = 0, ResultMarkdown = "done" }));
var hub = new CapturingHubContext();
var state = TaskStateServiceBuilder.Build(dbFactory).State;
var wt = new WorktreeManager(new GitService(), dbFactory, _cfg, NullLogger<WorktreeManager>.Instance);
var runner = new TaskRunner(fake, dbFactory, new HubBroadcaster(hub), wt,
new ClaudeArgsBuilder(), _cfg, NullLogger<TaskRunner>.Instance, state, new TaskRunTokenRegistry(),
new AttachmentStore(), new FakeSessionSkillSeeder(), new FakeTranscriptUsageReader());
using (var ctx = _db.CreateContext())
await runner.RunAsync((await new TaskRepository(ctx).GetByIdAsync("solo"))!, "slot-1", default, alreadyClaimed: true);
Assert.Contains(hub.Proxy.Calls, c => c.Method == "WorkerLog"
&& c.Args[0] is string s && s.Contains("#123"));
}
[Fact]
public async Task RunAsync_Failure_BroadcastsWorkerLog_ContainingTaskNumber()
{
var dbFactory = _db.CreateFactory();
using (var ctx = _db.CreateContext())
{
ctx.Lists.Add(new ListEntity { Id = "l1", Name = "L", WorkingDir = null, CreatedAt = DateTime.UtcNow });
ctx.Tasks.Add(new TaskEntity { Id = "fail1", ListId = "l1", Title = "Boom", Number = 456,
Status = TaskStatus.Running, CreatedAt = DateTime.UtcNow });
await ctx.SaveChangesAsync();
}
var fake = new FakeClaudeProcess((_, _, _, _, _) =>
Task.FromResult(new RunResult { ExitCode = 1, ErrorMarkdown = "nope" }));
var hub = new CapturingHubContext();
var state = TaskStateServiceBuilder.Build(dbFactory).State;
var wt = new WorktreeManager(new GitService(), dbFactory, _cfg, NullLogger<WorktreeManager>.Instance);
var runner = new TaskRunner(fake, dbFactory, new HubBroadcaster(hub), wt,
new ClaudeArgsBuilder(), _cfg, NullLogger<TaskRunner>.Instance, state, new TaskRunTokenRegistry(),
new AttachmentStore(), new FakeSessionSkillSeeder(), new FakeTranscriptUsageReader());
using (var ctx = _db.CreateContext())
await runner.RunAsync((await new TaskRepository(ctx).GetByIdAsync("fail1"))!, "slot-1", default, alreadyClaimed: true);
Assert.Contains(hub.Proxy.Calls, c => c.Method == "WorkerLog"
&& c.Args[0] is string s && s.Contains("(failed)") && s.Contains("#456"));
}
}
@@ -0,0 +1,84 @@
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Lifecycle;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.Extensions.Logging.Abstractions;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Tests.Services;
// Slice 4 of the task-numbers feature: TaskMergeService's curated WorkerLog business events must
// say "#<Number>" alongside the quoted title.
public sealed class TaskMergeServiceTaskNumberTests : IDisposable
{
private readonly List<DbFixture> _dbs = new();
private readonly List<GitRepoFixture> _repos = new();
private readonly List<(string repoDir, string wtPath)> _wtCleanups = new();
private DbFixture NewDb() { var d = new DbFixture(); _dbs.Add(d); return d; }
private GitRepoFixture NewRepo() { var r = new GitRepoFixture(); _repos.Add(r); return r; }
public void Dispose()
{
foreach (var (repoDir, wtPath) in _wtCleanups)
{
try { GitRepoFixture.RunGit(repoDir, "worktree", "remove", "--force", wtPath); } catch { }
}
foreach (var d in _dbs) try { d.Dispose(); } catch { }
foreach (var r in _repos) try { r.Dispose(); } catch { }
}
[Fact]
public async Task MergeAsync_Success_BroadcastsWorkerLog_ContainingTaskNumber()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var list = new ListEntity
{
Id = Guid.NewGuid().ToString(), Name = "merge-num-test", WorkingDir = repo.RepoDir,
DefaultCommitType = "feat", CreatedAt = DateTime.UtcNow,
};
var task = new TaskEntity
{
Id = Guid.NewGuid().ToString(), ListId = list.Id, Title = "merge-task", Number = 789,
Status = TaskStatus.Done, CreatedAt = DateTime.UtcNow,
};
using (var ctx = db.CreateContext())
{
// Bypass TaskRepository.AddAsync — it allocates its own Number via
// TaskNumberAllocator and would silently overwrite the one this test sets.
ctx.Lists.Add(list);
ctx.Tasks.Add(task);
await ctx.SaveChangesAsync();
}
var wtMgr = new WorktreeManager(new GitService(), db.CreateFactory(),
new WorkerConfig { WorktreeRootStrategy = "sibling" }, NullLogger<WorktreeManager>.Instance);
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "added.txt"), "new\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
var hub = new CapturingHubContext();
var state = TaskStateServiceBuilder.Build(db.CreateFactory()).State;
var svc = new TaskMergeService(
db.CreateFactory(), new GitService(), new HubBroadcaster(hub), state,
new VerifyCommandRunner(), NullLogger<TaskMergeService>.Instance);
var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
var result = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: false,
commitMessage: "Merge task", ct: CancellationToken.None);
Assert.Equal("merged", result.Status);
Assert.Contains(hub.Proxy.Calls, c => c.Method == "WorkerLog"
&& c.Args[0] is string s && s.Contains("Merged") && s.Contains("#789"));
}
}
@@ -143,6 +143,55 @@ public class TaskResetServiceTests : IDisposable
Assert.Contains(proxy.Calls, i => i.Method == "WorktreeUpdated" && i.Args[0] is string s && s == task.Id);
}
[Fact]
public async Task ResetAsync_BroadcastsWorkerLog_ContainingTaskNumber()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var wtMgr = BuildWorktreeManager(db);
var list = new ListEntity
{
Id = Guid.NewGuid().ToString(),
Name = "reset-number-test",
WorkingDir = repo.RepoDir,
DefaultCommitType = "feat",
CreatedAt = DateTime.UtcNow,
};
var task = new TaskEntity
{
Id = Guid.NewGuid().ToString(),
ListId = list.Id,
Title = "numbered task",
Number = 321,
Status = TaskStatus.Failed,
StartedAt = DateTime.UtcNow.AddMinutes(-5),
FinishedAt = DateTime.UtcNow.AddMinutes(-1),
Result = "some error",
CreatedAt = DateTime.UtcNow,
};
using (var ctx = db.CreateContext())
{
// Bypass TaskRepository.AddAsync — it allocates its own Number via
// TaskNumberAllocator and would silently overwrite the one this test sets.
ctx.Lists.Add(list);
ctx.Tasks.Add(task);
await ctx.SaveChangesAsync();
}
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
_worktreeCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
var (svc, proxy) = BuildService(db, wtMgr);
await svc.ResetAsync(task.Id, CancellationToken.None);
Assert.Contains(proxy.Calls, c => c.Method == "WorkerLog"
&& c.Args[0] is string s && s.Contains("#321"));
}
[Fact]
public async Task ResetAsync_RunningTask_Throws_AndDoesNotMutate()
{
@@ -22,6 +22,24 @@ public class TaskRowViewModelTests
Assert.Equal(expected, vm.StatusChipClass);
}
[Fact]
public void Number_SetFromEntity_SurvivesUpdateFromEntity()
{
var entity = new TaskEntity
{
Id = "t", ListId = "l1", Title = "Task", Number = 123, CreatedAt = DateTime.UtcNow,
};
var vm = TaskRowViewModel.FromEntity(entity);
Assert.Equal(123, vm.Number);
// Simulates the TaskUpdated refresh path: the entity is re-fetched and the same
// row is updated in place rather than replaced.
entity.Title = "Task (edited)";
vm.UpdateFromEntity(entity);
Assert.Equal(123, vm.Number);
}
[Fact]
public void IsDropTarget_Follows_Either_DropHint_And_Raises_PropertyChanged()
{