feat(refine): add RefineRunner, prompt/args helper, and interfaces

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mika kuns
2026-06-04 23:09:30 +02:00
parent 66a7b2377f
commit 0460d7bea5
6 changed files with 351 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
namespace ClaudeDo.Worker.Refine;
public interface IRefineBroadcaster
{
Task RefineStartedAsync(string taskId);
Task RefineFinishedAsync(string taskId, bool success, string? error);
}

View 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);

View 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;
}

View 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); }
}
}
}

View 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);
}
}

View 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;
}
}