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:
mika kuns
2026-04-16 08:59:24 +02:00
parent b7be52a623
commit 36484ed45a
18 changed files with 479 additions and 232 deletions

View File

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