13 Commits

Author SHA1 Message Date
Mika Kuns
fb89e02b02 docs: note ResetTask hub method and TaskResetService 2026-04-21 17:46:00 +02:00
Mika Kuns
58c8210afa fix(ui): correct Reset button tooltip wording 2026-04-21 17:44:50 +02:00
Mika Kuns
2ce6b7bd3a feat(ui): add Continue and Reset buttons to agent strip 2026-04-21 17:44:00 +02:00
Mika Kuns
f90d3d8375 fix(ui): early-return in ResetAsync when ConfirmAsync is unwired 2026-04-21 17:42:36 +02:00
Mika Kuns
b03e858a8f feat(ui): add Continue and Reset commands to DetailsIslandViewModel 2026-04-21 17:40:32 +02:00
Mika Kuns
2278b516ea feat(ui): add ContinueTaskAsync and ResetTaskAsync to WorkerClient 2026-04-21 17:37:41 +02:00
Mika Kuns
219a231f32 feat(worker): expose ResetTask hub method
Wire TaskResetService into DI and add WorkerHub.ResetTask with the
same InvalidOperationException/KeyNotFoundException error-translation
pattern as ContinueTask.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 17:35:37 +02:00
Mika Kuns
74eb36d3c0 feat(worker): add TaskResetService for discard + reset flow
Orchestrates worktree discard, task reset to Manual, and SignalR broadcast.
Includes integration tests (happy path + running-task rejection).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 17:31:52 +02:00
Mika Kuns
202236a45b feat(data): add TaskRepository.ResetToManualAsync 2026-04-21 17:26:01 +02:00
Mika Kuns
88be19a231 test(worker): strengthen DiscardAsync test (cleanup + branch assertion) 2026-04-21 17:23:58 +02:00
Mika Kuns
44203f3c67 feat(worker): add WorktreeManager.DiscardAsync for task reset
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 17:21:08 +02:00
Mika Kuns
133774cb86 docs: add implementation plan for continue and reset buttons 2026-04-21 16:47:51 +02:00
Mika Kuns
a3bb557d76 docs: add spec for continue and reset buttons on failed tasks 2026-04-21 16:43:54 +02:00
15 changed files with 1425 additions and 4 deletions

View File

