Compare commits
13 Commits
23f8fddc4d
...
fb89e02b02
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb89e02b02 | ||
|
|
58c8210afa | ||
|
|
2ce6b7bd3a | ||
|
|
f90d3d8375 | ||
|
|
b03e858a8f | ||
|
|
2278b516ea | ||
|
|
219a231f32 | ||
|
|
74eb36d3c0 | ||
|
|
202236a45b | ||
|
|
88be19a231 | ||
|
|
44203f3c67 | ||
|
|
133774cb86 | ||
|
|
a3bb557d76 |
@@ -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.
|
||||
@@ -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.
|
||||
@@ -98,6 +98,17 @@ public sealed class TaskRepository
|
||||
.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
|
||||
|
||||
#region Tags
|
||||
|
||||
@@ -31,7 +31,7 @@ All views use compiled bindings (`x:DataType`).
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -169,6 +169,16 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable
|
||||
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)
|
||||
{
|
||||
await _hub.InvokeAsync("CancelTask", taskId);
|
||||
|
||||
@@ -39,11 +39,21 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
public bool IsDone => AgentStatusLabel == "Done";
|
||||
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)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsRunning));
|
||||
OnPropertyChanged(nameof(IsDone));
|
||||
OnPropertyChanged(nameof(IsFailed));
|
||||
ShowFailedActions = value == "Failed";
|
||||
}
|
||||
[ObservableProperty] private string? _model;
|
||||
[ObservableProperty] private string? _worktreePath;
|
||||
@@ -108,11 +118,15 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
// Subscribe once; filter by current task id inside the handler
|
||||
_worker.TaskMessageEvent += OnTaskMessage;
|
||||
|
||||
// Re-evaluate RunNow CanExecute when worker connection flips.
|
||||
// Re-evaluate CanExecute when worker connection flips.
|
||||
_worker.PropertyChanged += (_, e) =>
|
||||
{
|
||||
if (e.PropertyName == nameof(WorkerClient.IsConnected))
|
||||
{
|
||||
RunNowCommand.NotifyCanExecuteChanged();
|
||||
ContinueCommand.NotifyCanExecuteChanged();
|
||||
ResetCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
};
|
||||
|
||||
// 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;
|
||||
BranchLine = null;
|
||||
AgentStatusLabel = "Idle";
|
||||
LatestRunSessionId = null;
|
||||
ShowFailedActions = false;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -243,6 +259,11 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
BranchLine = entity.Worktree is { } w ? $"{w.BranchName} \u2190 main" : null;
|
||||
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
|
||||
_subscribedTaskId = row.Id;
|
||||
|
||||
@@ -391,6 +412,32 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
|
||||
private bool CanRunNow() =>
|
||||
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
|
||||
|
||||
@@ -141,6 +141,18 @@
|
||||
<TextBlock Text="Worktree" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</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>
|
||||
|
||||
@@ -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).
|
||||
- **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.
|
||||
- **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
|
||||
- **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
|
||||
@@ -44,7 +45,7 @@ Each CLI invocation is recorded in the `task_runs` table via `TaskRunRepository`
|
||||
|
||||
## 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`
|
||||
|
||||
|
||||
@@ -33,19 +33,22 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
private readonly HubBroadcaster _broadcaster;
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
private readonly WorktreeMaintenanceService _wtMaintenance;
|
||||
private readonly TaskResetService _resetService;
|
||||
|
||||
public WorkerHub(
|
||||
QueueService queue,
|
||||
AgentFileService agentService,
|
||||
HubBroadcaster broadcaster,
|
||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||
WorktreeMaintenanceService wtMaintenance)
|
||||
WorktreeMaintenanceService wtMaintenance,
|
||||
TaskResetService resetService)
|
||||
{
|
||||
_queue = queue;
|
||||
_agentService = agentService;
|
||||
_broadcaster = broadcaster;
|
||||
_dbFactory = dbFactory;
|
||||
_wtMaintenance = wtMaintenance;
|
||||
_resetService = resetService;
|
||||
}
|
||||
|
||||
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 void WakeQueue() => _queue.WakeQueue();
|
||||
|
||||
@@ -30,6 +30,7 @@ builder.Services.AddSingleton<WorktreeManager>();
|
||||
builder.Services.AddSingleton<ClaudeArgsBuilder>();
|
||||
builder.Services.AddSingleton<TaskRunner>();
|
||||
builder.Services.AddSingleton<WorktreeMaintenanceService>();
|
||||
builder.Services.AddSingleton<TaskResetService>();
|
||||
|
||||
// Agent file management.
|
||||
var agentsDir = Path.Combine(ClaudeDo.Data.Paths.AppDataRoot(), "agents");
|
||||
|
||||
@@ -140,4 +140,32 @@ public sealed class WorktreeManager
|
||||
_logger.LogInformation("Committed changes for task {TaskId}: {Head}", task.Id, head);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
68
src/ClaudeDo.Worker/Services/TaskResetService.cs
Normal file
68
src/ClaudeDo.Worker/Services/TaskResetService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -198,6 +198,35 @@ public sealed class TaskRepositoryTests : IDisposable
|
||||
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]
|
||||
public async Task GetEffectiveTagsAsync_Returns_Union_Of_ListTags_And_TaskTags()
|
||||
{
|
||||
|
||||
@@ -181,6 +181,37 @@ public class WorktreeManagerTests : IDisposable
|
||||
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()
|
||||
{
|
||||
foreach (var (repoDir, wtPath) in _worktreeCleanups)
|
||||
|
||||
231
tests/ClaudeDo.Worker.Tests/Services/TaskResetServiceTests.cs
Normal file
231
tests/ClaudeDo.Worker.Tests/Services/TaskResetServiceTests.cs
Normal 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
|
||||
Reference in New Issue
Block a user