diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs index 8d7cb7ca..1133d1aa 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs @@ -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))] diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs index d278ce72..9c359ccf 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs @@ -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; diff --git a/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml b/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml index 116acd43..213cd0e5 100644 --- a/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml +++ b/src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml @@ -66,14 +66,18 @@ - + + + + (), 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(), null); } diff --git a/src/ClaudeDo.Worker/Lifecycle/TaskResetService.cs b/src/ClaudeDo.Worker/Lifecycle/TaskResetService.cs index c4316a73..9a1d4f5e 100644 --- a/src/ClaudeDo.Worker/Lifecycle/TaskResetService.cs +++ b/src/ClaudeDo.Worker/Lifecycle/TaskResetService.cs @@ -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); } } diff --git a/src/ClaudeDo.Worker/Runner/TaskRunner.cs b/src/ClaudeDo.Worker/Runner/TaskRunner.cs index 376d541d..4c38d745 100644 --- a/src/ClaudeDo.Worker/Runner/TaskRunner.cs +++ b/src/ClaudeDo.Worker/Runner/TaskRunner.cs @@ -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 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); } diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandTaskIdBadgeTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandTaskIdBadgeTests.cs new file mode 100644 index 00000000..08786ed1 --- /dev/null +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandTaskIdBadgeTests.cs @@ -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 "#" instead of +// the old "#T" 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() + .UseSqlite($"Data Source={_dbPath}") + .Options; + return new ClaudeDoDbContext(opts); + } + + private sealed class TestDbFactory : IDbContextFactory + { + private readonly Func _create; + public TestDbFactory(Func create) => _create = create; + public ClaudeDoDbContext CreateDbContext() => _create(); + } + + private sealed class StubNotesApi : ClaudeDo.Ui.Services.Interfaces.INotesApi + { + public Task> ListAsync(DateOnly day) => Task.FromResult(new List()); + public Task AddAsync(DateOnly day, string text) => Task.FromResult(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); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Runner/WorkerLogTaskNumberTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/WorkerLogTaskNumberTests.cs new file mode 100644 index 00000000..fdc513a7 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Runner/WorkerLogTaskNumberTests.cs @@ -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 "#" +// 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.Instance); + var runner = new TaskRunner(fake, dbFactory, new HubBroadcaster(hub), wt, + new ClaudeArgsBuilder(), _cfg, NullLogger.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.Instance); + var runner = new TaskRunner(fake, dbFactory, new HubBroadcaster(hub), wt, + new ClaudeArgsBuilder(), _cfg, NullLogger.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")); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTaskNumberTests.cs b/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTaskNumberTests.cs new file mode 100644 index 00000000..ca669d07 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTaskNumberTests.cs @@ -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 "#" alongside the quoted title. +public sealed class TaskMergeServiceTaskNumberTests : IDisposable +{ + private readonly List _dbs = new(); + private readonly List _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.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.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")); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Services/TaskResetServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Services/TaskResetServiceTests.cs index d2d424e5..324b4115 100644 --- a/tests/ClaudeDo.Worker.Tests/Services/TaskResetServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Services/TaskResetServiceTests.cs @@ -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() { diff --git a/tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelTests.cs b/tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelTests.cs index a8a2166d..46c3eaa0 100644 --- a/tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelTests.cs +++ b/tests/ClaudeDo.Worker.Tests/UiVm/TaskRowViewModelTests.cs @@ -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() {