109 lines
4.0 KiB
C#
109 lines
4.0 KiB
C#
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); }
|
|
}
|
|
}
|
|
}
|