@@ -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 <branch>` 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<GitRepoFixture> _fixtures = new();
private readonly List<DbFixture> _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<WorktreeManager>.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<TaskResetService>.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<WorktreeManager>.Instance);
var svc = new TaskResetService(db.CreateFactory(), wtMgr, new FakeHubBroadcaster(), NullLogger<TaskResetService>.Instance);
await Assert.ThrowsAsync<InvalidOperationException>(() => 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<string> TaskUpdatedIds { get; } = new();
public List<string> 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<ClaudeDoDbContext> _dbFactory;
private readonly WorktreeManager _wtManager;
private readonly HubBroadcaster _broadcaster;
private readonly ILogger<TaskResetService> _logger;
public TaskResetService(
IDbContextFactory<ClaudeDoDbContext> dbFactory,
WorktreeManager wtManager,
HubBroadcaster broadcaster,
ILogger<TaskResetService> 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<QueueService>` or similar). Add next to them:
```csharp
builder.Services.AddSingleton<TaskResetService>();
```
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<ClaudeDoDbContext> 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
<Button
Content="Continue"
Command="{Binding ContinueCommand}"
IsVisible="{Binding ShowFailedActions}"
ToolTip.Tip="Resume the failed Claude session with 'Continue working on this task.'"
Margin="4,0,0,0"/>
<Button
Content="Reset"
Command="{Binding ResetCommand}"
IsVisible="{Binding ShowFailedActions}"
ToolTip.Tip="Discard the worktree and return the task to Manual"
Margin="4,0,0,0"/>
```
Match the style (classes, padding, height) of the surrounding `RunNow` / `Stop` buttons — copy their `Classes`, `Padding`, and `Height` attributes verbatim so the row stays visually consistent.
For the Continue button's disabled-with-tooltip affordance when there's no session_id: the `CanExecute` binding already disables the button; Avalonia shows tooltips on disabled controls when `ToolTip.ShowOnDisabled="True"` — set that on the Continue button and add a second tooltip hinting at the reason is unnecessary since the button will simply be greyed out. If you want an explicit "No session to resume" hint, add a `Classes.disabled` trigger or use a `MultiBinding`; skip this refinement unless it is trivial in the existing theme.
- [ ] **Step 3: Wire the confirmation dialog**
The VM's `ResetAsync` uses `ConfirmAsync` (a `Func<string, Task<bool>>` already declared on the VM at line 100). Search the codebase for where `ConfirmAsync` is assigned on the `DetailsIslandViewModel` instance — there is an existing assignment because `DeleteTaskCommand` already uses it. No new wiring needed; the same dialog will handle Reset confirmations.
- [ ] **Step 4: Launch the UI and smoke-test**
1. In one terminal: `dotnet run --project src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
2. In another terminal: `dotnet run --project src/ClaudeDo.App/ClaudeDo.App.csproj`
3. Create a task that will fail (e.g. a task pointing at a non-existent working dir, or type something into Claude's prompt that makes it error). Wait for status `Failed`.
4. Verify the Continue and Reset buttons appear in the details pane.
5. Click Reset → confirm → verify the task row flips to `Manual`, the worktree directory is gone from disk, and the branch is gone from `git branch --list | grep claudedo/` in the target repo.
6. Create another failing task. Click Continue → verify a new run starts (status flips to `Running`), resumes the same Claude session, and completes (Done or Failed again).
7. Verify on a task that has no session_id (e.g. cancel before Claude emits anything), the Continue button is disabled.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Ui/Views/Islands/AgentStripView.axaml
git commit -m "feat(ui): add Continue and Reset buttons to agent strip"
```
---
## Task 8: Update project docs
**Files:**
- Modify: `src/ClaudeDo.Worker/CLAUDE.md`
- Modify: `src/ClaudeDo.Ui/CLAUDE.md`
- [ ] **Step 1: Update Worker CLAUDE.md**
Under the `SignalR Hub` section, extend the `WorkerHub methods` line:
```
**WorkerHub** methods: `Ping()`, `GetActive()`, `RunNow(taskId)`, `CancelTask(taskId)`, `WakeQueue()`, `ContinueTask(taskId, prompt)`, `ResetTask(taskId)`, `GetAgents()`, `RefreshAgents()`
```
Under `Key Components`, add one line:
```
- **TaskResetService** — discards a failed task's worktree and resets the task row to Manual; preserves run history.
```
- [ ] **Step 2: Update UI CLAUDE.md**
Extend the `WorkerClient` description to mention the two new methods:
```
- **WorkerClient** — ... Methods: StartAsync, RunNowAsync, CancelTaskAsync, ContinueTaskAsync, ResetTaskAsync, WakeQueueAsync. Events: ...
```
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Worker/CLAUDE.md src/ClaudeDo.Ui/CLAUDE.md
git commit -m "docs: note ResetTask hub method and TaskResetService"
```
---
## Self-Review
**Spec coverage:**
- Continue button (canned prompt, one-click, disabled without session) → Task 6 (`ContinueCommand`, `CanContinue`) + Task 7 (button).
- Reset button (always enabled on Failed, confirm dialog) → Task 6 (`ResetCommand`, `CanReset`) + Task 7 (button + confirm).
- Buttons only on Failed → `ShowFailedActions` drives `IsVisible` (Task 6, Task 7).
- Hub `ResetTask` → Task 4.
- `WorktreeManager.DiscardAsync` → Task 1.
- `TaskRepository.ResetToManualAsync` → Task 2.
- Reject reset on Running → Task 3.
- Worktree-remove failure leaves task Failed → Task 3 (`DiscardAsync` throws before `ResetToManualAsync` is called).
- Run history preserved → Task 2 and Task 3 assertions.
- Tests — WorktreeManager.DiscardAsync, TaskRepository.ResetToManualAsync, TaskResetService full flow, reject running → Tasks 1, 2, 3.
- Test for "ResetTask rejects running" → Task 3 test 2.
- Test for "worktree remove failure leaves task Failed" → covered implicitly by the code structure (Task 3 does not call `ResetToManualAsync` if `DiscardAsync` throws). If you want an explicit test, add one in Task 3 by injecting a failure; marking optional as the control flow is straightforward.
**Placeholder scan:** no TBDs; every code step has code; commands include expected output.
**Type consistency:** `DiscardAsync(WorktreeEntity wt, string workingDir, ct)` used consistently (Tasks 1 and 3). `ResetToManualAsync(taskId, ct)` used consistently (Tasks 2 and 3). `ContinueTaskAsync`/`ResetTaskAsync` on `WorkerClient` match the hub method names. `ShowFailedActions`, `LatestRunSessionId`, `CanContinue`, `CanReset` referenced consistently across Task 6 and Task 7.

View File

@@ -0,0 +1,130 @@
# Continue & Reset Buttons for Failed Tasks
## Problem
When a task ends in `Failed` status (Claude exited without marking the work done, cancelled mid-run, crashed, etc.), the user has no way to act on it from the UI:
- **Nudging the agent** is only possible via the hub method `ContinueTask`, which is not wired into the UI.
- **Rolling back** the worktree requires shelling into git manually to remove the branch and folder, then editing the task in the DB. In practice the worktree is just abandoned.
We want two explicit actions in the details pane for a failed task: **Continue** (resume the Claude session with a follow-up prompt) and **Reset** (discard the worktree and return the task to an editable `Manual` state).
## Scope
- Actions are shown **only when the selected task has `Status == Failed`**.
- `Continue` is the multi-turn mechanism already implemented in `TaskRunner.ContinueAsync` — this spec only wires it into the UI.
- `Reset` is new end-to-end (hub method, worktree discard, task status reset).
- Run history (`task_runs` rows) is **preserved** across a Reset for audit.
- Out of scope: Continue/Reset on `Done` tasks, undo of Reset, modifying the follow-up prompt before sending.
## UX
Both buttons live in `DetailsIslandView`, inside a new horizontal button row that is visible only when the currently selected task is `Failed`.
### Continue
- One-click. Sends the canned prompt `"Continue working on this task."` via `WorkerHub.ContinueTask(taskId, prompt)`.
- Enabled **only if** the task's latest `TaskRunEntity` has a non-null `SessionId`.
- When disabled, a tooltip reads `No session to resume`.
- No confirmation dialog.
### Reset
- Always enabled when the task is `Failed`.
- Opens a confirmation dialog:
> Discard worktree and reset task?
> This deletes branch `claudedo/<id>` and all uncommitted changes.
- On confirm, calls `WorkerHub.ResetTask(taskId)`.
## Backend
### New hub method — `WorkerHub.ResetTask(string taskId)`
Preconditions:
- Task exists.
- Task status is **not** `Running`. If it is, throw — resetting a task that is actively executing would race with the runner.
Steps:
1. Load the task and its worktree (if any).
2. If a worktree exists and its `State == Active`, call `WorktreeManager.DiscardAsync(worktree, ct)` (see below).
3. Call `TaskRepository.ResetToManualAsync(taskId, ct)` to clear the result fields and flip the status.
4. Broadcast `TaskUpdated(taskId)`; broadcast `WorktreeUpdated(taskId)` if the worktree state changed.
If `WorktreeManager.DiscardAsync` throws (e.g. folder locked, branch checked out elsewhere), the hub method surfaces the error to the caller and leaves the task as `Failed` with the worktree still `Active`, so the user can retry. `TaskRepository.ResetToManualAsync` is **not** called in the failure path.
### New — `WorktreeManager.DiscardAsync(WorktreeEntity wt, CancellationToken ct)`
Shape mirrors the existing `CommitIfChangedAsync`. Steps:
1. `git worktree remove --force <wt.Path>` via `GitService`. The `--force` flag drops any uncommitted changes — expected, since the user already confirmed.
2. `git branch -D <wt.BranchName>` via `GitService`.
3. Update `WorktreeRepository`: set `State = Discarded`.
`GitService` gains two thin wrappers if they do not already exist: `WorktreeRemoveAsync(path, force: true)` and `BranchDeleteForceAsync(branch)`.
### New — `TaskRepository.ResetToManualAsync(string taskId, CancellationToken ct)`
Single UPDATE that sets:
- `Status = Manual`
- `Result = null`
- `StartedAt = null`
- `FinishedAt = null`
`LogPath` and the `task_runs` rows are left intact — they are the audit trail.
### Continue wiring
No backend changes. The UI calls `WorkerHub.ContinueTask(taskId, prompt)` and `TaskRunner.ContinueAsync` handles the rest.
## UI
### `DetailsIslandViewModel`
New members:
- `[ObservableProperty] bool showFailedActions` — true when the selected task's status is `Failed`.
- `[ObservableProperty] bool canContinue` — true when `showFailedActions` **and** the latest run of the selected task has a non-null `SessionId`.
- `[RelayCommand(CanExecute = nameof(CanContinue))] Task ContinueAsync()` — calls `HubClient.ContinueTask(task.Id, "Continue working on this task.")`.
- `[RelayCommand(CanExecute = nameof(ShowFailedActions))] Task ResetAsync()` — opens confirmation; on confirm, calls `HubClient.ResetTask(task.Id)`.
`ShowFailedActions` and `CanContinue` recompute whenever the selected task or its runs change (subscribe to the existing selection / task-updated signals).
### `DetailsIslandView.axaml`
A single `StackPanel` (orientation horizontal) inside the existing details layout, bound to `ShowFailedActions` for visibility, with two `Button`s wired to the commands.
### Confirmation dialog
Reuse the existing modal pattern (see `WorktreeModalView` for the shape). A minimal `ConfirmDialog` with title, body, `Cancel` + `Confirm` buttons is acceptable and reusable; if a simpler inline approach is idiomatic in this codebase, use that instead.
### `HubClient`
Add `Task ResetTask(string taskId)` alongside the existing `ContinueTask` wrapper.
## Error handling
| Failure | Behaviour |
|---|---|
| `ResetTask` called on a `Running` task | Hub throws; UI shows the error. The Reset button is CanExecute-gated anyway, so this is a defensive check. |
| `git worktree remove` fails | Hub throws; task stays `Failed`, worktree stays `Active`, user can retry or clean up manually. |
| `git branch -D` fails after worktree removal succeeded | Worktree state still gets set to `Discarded` (the folder is gone; leaving the branch dangling is less bad than leaving the DB out of sync). Log a warning. |
| `Continue` with no session_id | Button is disabled — the call cannot happen from the UI. Hub still guards with the existing `InvalidOperationException` in `ContinueAsync` for safety. |
## Testing
Integration tests (real SQLite, real git) in `ClaudeDo.Worker.Tests`:
1. **`WorktreeManager_DiscardAsync_removes_worktree_and_branch`** — create a worktree, call Discard, assert branch is gone from `git branch --list`, folder is gone, DB state is `Discarded`.
2. **`TaskRepository_ResetToManualAsync_clears_result_fields`** — seed a Failed task with Result/FinishedAt/StartedAt, call Reset, assert all cleared and status is Manual.
3. **`ResetTask_full_flow`** — seed a Failed task with an Active worktree and run history; invoke the hub method; assert status=Manual, worktree=Discarded, `task_runs` rows still present.
4. **`ResetTask_rejects_running_task`** — seed a Running task, assert the hub method throws and nothing is modified.
5. **`ResetTask_worktree_remove_failure_leaves_task_failed`** — simulate a git failure (e.g. lock the folder), assert task stays Failed and worktree stays Active.
No new UI tests — the commands are thin forwarders and are exercised manually.
## Open questions
None.

View File

@@ -98,6 +98,17 @@ public sealed class TaskRepository
.SetProperty(t => t.Result, resultText), ct); .SetProperty(t => t.Result, resultText), ct);
} }
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);
}
#endregion #endregion
#region Tags #region Tags

