feat(worker,ui): wire EF Core into DI and update all consumers to IDbContextFactory
Worker and App Program.cs: replace SqliteConnectionFactory+SchemaInitializer with AddDbContextFactory<ClaudeDoDbContext> + Database.Migrate(). Repos changed from AddSingleton to AddScoped. All singleton services (QueueService, StaleTaskRecovery, WorktreeManager, TaskRunner) and singleton ViewModels (MainWindowViewModel, TaskDetailViewModel, TaskListViewModel, TaskEditorViewModel) now take IDbContextFactory<ClaudeDoDbContext> and create short-lived contexts per operation. Test infrastructure: DbFixture now uses EF migrations instead of SchemaInitializer; all test classes create contexts via DbFixture.CreateContext(). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,18 +1,16 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Config;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ClaudeDo.Worker.Runner;
|
||||
|
||||
public sealed class TaskRunner
|
||||
{
|
||||
private readonly IClaudeProcess _claude;
|
||||
private readonly TaskRepository _taskRepo;
|
||||
private readonly TaskRunRepository _runRepo;
|
||||
private readonly ListRepository _listRepo;
|
||||
private readonly WorktreeRepository _wtRepo;
|
||||
private readonly SubtaskRepository _subtaskRepo;
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
private readonly HubBroadcaster _broadcaster;
|
||||
private readonly WorktreeManager _wtManager;
|
||||
private readonly ClaudeArgsBuilder _argsBuilder;
|
||||
@@ -21,11 +19,7 @@ public sealed class TaskRunner
|
||||
|
||||
public TaskRunner(
|
||||
IClaudeProcess claude,
|
||||
TaskRepository taskRepo,
|
||||
TaskRunRepository runRepo,
|
||||
ListRepository listRepo,
|
||||
WorktreeRepository wtRepo,
|
||||
SubtaskRepository subtaskRepo,
|
||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||
HubBroadcaster broadcaster,
|
||||
WorktreeManager wtManager,
|
||||
ClaudeArgsBuilder argsBuilder,
|
||||
@@ -33,11 +27,7 @@ public sealed class TaskRunner
|
||||
ILogger<TaskRunner> logger)
|
||||
{
|
||||
_claude = claude;
|
||||
_taskRepo = taskRepo;
|
||||
_runRepo = runRepo;
|
||||
_listRepo = listRepo;
|
||||
_wtRepo = wtRepo;
|
||||
_subtaskRepo = subtaskRepo;
|
||||
_dbFactory = dbFactory;
|
||||
_broadcaster = broadcaster;
|
||||
_wtManager = wtManager;
|
||||
_argsBuilder = argsBuilder;
|
||||
@@ -49,11 +39,23 @@ public sealed class TaskRunner
|
||||
{
|
||||
try
|
||||
{
|
||||
var list = await _listRepo.GetByIdAsync(task.ListId, ct);
|
||||
if (list is null)
|
||||
ListEntity? list;
|
||||
ListConfigEntity? listConfig;
|
||||
List<SubtaskEntity> subtasks;
|
||||
|
||||
using (var context = _dbFactory.CreateDbContext())
|
||||
{
|
||||
await MarkFailed(task.Id, slot, "List not found.");
|
||||
return;
|
||||
var listRepo = new ListRepository(context);
|
||||
list = await listRepo.GetByIdAsync(task.ListId, ct);
|
||||
if (list is null)
|
||||
{
|
||||
await MarkFailed(task.Id, slot, "List not found.");
|
||||
return;
|
||||
}
|
||||
listConfig = await listRepo.GetConfigAsync(task.ListId, ct);
|
||||
|
||||
var subtaskRepo = new SubtaskRepository(context);
|
||||
subtasks = await subtaskRepo.GetByTaskIdAsync(task.Id, ct);
|
||||
}
|
||||
|
||||
// Determine working directory: worktree or sandbox.
|
||||
@@ -81,7 +83,6 @@ public sealed class TaskRunner
|
||||
}
|
||||
|
||||
// Resolve config: task overrides > list config > null.
|
||||
var listConfig = await _listRepo.GetConfigAsync(task.ListId, ct);
|
||||
var resolvedConfig = new ClaudeRunConfig(
|
||||
Model: task.Model ?? listConfig?.Model ?? "claude-sonnet-4-6",
|
||||
SystemPrompt: task.SystemPrompt ?? listConfig?.SystemPrompt,
|
||||
@@ -90,11 +91,14 @@ public sealed class TaskRunner
|
||||
);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
await _taskRepo.MarkRunningAsync(task.Id, now, ct);
|
||||
using (var context = _dbFactory.CreateDbContext())
|
||||
{
|
||||
var taskRepo = new TaskRepository(context);
|
||||
await taskRepo.MarkRunningAsync(task.Id, now, ct);
|
||||
}
|
||||
await _broadcaster.TaskStarted(slot, task.Id, now);
|
||||
|
||||
// Build prompt.
|
||||
var subtasks = await _subtaskRepo.GetByTaskIdAsync(task.Id, ct);
|
||||
var sb = new System.Text.StringBuilder(task.Title);
|
||||
if (!string.IsNullOrWhiteSpace(task.Description)) sb.Append("\n\n").Append(task.Description.Trim());
|
||||
if (subtasks.Count > 0)
|
||||
@@ -155,19 +159,34 @@ public sealed class TaskRunner
|
||||
|
||||
public async Task ContinueAsync(string taskId, string followUpPrompt, string slot, CancellationToken ct)
|
||||
{
|
||||
var task = await _taskRepo.GetByIdAsync(taskId, ct)
|
||||
?? throw new KeyNotFoundException($"Task '{taskId}' not found.");
|
||||
TaskEntity task;
|
||||
TaskRunEntity lastRun;
|
||||
ListEntity list;
|
||||
ListConfigEntity? listConfig;
|
||||
WorktreeEntity? worktree;
|
||||
|
||||
var lastRun = await _runRepo.GetLatestByTaskIdAsync(taskId, ct)
|
||||
?? throw new InvalidOperationException("No previous run to continue.");
|
||||
using (var context = _dbFactory.CreateDbContext())
|
||||
{
|
||||
var taskRepo = new TaskRepository(context);
|
||||
task = await taskRepo.GetByIdAsync(taskId, ct)
|
||||
?? throw new KeyNotFoundException($"Task '{taskId}' not found.");
|
||||
|
||||
if (lastRun.SessionId is null)
|
||||
throw new InvalidOperationException("Previous run has no session ID — cannot resume.");
|
||||
var runRepo = new TaskRunRepository(context);
|
||||
lastRun = await runRepo.GetLatestByTaskIdAsync(taskId, ct)
|
||||
?? throw new InvalidOperationException("No previous run to continue.");
|
||||
|
||||
var list = await _listRepo.GetByIdAsync(task.ListId, ct)
|
||||
?? throw new InvalidOperationException("List not found.");
|
||||
if (lastRun.SessionId is null)
|
||||
throw new InvalidOperationException("Previous run has no session ID — cannot resume.");
|
||||
|
||||
var listRepo = new ListRepository(context);
|
||||
list = await listRepo.GetByIdAsync(task.ListId, ct)
|
||||
?? throw new InvalidOperationException("List not found.");
|
||||
listConfig = await listRepo.GetConfigAsync(task.ListId, ct);
|
||||
|
||||
var wtRepo = new WorktreeRepository(context);
|
||||
worktree = await wtRepo.GetByTaskIdAsync(taskId, ct);
|
||||
}
|
||||
|
||||
var listConfig = await _listRepo.GetConfigAsync(task.ListId, ct);
|
||||
var resolvedConfig = new ClaudeRunConfig(
|
||||
Model: task.Model ?? listConfig?.Model,
|
||||
SystemPrompt: task.SystemPrompt ?? listConfig?.SystemPrompt,
|
||||
@@ -178,7 +197,6 @@ public sealed class TaskRunner
|
||||
// Determine run directory from existing worktree or sandbox.
|
||||
string runDir;
|
||||
WorktreeContext? wtCtx = null;
|
||||
var worktree = await _wtRepo.GetByTaskIdAsync(taskId, ct);
|
||||
if (worktree is not null)
|
||||
{
|
||||
runDir = worktree.Path;
|
||||
@@ -190,7 +208,11 @@ public sealed class TaskRunner
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
await _taskRepo.MarkRunningAsync(taskId, now, ct);
|
||||
using (var context = _dbFactory.CreateDbContext())
|
||||
{
|
||||
var taskRepo = new TaskRepository(context);
|
||||
await taskRepo.MarkRunningAsync(taskId, now, ct);
|
||||
}
|
||||
await _broadcaster.TaskStarted(slot, taskId, now);
|
||||
|
||||
var nextRunNumber = lastRun.RunNumber + 1;
|
||||
@@ -226,7 +248,12 @@ public sealed class TaskRunner
|
||||
LogPath = logPath,
|
||||
StartedAt = DateTime.UtcNow,
|
||||
};
|
||||
await _runRepo.AddAsync(run, ct);
|
||||
|
||||
using (var context = _dbFactory.CreateDbContext())
|
||||
{
|
||||
var runRepo = new TaskRunRepository(context);
|
||||
await runRepo.AddAsync(run, ct);
|
||||
}
|
||||
|
||||
var arguments = _argsBuilder.Build(config);
|
||||
|
||||
@@ -257,10 +284,15 @@ public sealed class TaskRunner
|
||||
run.TokensIn = result.TokensIn;
|
||||
run.TokensOut = result.TokensOut;
|
||||
run.FinishedAt = DateTime.UtcNow;
|
||||
await _runRepo.UpdateAsync(run, CancellationToken.None);
|
||||
|
||||
// Update denormalized fields on the task.
|
||||
await _taskRepo.SetLogPathAsync(taskId, logPath, CancellationToken.None);
|
||||
using (var context = _dbFactory.CreateDbContext())
|
||||
{
|
||||
var runRepo = new TaskRunRepository(context);
|
||||
await runRepo.UpdateAsync(run, CancellationToken.None);
|
||||
|
||||
var taskRepo = new TaskRepository(context);
|
||||
await taskRepo.SetLogPathAsync(taskId, logPath, CancellationToken.None);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -273,8 +305,12 @@ public sealed class TaskRunner
|
||||
run.FinishedAt = DateTime.UtcNow;
|
||||
try
|
||||
{
|
||||
await _runRepo.UpdateAsync(run, CancellationToken.None);
|
||||
await _taskRepo.SetLogPathAsync(taskId, logPath, CancellationToken.None);
|
||||
using var context = _dbFactory.CreateDbContext();
|
||||
var runRepo = new TaskRunRepository(context);
|
||||
await runRepo.UpdateAsync(run, CancellationToken.None);
|
||||
|
||||
var taskRepo = new TaskRepository(context);
|
||||
await taskRepo.SetLogPathAsync(taskId, logPath, CancellationToken.None);
|
||||
}
|
||||
catch (Exception updateEx)
|
||||
{
|
||||
@@ -297,7 +333,11 @@ public sealed class TaskRunner
|
||||
// is never left as 'running' because of a cancel that arrived
|
||||
// after the Claude run already succeeded.
|
||||
var finishedAt = DateTime.UtcNow;
|
||||
await _taskRepo.MarkDoneAsync(task.Id, finishedAt, result.ResultMarkdown, CancellationToken.None);
|
||||
using (var context = _dbFactory.CreateDbContext())
|
||||
{
|
||||
var taskRepo = new TaskRepository(context);
|
||||
await taskRepo.MarkDoneAsync(task.Id, finishedAt, result.ResultMarkdown, CancellationToken.None);
|
||||
}
|
||||
await _broadcaster.TaskFinished(slot, task.Id, "done", finishedAt);
|
||||
_logger.LogInformation("Task {TaskId} completed (turns={Turns}, tokens_in={In}, tokens_out={Out})",
|
||||
task.Id, result.TurnCount, result.TokensIn, result.TokensOut);
|
||||
@@ -308,7 +348,9 @@ public sealed class TaskRunner
|
||||
// Intentionally does not accept a CancellationToken: this is the
|
||||
// terminal write for a failed task and must always be persisted.
|
||||
var finishedAt = DateTime.UtcNow;
|
||||
await _taskRepo.MarkFailedAsync(taskId, finishedAt, result.ErrorMarkdown, CancellationToken.None);
|
||||
using var context = _dbFactory.CreateDbContext();
|
||||
var taskRepo = new TaskRepository(context);
|
||||
await taskRepo.MarkFailedAsync(taskId, finishedAt, result.ErrorMarkdown, CancellationToken.None);
|
||||
await _broadcaster.TaskFinished(slot, taskId, "failed", finishedAt);
|
||||
_logger.LogWarning("Task {TaskId} failed (turns={Turns}): {Error}", taskId, result.TurnCount, result.ErrorMarkdown);
|
||||
}
|
||||
@@ -319,7 +361,9 @@ public sealed class TaskRunner
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
// Terminal write — never cancel.
|
||||
await _taskRepo.MarkFailedAsync(taskId, now, error, CancellationToken.None);
|
||||
using var context = _dbFactory.CreateDbContext();
|
||||
var taskRepo = new TaskRepository(context);
|
||||
await taskRepo.MarkFailedAsync(taskId, now, error, CancellationToken.None);
|
||||
await _broadcaster.TaskFinished(slot, taskId, "failed", now);
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user