From 133774cb86b074024e54c8ac16f5a6067723ea52 Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Tue, 21 Apr 2026 16:47:51 +0200 Subject: [PATCH] docs: add implementation plan for continue and reset buttons --- ...6-04-21-continue-and-reset-failed-tasks.md | 803 ++++++++++++++++++ 1 file changed, 803 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-21-continue-and-reset-failed-tasks.md diff --git a/docs/superpowers/plans/2026-04-21-continue-and-reset-failed-tasks.md b/docs/superpowers/plans/2026-04-21-continue-and-reset-failed-tasks.md new file mode 100644 index 0000000..d36c131 --- /dev/null +++ b/docs/superpowers/plans/2026-04-21-continue-and-reset-failed-tasks.md @@ -0,0 +1,803 @@ +# Continue & Reset Buttons for Failed Tasks — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add two buttons (Continue, Reset) to the details pane for `Failed` tasks so the user can either nudge the agent to continue or discard the worktree and return the task to `Manual`. + +**Architecture:** Spec is at `docs/superpowers/specs/2026-04-21-continue-and-reset-failed-tasks-design.md`. Backend adds one git-discard helper, one task-repository method, a small orchestration service, and a new hub method `ResetTask`. `ContinueTask` is already wired in the hub. UI adds two commands in `DetailsIslandViewModel` and a button row in `DetailsIslandView`. + +**Tech Stack:** .NET 8, EF Core (SQLite), ASP.NET Core SignalR, Avalonia 12, CommunityToolkit.Mvvm, xUnit 2.5 (integration tests with real SQLite + real git). + +--- + +## File Structure + +**New files:** +- `src/ClaudeDo.Worker/Services/TaskResetService.cs` — orchestrates the reset (load task, discard worktree, reset DB row, broadcast). +- `tests/ClaudeDo.Worker.Tests/Services/TaskResetServiceTests.cs` — integration tests for the orchestration. + +**Modified files:** +- `src/ClaudeDo.Worker/Runner/WorktreeManager.cs` — add `DiscardAsync`. +- `src/ClaudeDo.Data/Repositories/TaskRepository.cs` — add `ResetToManualAsync`. +- `src/ClaudeDo.Worker/Hub/WorkerHub.cs` — add `ResetTask` endpoint; DI-inject the new service. +- `src/ClaudeDo.Worker/Program.cs` — register `TaskResetService` in DI. +- `src/ClaudeDo.Ui/Services/WorkerClient.cs` — add `ContinueTaskAsync` and `ResetTaskAsync` wrappers. +- `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs` — add observable properties and commands. +- `src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml` — add button row bound to the new commands. +- `tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs` — add `DiscardAsync` test. +- `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryTests.cs` — add `ResetToManualAsync` test. + +--- + +## Task 1: `WorktreeManager.DiscardAsync` (TDD) + +**Files:** +- Modify: `src/ClaudeDo.Worker/Runner/WorktreeManager.cs` +- Test: `tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs` + +`GitService` already exposes `WorktreeRemoveAsync(workingDir, path, force, ct)` and `BranchDeleteAsync(workingDir, branch, force, ct)` — verify via `git grep -n "public async Task WorktreeRemoveAsync\|public async Task BranchDeleteAsync" src/ClaudeDo.Data/Git`. If either is missing, stop and add the git wrapper first. + +- [ ] **Step 1: Add the failing test** + +Add at the bottom of `tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs` (before the `Dispose` method): + +```csharp +[Fact] +public async Task DiscardAsync_RemovesWorktreeAndBranch_AndSetsStateDiscarded() +{ + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var repo = CreateRepo(); + var (task, list) = MakeEntities(repo.RepoDir); + var (mgr, db) = await CreateManagerAsync(task, list); + + var ctx = await mgr.CreateAsync(task, list, CancellationToken.None); + var worktreePath = ctx.WorktreePath; + + WorktreeEntity wt; + using (var readCtx = db.CreateContext()) + wt = (await new WorktreeRepository(readCtx).GetByTaskIdAsync(task.Id))!; + + await mgr.DiscardAsync(wt, list.WorkingDir!, CancellationToken.None); + + Assert.False(Directory.Exists(worktreePath), "worktree directory should be gone"); + + using var readCtx2 = db.CreateContext(); + var row = await new WorktreeRepository(readCtx2).GetByTaskIdAsync(task.Id); + Assert.NotNull(row); + Assert.Equal(WorktreeState.Discarded, row!.State); + + // Branch should no longer exist on the main repo. + var branchList = await new GitService().RunForOutputAsync(repo.RepoDir, new[] { "branch", "--list", ctx.BranchName }, CancellationToken.None); + Assert.True(string.IsNullOrWhiteSpace(branchList), + $"branch {ctx.BranchName} should be deleted, got: {branchList}"); +} +``` + +Note on `RunForOutputAsync`: if `GitService` does not expose a generic run helper, replace the branch-check with a direct `System.Diagnostics.Process` invocation of `git branch --list ` in the test. If such a helper exists with a different name, use it. + +- [ ] **Step 2: Run test, verify it fails** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~DiscardAsync_RemovesWorktreeAndBranch" -v minimal` +Expected: FAIL — `DiscardAsync` does not exist. + +- [ ] **Step 3: Implement `DiscardAsync`** + +Add to `src/ClaudeDo.Worker/Runner/WorktreeManager.cs` after `CommitIfChangedAsync`: + +```csharp +public async Task DiscardAsync(WorktreeEntity wt, string workingDir, CancellationToken ct) +{ + // Remove the git worktree first; --force drops uncommitted changes (user already confirmed). + try + { + await _git.WorktreeRemoveAsync(workingDir, wt.Path, force: true, ct); + } + catch (Exception ex) + { + _logger.LogError(ex, "git worktree remove failed for {Path}", wt.Path); + throw; + } + + // Delete the branch. If worktree removal succeeded but branch delete fails, + // we still record the worktree as Discarded — the folder is gone, and a dangling + // branch is recoverable; leaving the DB out of sync is worse. + try + { + await _git.BranchDeleteAsync(workingDir, wt.BranchName, force: true, ct); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "git branch -D {Branch} failed after worktree removal; continuing", wt.BranchName); + } + + using var context = _dbFactory.CreateDbContext(); + var wtRepo = new WorktreeRepository(context); + await wtRepo.SetStateAsync(wt.TaskId, WorktreeState.Discarded, ct); + + _logger.LogInformation("Discarded worktree for task {TaskId} (branch {Branch})", wt.TaskId, wt.BranchName); +} +``` + +- [ ] **Step 4: Run test, verify it passes** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~DiscardAsync_RemovesWorktreeAndBranch" -v minimal` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Worker/Runner/WorktreeManager.cs tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs +git commit -m "feat(worker): add WorktreeManager.DiscardAsync for task reset" +``` + +--- + +## Task 2: `TaskRepository.ResetToManualAsync` (TDD) + +**Files:** +- Modify: `src/ClaudeDo.Data/Repositories/TaskRepository.cs` +- Test: `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryTests.cs` + +- [ ] **Step 1: Add the failing test** + +Add to `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryTests.cs` (follow the existing test style in that file — reuse any helpers it already has for creating a list + task): + +```csharp +[Fact] +public async Task ResetToManualAsync_ClearsResultFields_AndSetsStatusManual() +{ + using var db = new DbFixture(); + using var ctx = db.CreateContext(); + var listRepo = new ListRepository(ctx); + var taskRepo = new TaskRepository(ctx); + + var list = new ListEntity { Id = Guid.NewGuid().ToString(), Name = "L", CreatedAt = DateTime.UtcNow }; + await listRepo.AddAsync(list); + + var task = new TaskEntity + { + Id = Guid.NewGuid().ToString(), + ListId = list.Id, + Title = "T", + CreatedAt = DateTime.UtcNow, + Status = TaskStatus.Failed, + StartedAt = DateTime.UtcNow.AddMinutes(-5), + FinishedAt = DateTime.UtcNow, + Result = "boom", + }; + await taskRepo.AddAsync(task); + + await taskRepo.ResetToManualAsync(task.Id); + + using var readCtx = db.CreateContext(); + var after = await new TaskRepository(readCtx).GetByIdAsync(task.Id); + Assert.NotNull(after); + Assert.Equal(TaskStatus.Manual, after!.Status); + Assert.Null(after.StartedAt); + Assert.Null(after.FinishedAt); + Assert.Null(after.Result); +} +``` + +- [ ] **Step 2: Run test, verify it fails** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~ResetToManualAsync_ClearsResultFields" -v minimal` +Expected: FAIL — `ResetToManualAsync` does not exist. + +- [ ] **Step 3: Implement `ResetToManualAsync`** + +Add to `src/ClaudeDo.Data/Repositories/TaskRepository.cs` inside the `#region Status transitions` block, after `FlipAllRunningToFailedAsync`: + +```csharp +public async Task ResetToManualAsync(string taskId, CancellationToken ct = default) +{ + await _context.Tasks + .Where(t => t.Id == taskId) + .ExecuteUpdateAsync(s => s + .SetProperty(t => t.Status, TaskStatus.Manual) + .SetProperty(t => t.StartedAt, (DateTime?)null) + .SetProperty(t => t.FinishedAt, (DateTime?)null) + .SetProperty(t => t.Result, (string?)null), ct); +} +``` + +- [ ] **Step 4: Run test, verify it passes** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~ResetToManualAsync_ClearsResultFields" -v minimal` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Data/Repositories/TaskRepository.cs tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryTests.cs +git commit -m "feat(data): add TaskRepository.ResetToManualAsync" +``` + +--- + +## Task 3: `TaskResetService` (TDD) + +**Files:** +- Create: `src/ClaudeDo.Worker/Services/TaskResetService.cs` +- Create: `tests/ClaudeDo.Worker.Tests/Services/TaskResetServiceTests.cs` + +This service orchestrates Task 1 + Task 2, plus the "reject if Running" safety check and the SignalR broadcast. + +- [ ] **Step 1: Add the failing test — happy path** + +Create `tests/ClaudeDo.Worker.Tests/Services/TaskResetServiceTests.cs`: + +```csharp +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.Services; +using ClaudeDo.Worker.Tests.Infrastructure; +using Microsoft.Extensions.Logging.Abstractions; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Tests.Services; + +public class TaskResetServiceTests : IDisposable +{ + private readonly List _fixtures = new(); + private readonly List _dbFixtures = new(); + private static bool GitAvailable => GitRepoFixture.IsGitAvailable(); + + [Fact] + public async Task ResetAsync_FailedTaskWithWorktree_ClearsEverything_AndPreservesRunHistory() + { + if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; } + + var repo = new GitRepoFixture(); _fixtures.Add(repo); + var db = new DbFixture(); _dbFixtures.Add(db); + + var list = new ListEntity { Id = Guid.NewGuid().ToString(), Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow }; + var task = new TaskEntity { Id = Guid.NewGuid().ToString(), ListId = list.Id, Title = "T", CreatedAt = DateTime.UtcNow }; + + using (var seed = db.CreateContext()) + { + await new ListRepository(seed).AddAsync(list); + await new TaskRepository(seed).AddAsync(task); + } + + var wtMgr = new WorktreeManager(new GitService(), db.CreateFactory(), new WorkerConfig(), NullLogger.Instance); + var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None); + + // Seed a Failed task with a run row (we'll assert it's preserved). + using (var ctx = db.CreateContext()) + { + await new TaskRepository(ctx).MarkFailedAsync(task.Id, DateTime.UtcNow, "it broke"); + await new TaskRunRepository(ctx).AddAsync(new TaskRunEntity + { + Id = Guid.NewGuid().ToString(), + TaskId = task.Id, + RunNumber = 1, + IsRetry = false, + Prompt = "p", + SessionId = "s1", + FinishedAt = DateTime.UtcNow, + }); + } + + var broadcaster = new FakeHubBroadcaster(); + var svc = new TaskResetService(db.CreateFactory(), wtMgr, broadcaster, NullLogger.Instance); + + await svc.ResetAsync(task.Id, CancellationToken.None); + + using var readCtx = db.CreateContext(); + var after = await new TaskRepository(readCtx).GetByIdAsync(task.Id); + Assert.Equal(TaskStatus.Manual, after!.Status); + Assert.Null(after.Result); + Assert.Null(after.StartedAt); + Assert.Null(after.FinishedAt); + + var wtAfter = await new WorktreeRepository(readCtx).GetByTaskIdAsync(task.Id); + Assert.Equal(WorktreeState.Discarded, wtAfter!.State); + Assert.False(Directory.Exists(wtCtx.WorktreePath)); + + var runs = await new TaskRunRepository(readCtx).GetByTaskIdAsync(task.Id); + Assert.Single(runs); + + Assert.Contains(task.Id, broadcaster.TaskUpdatedIds); + Assert.Contains(task.Id, broadcaster.WorktreeUpdatedIds); + } + + [Fact] + public async Task ResetAsync_RunningTask_Throws_AndDoesNotMutate() + { + var db = new DbFixture(); _dbFixtures.Add(db); + var list = new ListEntity { Id = Guid.NewGuid().ToString(), Name = "L", CreatedAt = DateTime.UtcNow }; + var task = new TaskEntity { Id = Guid.NewGuid().ToString(), ListId = list.Id, Title = "T", CreatedAt = DateTime.UtcNow, Status = TaskStatus.Running, StartedAt = DateTime.UtcNow }; + + using (var seed = db.CreateContext()) + { + await new ListRepository(seed).AddAsync(list); + await new TaskRepository(seed).AddAsync(task); + } + + var wtMgr = new WorktreeManager(new GitService(), db.CreateFactory(), new WorkerConfig(), NullLogger.Instance); + var svc = new TaskResetService(db.CreateFactory(), wtMgr, new FakeHubBroadcaster(), NullLogger.Instance); + + await Assert.ThrowsAsync(() => svc.ResetAsync(task.Id, CancellationToken.None)); + + using var readCtx = db.CreateContext(); + var after = await new TaskRepository(readCtx).GetByIdAsync(task.Id); + Assert.Equal(TaskStatus.Running, after!.Status); + } + + public void Dispose() + { + foreach (var f in _fixtures) f.Dispose(); + foreach (var d in _dbFixtures) d.Dispose(); + } + + private sealed class FakeHubBroadcaster : HubBroadcaster + { + public List TaskUpdatedIds { get; } = new(); + public List WorktreeUpdatedIds { get; } = new(); + public FakeHubBroadcaster() : base(new FakeHubContext()) { } + public new Task TaskUpdated(string taskId) { TaskUpdatedIds.Add(taskId); return Task.CompletedTask; } + public new Task WorktreeUpdated(string taskId) { WorktreeUpdatedIds.Add(taskId); return Task.CompletedTask; } + } +} +``` + +Check existing fakes: the test file assumes `FakeHubContext` exists under `ClaudeDo.Worker.Tests.Infrastructure` (the Worker.Tests CLAUDE.md lists `FakeHubContext`, `FakeHubClients`, `FakeClientProxy`). If `HubBroadcaster` methods are not virtual, the `new` keyword above will not intercept calls — instead, use the real `HubBroadcaster` with `FakeHubContext` and inspect the fake's recorded calls. Adjust the test implementation to use whichever approach matches the existing test conventions (see `QueueServiceTests` for precedent). + +- [ ] **Step 2: Run tests, verify they fail** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskResetServiceTests" -v minimal` +Expected: FAIL — `TaskResetService` does not exist. + +- [ ] **Step 3: Implement `TaskResetService`** + +Create `src/ClaudeDo.Worker/Services/TaskResetService.cs`: + +```csharp +using ClaudeDo.Data; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Hub; +using ClaudeDo.Worker.Runner; +using Microsoft.EntityFrameworkCore; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Services; + +public sealed class TaskResetService +{ + private readonly IDbContextFactory _dbFactory; + private readonly WorktreeManager _wtManager; + private readonly HubBroadcaster _broadcaster; + private readonly ILogger _logger; + + public TaskResetService( + IDbContextFactory dbFactory, + WorktreeManager wtManager, + HubBroadcaster broadcaster, + ILogger logger) + { + _dbFactory = dbFactory; + _wtManager = wtManager; + _broadcaster = broadcaster; + _logger = logger; + } + + public async Task ResetAsync(string taskId, CancellationToken ct) + { + bool worktreeChanged = false; + + using (var ctx = _dbFactory.CreateDbContext()) + { + var taskRepo = new TaskRepository(ctx); + var task = await taskRepo.GetByIdAsync(taskId, ct) + ?? throw new KeyNotFoundException($"Task '{taskId}' not found."); + + if (task.Status == TaskStatus.Running) + throw new InvalidOperationException("Cannot reset a running task. Cancel it first."); + + var listRepo = new ListRepository(ctx); + var list = await listRepo.GetByIdAsync(task.ListId, ct) + ?? throw new InvalidOperationException("List not found."); + + var wtRepo = new WorktreeRepository(ctx); + var wt = await wtRepo.GetByTaskIdAsync(taskId, ct); + + if (wt is not null && wt.State == Data.Models.WorktreeState.Active && list.WorkingDir is not null) + { + // DiscardAsync uses its own DbContext internally; we close this one first. + ctx.Dispose(); + await _wtManager.DiscardAsync(wt, list.WorkingDir, ct); + worktreeChanged = true; + } + } + + using (var ctx = _dbFactory.CreateDbContext()) + { + var taskRepo = new TaskRepository(ctx); + await taskRepo.ResetToManualAsync(taskId, ct); + } + + await _broadcaster.TaskUpdated(taskId); + if (worktreeChanged) + await _broadcaster.WorktreeUpdated(taskId); + + _logger.LogInformation("Reset task {TaskId} to Manual (worktree discarded: {Discarded})", taskId, worktreeChanged); + } +} +``` + +Note: the `ctx.Dispose()` inside a `using` block works because `Dispose` is idempotent. If you prefer, refactor to scope the first block with `{ }` + explicit `await using` and move the dispose before the call. + +- [ ] **Step 4: Run tests, verify they pass** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskResetServiceTests" -v minimal` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Worker/Services/TaskResetService.cs tests/ClaudeDo.Worker.Tests/Services/TaskResetServiceTests.cs +git commit -m "feat(worker): add TaskResetService for discard + reset flow" +``` + +--- + +## Task 4: Wire `TaskResetService` into DI and add `WorkerHub.ResetTask` + +**Files:** +- Modify: `src/ClaudeDo.Worker/Program.cs` +- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs` + +- [ ] **Step 1: Register the service in DI** + +Open `src/ClaudeDo.Worker/Program.cs`. Locate the block where `QueueService`, `WorktreeManager`, `HubBroadcaster`, `WorktreeMaintenanceService`, etc. are registered (look for `builder.Services.AddSingleton` or similar). Add next to them: + +```csharp +builder.Services.AddSingleton(); +``` + +Match the lifetime of sibling services (most are `AddSingleton`). If the sibling services use a different lifetime, match it. + +- [ ] **Step 2: Inject it into `WorkerHub`** + +Modify `src/ClaudeDo.Worker/Hub/WorkerHub.cs`: + +In the field block (near `_wtMaintenance`): + +```csharp +private readonly TaskResetService _resetService; +``` + +In the constructor signature, append `TaskResetService resetService` and assign it. The full updated constructor: + +```csharp +public WorkerHub( + QueueService queue, + AgentFileService agentService, + HubBroadcaster broadcaster, + IDbContextFactory dbFactory, + WorktreeMaintenanceService wtMaintenance, + TaskResetService resetService) +{ + _queue = queue; + _agentService = agentService; + _broadcaster = broadcaster; + _dbFactory = dbFactory; + _wtMaintenance = wtMaintenance; + _resetService = resetService; +} +``` + +- [ ] **Step 3: Add the `ResetTask` hub method** + +Add inside `WorkerHub` (place it near `ContinueTask` for symmetry): + +```csharp +public async Task ResetTask(string taskId) +{ + try + { + await _resetService.ResetAsync(taskId, CancellationToken.None); + } + catch (InvalidOperationException ex) + { + throw new HubException(ex.Message); + } + catch (KeyNotFoundException) + { + throw new HubException("task not found"); + } +} +``` + +- [ ] **Step 4: Build the worker to verify wiring** + +Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj` +Expected: SUCCESS (no compile errors). Note: `dotnet build ClaudeDo.slnx` requires .NET 9 — build individual csproj files instead. + +- [ ] **Step 5: Run the full worker test suite** + +Run: `dotnet test tests/ClaudeDo.Worker.Tests -v minimal` +Expected: PASS (all existing tests plus the new ones from Tasks 1-3). + +- [ ] **Step 6: Commit** + +```bash +git add src/ClaudeDo.Worker/Program.cs src/ClaudeDo.Worker/Hub/WorkerHub.cs +git commit -m "feat(worker): expose ResetTask hub method" +``` + +--- + +## Task 5: Add `ContinueTaskAsync` and `ResetTaskAsync` to `WorkerClient` + +**Files:** +- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs` + +- [ ] **Step 1: Add both methods** + +Open `src/ClaudeDo.Ui/Services/WorkerClient.cs`. Next to `RunNowAsync` (around line 166): + +```csharp +public async Task ContinueTaskAsync(string taskId, string followUpPrompt) +{ + await _hub.InvokeAsync("ContinueTask", taskId, followUpPrompt); +} + +public async Task ResetTaskAsync(string taskId) +{ + await _hub.InvokeAsync("ResetTask", taskId); +} +``` + +If the existing `RunNowAsync` fires a local event first (e.g. `RunNowRequestedEvent?.Invoke(taskId)`), do **not** mirror that — Continue/Reset don't need UI-local optimistic state; we rely on `TaskUpdated` broadcasts. + +- [ ] **Step 2: Build the UI project** + +Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj` +Expected: SUCCESS. + +- [ ] **Step 3: Commit** + +```bash +git add src/ClaudeDo.Ui/Services/WorkerClient.cs +git commit -m "feat(ui): add ContinueTaskAsync and ResetTaskAsync to WorkerClient" +``` + +--- + +## Task 6: Add commands and state to `DetailsIslandViewModel` + +**Files:** +- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs` + +Background you need before editing: +- The VM already has a `Task` property (`TaskRowViewModel?`) that represents the selected task. +- Status is tracked via `AgentStatusLabel` and exposed as `IsRunning`/`IsDone`/`IsFailed`. +- `TaskRowViewModel` may not currently hold the latest `SessionId`. You need a way to read the latest run's `SessionId` for the selected task — query `TaskRunRepository` during the existing task-load flow. If the VM already loads task runs (search for `TaskRunRepository` usage in `DetailsIslandViewModel`), piggyback on that; otherwise add a DB query inside the task-load method. + +- [ ] **Step 1: Add observable properties for button visibility/enablement** + +In the observable-property block (after `_promptInput`, around line 29), add: + +```csharp +[ObservableProperty] +[NotifyCanExecuteChangedFor(nameof(ContinueCommand))] +[NotifyCanExecuteChangedFor(nameof(ResetCommand))] +private bool _showFailedActions; + +[ObservableProperty] +[NotifyCanExecuteChangedFor(nameof(ContinueCommand))] +private string? _latestRunSessionId; +``` + +Also hook `AgentStatusLabel` changes to refresh `ShowFailedActions`. Update the existing `OnAgentStatusLabelChanged` partial method: + +```csharp +partial void OnAgentStatusLabelChanged(string value) +{ + OnPropertyChanged(nameof(IsRunning)); + OnPropertyChanged(nameof(IsDone)); + OnPropertyChanged(nameof(IsFailed)); + ShowFailedActions = value == "Failed"; +} +``` + +- [ ] **Step 2: Populate `LatestRunSessionId` during task load** + +Find the method in `DetailsIslandViewModel` that loads details for the selected task (likely named `LoadAsync`, `OnTaskChanged`, or similar — search for where `Turns`, `Tokens`, or `AgentStatusLabel` are assigned). Inside that method, after loading the task entity: + +```csharp +using var runCtx = _dbFactory.CreateDbContext(); +var runRepo = new TaskRunRepository(runCtx); +var latestRun = await runRepo.GetLatestByTaskIdAsync(Task.Id); +LatestRunSessionId = latestRun?.SessionId; +``` + +Verify the method name `GetLatestByTaskIdAsync` exists on `TaskRunRepository` (it is used in `TaskRunner.ContinueAsync`). If the name differs, use whatever is exposed. Make sure this runs inside the same cancellation-safe block as the other loads — copy the existing pattern verbatim. + +Also ensure `LatestRunSessionId` is reset to `null` when the selected task clears. If the VM has an `OnTaskChanged` partial method that clears other fields, add `LatestRunSessionId = null;` there too. + +- [ ] **Step 3: Add the two commands** + +Add at the end of the class (next to `RunNowAsync` / `CanRunNow`): + +```csharp +[RelayCommand(CanExecute = nameof(CanContinue))] +private async System.Threading.Tasks.Task ContinueAsync() +{ + if (Task == null) return; + await _worker.ContinueTaskAsync(Task.Id, "Continue working on this task."); +} + +private bool CanContinue() => + Task != null + && _worker.IsConnected + && ShowFailedActions + && !string.IsNullOrEmpty(LatestRunSessionId); + +[RelayCommand(CanExecute = nameof(CanReset))] +private async System.Threading.Tasks.Task ResetAsync() +{ + if (Task == null) return; + if (ConfirmAsync == null) return; + + var confirmed = await ConfirmAsync( + $"Discard worktree and reset task?\nThis deletes branch claudedo/{Task.Id.Replace("-", "")} and all uncommitted changes."); + if (!confirmed) return; + + await _worker.ResetTaskAsync(Task.Id); +} + +private bool CanReset() => + Task != null + && _worker.IsConnected + && ShowFailedActions; +``` + +Also update the worker-connection PropertyChanged handler (around line 112) to notify the new commands: + +```csharp +_worker.PropertyChanged += (_, e) => +{ + if (e.PropertyName == nameof(WorkerClient.IsConnected)) + { + RunNowCommand.NotifyCanExecuteChanged(); + ContinueCommand.NotifyCanExecuteChanged(); + ResetCommand.NotifyCanExecuteChanged(); + } +}; +``` + +- [ ] **Step 4: Build the UI project** + +Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj` +Expected: SUCCESS. + +- [ ] **Step 5: Commit** + +```bash +git add src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs +git commit -m "feat(ui): add Continue and Reset commands to DetailsIslandViewModel" +``` + +--- + +## Task 7: Add the button row to `DetailsIslandView.axaml` + +**Files:** +- Modify: `src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml` + +- [ ] **Step 1: Inspect the existing layout** + +Read `src/ClaudeDo.Ui/Views/Islands/DetailsIslandView.axaml` end-to-end so you understand the grid layout. The `AgentStripView` sits at `Grid.Row="0"`. Decide whether to add a new grid row below it or to extend the agent strip itself. Simplest: add the button row to `AgentStripView.axaml`, since that control already contains `RunNowCommand` / `StopCommand` buttons and is bound to the same VM. + +- [ ] **Step 2: Add the buttons to `AgentStripView.axaml`** + +Open `src/ClaudeDo.Ui/Views/Islands/AgentStripView.axaml`. Locate the existing `RunNowCommand` button (around line 49). After it, add: + +```xml +