View File

@@ -31,7 +31,7 @@ All views use compiled bindings (`x:DataType`).
## Services ## Services
- **WorkerClient** — SignalR client connecting to `http://127.0.0.1:47821/hub`. Auto-reconnect with exponential backoff. Methods: StartAsync, RunNowAsync, CancelTaskAsync, WakeQueueAsync. Events: TaskStarted, TaskFinished, TaskMessage, TaskUpdated, WorktreeUpdated - **WorkerClient** — SignalR client connecting to `http://127.0.0.1:47821/hub`. Auto-reconnect with exponential backoff. Methods: StartAsync, RunNowAsync, CancelTaskAsync, WakeQueueAsync, ContinueTaskAsync, ResetTaskAsync. Events: TaskStarted, TaskFinished, TaskMessage, TaskUpdated, WorktreeUpdated
## Converters ## Converters

View File

@@ -169,6 +169,16 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable
await _hub.InvokeAsync("RunNow", taskId); await _hub.InvokeAsync("RunNow", taskId);
} }
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);
}
public async Task CancelTaskAsync(string taskId) public async Task CancelTaskAsync(string taskId)
{ {
await _hub.InvokeAsync("CancelTask", taskId); await _hub.InvokeAsync("CancelTask", taskId);

View File

@@ -39,11 +39,21 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
public bool IsDone => AgentStatusLabel == "Done"; public bool IsDone => AgentStatusLabel == "Done";
public bool IsFailed => AgentStatusLabel == "Failed"; public bool IsFailed => AgentStatusLabel == "Failed";
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(ContinueCommand))]
[NotifyCanExecuteChangedFor(nameof(ResetCommand))]
private bool _showFailedActions;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(ContinueCommand))]
private string? _latestRunSessionId;
partial void OnAgentStatusLabelChanged(string value) partial void OnAgentStatusLabelChanged(string value)
{ {
OnPropertyChanged(nameof(IsRunning)); OnPropertyChanged(nameof(IsRunning));
OnPropertyChanged(nameof(IsDone)); OnPropertyChanged(nameof(IsDone));
OnPropertyChanged(nameof(IsFailed)); OnPropertyChanged(nameof(IsFailed));
ShowFailedActions = value == "Failed";
} }
[ObservableProperty] private string? _model; [ObservableProperty] private string? _model;
[ObservableProperty] private string? _worktreePath; [ObservableProperty] private string? _worktreePath;
@@ -108,11 +118,15 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
// Subscribe once; filter by current task id inside the handler // Subscribe once; filter by current task id inside the handler
_worker.TaskMessageEvent += OnTaskMessage; _worker.TaskMessageEvent += OnTaskMessage;
// Re-evaluate RunNow CanExecute when worker connection flips. // Re-evaluate CanExecute when worker connection flips.
_worker.PropertyChanged += (_, e) => _worker.PropertyChanged += (_, e) =>
{ {
if (e.PropertyName == nameof(WorkerClient.IsConnected)) if (e.PropertyName == nameof(WorkerClient.IsConnected))
{
RunNowCommand.NotifyCanExecuteChanged(); RunNowCommand.NotifyCanExecuteChanged();
ContinueCommand.NotifyCanExecuteChanged();
ResetCommand.NotifyCanExecuteChanged();
}
}; };
// If the task row's live status changes (e.g. TaskStarted/Finished), mirror it. // If the task row's live status changes (e.g. TaskStarted/Finished), mirror it.
@@ -215,6 +229,8 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
WorktreePath = null; WorktreePath = null;
BranchLine = null; BranchLine = null;
AgentStatusLabel = "Idle"; AgentStatusLabel = "Idle";
LatestRunSessionId = null;
ShowFailedActions = false;
return; return;
} }
@@ -243,6 +259,11 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
BranchLine = entity.Worktree is { } w ? $"{w.BranchName} \u2190 main" : null; BranchLine = entity.Worktree is { } w ? $"{w.BranchName} \u2190 main" : null;
AgentStatusLabel = entity.Status.ToString(); AgentStatusLabel = entity.Status.ToString();
var runRepo = new TaskRunRepository(ctx);
var latestRun = await runRepo.GetLatestByTaskIdAsync(row.Id, ct);
ct.ThrowIfCancellationRequested();
LatestRunSessionId = latestRun?.SessionId;
// Subscribe only after DB load confirms the task exists // Subscribe only after DB load confirms the task exists
_subscribedTaskId = row.Id; _subscribedTaskId = row.Id;
@@ -391,6 +412,32 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
private bool CanRunNow() => private bool CanRunNow() =>
Task != null && _worker.IsConnected && !IsRunning; Task != null && _worker.IsConnected && !IsRunning;
[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 branchName = $"claudedo/{Task.Id.Replace("-", "")}";
var ok = await ConfirmAsync($"Discard worktree and reset task?\nThis deletes branch {branchName} and all uncommitted changes.");
if (!ok) return;
await _worker.ResetTaskAsync(Task.Id);
}
private bool CanReset() =>
Task != null && _worker.IsConnected && ShowFailedActions;
} }
public sealed partial class SubtaskRowViewModel : ViewModelBase public sealed partial class SubtaskRowViewModel : ViewModelBase

