Files
ClaudeDo/tests/ClaudeDo.Worker.Tests/Services/TaskResetServiceTests.cs
Mika Kuns 74eb36d3c0 feat(worker): add TaskResetService for discard + reset flow
Orchestrates worktree discard, task reset to Manual, and SignalR broadcast.
Includes integration tests (happy path + running-task rejection).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 17:31:52 +02:00

232 lines
8.0 KiB
C#

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