feat/planning-sessions-worker #7

Merged
mikakuns merged 15 commits from feat/planning-sessions-worker into main 2026-04-24 06:02:50 +00:00
2 changed files with 268 additions and 1 deletions
Showing only changes of commit 7b67e35720 - Show all commits

View File

@@ -2,6 +2,7 @@ using System.Reflection;
using ClaudeDo.Data; using ClaudeDo.Data;
using ClaudeDo.Data.Models; using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories; using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Planning;
using ClaudeDo.Worker.Services; using ClaudeDo.Worker.Services;
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -43,6 +44,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
private readonly WorktreeMaintenanceService _wtMaintenance; private readonly WorktreeMaintenanceService _wtMaintenance;
private readonly TaskResetService _resetService; private readonly TaskResetService _resetService;
private readonly TaskMergeService _mergeService; private readonly TaskMergeService _mergeService;
private readonly PlanningSessionManager _planning;
private readonly IPlanningTerminalLauncher _launcher;
public WorkerHub( public WorkerHub(
QueueService queue, QueueService queue,
@@ -52,7 +55,9 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
IDbContextFactory<ClaudeDoDbContext> dbFactory, IDbContextFactory<ClaudeDoDbContext> dbFactory,
WorktreeMaintenanceService wtMaintenance, WorktreeMaintenanceService wtMaintenance,
TaskResetService resetService, TaskResetService resetService,
TaskMergeService mergeService) TaskMergeService mergeService,
PlanningSessionManager planning,
IPlanningTerminalLauncher launcher)
{ {
_queue = queue; _queue = queue;
_agentService = agentService; _agentService = agentService;
@@ -62,6 +67,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
_wtMaintenance = wtMaintenance; _wtMaintenance = wtMaintenance;
_resetService = resetService; _resetService = resetService;
_mergeService = mergeService; _mergeService = mergeService;
_planning = planning;
_launcher = launcher;
} }
public string Ping() => $"pong v{Version}"; public string Ping() => $"pong v{Version}";
@@ -284,5 +291,44 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
await _broadcaster.TaskUpdated(dto.TaskId); await _broadcaster.TaskUpdated(dto.TaskId);
} }
public async Task<PlanningSessionStartContext> StartPlanningSessionAsync(string taskId)
{
var ctx = await _planning.StartAsync(taskId, Context.ConnectionAborted);
try
{
await _launcher.LaunchStartAsync(ctx, Context.ConnectionAborted);
}
catch (PlanningLaunchException)
{
await _planning.DiscardAsync(taskId, Context.ConnectionAborted);
throw;
}
await Clients.All.SendAsync("TaskUpdated", taskId);
return ctx;
}
public async Task<PlanningSessionResumeContext> ResumePlanningSessionAsync(string taskId)
{
var ctx = await _planning.ResumeAsync(taskId, Context.ConnectionAborted);
await _launcher.LaunchResumeAsync(ctx, Context.ConnectionAborted);
return ctx;
}
public async Task DiscardPlanningSessionAsync(string taskId)
{
await _planning.DiscardAsync(taskId, Context.ConnectionAborted);
await Clients.All.SendAsync("TaskUpdated", taskId);
}
public async Task<int> FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true)
{
var count = await _planning.FinalizeAsync(taskId, queueAgentTasks, Context.ConnectionAborted);
await Clients.All.SendAsync("TaskUpdated", taskId);
return count;
}
public Task<int> GetPendingDraftCountAsync(string taskId)
=> _planning.GetPendingDraftCountAsync(taskId, Context.ConnectionAborted);
private static string? Nullify(string? s) => string.IsNullOrWhiteSpace(s) ? null : s; private static string? Nullify(string? s) => string.IsNullOrWhiteSpace(s) ? null : s;
} }

View File