View File

@@ -141,6 +141,18 @@
<TextBlock Text="Worktree" VerticalAlignment="Center"/> <TextBlock Text="Worktree" VerticalAlignment="Center"/>
</StackPanel> </StackPanel>
</Button> </Button>
<Button Classes="btn accent"
Content="Continue"
Command="{Binding ContinueCommand}"
IsVisible="{Binding ShowFailedActions}"
ToolTip.Tip="Resume the task from where it failed"
Padding="10,4"/>
<Button Classes="btn"
Content="Reset"
Command="{Binding ResetCommand}"
IsVisible="{Binding ShowFailedActions}"
ToolTip.Tip="Discard the worktree and move the task back to Manual"
Padding="10,4"/>
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>

View File

@@ -27,6 +27,7 @@ ASP.NET Core hosted service that executes tasks via Claude CLI in isolated envir
- **ClaudeProcess** — spawns `claude -p --output-format stream-json --verbose --dangerously-skip-permissions`. Writes prompt to stdin, reads NDJSON from stdout. Supports CancellationToken (kills process tree). - **ClaudeProcess** — spawns `claude -p --output-format stream-json --verbose --dangerously-skip-permissions`. Writes prompt to stdin, reads NDJSON from stdout. Supports CancellationToken (kills process tree).
- **ClaudeArgsBuilder** — dynamically constructs CLI args; supports `--model`, `--append-system-prompt`, `--agents`, `--json-schema`, `--resume` - **ClaudeArgsBuilder** — dynamically constructs CLI args; supports `--model`, `--append-system-prompt`, `--agents`, `--json-schema`, `--resume`
- **StreamAnalyzer** — parses rich NDJSON output; extracts session_id, token counts, turn counts, result text, structured output. Replaces MessageParser. - **StreamAnalyzer** — parses rich NDJSON output; extracts session_id, token counts, turn counts, result text, structured output. Replaces MessageParser.
- **TaskResetService** — discards a failed task's worktree and resets the task row to Manual; preserves run history.
- **WorktreeManager** — creates worktrees at `claudedo/{taskId[:8]}` branches, commits changes with semantic messages, updates DB with head commit and diff stats - **WorktreeManager** — creates worktrees at `claudedo/{taskId[:8]}` branches, commits changes with semantic messages, updates DB with head commit and diff stats
- **CommitMessageBuilder** — formats `{commitType}(slug): title\n\ndescription\n\nClaudeDo-Task: taskId` - **CommitMessageBuilder** — formats `{commitType}(slug): title\n\ndescription\n\nClaudeDo-Task: taskId`
- **AgentFileService** — manages `~/.todo-app/agents/*.md` agent definition files; exposes list/refresh via SignalR - **AgentFileService** — manages `~/.todo-app/agents/*.md` agent definition files; exposes list/refresh via SignalR
@@ -44,7 +45,7 @@ Each CLI invocation is recorded in the `task_runs` table via `TaskRunRepository`
## SignalR Hub ## SignalR Hub
**WorkerHub** methods: `Ping()`, `GetActive()`, `RunNow(taskId)`, `CancelTask(taskId)`, `WakeQueue()`, `ContinueTask(taskId, prompt)`, `GetAgents()`, `RefreshAgents()` **WorkerHub** methods: `Ping()`, `GetActive()`, `RunNow(taskId)`, `CancelTask(taskId)`, `WakeQueue()`, `ContinueTask(taskId, prompt)`, `ResetTask(taskId)`, `GetAgents()`, `RefreshAgents()`
**HubBroadcaster** events: `TaskStarted`, `TaskFinished`, `TaskMessage`, `WorktreeUpdated`, `TaskUpdated`, `RunCreated` **HubBroadcaster** events: `TaskStarted`, `TaskFinished`, `TaskMessage`, `WorktreeUpdated`, `TaskUpdated`, `RunCreated`

