From 6a0c0f59a514340063cbea5364c0b8f19ded3b5a Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Mon, 22 Jun 2026 17:22:36 +0200 Subject: [PATCH] feat(attachments): inject reference files into the run + clean up files on delete TaskRunner appends attached files (absolute paths) to the run prompt as the read-only Reference files section. Task and list deletes now remove the on-disk attachment dir eagerly, and a startup AttachmentOrphanRecovery sweep drops any attachments// whose task no longer exists (covers list cascade and planning-discard paths). --- src/ClaudeDo.Data/AttachmentStore.cs | 12 +++ .../Repositories/ListRepository.cs | 13 ++- .../Repositories/TaskRepository.cs | 8 +- .../Lifecycle/AttachmentOrphanRecovery.cs | 51 +++++++++++ src/ClaudeDo.Worker/Program.cs | 2 + src/ClaudeDo.Worker/Runner/TaskRunner.cs | 14 ++- .../RepositoryAttachmentCleanupTests.cs | 85 +++++++++++++++++++ .../External/AddSubtaskToolTests.cs | 2 +- .../External/ExternalMcpServiceTests.cs | 2 +- .../AttachmentOrphanRecoveryTests.cs | 74 ++++++++++++++++ .../Runner/ContinueAsyncExceptionTests.cs | 2 +- .../Runner/StandaloneChildrenRoutingTests.cs | 5 +- .../Runner/StartRunningGuardTests.cs | 3 +- .../Services/QueueServiceSlotGuardTests.cs | 2 +- .../Services/QueueServiceTests.cs | 2 +- 15 files changed, 265 insertions(+), 12 deletions(-) create mode 100644 src/ClaudeDo.Worker/Lifecycle/AttachmentOrphanRecovery.cs create mode 100644 tests/ClaudeDo.Data.Tests/RepositoryAttachmentCleanupTests.cs create mode 100644 tests/ClaudeDo.Worker.Tests/Lifecycle/AttachmentOrphanRecoveryTests.cs diff --git a/src/ClaudeDo.Data/AttachmentStore.cs b/src/ClaudeDo.Data/AttachmentStore.cs index 9219d36..c758f10 100644 --- a/src/ClaudeDo.Data/AttachmentStore.cs +++ b/src/ClaudeDo.Data/AttachmentStore.cs @@ -9,6 +9,18 @@ public sealed class AttachmentStore public AttachmentStore(string? root = null) => _root = root ?? Paths.Expand("~/.todo-app/attachments"); + public string Root => _root; + + public IReadOnlyList EnumerateTaskIds() + { + if (!Directory.Exists(_root)) return Array.Empty(); + return Directory.GetDirectories(_root) + .Select(Path.GetFileName) + .Where(n => n is not null) + .Select(n => n!) + .ToList(); + } + public string TaskDir(string taskId) => Path.Combine(_root, taskId); diff --git a/src/ClaudeDo.Data/Repositories/ListRepository.cs b/src/ClaudeDo.Data/Repositories/ListRepository.cs index 8346875..e8e2d64 100644 --- a/src/ClaudeDo.Data/Repositories/ListRepository.cs +++ b/src/ClaudeDo.Data/Repositories/ListRepository.cs @@ -6,8 +6,13 @@ namespace ClaudeDo.Data.Repositories; public sealed class ListRepository { private readonly ClaudeDoDbContext _context; + private readonly AttachmentStore _attachments; - public ListRepository(ClaudeDoDbContext context) => _context = context; + public ListRepository(ClaudeDoDbContext context, AttachmentStore? attachments = null) + { + _context = context; + _attachments = attachments ?? new AttachmentStore(); + } public async Task AddAsync(ListEntity entity, CancellationToken ct = default) { @@ -23,7 +28,13 @@ public sealed class ListRepository public async Task DeleteAsync(string listId, CancellationToken ct = default) { + var taskIds = await _context.Tasks + .Where(t => t.ListId == listId) + .Select(t => t.Id) + .ToListAsync(ct); await _context.Lists.Where(l => l.Id == listId).ExecuteDeleteAsync(ct); + foreach (var id in taskIds) + _attachments.DeleteTaskDir(id); } public async Task GetByIdAsync(string listId, CancellationToken ct = default) diff --git a/src/ClaudeDo.Data/Repositories/TaskRepository.cs b/src/ClaudeDo.Data/Repositories/TaskRepository.cs index 329c32b..f459819 100644 --- a/src/ClaudeDo.Data/Repositories/TaskRepository.cs +++ b/src/ClaudeDo.Data/Repositories/TaskRepository.cs @@ -7,8 +7,13 @@ namespace ClaudeDo.Data.Repositories; public sealed class TaskRepository { private readonly ClaudeDoDbContext _context; + private readonly AttachmentStore _attachments; - public TaskRepository(ClaudeDoDbContext context) => _context = context; + public TaskRepository(ClaudeDoDbContext context, AttachmentStore? attachments = null) + { + _context = context; + _attachments = attachments ?? new AttachmentStore(); + } #region CRUD @@ -37,6 +42,7 @@ public sealed class TaskRepository public async Task DeleteAsync(string taskId, CancellationToken ct = default) { await _context.Tasks.Where(t => t.Id == taskId).ExecuteDeleteAsync(ct); + _attachments.DeleteTaskDir(taskId); } public async Task GetByIdAsync(string taskId, CancellationToken ct = default) diff --git a/src/ClaudeDo.Worker/Lifecycle/AttachmentOrphanRecovery.cs b/src/ClaudeDo.Worker/Lifecycle/AttachmentOrphanRecovery.cs new file mode 100644 index 0000000..596076c --- /dev/null +++ b/src/ClaudeDo.Worker/Lifecycle/AttachmentOrphanRecovery.cs @@ -0,0 +1,51 @@ +using ClaudeDo.Data; +using Microsoft.EntityFrameworkCore; + +namespace ClaudeDo.Worker.Lifecycle; + +/// +/// Startup-only sweep: deletes attachment directories whose task no longer exists in the DB. +/// +public sealed class AttachmentOrphanRecovery : IHostedService +{ + private readonly IDbContextFactory _dbFactory; + private readonly AttachmentStore _store; + private readonly ILogger _logger; + + public AttachmentOrphanRecovery( + IDbContextFactory dbFactory, + AttachmentStore store, + ILogger logger) + { + _dbFactory = dbFactory; + _store = store; + _logger = logger; + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + var taskIds = _store.EnumerateTaskIds(); + if (taskIds.Count == 0) + { + _logger.LogInformation("Attachment orphan recovery: no attachment directories found"); + return; + } + + await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken); + var existingIds = (await ctx.Tasks + .Where(t => taskIds.Contains(t.Id)) + .Select(t => t.Id) + .ToListAsync(cancellationToken)).ToHashSet(); + + var orphans = taskIds.Where(id => !existingIds.Contains(id)).ToList(); + foreach (var id in orphans) + _store.DeleteTaskDir(id); + + if (orphans.Count > 0) + _logger.LogWarning("Attachment orphan recovery: removed {Count} orphaned attachment director(ies)", orphans.Count); + else + _logger.LogInformation("Attachment orphan recovery: no orphaned attachment directories found"); + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs index ca7a6d7..2c34776 100644 --- a/src/ClaudeDo.Worker/Program.cs +++ b/src/ClaudeDo.Worker/Program.cs @@ -46,8 +46,10 @@ builder.Services.AddDbContextFactory(opt => opt.UseSqlite($"Data Source={cfg.DbPath}")); builder.Services.AddSingleton(cfg); +builder.Services.AddSingleton(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); +builder.Services.AddHostedService(); builder.Services.AddSignalR().AddJsonProtocol(options => { options.PayloadSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter()); diff --git a/src/ClaudeDo.Worker/Runner/TaskRunner.cs b/src/ClaudeDo.Worker/Runner/TaskRunner.cs index 7866445..1b9721e 100644 --- a/src/ClaudeDo.Worker/Runner/TaskRunner.cs +++ b/src/ClaudeDo.Worker/Runner/TaskRunner.cs @@ -21,6 +21,7 @@ public sealed class TaskRunner private readonly ILogger _logger; private readonly ITaskStateService _state; private readonly TaskRunTokenRegistry _tokens; + private readonly AttachmentStore _attachments; public TaskRunner( IClaudeProcess claude, @@ -31,7 +32,8 @@ public sealed class TaskRunner WorkerConfig cfg, ILogger logger, ITaskStateService state, - TaskRunTokenRegistry tokens) + TaskRunTokenRegistry tokens, + AttachmentStore attachments) { _claude = claude; _dbFactory = dbFactory; @@ -42,6 +44,7 @@ public sealed class TaskRunner _logger = logger; _state = state; _tokens = tokens; + _attachments = attachments; } public async Task RunAsync(TaskEntity task, string slot, CancellationToken ct, bool alreadyClaimed = false) @@ -54,6 +57,7 @@ public sealed class TaskRunner ListConfigEntity? listConfig; List subtasks; + List? attachmentPaths = null; using (var context = _dbFactory.CreateDbContext()) { var listRepo = new ListRepository(context); @@ -67,6 +71,11 @@ public sealed class TaskRunner var subtaskRepo = new SubtaskRepository(context); subtasks = await subtaskRepo.GetByTaskIdAsync(task.Id, ct); + + var attachmentRepo = new TaskAttachmentRepository(context); + var attachments = await attachmentRepo.ListByTaskIdAsync(task.Id, ct); + if (attachments.Count > 0) + attachmentPaths = attachments.Select(a => Path.Combine(_attachments.TaskDir(task.Id), a.FileName)).ToList(); } // Determine working directory: worktree or sandbox. @@ -114,7 +123,8 @@ public sealed class TaskRunner // Build prompt: title + description + only the OPEN sub-tasks (resolved ones are dropped). var prompt = TaskPromptComposer.Compose( task.Title, task.Description, - subtasks.Select(s => (s.Title, s.Completed))); + subtasks.Select(s => (s.Title, s.Completed)), + attachmentPaths); // Run 1. var result = await RunOnceAsync(task.Id, task.Title, slot, runDir, resolvedConfig, 1, false, prompt, ct); diff --git a/tests/ClaudeDo.Data.Tests/RepositoryAttachmentCleanupTests.cs b/tests/ClaudeDo.Data.Tests/RepositoryAttachmentCleanupTests.cs new file mode 100644 index 0000000..2c27552 --- /dev/null +++ b/tests/ClaudeDo.Data.Tests/RepositoryAttachmentCleanupTests.cs @@ -0,0 +1,85 @@ +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using Microsoft.EntityFrameworkCore; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Data.Tests; + +/// +/// Verifies that TaskRepository.DeleteAsync and ListRepository.DeleteAsync +/// delete the on-disk attachment directories for the affected tasks. +/// +public sealed class RepositoryAttachmentCleanupTests : IDisposable +{ + private readonly string _dbPath; + private readonly string _attachRoot; + private readonly ClaudeDoDbContext _ctx; + private readonly AttachmentStore _store; + + public RepositoryAttachmentCleanupTests() + { + _dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_repoclean_{Guid.NewGuid():N}.db"); + _attachRoot = Path.Combine(Path.GetTempPath(), $"claudedo_repoclean_att_{Guid.NewGuid():N}"); + Directory.CreateDirectory(_attachRoot); + + var options = new DbContextOptionsBuilder() + .UseSqlite($"Data Source={_dbPath}") + .Options; + _ctx = new ClaudeDoDbContext(options); + _ctx.Database.EnsureCreated(); + _store = new AttachmentStore(_attachRoot); + } + + public void Dispose() + { + _ctx.Dispose(); + foreach (var suffix in new[] { "", "-wal", "-shm" }) + try { File.Delete(_dbPath + suffix); } catch { } + try { Directory.Delete(_attachRoot, recursive: true); } catch { } + } + + private async Task<(string listId, string taskId)> SeedAsync() + { + var listId = Guid.NewGuid().ToString(); + var taskId = Guid.NewGuid().ToString(); + _ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow }); + _ctx.Tasks.Add(new TaskEntity + { + Id = taskId, ListId = listId, Title = "T", + Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, + }); + await _ctx.SaveChangesAsync(); + return (listId, taskId); + } + + [Fact] + public async Task TaskRepository_DeleteAsync_RemovesAttachmentDir() + { + var (_, taskId) = await SeedAsync(); + + var taskDir = _store.TaskDir(taskId); + Directory.CreateDirectory(taskDir); + Assert.True(Directory.Exists(taskDir)); + + var repo = new TaskRepository(_ctx, _store); + await repo.DeleteAsync(taskId); + + Assert.False(Directory.Exists(taskDir)); + } + + [Fact] + public async Task ListRepository_DeleteAsync_RemovesAttachmentDirForEachTask() + { + var (listId, taskId) = await SeedAsync(); + + var taskDir = _store.TaskDir(taskId); + Directory.CreateDirectory(taskDir); + Assert.True(Directory.Exists(taskDir)); + + var repo = new ListRepository(_ctx, _store); + await repo.DeleteAsync(listId); + + Assert.False(Directory.Exists(taskDir)); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/External/AddSubtaskToolTests.cs b/tests/ClaudeDo.Worker.Tests/External/AddSubtaskToolTests.cs index 8fc3ccd..af763dd 100644 --- a/tests/ClaudeDo.Worker.Tests/External/AddSubtaskToolTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/AddSubtaskToolTests.cs @@ -69,7 +69,7 @@ public sealed class AddSubtaskToolTests : IDisposable var argsBuilder = new ClaudeArgsBuilder(); var state = TaskStateServiceBuilder.Build(dbFactory).State; var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, argsBuilder, cfg, - NullLogger.Instance, state, new TaskRunTokenRegistry()); + NullLogger.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore()); var waker = new ClaudeDo.Worker.Queue.QueueWaker(); var picker = new ClaudeDo.Worker.Queue.QueuePicker(dbFactory); var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger.Instance); diff --git a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs index ee79330..31fc768 100644 --- a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs @@ -149,7 +149,7 @@ public sealed class ExternalMcpServiceTests : IDisposable var argsBuilder = new ClaudeArgsBuilder(); var state = TaskStateServiceBuilder.Build(dbFactory).State; var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, argsBuilder, cfg, - NullLogger.Instance, state, new TaskRunTokenRegistry()); + NullLogger.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore()); var waker = new ClaudeDo.Worker.Queue.QueueWaker(); var picker = new ClaudeDo.Worker.Queue.QueuePicker(dbFactory); var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger.Instance); diff --git a/tests/ClaudeDo.Worker.Tests/Lifecycle/AttachmentOrphanRecoveryTests.cs b/tests/ClaudeDo.Worker.Tests/Lifecycle/AttachmentOrphanRecoveryTests.cs new file mode 100644 index 0000000..cbd0a13 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Lifecycle/AttachmentOrphanRecoveryTests.cs @@ -0,0 +1,74 @@ +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Worker.Lifecycle; +using ClaudeDo.Worker.Tests.Infrastructure; +using Microsoft.Extensions.Logging.Abstractions; + +namespace ClaudeDo.Worker.Tests.Lifecycle; + +public sealed class AttachmentOrphanRecoveryTests : IDisposable +{ + private readonly DbFixture _db = new(); + private readonly string _attachRoot; + + public AttachmentOrphanRecoveryTests() + { + _attachRoot = Path.Combine(Path.GetTempPath(), $"claudedo_orph_{Guid.NewGuid():N}"); + Directory.CreateDirectory(_attachRoot); + } + + public void Dispose() + { + _db.Dispose(); + try { Directory.Delete(_attachRoot, recursive: true); } catch { } + } + + [Fact] + public async Task StartAsync_DeletesOrphanDir_KeepsLiveTaskDir() + { + // Seed one real task. + string listId = Guid.NewGuid().ToString(); + string liveTaskId = Guid.NewGuid().ToString(); + using (var ctx = _db.CreateContext()) + { + ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow }); + ctx.Tasks.Add(new TaskEntity + { + Id = liveTaskId, ListId = listId, Title = "T", + Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow, + }); + await ctx.SaveChangesAsync(); + } + + // Create two attachment directories: one for the live task, one orphan. + var store = new AttachmentStore(_attachRoot); + var liveDir = store.TaskDir(liveTaskId); + var orphanId = Guid.NewGuid().ToString(); + var orphanDir = store.TaskDir(orphanId); + Directory.CreateDirectory(liveDir); + Directory.CreateDirectory(orphanDir); + + var sut = new AttachmentOrphanRecovery( + _db.CreateFactory(), store, + NullLogger.Instance); + + await sut.StartAsync(CancellationToken.None); + + Assert.True(Directory.Exists(liveDir), "Live task dir should be kept"); + Assert.False(Directory.Exists(orphanDir), "Orphan dir should be deleted"); + } + + [Fact] + public async Task StartAsync_NoAttachmentRoot_IsNoop() + { + // Use a root that does not exist — should complete without throwing. + var missingRoot = Path.Combine(Path.GetTempPath(), $"claudedo_missing_{Guid.NewGuid():N}"); + var store = new AttachmentStore(missingRoot); + + var sut = new AttachmentOrphanRecovery( + _db.CreateFactory(), store, + NullLogger.Instance); + + await sut.StartAsync(CancellationToken.None); // must not throw + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Runner/ContinueAsyncExceptionTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/ContinueAsyncExceptionTests.cs index 8f96317..4ecdd5b 100644 --- a/tests/ClaudeDo.Worker.Tests/Runner/ContinueAsyncExceptionTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Runner/ContinueAsyncExceptionTests.cs @@ -36,7 +36,7 @@ public sealed class ContinueAsyncExceptionTests : IDisposable var state = TaskStateServiceBuilder.Build(dbFactory).State; var wt = new WorktreeManager(new ClaudeDo.Data.Git.GitService(), dbFactory, _cfg, NullLogger.Instance); return new TaskRunner(claude, dbFactory, broadcaster, wt, new ClaudeArgsBuilder(), _cfg, - NullLogger.Instance, state, new TaskRunTokenRegistry()); + NullLogger.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore()); } [Fact] diff --git a/tests/ClaudeDo.Worker.Tests/Runner/StandaloneChildrenRoutingTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/StandaloneChildrenRoutingTests.cs index 5d3fe6d..b27ad49 100644 --- a/tests/ClaudeDo.Worker.Tests/Runner/StandaloneChildrenRoutingTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Runner/StandaloneChildrenRoutingTests.cs @@ -1,3 +1,4 @@ +using ClaudeDo.Data; using ClaudeDo.Data.Git; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; @@ -44,7 +45,7 @@ public sealed class StandaloneChildrenRoutingTests : IDisposable var state = TaskStateServiceBuilder.Build(dbFactory).State; var wt = new WorktreeManager(new GitService(), dbFactory, _cfg, NullLogger.Instance); var runner = new TaskRunner(fake, dbFactory, broadcaster, wt, new ClaudeArgsBuilder(), _cfg, - NullLogger.Instance, state, new TaskRunTokenRegistry()); + NullLogger.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore()); using (var ctx = _db.CreateContext()) await runner.RunAsync((await new TaskRepository(ctx).GetByIdAsync("p1"))!, "slot-1", default, alreadyClaimed: true); @@ -71,7 +72,7 @@ public sealed class StandaloneChildrenRoutingTests : IDisposable var state = TaskStateServiceBuilder.Build(dbFactory).State; var wt = new WorktreeManager(new GitService(), dbFactory, _cfg, NullLogger.Instance); var runner = new TaskRunner(fake, dbFactory, new HubBroadcaster(new CapturingHubContext()), wt, - new ClaudeArgsBuilder(), _cfg, NullLogger.Instance, state, new TaskRunTokenRegistry()); + new ClaudeArgsBuilder(), _cfg, NullLogger.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore()); using (var ctx = _db.CreateContext()) await runner.RunAsync((await new TaskRepository(ctx).GetByIdAsync("solo"))!, "slot-1", default, alreadyClaimed: true); diff --git a/tests/ClaudeDo.Worker.Tests/Runner/StartRunningGuardTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/StartRunningGuardTests.cs index c30451b..6664b54 100644 --- a/tests/ClaudeDo.Worker.Tests/Runner/StartRunningGuardTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Runner/StartRunningGuardTests.cs @@ -1,3 +1,4 @@ +using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Config; @@ -32,7 +33,7 @@ public sealed class StartRunningGuardTests : IDisposable var state = TaskStateServiceBuilder.Build(dbFactory).State; var wt = new WorktreeManager(new ClaudeDo.Data.Git.GitService(), dbFactory, _cfg, NullLogger.Instance); return new TaskRunner(claude, dbFactory, new HubBroadcaster(new CapturingHubContext()), wt, - new ClaudeArgsBuilder(), _cfg, NullLogger.Instance, state, new TaskRunTokenRegistry()); + new ClaudeArgsBuilder(), _cfg, NullLogger.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore()); } [Fact] diff --git a/tests/ClaudeDo.Worker.Tests/Services/QueueServiceSlotGuardTests.cs b/tests/ClaudeDo.Worker.Tests/Services/QueueServiceSlotGuardTests.cs index be47d60..43674da 100644 --- a/tests/ClaudeDo.Worker.Tests/Services/QueueServiceSlotGuardTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Services/QueueServiceSlotGuardTests.cs @@ -54,7 +54,7 @@ public sealed class QueueServiceSlotGuardTests : IDisposable var argsBuilder = new ClaudeArgsBuilder(); var state = TaskStateServiceBuilder.Build(dbFactory).State; var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, argsBuilder, _cfg, - NullLogger.Instance, state, new TaskRunTokenRegistry()); + NullLogger.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore()); _waker = new QueueWaker(); var picker = new QueuePicker(dbFactory); var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger.Instance); diff --git a/tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs index d0444e9..18cc7b5 100644 --- a/tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs @@ -55,7 +55,7 @@ public sealed class QueueServiceTests : IDisposable var argsBuilder = new ClaudeArgsBuilder(); var state = TaskStateServiceBuilder.Build(dbFactory).State; var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, argsBuilder, _cfg, - NullLogger.Instance, state, new TaskRunTokenRegistry()); + NullLogger.Instance, state, new TaskRunTokenRegistry(), new AttachmentStore()); _waker = new QueueWaker(); var picker = new QueuePicker(dbFactory); var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger.Instance);