diff --git a/src/ClaudeDo.Worker/Refine/Interfaces/IRefineBroadcaster.cs b/src/ClaudeDo.Worker/Refine/Interfaces/IRefineBroadcaster.cs new file mode 100644 index 0000000..e31cb29 --- /dev/null +++ b/src/ClaudeDo.Worker/Refine/Interfaces/IRefineBroadcaster.cs @@ -0,0 +1,7 @@ +namespace ClaudeDo.Worker.Refine; + +public interface IRefineBroadcaster +{ + Task RefineStartedAsync(string taskId); + Task RefineFinishedAsync(string taskId, bool success, string? error); +} diff --git a/src/ClaudeDo.Worker/Refine/Interfaces/IRefineRunner.cs b/src/ClaudeDo.Worker/Refine/Interfaces/IRefineRunner.cs new file mode 100644 index 0000000..3f166ef --- /dev/null +++ b/src/ClaudeDo.Worker/Refine/Interfaces/IRefineRunner.cs @@ -0,0 +1,8 @@ +namespace ClaudeDo.Worker.Refine; + +public interface IRefineRunner +{ + Task RefineAsync(string taskId, CancellationToken ct); +} + +public sealed record RefineRunOutcome(bool Success, string Message); diff --git a/src/ClaudeDo.Worker/Refine/RefinePrompt.cs b/src/ClaudeDo.Worker/Refine/RefinePrompt.cs new file mode 100644 index 0000000..6dc5401 --- /dev/null +++ b/src/ClaudeDo.Worker/Refine/RefinePrompt.cs @@ -0,0 +1,38 @@ +using ClaudeDo.Data; +using ClaudeDo.Data.Models; + +namespace ClaudeDo.Worker.Refine; + +public static class RefinePrompt +{ + public const string GetTaskTool = "mcp__claudedo__get_task"; + public const string UpdateTaskTool = "mcp__claudedo__update_task"; + public const string AddSubtaskTool = "mcp__claudedo__add_subtask"; + + public static string LogPath(string taskId) => + System.IO.Path.Combine(Paths.AppDataRoot(), "logs", $"refine-{Short(taskId)}.log"); + + public static string BuildArgs(int maxTurns, bool canReadRepo) + { + var tools = canReadRepo + ? $"{GetTaskTool} {UpdateTaskTool} {AddSubtaskTool} Read Grep Glob" + : $"{GetTaskTool} {UpdateTaskTool} {AddSubtaskTool}"; + return "-p --output-format stream-json --verbose --permission-mode acceptEdits " + + $"--max-turns {maxTurns} --allowedTools {tools}"; + } + + public static string BuildPrompt(TaskEntity task, IEnumerable subtasks) + { + var open = subtasks.Where(s => !s.Completed).Select(s => $"- {s.Title}").ToList(); + var subText = open.Count == 0 ? "(none)" : string.Join("\n", open); + return PromptFiles.Render(PromptKind.Refine, new Dictionary + { + ["taskId"] = task.Id, + ["title"] = task.Title, + ["description"] = string.IsNullOrWhiteSpace(task.Description) ? "(empty)" : task.Description!, + ["subtasks"] = subText, + }); + } + + private static string Short(string id) => id.Length >= 8 ? id[..8] : id; +} diff --git a/src/ClaudeDo.Worker/Refine/RefineRunner.cs b/src/ClaudeDo.Worker/Refine/RefineRunner.cs new file mode 100644 index 0000000..14fdd1d --- /dev/null +++ b/src/ClaudeDo.Worker/Refine/RefineRunner.cs @@ -0,0 +1,108 @@ +using ClaudeDo.Data; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Runner; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Refine; + +public sealed class RefineRunner : IRefineRunner +{ + private static readonly TimeSpan RunTimeout = TimeSpan.FromMinutes(5); + private const int MaxTurns = 25; + + private readonly IClaudeProcess _claude; + private readonly IDbContextFactory _dbFactory; + private readonly ILogger _logger; + private readonly IRefineBroadcaster _broadcaster; + + private readonly object _lock = new(); + private readonly HashSet _inFlight = new(); + + public RefineRunner( + IClaudeProcess claude, + IDbContextFactory dbFactory, + ILogger logger, + IRefineBroadcaster broadcaster) + { + _claude = claude; + _dbFactory = dbFactory; + _logger = logger; + _broadcaster = broadcaster; + } + + public async Task RefineAsync(string taskId, CancellationToken ct) + { + lock (_lock) + { + if (!_inFlight.Add(taskId)) + return new RefineRunOutcome(false, "Already refining this task"); + } + + var success = false; + string? error = null; + try + { + ClaudeDo.Data.Models.TaskEntity task; + List subs; + string? workingDir; + await using (var dbCtx = await _dbFactory.CreateDbContextAsync(ct)) + { + var tasks = new TaskRepository(dbCtx); + task = await tasks.GetByIdAsync(taskId, ct) + ?? throw new InvalidOperationException($"Task {taskId} not found."); + if (task.Status != TaskStatus.Idle) + return new RefineRunOutcome(false, $"Task must be Idle to refine (is {task.Status})."); + subs = await new SubtaskRepository(dbCtx).GetByTaskIdAsync(taskId, ct); + var list = await new ListRepository(dbCtx).GetByIdAsync(task.ListId, ct); + workingDir = list?.WorkingDir; + } + + var canReadRepo = !string.IsNullOrWhiteSpace(workingDir) && Directory.Exists(workingDir); + var cwd = canReadRepo ? workingDir! : Paths.AppDataRoot(); + Directory.CreateDirectory(cwd); + + var logPath = RefinePrompt.LogPath(taskId); + try { if (File.Exists(logPath)) File.Delete(logPath); } catch { } + await using var logWriter = new LogWriter(logPath); + + await _broadcaster.RefineStartedAsync(taskId); + + var prompt = RefinePrompt.BuildPrompt(task, subs); + var args = RefinePrompt.BuildArgs(MaxTurns, canReadRepo); + + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeoutCts.CancelAfter(RunTimeout); + + var result = await _claude.RunAsync( + arguments: args, + prompt: prompt, + workingDirectory: cwd, + onStdoutLine: async line => await logWriter.WriteLineAsync(line), + ct: timeoutCts.Token); + + success = result.IsSuccess; + if (!success) error = $"exit code {result.ExitCode}"; + return success + ? new RefineRunOutcome(true, "Refine complete") + : new RefineRunOutcome(false, error!); + } + catch (OperationCanceledException) when (!ct.IsCancellationRequested) + { + error = $"timed out after {RunTimeout.TotalMinutes:0} min"; + return new RefineRunOutcome(false, error); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Refine run failed for {TaskId}", taskId); + error = ex.Message; + return new RefineRunOutcome(false, ex.Message); + } + finally + { + await _broadcaster.RefineFinishedAsync(taskId, success, error); + lock (_lock) { _inFlight.Remove(taskId); } + } + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Refine/RefinePromptTests.cs b/tests/ClaudeDo.Worker.Tests/Refine/RefinePromptTests.cs new file mode 100644 index 0000000..57e7027 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Refine/RefinePromptTests.cs @@ -0,0 +1,52 @@ +using ClaudeDo.Data.Models; +using ClaudeDo.Worker.Refine; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Tests.Refine; + +public sealed class RefinePromptTests +{ + [Fact] + public void BuildArgs_includes_read_tools_when_repo_available() + { + var args = RefinePrompt.BuildArgs(20, canReadRepo: true); + + Assert.Contains("--permission-mode acceptEdits", args); + Assert.Contains("mcp__claudedo__add_subtask", args); + Assert.Contains(" Read Grep Glob", args); + } + + [Fact] + public void BuildArgs_drops_read_tools_in_text_only_mode() + { + var args = RefinePrompt.BuildArgs(20, canReadRepo: false); + + Assert.DoesNotContain("Glob", args); + Assert.Contains("mcp__claudedo__update_task", args); + } + + [Fact] + public void BuildPrompt_seeds_task_fields_and_open_subtasks() + { + var task = new TaskEntity + { + Id = "abc12345", + ListId = "l", + Title = "T", + Description = "D", + Status = TaskStatus.Idle, + CreatedAt = DateTime.UtcNow, + }; + var subtasks = new List + { + new() { Id = "s1", TaskId = "abc12345", Title = "open one", Completed = false, CreatedAt = DateTime.UtcNow }, + new() { Id = "s2", TaskId = "abc12345", Title = "done one", Completed = true, CreatedAt = DateTime.UtcNow }, + }; + + var prompt = RefinePrompt.BuildPrompt(task, subtasks); + + Assert.Contains("abc12345", prompt); + Assert.Contains("open one", prompt); + Assert.DoesNotContain("done one", prompt); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Refine/RefineRunnerTests.cs b/tests/ClaudeDo.Worker.Tests/Refine/RefineRunnerTests.cs new file mode 100644 index 0000000..ed4b534 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Refine/RefineRunnerTests.cs @@ -0,0 +1,138 @@ +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Refine; +using ClaudeDo.Worker.Runner; +using ClaudeDo.Worker.Tests.Infrastructure; +using Microsoft.Extensions.Logging.Abstractions; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Tests.Refine; + +public sealed class RefineRunnerTests : IDisposable +{ + private readonly DbFixture _db = new(); + private readonly ClaudeDoDbContext _ctx; + + public RefineRunnerTests() + { + _ctx = _db.CreateContext(); + } + + public void Dispose() + { + _ctx.Dispose(); + _db.Dispose(); + } + + private async Task SeedListAsync() + { + var listId = Guid.NewGuid().ToString(); + await new ListRepository(_ctx).AddAsync(new ListEntity + { + Id = listId, + Name = "Test", + CreatedAt = DateTime.UtcNow, + WorkingDir = null, + }); + return listId; + } + + private async Task SeedTaskAsync(string listId, TaskStatus status) + { + var task = new TaskEntity + { + Id = Guid.NewGuid().ToString(), + ListId = listId, + Title = "Test task", + Status = status, + CreatedAt = DateTime.UtcNow, + }; + await new TaskRepository(_ctx).AddAsync(task); + return task; + } + + private RefineRunner BuildRunner(RecordingClaudeProcess claude, RecordingRefineBroadcaster broadcaster) + { + return new RefineRunner( + claude, + _db.CreateFactory(), + NullLogger.Instance, + broadcaster); + } + + [Fact] + public async Task Refuses_when_task_not_idle() + { + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId, TaskStatus.Queued); + + var claude = new RecordingClaudeProcess(success: true); + var broadcaster = new RecordingRefineBroadcaster(); + var runner = BuildRunner(claude, broadcaster); + + var outcome = await runner.RefineAsync(task.Id, CancellationToken.None); + + Assert.False(outcome.Success); + Assert.Equal(0, claude.CallCount); + } + + [Fact] + public async Task Idle_task_invokes_claude_once_and_brackets_with_events() + { + var listId = await SeedListAsync(); + var task = await SeedTaskAsync(listId, TaskStatus.Idle); + + var claude = new RecordingClaudeProcess(success: true); + var broadcaster = new RecordingRefineBroadcaster(); + var runner = BuildRunner(claude, broadcaster); + + var outcome = await runner.RefineAsync(task.Id, CancellationToken.None); + + Assert.True(outcome.Success); + Assert.Equal(1, claude.CallCount); + Assert.Equal(1, broadcaster.StartedCount); + Assert.Equal(1, broadcaster.FinishedCount); + } +} + +internal sealed class RecordingClaudeProcess : IClaudeProcess +{ + private readonly bool _success; + private int _callCount; + + public int CallCount => _callCount; + + public RecordingClaudeProcess(bool success) => _success = success; + + public Task RunAsync(string arguments, string prompt, string workingDirectory, + Func onStdoutLine, CancellationToken ct) + { + Interlocked.Increment(ref _callCount); + var result = _success + ? new RunResult { ExitCode = 0, ResultMarkdown = "ok" } + : new RunResult { ExitCode = 1, ResultMarkdown = null }; + return Task.FromResult(result); + } +} + +internal sealed class RecordingRefineBroadcaster : IRefineBroadcaster +{ + private int _startedCount; + private int _finishedCount; + + public int StartedCount => _startedCount; + public int FinishedCount => _finishedCount; + + public Task RefineStartedAsync(string taskId) + { + Interlocked.Increment(ref _startedCount); + return Task.CompletedTask; + } + + public Task RefineFinishedAsync(string taskId, bool success, string? error) + { + Interlocked.Increment(ref _finishedCount); + return Task.CompletedTask; + } +}