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