View File

@@ -33,19 +33,22 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
private readonly HubBroadcaster _broadcaster; private readonly HubBroadcaster _broadcaster;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory; private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly WorktreeMaintenanceService _wtMaintenance; private readonly WorktreeMaintenanceService _wtMaintenance;
private readonly TaskResetService _resetService;
public WorkerHub( public WorkerHub(
QueueService queue, QueueService queue,
AgentFileService agentService, AgentFileService agentService,
HubBroadcaster broadcaster, HubBroadcaster broadcaster,
IDbContextFactory<ClaudeDoDbContext> dbFactory, IDbContextFactory<ClaudeDoDbContext> dbFactory,
WorktreeMaintenanceService wtMaintenance) WorktreeMaintenanceService wtMaintenance,
TaskResetService resetService)
{ {
_queue = queue; _queue = queue;
_agentService = agentService; _agentService = agentService;
_broadcaster = broadcaster; _broadcaster = broadcaster;
_dbFactory = dbFactory; _dbFactory = dbFactory;
_wtMaintenance = wtMaintenance; _wtMaintenance = wtMaintenance;
_resetService = resetService;
} }
public string Ping() => $"pong v{Version}"; public string Ping() => $"pong v{Version}";
@@ -89,6 +92,22 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
} }
} }
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");
}
}
public bool CancelTask(string taskId) => _queue.CancelTask(taskId); public bool CancelTask(string taskId) => _queue.CancelTask(taskId);
public void WakeQueue() => _queue.WakeQueue(); public void WakeQueue() => _queue.WakeQueue();