@@ -0,0 +1,221 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Planning;
using ClaudeDo.Worker.Services;
using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.AspNetCore.SignalR;
using Xunit;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Tests.Hub;
public sealed class PlanningHubTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly ClaudeDoDbContext _ctx;
private readonly TaskRepository _tasks;
private readonly ListRepository _lists;
private readonly string _rootDir;
private readonly PlanningSessionManager _planning;
private readonly FakePlanningLauncher _launcher;
private readonly RecordingClientProxy _proxy;
public PlanningHubTests()
{
_ctx = _db.CreateContext();
_tasks = new TaskRepository(_ctx);
_lists = new ListRepository(_ctx);
_rootDir = Path.Combine(Path.GetTempPath(), $"cd_hub_planning_{Guid.NewGuid():N}");
_planning = new PlanningSessionManager(_tasks, _lists, _rootDir);
_launcher = new FakePlanningLauncher();
_proxy = new RecordingClientProxy();
}
public void Dispose()
{
_ctx.Dispose();
_db.Dispose();
try { Directory.Delete(_rootDir, recursive: true); } catch { }
}
private WorkerHub CreateHub()
{
var hub = new WorkerHub(
null!, null!, null!, null!, null!, null!, null!, null!,
_planning, _launcher);
hub.Clients = new FakeHubCallerClients(_proxy);
hub.Context = new FakeHubCallerContext();
return hub;
}
private async Task<(string listId, string taskId)> SeedAsync()
{
var listId = Guid.NewGuid().ToString();
var wd = Path.Combine(Path.GetTempPath(), $"cd_wd_{Guid.NewGuid():N}");
Directory.CreateDirectory(wd);
await _lists.AddAsync(new ListEntity
{
Id = listId, Name = "L", WorkingDir = wd, CreatedAt = DateTime.UtcNow,
});
var task = new TaskEntity
{
Id = Guid.NewGuid().ToString(),
ListId = listId,
Title = "Do something",
Status = TaskStatus.Manual,
CreatedAt = DateTime.UtcNow,
CommitType = "feat",
};
await _tasks.AddAsync(task);
return (listId, task.Id);
}
[Fact]
public async Task StartPlanningSessionAsync_ChangesStatusToPlanning_AndInvokesLauncher()
{
var (_, taskId) = await SeedAsync();
var hub = CreateHub();
var ctx = await hub.StartPlanningSessionAsync(taskId);
Assert.Equal(taskId, ctx.ParentTaskId);
Assert.Equal(1, _launcher.LaunchStartCalls);
Assert.Equal(0, _launcher.LaunchResumeCalls);
var loaded = await _tasks.GetByIdAsync(taskId);
Assert.Equal(TaskStatus.Planning, loaded!.Status);
Assert.Contains(_proxy.Sent, m => m.method == "TaskUpdated");
}
[Fact]
public async Task StartPlanningSessionAsync_LauncherFails_Discards()
{
var (_, taskId) = await SeedAsync();
_launcher.ShouldThrow = true;
var hub = CreateHub();
await Assert.ThrowsAsync<PlanningLaunchException>(() =>
hub.StartPlanningSessionAsync(taskId));
var loaded = await _tasks.GetByIdAsync(taskId);
Assert.Equal(TaskStatus.Manual, loaded!.Status);
var sessionDir = Path.Combine(_rootDir, taskId);
Assert.False(Directory.Exists(sessionDir));
}
[Fact]
public async Task DiscardPlanningSessionAsync_ResetsTask_AndBroadcasts()
{
var (_, taskId) = await SeedAsync();
// Put task into Planning state first
await _planning.StartAsync(taskId, CancellationToken.None);
_proxy.Sent.Clear();
var hub = CreateHub();
await hub.DiscardPlanningSessionAsync(taskId);
var loaded = await _tasks.GetByIdAsync(taskId);
Assert.Equal(TaskStatus.Manual, loaded!.Status);
Assert.Contains(_proxy.Sent, m => m.method == "TaskUpdated");
}
[Fact]
public async Task FinalizePlanningSessionAsync_PromotesDraftsAndBroadcasts()
{
var (_, taskId) = await SeedAsync();
await _planning.StartAsync(taskId, CancellationToken.None);
await _tasks.CreateChildAsync(taskId, "child 1", null, null, null);
await _tasks.CreateChildAsync(taskId, "child 2", null, null, null);
_proxy.Sent.Clear();
var hub = CreateHub();
var count = await hub.FinalizePlanningSessionAsync(taskId, queueAgentTasks: false);
Assert.Equal(2, count);
Assert.Contains(_proxy.Sent, m => m.method == "TaskUpdated");
}
[Fact]
public async Task GetPendingDraftCountAsync_ReturnsCount()
{
var (_, taskId) = await SeedAsync();
await _planning.StartAsync(taskId, CancellationToken.None);
await _tasks.CreateChildAsync(taskId, "c1", null, null, null);
await _tasks.CreateChildAsync(taskId, "c2", null, null, null);
var hub = CreateHub();
var count = await hub.GetPendingDraftCountAsync(taskId);
Assert.Equal(2, count);
}
}
// ---------------------------------------------------------------------------
// Fakes
// ---------------------------------------------------------------------------
internal sealed class FakePlanningLauncher : IPlanningTerminalLauncher
{
public bool ShouldThrow { get; set; }
public int LaunchStartCalls { get; private set; }
public int LaunchResumeCalls { get; private set; }
public Task LaunchStartAsync(PlanningSessionStartContext ctx, CancellationToken cancellationToken)
{
if (ShouldThrow) throw new PlanningLaunchException("fake launch failure");
LaunchStartCalls++;
return Task.CompletedTask;
}
public Task LaunchResumeAsync(PlanningSessionResumeContext ctx, CancellationToken cancellationToken)
{
LaunchResumeCalls++;
return Task.CompletedTask;
}
}
internal sealed class RecordingClientProxy : IClientProxy
{
public List<(string method, object?[] args)> Sent { get; } = new();
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
{
Sent.Add((method, args));
return Task.CompletedTask;
}
}
internal sealed class FakeHubCallerClients : IHubCallerClients
{
private readonly IClientProxy _all;
public FakeHubCallerClients(IClientProxy proxy) => _all = proxy;
public IClientProxy All => _all;
public IClientProxy Caller => _all;
public IClientProxy Others => _all;
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => _all;
public IClientProxy Client(string connectionId) => _all;
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => _all;
public IClientProxy Group(string groupName) => _all;
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => _all;
public IClientProxy Groups(IReadOnlyList<string> groupNames) => _all;
public IClientProxy OthersInGroup(string groupName) => _all;
public IClientProxy User(string userId) => _all;
public IClientProxy Users(IReadOnlyList<string> userIds) => _all;
}
internal sealed class FakeHubCallerContext : HubCallerContext
{
public override string ConnectionId => "test-conn";
public override string? UserIdentifier => null;
public override System.Security.Claims.ClaimsPrincipal? User => null;
public override IDictionary<object, object?> Items { get; } = new Dictionary<object, object?>();
public override Microsoft.AspNetCore.Http.Features.IFeatureCollection Features { get; } =
new Microsoft.AspNetCore.Http.Features.FeatureCollection();
public override CancellationToken ConnectionAborted => CancellationToken.None;
public override void Abort() { }
}