feat(refine): add RefineRunner, prompt/args helper, and interfaces
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
namespace ClaudeDo.Worker.Refine;
|
||||
|
||||
public interface IRefineBroadcaster
|
||||
{
|
||||
Task RefineStartedAsync(string taskId);
|
||||
Task RefineFinishedAsync(string taskId, bool success, string? error);
|
||||
}
|
||||
8
src/ClaudeDo.Worker/Refine/Interfaces/IRefineRunner.cs
Normal file
8
src/ClaudeDo.Worker/Refine/Interfaces/IRefineRunner.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace ClaudeDo.Worker.Refine;
|
||||
|
||||
public interface IRefineRunner
|
||||
{
|
||||
Task<RefineRunOutcome> RefineAsync(string taskId, CancellationToken ct);
|
||||
}
|
||||
|
||||
public sealed record RefineRunOutcome(bool Success, string Message);
|
||||
38
src/ClaudeDo.Worker/Refine/RefinePrompt.cs
Normal file
38
src/ClaudeDo.Worker/Refine/RefinePrompt.cs
Normal file
@@ -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<SubtaskEntity> 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<string, string>
|
||||
{
|
||||
["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;
|
||||
}
|
||||
108
src/ClaudeDo.Worker/Refine/RefineRunner.cs
Normal file
108
src/ClaudeDo.Worker/Refine/RefineRunner.cs
Normal file
@@ -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<ClaudeDoDbContext> _dbFactory;
|
||||
private readonly ILogger<RefineRunner> _logger;
|
||||
private readonly IRefineBroadcaster _broadcaster;
|
||||
|
||||
private readonly object _lock = new();
|
||||
private readonly HashSet<string> _inFlight = new();
|
||||
|
||||
public RefineRunner(
|
||||
IClaudeProcess claude,
|
||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||
ILogger<RefineRunner> logger,
|
||||
IRefineBroadcaster broadcaster)
|
||||
{
|
||||
_claude = claude;
|
||||
_dbFactory = dbFactory;
|
||||
_logger = logger;
|
||||
_broadcaster = broadcaster;
|
||||
}
|
||||
|
||||
public async Task<RefineRunOutcome> 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<ClaudeDo.Data.Models.SubtaskEntity> 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); }
|
||||
}
|
||||
}
|
||||
}
|
||||
52
tests/ClaudeDo.Worker.Tests/Refine/RefinePromptTests.cs
Normal file
52
tests/ClaudeDo.Worker.Tests/Refine/RefinePromptTests.cs
Normal file
@@ -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<SubtaskEntity>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
138
tests/ClaudeDo.Worker.Tests/Refine/RefineRunnerTests.cs
Normal file
138
tests/ClaudeDo.Worker.Tests/Refine/RefineRunnerTests.cs
Normal file
@@ -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<string> 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<TaskEntity> 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<RefineRunner>.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<RunResult> RunAsync(string arguments, string prompt, string workingDirectory,
|
||||
Func<string, Task> 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user