View File

@@ -30,6 +30,7 @@ builder.Services.AddSingleton<WorktreeManager>();
builder.Services.AddSingleton<ClaudeArgsBuilder>(); builder.Services.AddSingleton<ClaudeArgsBuilder>();
builder.Services.AddSingleton<TaskRunner>(); builder.Services.AddSingleton<TaskRunner>();
builder.Services.AddSingleton<WorktreeMaintenanceService>(); builder.Services.AddSingleton<WorktreeMaintenanceService>();
builder.Services.AddSingleton<TaskResetService>();
// Agent file management. // Agent file management.
var agentsDir = Path.Combine(ClaudeDo.Data.Paths.AppDataRoot(), "agents"); var agentsDir = Path.Combine(ClaudeDo.Data.Paths.AppDataRoot(), "agents");

View File

@@ -140,4 +140,32 @@ public sealed class WorktreeManager
_logger.LogInformation("Committed changes for task {TaskId}: {Head}", task.Id, head); _logger.LogInformation("Committed changes for task {TaskId}: {Head}", task.Id, head);
return true; return true;
} }
public async Task DiscardAsync(WorktreeEntity wt, string workingDir, CancellationToken ct)
{
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;
}
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);
}
} }

View File

@@ -0,0 +1,68 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
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<ClaudeDoDbContext> _dbFactory;
private readonly WorktreeManager _wtManager;
private readonly HubBroadcaster _broadcaster;
private readonly ILogger<TaskResetService> _logger;
public TaskResetService(
IDbContextFactory<ClaudeDoDbContext> dbFactory,
WorktreeManager wtManager,
HubBroadcaster broadcaster,
ILogger<TaskResetService> logger)
{
_dbFactory = dbFactory;
_wtManager = wtManager;
_broadcaster = broadcaster;
_logger = logger;
}
public async Task ResetAsync(string taskId, CancellationToken ct)
{
TaskEntity task;
ListEntity list;
WorktreeEntity? wt;
using (var ctx = _dbFactory.CreateDbContext())
{
task = await new TaskRepository(ctx).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.");
list = await new ListRepository(ctx).GetByIdAsync(task.ListId, ct)
?? throw new InvalidOperationException("List not found.");
wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, ct);
}
bool worktreeChanged = false;
if (wt is not null && wt.State == WorktreeState.Active && list.WorkingDir is not null)
{
await _wtManager.DiscardAsync(wt, list.WorkingDir, ct);
worktreeChanged = true;
}
using (var ctx = _dbFactory.CreateDbContext())
{
await new TaskRepository(ctx).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);
}
}

