Files
ClaudeDo/docs/superpowers/plans/2026-04-21-continue-and-reset-failed-tasks.md

32 KiB

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):

[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:

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
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):

[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:

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
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:

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:

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
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:

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):

private readonly TaskResetService _resetService;

In the constructor signature, append TaskResetService resetService and assign it. The full updated constructor:

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):

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
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):

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
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:

[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:

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:

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):

[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:

_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
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:

<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
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
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.