View File

@@ -198,6 +198,35 @@ public sealed class TaskRepositoryTests : IDisposable
Assert.Equal(TaskStatus.Done, d!.Status); Assert.Equal(TaskStatus.Done, d!.Status);
} }
[Fact]
public async Task ResetToManualAsync_ClearsResultFields_AndSetsStatusManual()
{
var listId = await CreateListAsync();
var task = new TaskEntity
{
Id = Guid.NewGuid().ToString(),
ListId = listId,
Title = "T",
CreatedAt = DateTime.UtcNow,
Status = TaskStatus.Failed,
StartedAt = DateTime.UtcNow.AddMinutes(-5),
FinishedAt = DateTime.UtcNow,
Result = "boom",
CommitType = "feat",
};
await _tasks.AddAsync(task);
await _tasks.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);
}
[Fact] [Fact]
public async Task GetEffectiveTagsAsync_Returns_Union_Of_ListTags_And_TaskTags() public async Task GetEffectiveTagsAsync_Returns_Union_Of_ListTags_And_TaskTags()
{ {

View File

@@ -181,6 +181,37 @@ public class WorktreeManagerTests : IDisposable
return (task, list); return (task, list);
} }
[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);
_worktreeCleanups.Add((repo.RepoDir, ctx.WorktreePath));
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");
var branchList = GitRepoFixture.RunGit(repo.RepoDir, "branch", "--list", ctx.BranchName);
Assert.True(string.IsNullOrWhiteSpace(branchList),
$"branch {ctx.BranchName} should be deleted, got: {branchList}");
using var readCtx2 = db.CreateContext();
var row = await new WorktreeRepository(readCtx2).GetByTaskIdAsync(task.Id);
Assert.NotNull(row);
Assert.Equal(WorktreeState.Discarded, row!.State);
}
public void Dispose() public void Dispose()
{ {
foreach (var (repoDir, wtPath) in _worktreeCleanups) foreach (var (repoDir, wtPath) in _worktreeCleanups)

View File

@@ -0,0 +1,231 @@
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.AspNetCore.SignalR;
using Microsoft.Extensions.Logging.Abstractions;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Tests.Services;
public class TaskResetServiceTests : IDisposable
{
private readonly List<DbFixture> _dbs = new();
private readonly List<GitRepoFixture> _repos = new();
private readonly List<(string repoDir, string wtPath)> _worktreeCleanups = 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 _worktreeCleanups)
{
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 { }
}
private static (TaskResetService svc, RecordingClientProxy proxy) BuildService(DbFixture db, WorktreeManager wtMgr)
{
var fakeHub = new RecordingHubContext();
var broadcaster = new HubBroadcaster(fakeHub);
var svc = new TaskResetService(
db.CreateFactory(),
wtMgr,
broadcaster,
NullLogger<TaskResetService>.Instance);
return (svc, fakeHub.Proxy);
}
private static WorktreeManager BuildWorktreeManager(DbFixture db)
{
var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" };
return new WorktreeManager(
new ClaudeDo.Data.Git.GitService(),
db.CreateFactory(),
cfg,
NullLogger<WorktreeManager>.Instance);
}
[Fact]
public async Task ResetAsync_FailedTaskWithWorktree_ClearsEverything_AndPreservesRunHistory()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var wtMgr = BuildWorktreeManager(db);
var list = new ListEntity
{
Id = Guid.NewGuid().ToString(),
Name = "reset-test",
WorkingDir = repo.RepoDir,
DefaultCommitType = "feat",
CreatedAt = DateTime.UtcNow,
};
var task = new TaskEntity
{
Id = Guid.NewGuid().ToString(),
ListId = list.Id,
Title = "test task",
Status = TaskStatus.Failed,
StartedAt = DateTime.UtcNow.AddMinutes(-5),
FinishedAt = DateTime.UtcNow.AddMinutes(-1),
Result = "some error",
CreatedAt = DateTime.UtcNow,
};
using (var ctx = db.CreateContext())
{
await new ListRepository(ctx).AddAsync(list);
await new TaskRepository(ctx).AddAsync(task);
}
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
_worktreeCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
using (var ctx = db.CreateContext())
{
await new TaskRunRepository(ctx).AddAsync(new TaskRunEntity
{
Id = Guid.NewGuid().ToString(),
TaskId = task.Id,
RunNumber = 1,
IsRetry = false,
Prompt = "do the thing",
SessionId = "s1",
});
}
var (svc, proxy) = BuildService(db, wtMgr);
await svc.ResetAsync(task.Id, CancellationToken.None);
// Task must be Manual with cleared timestamps/result
using (var ctx = db.CreateContext())
{
var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
Assert.NotNull(updated);
Assert.Equal(TaskStatus.Manual, updated!.Status);
Assert.Null(updated.Result);
Assert.Null(updated.StartedAt);
Assert.Null(updated.FinishedAt);
}
// Worktree directory must be gone
Assert.False(Directory.Exists(wtCtx.WorktreePath));
// Worktree DB row must be Discarded
using (var ctx = db.CreateContext())
{
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
Assert.NotNull(wt);
Assert.Equal(WorktreeState.Discarded, wt!.State);
}
// task_runs row must still be present
using (var ctx = db.CreateContext())
{
var runs = await new TaskRunRepository(ctx).GetByTaskIdAsync(task.Id);
Assert.Single(runs);
Assert.Equal("s1", runs[0].SessionId);
}
// Broadcaster must have fired TaskUpdated AND WorktreeUpdated
Assert.Contains(proxy.Calls, i => i.Method == "TaskUpdated" && i.Args[0] is string s && s == task.Id);
Assert.Contains(proxy.Calls, i => i.Method == "WorktreeUpdated" && i.Args[0] is string s && s == task.Id);
}
[Fact]
public async Task ResetAsync_RunningTask_Throws_AndDoesNotMutate()
{
var db = NewDb();
var wtMgr = BuildWorktreeManager(db);
var list = new ListEntity
{
Id = Guid.NewGuid().ToString(),
Name = "no-op list",
WorkingDir = null,
DefaultCommitType = "feat",
CreatedAt = DateTime.UtcNow,
};
var startedAt = DateTime.UtcNow.AddMinutes(-2);
var task = new TaskEntity
{
Id = Guid.NewGuid().ToString(),
ListId = list.Id,
Title = "running task",
Status = TaskStatus.Running,
StartedAt = startedAt,
CreatedAt = DateTime.UtcNow,
};
using (var ctx = db.CreateContext())
{
await new ListRepository(ctx).AddAsync(list);
await new TaskRepository(ctx).AddAsync(task);
}
var (svc, proxy) = BuildService(db, wtMgr);
await Assert.ThrowsAsync<InvalidOperationException>(
() => svc.ResetAsync(task.Id, CancellationToken.None));
// Task must be unchanged
using (var ctx = db.CreateContext())
{
var unchanged = await new TaskRepository(ctx).GetByIdAsync(task.Id);
Assert.NotNull(unchanged);
Assert.Equal(TaskStatus.Running, unchanged!.Status);
Assert.Equal(startedAt, unchanged.StartedAt);
}
// No broadcaster invocations
Assert.Empty(proxy.Calls);
}
}
#region Test doubles
internal sealed record HubCall(string Method, object?[] Args);
internal sealed class RecordingClientProxy : IClientProxy
{
public readonly List<HubCall> Calls = new();
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
{
Calls.Add(new HubCall(method, args));
return Task.CompletedTask;
}
}
internal sealed class RecordingHubClients : IHubClients
{
public RecordingClientProxy AllProxy { get; } = new();
public IClientProxy All => AllProxy;
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => AllProxy;
public IClientProxy Client(string connectionId) => AllProxy;
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => AllProxy;
public IClientProxy Group(string groupName) => AllProxy;
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => AllProxy;
public IClientProxy Groups(IReadOnlyList<string> groupNames) => AllProxy;
public IClientProxy User(string userId) => AllProxy;
public IClientProxy Users(IReadOnlyList<string> userIds) => AllProxy;
}
internal sealed class RecordingHubContext : IHubContext<ClaudeDo.Worker.Hub.WorkerHub>
{
private readonly RecordingHubClients _clients = new();
public RecordingClientProxy Proxy => _clients.AllProxy;
public IHubClients Clients => _clients;
public IGroupManager Groups => throw new NotImplementedException();
}
#endregion