From f62dbb9239c672ef7b43bb8a42e453e2da38442c Mon Sep 17 00:00:00 2001 From: mika kuns Date: Fri, 7 Aug 2026 09:13:21 +0200 Subject: [PATCH 01/11] fix(data): give both processes a SQLite busy timeout --- src/ClaudeDo.App/Program.cs | 5 ++++- src/ClaudeDo.Worker/Program.cs | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/ClaudeDo.App/Program.cs b/src/ClaudeDo.App/Program.cs index cb3e95aa..fffebcdc 100644 --- a/src/ClaudeDo.App/Program.cs +++ b/src/ClaudeDo.App/Program.cs @@ -91,8 +91,11 @@ sealed class Program TrExtension.Localizer = localizer; ClaudeDo.Ui.Localization.Loc.Current = localizer; sc.AddSingleton(localizer); + // Default Timeout maps to SQLite's busy handler. Without it a momentarily locked + // database throws SqliteException immediately instead of waiting out the writer, + // which is what leaves task rows stuck on a stale status. sc.AddDbContextFactory(opt => - opt.UseSqlite($"Data Source={dbPath}")); + opt.UseSqlite($"Data Source={dbPath};Default Timeout=30")); sc.AddScoped(sp => sp.GetRequiredService>().CreateDbContext()); diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs index c4fd4ed1..aa2f06fd 100644 --- a/src/ClaudeDo.Worker/Program.cs +++ b/src/ClaudeDo.Worker/Program.cs @@ -57,8 +57,9 @@ builder.Host.UseSerilog((ctx, lc) => lc .WriteTo.Sink(broadcastSink)); builder.Services.AddSingleton(logBuffer); +// See ClaudeDo.App/Program.cs — Default Timeout maps to SQLite's busy handler. builder.Services.AddDbContextFactory(opt => - opt.UseSqlite($"Data Source={cfg.DbPath}")); + opt.UseSqlite($"Data Source={cfg.DbPath};Default Timeout=30")); builder.Services.AddSingleton(cfg); builder.Services.AddSingleton(); From cba7d01c31f9b572327ab2485b0bd9a588ef1d00 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Fri, 7 Aug 2026 09:16:21 +0200 Subject: [PATCH 02/11] fix(ui): retry the task delta refresh instead of swallowing the error --- .../Islands/TasksIslandViewModel.cs | 114 +++++++++------- .../TasksIslandDeltaResilienceTests.cs | 125 ++++++++++++++++++ 2 files changed, 194 insertions(+), 45 deletions(-) create mode 100644 tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandDeltaResilienceTests.cs diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs index c4bba7fc..53e62fbd 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs @@ -161,6 +161,11 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable } private async void OnWorkerTaskUpdated(string taskId) + => await RefreshTaskFromWorkerAsync(taskId); + + // Awaitable so tests can drive it deterministically. One retry, then a full reload: + // a swallowed exception here used to leave the row on a stale status permanently. + internal async Task RefreshTaskFromWorkerAsync(string taskId) { var list = _currentList; if (list is null) return; @@ -176,52 +181,71 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable try { - await using var db = await _dbFactory.CreateDbContextAsync(); - var entity = await db.Tasks - .Include(t => t.List) - .Include(t => t.Worktree) - .FirstOrDefaultAsync(t => t.Id == taskId); - - // A parent transition (finalize/discard) broadcasts only the parent's id, but it - // changes its children's derived state — finalize flips them Draft→Planned, discard - // deletes them. The delta path below only touches the parent row and never recomputes - // the child-derived flags (ParentFinalized, HasPlanningChildren) nor drops deleted - // children, so reconcile the whole list when the updated task is (or owns) a subtree. - if (entity is not null && - (entity.PlanningPhase != PlanningPhase.None || Items.Any(r => r.ParentTaskId == entity.Id))) - { - LoadForList(list); - return; - } - - var existing = Items.FirstOrDefault(r => r.Id == taskId); - - if (entity is null) - { - if (existing is not null) Items.Remove(existing); - } - else - { - var matches = TaskMatchesList(entity, list); - if (existing is not null && matches) existing.UpdateFromEntity(entity); - else if (existing is not null) Items.Remove(existing); - else if (matches) { LoadForList(list); return; } - else return; - } - - // Keep the parent's HasQueuedSubtasks flag in sync when a child's status flips. - if (entity is not null && !string.IsNullOrEmpty(entity.ParentTaskId)) - { - var parent = Items.FirstOrDefault(r => r.Id == entity.ParentTaskId); - if (parent is not null) - parent.HasQueuedSubtasks = Items.Any(r => - r.ParentTaskId == parent.Id && (r.IsQueued || r.IsWaiting)); - } - - Regroup(); - UpdateSubtitle(); + await ApplyDeltaAsync(taskId, list); } - catch { } + catch (Exception first) + { + System.Diagnostics.Debug.WriteLine( + $"TasksIsland: delta refresh for {taskId} failed ({first.Message}); retrying"); + try + { + await ApplyDeltaAsync(taskId, list); + } + catch (Exception second) + { + System.Diagnostics.Debug.WriteLine( + $"TasksIsland: delta retry for {taskId} failed ({second.Message}); full reload"); + LoadForList(list); + } + } + } + + private async Task ApplyDeltaAsync(string taskId, ListNavItemViewModel list) + { + await using var db = await _dbFactory.CreateDbContextAsync(); + var entity = await db.Tasks + .Include(t => t.List) + .Include(t => t.Worktree) + .FirstOrDefaultAsync(t => t.Id == taskId); + + // A parent transition (finalize/discard) broadcasts only the parent's id, but it + // changes its children's derived state — finalize flips them Draft→Planned, discard + // deletes them. The delta path below only touches the parent row and never recomputes + // the child-derived flags (ParentFinalized, HasPlanningChildren) nor drops deleted + // children, so reconcile the whole list when the updated task is (or owns) a subtree. + if (entity is not null && + (entity.PlanningPhase != PlanningPhase.None || Items.Any(r => r.ParentTaskId == entity.Id))) + { + LoadForList(list); + return; + } + + var existing = Items.FirstOrDefault(r => r.Id == taskId); + + if (entity is null) + { + if (existing is not null) Items.Remove(existing); + } + else + { + var matches = TaskMatchesList(entity, list); + if (existing is not null && matches) existing.UpdateFromEntity(entity); + else if (existing is not null) Items.Remove(existing); + else if (matches) { LoadForList(list); return; } + else return; + } + + // Keep the parent's HasQueuedSubtasks flag in sync when a child's status flips. + if (entity is not null && !string.IsNullOrEmpty(entity.ParentTaskId)) + { + var parent = Items.FirstOrDefault(r => r.Id == entity.ParentTaskId); + if (parent is not null) + parent.HasQueuedSubtasks = Items.Any(r => + r.ParentTaskId == parent.Id && (r.IsQueued || r.IsWaiting)); + } + + Regroup(); + UpdateSubtitle(); } // NOTE: virtual:queued/virtual:running cannot be decided by a single entity — a Planning diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandDeltaResilienceTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandDeltaResilienceTests.cs new file mode 100644 index 00000000..1d7548bd --- /dev/null +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandDeltaResilienceTests.cs @@ -0,0 +1,125 @@ +using ClaudeDo.Data; +using ClaudeDo.Data.Models; +using ClaudeDo.Ui.ViewModels.Islands; +using Microsoft.EntityFrameworkCore; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Ui.Tests.ViewModels; + +// The delta path in OnWorkerTaskUpdated used to be wrapped in a blank `catch { }`. A single +// transient DB error therefore left the row on its old status forever — the "task stuck on +// Queued although it is running" bug. It must retry, and fall back to a full reload. +public class TasksIslandDeltaResilienceTests : IDisposable +{ + private readonly string _dbPath; + + public TasksIslandDeltaResilienceTests() + { + _dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_ui_delta_{Guid.NewGuid():N}.db"); + using var ctx = NewContext(); + ctx.Database.EnsureCreated(); + } + + public void Dispose() + { + try { File.Delete(_dbPath); } catch { } + try { File.Delete(_dbPath + "-wal"); } catch { } + try { File.Delete(_dbPath + "-shm"); } catch { } + } + + private ClaudeDoDbContext NewContext() + { + var opts = new DbContextOptionsBuilder() + .UseSqlite($"Data Source={_dbPath}") + .Options; + return new ClaudeDoDbContext(opts); + } + + // Throws on the first N CreateDbContext calls, then behaves normally. + private sealed class FlakyDbFactory : IDbContextFactory + { + private readonly Func _create; + private int _failuresLeft; + public int CreateCalls { get; private set; } + + public FlakyDbFactory(Func create, int failuresLeft) + { + _create = create; + _failuresLeft = failuresLeft; + } + + public ClaudeDoDbContext CreateDbContext() + { + CreateCalls++; + if (_failuresLeft > 0) + { + _failuresLeft--; + throw new InvalidOperationException("simulated transient DB failure"); + } + return _create(); + } + + public void FailNext() => _failuresLeft++; + } + + private sealed class FakeWorker : StubWorkerClient + { + } + + // A user list's nav id is prefixed — see TasksIslandRegroupTests.UserList. + private static ListNavItemViewModel UserList(string listEntityId, string name) => + new() { Id = $"user:{listEntityId}", Kind = ListKind.User, Name = name }; + + // LoadForList is void and fires a background task; this is the wait idiom the other + // TasksIsland test files use. + private static async Task LoadAndWaitAsync(TasksIslandViewModel vm, ListNavItemViewModel list) + { + vm.LoadForList(list); + var deadline = DateTime.UtcNow.AddSeconds(5); + while (DateTime.UtcNow < deadline) + { + await Task.Delay(25); + if (vm.Items.Count > 0) break; + } + await Task.Delay(50); + } + + private async Task SeedAsync() + { + await using var db = NewContext(); + db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow }); + db.Tasks.Add(new TaskEntity + { + Id = "T1", ListId = "L1", Title = "Task one", + Status = TaskStatus.Queued, CreatedAt = DateTime.UtcNow, SortOrder = 0, + }); + await db.SaveChangesAsync(); + } + + [Fact] + public async Task Delta_refresh_retries_after_a_transient_failure_and_still_applies_the_new_status() + { + await SeedAsync(); + + var flaky = new FlakyDbFactory(NewContext, failuresLeft: 0); + var vm = new TasksIslandViewModel(flaky, new FakeWorker()); + var list = UserList("L1", "Work"); + + await LoadAndWaitAsync(vm, list); + Assert.Equal(TaskStatus.Queued, vm.Items.Single(r => r.Id == "T1").Status); + + // Worker flips the task to Running. + await using (var db = NewContext()) + { + var t = await db.Tasks.FirstAsync(x => x.Id == "T1"); + t.Status = TaskStatus.Running; + await db.SaveChangesAsync(); + } + + // The next delta read fails once; the retry must still land the new status. + flaky.FailNext(); + await vm.RefreshTaskFromWorkerAsync("T1"); + + Assert.Equal(TaskStatus.Running, vm.Items.Single(r => r.Id == "T1").Status); + } +} From ac099dd1a83de63c705f87c7a46a39b02be1a02f Mon Sep 17 00:00:00 2001 From: mika kuns Date: Fri, 7 Aug 2026 09:18:52 +0200 Subject: [PATCH 03/11] fix(ui): drop stale delta refreshes so the newest task state wins --- .../Islands/TasksIslandViewModel.cs | 16 +++++++++-- .../TasksIslandDeltaResilienceTests.cs | 28 +++++++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs index 53e62fbd..6ccd42f9 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs @@ -25,6 +25,10 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable // pick the flag up (see SyncInteractiveSessions). private readonly HashSet _interactiveSessionIds = new(); private static readonly TaskListFilterRegistry _filters = new(); + // Two events (TaskUpdated + WorktreeUpdated) drive the same delta refresh, so two reads for + // one task can be in flight at once. Only the newest may write to the row. + private readonly Dictionary _deltaSeq = new(); + private long _deltaCounter; public event EventHandler? SelectionChanged; public event EventHandler? FocusAddTaskRequested; @@ -179,9 +183,12 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable return; } + var seq = ++_deltaCounter; + _deltaSeq[taskId] = seq; + try { - await ApplyDeltaAsync(taskId, list); + await ApplyDeltaAsync(taskId, list, seq); } catch (Exception first) { @@ -189,7 +196,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable $"TasksIsland: delta refresh for {taskId} failed ({first.Message}); retrying"); try { - await ApplyDeltaAsync(taskId, list); + await ApplyDeltaAsync(taskId, list, seq); } catch (Exception second) { @@ -200,7 +207,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable } } - private async Task ApplyDeltaAsync(string taskId, ListNavItemViewModel list) + private async Task ApplyDeltaAsync(string taskId, ListNavItemViewModel list, long seq) { await using var db = await _dbFactory.CreateDbContextAsync(); var entity = await db.Tasks @@ -208,6 +215,9 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable .Include(t => t.Worktree) .FirstOrDefaultAsync(t => t.Id == taskId); + // A newer refresh for this task started while we were reading — its result is fresher. + if (_deltaSeq.TryGetValue(taskId, out var current) && current != seq) return; + // A parent transition (finalize/discard) broadcasts only the parent's id, but it // changes its children's derived state — finalize flips them Draft→Planned, discard // deletes them. The delta path below only touches the parent row and never recomputes diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandDeltaResilienceTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandDeltaResilienceTests.cs index 1d7548bd..99267f15 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandDeltaResilienceTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandDeltaResilienceTests.cs @@ -122,4 +122,32 @@ public class TasksIslandDeltaResilienceTests : IDisposable Assert.Equal(TaskStatus.Running, vm.Items.Single(r => r.Id == "T1").Status); } + + [Fact] + public async Task A_stale_delta_result_does_not_overwrite_a_newer_one() + { + await SeedAsync(); + + var factory = new FlakyDbFactory(NewContext, failuresLeft: 0); + var vm = new TasksIslandViewModel(factory, new FakeWorker()); + var list = UserList("L1", "Work"); + await LoadAndWaitAsync(vm, list); + + // Start refresh #1 while the DB still says Queued, but do not await it yet. + var first = vm.RefreshTaskFromWorkerAsync("T1"); + + await using (var db = NewContext()) + { + var t = await db.Tasks.FirstAsync(x => x.Id == "T1"); + t.Status = TaskStatus.Running; + await db.SaveChangesAsync(); + } + + // Refresh #2 sees Running and must win, regardless of completion order. + var second = vm.RefreshTaskFromWorkerAsync("T1"); + + await Task.WhenAll(first, second); + + Assert.Equal(TaskStatus.Running, vm.Items.Single(r => r.Id == "T1").Status); + } } From c1184adc92ce557428202ca545760ad7d0ffe744 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Fri, 7 Aug 2026 09:35:26 +0200 Subject: [PATCH 04/11] fix(worker): fail the task when a queue slot runner throws RunInSlotAsync only logged an unexpected exception, leaving a task stuck Running in the DB forever with the UI never notified (the raw-SQL queue claim that put it there never broadcasts). Cancellation is handled separately and left alone, since the cancel path already wrote the terminal status. --- src/ClaudeDo.Worker/Queue/QueueService.cs | 19 ++ .../Services/QueueServiceSlotFailureTests.cs | 216 ++++++++++++++++++ 2 files changed, 235 insertions(+) create mode 100644 tests/ClaudeDo.Worker.Tests/Services/QueueServiceSlotFailureTests.cs diff --git a/src/ClaudeDo.Worker/Queue/QueueService.cs b/src/ClaudeDo.Worker/Queue/QueueService.cs index b01a6ec9..6bede73e 100644 --- a/src/ClaudeDo.Worker/Queue/QueueService.cs +++ b/src/ClaudeDo.Worker/Queue/QueueService.cs @@ -343,9 +343,28 @@ public sealed class QueueService : BackgroundService await _runner.RunAsync(task, "queue", ct, alreadyClaimed: true); } + catch (OperationCanceledException) + { + // Cancellation is driven by the cancel path, which already wrote the terminal status. + // Marking the task Failed here would be a regression (it would stomp Cancelled). + _logger.LogInformation("Slot runner cancelled for task {TaskId}", taskId); + } catch (Exception ex) { _logger.LogError(ex, "Slot runner error for task {TaskId}", taskId); + + // The picker already committed status='running' before this ran. Without this the + // task stays Running forever and the UI never hears about it — it keeps showing the + // pre-claim status because the raw-SQL claim itself never broadcasts. + try + { + await _state.FailAsync(taskId, DateTime.UtcNow, + $"Slot runner error: {ex.Message}", CancellationToken.None); + } + catch (Exception failEx) + { + _logger.LogError(failEx, "Could not mark task {TaskId} as failed after a slot error", taskId); + } } } } diff --git a/tests/ClaudeDo.Worker.Tests/Services/QueueServiceSlotFailureTests.cs b/tests/ClaudeDo.Worker.Tests/Services/QueueServiceSlotFailureTests.cs new file mode 100644 index 00000000..82558c39 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Services/QueueServiceSlotFailureTests.cs @@ -0,0 +1,216 @@ +using ClaudeDo.Data; +using ClaudeDo.Data.Git; +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Config; +using ClaudeDo.Worker.Hub; +using ClaudeDo.Worker.Queue; +using ClaudeDo.Worker.Runner; +using ClaudeDo.Worker.Tests.Infrastructure; +using ClaudeDo.Worker.Usage; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Logging.Abstractions; +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.Tests.Services; + +// The queue picker's raw-SQL claim commits status='running' before the runner starts. If +// anything then throws before the runner's own terminal-status write, the task used to stay +// Running forever with the UI never notified (RunInSlotAsync's catch only logged the error). +// It must now mark the task Failed for a real exception (which broadcasts TaskUpdated), but +// must NOT do so for a cancellation — the cancel path already wrote the terminal status. +// +// These drive the real QueueService end to end (StartAsync + the waker), not just the +// FailAsync contract, so they actually exercise the fixed catch block. +public sealed class QueueServiceSlotFailureTests : IDisposable +{ + private readonly DbFixture _db = new(); + private readonly string _tempDir; + private readonly WorkerConfig _cfg; + + public QueueServiceSlotFailureTests() + { + _tempDir = Path.Combine(Path.GetTempPath(), $"claudedo_slotfail_{Guid.NewGuid():N}"); + Directory.CreateDirectory(_tempDir); + _cfg = new WorkerConfig + { + SandboxRoot = Path.Combine(_tempDir, "sandbox"), + LogRoot = Path.Combine(_tempDir, "logs"), + QueueBackstopIntervalMs = 50, // fast for tests + }; + } + + public void Dispose() + { + _db.Dispose(); + try { Directory.Delete(_tempDir, true); } catch { } + } + + // Mirrors QueueServiceTests.CreateService but takes the picker as a parameter so each test + // can engineer the exact failure path it needs to exercise. + // Build() wires its own CapturingHubContext internally and hands it back as .Hub — the + // broadcaster inside TaskStateService (and therefore FailAsync's TaskUpdated) uses that + // exact instance, so everything else here must share it too rather than constructing a + // second CapturingHubContext that would silently miss FailAsync's broadcast. + private (QueueService service, CapturingHubContext hub, QueueWaker waker) CreateService(IQueuePicker picker) + { + var dbFactory = _db.CreateFactory(); + var built = TaskStateServiceBuilder.Build(dbFactory); + var broadcaster = new HubBroadcaster(built.Hub); + var wtManager = new WorktreeManager(new GitService(), dbFactory, _cfg, NullLogger.Instance); + var argsBuilder = new ClaudeArgsBuilder(); + var runner = new TaskRunner(new FakeClaudeProcess(), dbFactory, broadcaster, wtManager, argsBuilder, _cfg, + NullLogger.Instance, built.State, new TaskRunTokenRegistry(), new AttachmentStore(), + new FakeSessionSkillSeeder(), new FakeTranscriptUsageReader()); + var waker = new QueueWaker(); + var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger.Instance, built.RunCancels); + var service = new QueueService(dbFactory, runner, _cfg, NullLogger.Instance, waker, picker, + overrideSlot, built.State, built.RunCancels, new FakeUsageGate(), new UsageState(), broadcaster); + return (service, built.Hub, waker); + } + + private async Task SeedListAsync() + { + var listId = Guid.NewGuid().ToString(); + using var ctx = _db.CreateContext(); + ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow }); + await ctx.SaveChangesAsync(); + return listId; + } + + // Directly rewrites the task's list_id via a raw connection with FK enforcement off, + // reproducing "the list vanished between the queue claim and the run" without going + // through EF's foreign-key-checked connections (which would reject the write). + private void OrphanTaskListId(string taskId) + { + using var conn = new SqliteConnection($"Data Source={_db.DbPath}"); + conn.Open(); + using (var pragmaCmd = conn.CreateCommand()) + { + pragmaCmd.CommandText = "PRAGMA foreign_keys=OFF;"; + pragmaCmd.ExecuteNonQuery(); + } + using var cmd = conn.CreateCommand(); + cmd.CommandText = "UPDATE tasks SET list_id = 'orphaned-missing-list' WHERE id = $id;"; + cmd.Parameters.AddWithValue("$id", taskId); + cmd.ExecuteNonQuery(); + } + + [Fact] + public async Task A_throwing_slot_run_marks_the_task_Failed_and_broadcasts_TaskUpdated() + { + var listId = await SeedListAsync(); + var taskId = Guid.NewGuid().ToString(); + + using (var ctx = _db.CreateContext()) + { + ctx.Tasks.Add(new TaskEntity + { + Id = taskId, ListId = listId, Title = "T", Status = TaskStatus.Queued, + ReviewFeedback = "please fix", CreatedAt = DateTime.UtcNow, + }); + await ctx.SaveChangesAsync(); + + // A prior run with a session id routes RunInSlotAsync into TaskRunner.ContinueAsync + // instead of RunAsync. + await new TaskRunRepository(ctx).AddAsync(new TaskRunEntity + { + Id = Guid.NewGuid().ToString(), TaskId = taskId, RunNumber = 1, IsRetry = false, + Prompt = "original", SessionId = "sess-1", StartedAt = DateTime.UtcNow.AddMinutes(-5), + }); + } + + // ContinueAsync's setup block reads the list *before* its own try/catch starts + // (TaskRunner.cs, ContinueAsync ~line 232-234) and throws InvalidOperationException + // ("List not found.") straight past TaskRunner's own protection. That's the exact gap + // QueueService.RunInSlotAsync's own catch now has to cover. + OrphanTaskListId(taskId); + + var (service, hub, waker) = CreateService(new QueuePicker(_db.CreateFactory())); + + using var cts = new CancellationTokenSource(); + await service.StartAsync(cts.Token); + waker.Wake(); + + TaskEntity? reloaded = null; + var deadline = DateTime.UtcNow.AddSeconds(10); + while (DateTime.UtcNow < deadline) + { + using var verify = _db.CreateContext(); + reloaded = await new TaskRepository(verify).GetByIdAsync(taskId); + if (reloaded!.Status == TaskStatus.Failed) break; + await Task.Delay(25); + } + cts.Cancel(); + + Assert.Equal(TaskStatus.Failed, reloaded!.Status); + Assert.Contains(hub.Proxy.Calls, + c => c.Method == "TaskUpdated" && (string)c.Args[0]! == taskId); + } + + // A fake IQueuePicker that performs the real atomic claim (so the DB row transitions + // Queued->Running exactly like production) and then, synchronously before returning, + // cancels the token QueueService's per-slot CTS is linked from. By the time + // QueueService.ExecuteAsync creates that linked CTS and dispatches RunInSlotAsync, the + // token is already cancelled — deterministic, no timing race required. + private sealed class ClaimThenCancelPicker : IQueuePicker + { + private readonly IQueuePicker _inner; + private readonly CancellationTokenSource _cancelAfterClaim; + + public ClaimThenCancelPicker(IQueuePicker inner, CancellationTokenSource cancelAfterClaim) + { + _inner = inner; + _cancelAfterClaim = cancelAfterClaim; + } + + public async Task ClaimNextAsync(DateTime now, CancellationToken ct) + { + var claimed = await _inner.ClaimNextAsync(now, ct); + if (claimed is not null) _cancelAfterClaim.Cancel(); + return claimed; + } + } + + [Fact] + public async Task A_cancelled_slot_run_does_not_mark_the_task_Failed() + { + var listId = await SeedListAsync(); + var taskId = Guid.NewGuid().ToString(); + + using (var ctx = _db.CreateContext()) + { + ctx.Tasks.Add(new TaskEntity + { + Id = taskId, ListId = listId, Title = "T", Status = TaskStatus.Queued, + CreatedAt = DateTime.UtcNow, + }); + await ctx.SaveChangesAsync(); + } + + var outerCts = new CancellationTokenSource(); + var realPicker = new QueuePicker(_db.CreateFactory()); + var picker = new ClaimThenCancelPicker(realPicker, outerCts); + var (service, hub, waker) = CreateService(picker); + + await service.StartAsync(outerCts.Token); + waker.Wake(); + + // Wait for the slot to be claimed and then released again (RunInSlotAsync's + // ContinueWith removes it once the catch block — ours or a stray one — finishes). + var deadline = DateTime.UtcNow.AddSeconds(10); + while (service.GetActive().Any(a => a.taskId == taskId) && DateTime.UtcNow < deadline) + await Task.Delay(25); + await Task.Delay(100); // let the fire-and-forget continuation fully settle + + TaskEntity? reloaded; + using (var verify = _db.CreateContext()) + reloaded = await new TaskRepository(verify).GetByIdAsync(taskId); + + // The picker's atomic claim already flipped it to Running; the cancelled slot run must + // leave it there rather than flipping it to Failed. + Assert.Equal(TaskStatus.Running, reloaded!.Status); + Assert.DoesNotContain(hub.Proxy.Calls, + c => c.Method == "TaskUpdated" && (string)c.Args[0]! == taskId); + } +} From a7a3545e2bfc11fca2d8fc7d1e5de99eb897a4c6 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Fri, 7 Aug 2026 09:40:56 +0200 Subject: [PATCH 05/11] fix(worker): broadcast WorktreeUpdated when a worktree is created --- src/ClaudeDo.Worker/Runner/TaskRunner.cs | 3 ++ .../QueueClaimTaskUpdatedBroadcastTests.cs | 48 +++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/src/ClaudeDo.Worker/Runner/TaskRunner.cs b/src/ClaudeDo.Worker/Runner/TaskRunner.cs index 423ec493..a4712b48 100644 --- a/src/ClaudeDo.Worker/Runner/TaskRunner.cs +++ b/src/ClaudeDo.Worker/Runner/TaskRunner.cs @@ -311,6 +311,9 @@ public sealed class TaskRunner { var wtCtx = await _wtManager.CreateAsync(task, list, ct); await _broadcaster.WorkerLog($"Created worktree for \"{task.Title}\"", WorkerLogLevel.Info, DateTime.UtcNow); + // The worktrees row was just inserted; without this the UI keeps showing the task + // as having no worktree until some unrelated event happens to refresh it. + await _broadcaster.WorktreeUpdated(task.Id); return new RunDirResult(wtCtx.WorktreePath, wtCtx, null); } catch (Exception ex) diff --git a/tests/ClaudeDo.Worker.Tests/Runner/QueueClaimTaskUpdatedBroadcastTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/QueueClaimTaskUpdatedBroadcastTests.cs index ecee41dd..83b4e465 100644 --- a/tests/ClaudeDo.Worker.Tests/Runner/QueueClaimTaskUpdatedBroadcastTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Runner/QueueClaimTaskUpdatedBroadcastTests.cs @@ -121,4 +121,52 @@ public sealed class QueueClaimTaskUpdatedBroadcastTests : IDisposable releaseProcess.TrySetResult(); await runTask; } + + [Fact] + public async Task Creating_a_worktree_broadcasts_WorktreeUpdated() + { + string listId = Guid.NewGuid().ToString(), taskId = Guid.NewGuid().ToString(); + var repoDir = Path.Combine(_tempDir, "repo"); + Directory.CreateDirectory(repoDir); + + // A real git repo — Worker.Tests run real git by design. + await RunGitAsync(repoDir, "init"); + await RunGitAsync(repoDir, "config user.email t@t.t"); + await RunGitAsync(repoDir, "config user.name t"); + await File.WriteAllTextAsync(Path.Combine(repoDir, "a.txt"), "hi"); + await RunGitAsync(repoDir, "add a.txt"); + await RunGitAsync(repoDir, "commit -m init"); + + using (var ctx = _db.CreateContext()) + { + ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", WorkingDir = repoDir, CreatedAt = DateTime.UtcNow }); + ctx.Tasks.Add(new TaskEntity + { + Id = taskId, ListId = listId, Title = "T", Status = TaskStatus.Running, + StartedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow, + }); + await ctx.SaveChangesAsync(); + } + + var fake = new FakeClaudeProcess((_, _, _, _, _) => + Task.FromResult(new RunResult { ExitCode = 0, ResultMarkdown = "ok" })); + var runner = BuildRunner(fake); + + using (var ctx = _db.CreateContext()) + await runner.RunAsync((await new TaskRepository(ctx).GetByIdAsync(taskId))!, "queue", + CancellationToken.None, alreadyClaimed: true); + + Assert.Contains(_hubContext.Proxy.Calls, + c => c.Method == "WorktreeUpdated" && (string)c.Args[0]! == taskId); + } + + private static async Task RunGitAsync(string dir, string args) + { + var psi = new System.Diagnostics.ProcessStartInfo("git", args) + { + WorkingDirectory = dir, RedirectStandardOutput = true, RedirectStandardError = true, + }; + using var p = System.Diagnostics.Process.Start(psi)!; + await p.WaitForExitAsync(); + } } From 3acb1cba8f2fb0c6f55a4dc931d918a7a233ed9d Mon Sep 17 00:00:00 2001 From: mika kuns Date: Fri, 7 Aug 2026 09:46:43 +0200 Subject: [PATCH 06/11] fix(worker): broadcast TaskUpdated after online-inbox import --- .../Online/OnlineSyncService.cs | 8 +++++- .../Online/OnlineSyncServiceTests.cs | 27 +++++++++++++++++-- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/src/ClaudeDo.Worker/Online/OnlineSyncService.cs b/src/ClaudeDo.Worker/Online/OnlineSyncService.cs index 3afb1e58..99e5c665 100644 --- a/src/ClaudeDo.Worker/Online/OnlineSyncService.cs +++ b/src/ClaudeDo.Worker/Online/OnlineSyncService.cs @@ -1,6 +1,7 @@ using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Online.Interfaces; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Hosting; @@ -15,19 +16,22 @@ public sealed class OnlineSyncService : BackgroundService private readonly IOnlineAuthProvider _auth; private readonly OnlineInboxConfig _config; private readonly ILogger _logger; + private readonly HubBroadcaster _broadcaster; public OnlineSyncService( IDbContextFactory dbFactory, IOnlineInboxApi api, IOnlineAuthProvider auth, OnlineInboxConfig config, - ILogger logger) + ILogger logger, + HubBroadcaster broadcaster) { _dbFactory = dbFactory; _api = api; _auth = auth; _config = config; _logger = logger; + _broadcaster = broadcaster; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) @@ -129,6 +133,8 @@ public sealed class OnlineSyncService : BackgroundService CommitType = CommitTypeRegistry.DefaultType, }; await tasks.AddAsync(entity, ct); + // Without this the imported task only shows up after a manual reload. + await _broadcaster.TaskUpdated(entity.Id); await _api.MarkImportedAsync(remote.Id, ct); _logger.LogInformation("OnlineSyncService: imported task {Id} ('{Title}')", remote.Id, remote.Title); diff --git a/tests/ClaudeDo.Worker.Tests/Online/OnlineSyncServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Online/OnlineSyncServiceTests.cs index 1edbe5c0..ca2bce98 100644 --- a/tests/ClaudeDo.Worker.Tests/Online/OnlineSyncServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Online/OnlineSyncServiceTests.cs @@ -1,6 +1,7 @@ using ClaudeDo.Data; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Online; using ClaudeDo.Worker.Tests.Infrastructure; using Microsoft.Extensions.Logging.Abstractions; @@ -55,7 +56,8 @@ public sealed class OnlineSyncServiceTests : IDisposable } } - private OnlineSyncService BuildService(FakeApi api, string? token = "test-token", bool enabled = true) + private OnlineSyncService BuildService( + FakeApi api, string? token = "test-token", bool enabled = true, HubBroadcaster? broadcaster = null) { var config = new OnlineInboxConfig { Enabled = enabled, PollIntervalSeconds = 60 }; var auth = new StaticTokenAuthProvider(token); @@ -64,7 +66,8 @@ public sealed class OnlineSyncServiceTests : IDisposable api, auth, config, - NullLogger.Instance); + NullLogger.Instance, + broadcaster ?? new HubBroadcaster(new CapturingHubContext())); } private async Task<(string ListId, ClaudeDoDbContext Ctx, TaskRepository Tasks, ListRepository Lists)> SeedAsync() @@ -103,6 +106,26 @@ public sealed class OnlineSyncServiceTests : IDisposable Assert.Contains(remoteId, api.MarkedImported); } + [Fact] + public async Task Tick_Imports_RemoteTask_BroadcastsTaskUpdated() + { + var (listId, ctx, _, _) = await SeedAsync(); + using var _ = ctx; + + var remoteId = Guid.NewGuid().ToString(); + var api = new FakeApi + { + UnimportedTasks = [new RemoteTask(remoteId, listId, "From Web", "desc", DateTimeOffset.UtcNow)], + }; + var hubContext = new CapturingHubContext(); + var svc = BuildService(api, broadcaster: new HubBroadcaster(hubContext)); + + await svc.TickAsync(CancellationToken.None); + + Assert.Contains(hubContext.Proxy.Calls, + c => c.Method == "TaskUpdated" && (string)c.Args[0]! == remoteId); + } + [Fact] public async Task Tick_UnknownList_Skips_And_DoesNotMark() { From 1e383e1c1fdb96cf28a50a7de486252086bd8cb5 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Fri, 7 Aug 2026 09:50:54 +0200 Subject: [PATCH 07/11] docs(plans): drop task 7, the handler-task broadcast already exists at the hub --- .../2026-08-07-phase1-reaktivitaets-loecher.md | 15 +++++++++++++-- ...-reaktivitaet-und-listen-performance-design.md | 5 +++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/plans/2026-08-07-phase1-reaktivitaets-loecher.md b/docs/superpowers/plans/2026-08-07-phase1-reaktivitaets-loecher.md index 65ec1793..199d3edd 100644 --- a/docs/superpowers/plans/2026-08-07-phase1-reaktivitaets-loecher.md +++ b/docs/superpowers/plans/2026-08-07-phase1-reaktivitaets-loecher.md @@ -869,9 +869,18 @@ git commit -m "fix(worker): broadcast TaskUpdated for tasks imported from the on --- -### Task 7: `TaskUpdated` nach dem Anlegen der List-Handler-Task +### Task 7: ~~`TaskUpdated` nach dem Anlegen der List-Handler-Task~~ — ENTFÄLLT -`InteractiveLaunchSpecService` legt die Handler-Task an (`InteractiveLaunchSpecService.cs:447`) ohne Broadcast. Einzige weitere Aufrufstelle des Konstruktors ist `tests/ClaudeDo.Worker.Tests/Hub/MergeHelperTaskHubTests.cs:64`. +**Bei der Umsetzung am 2026-08-07 verworfen. Prämisse war falsch, kein Code geändert.** + +`InteractiveLaunchSpecService.CreateMergeHelperTaskAsync` (`:447`) broadcastet selbst nichts — das stimmte. Aber ihr **einziger** Produktions-Aufrufer, `WorkerHub.CreateMergeHelperTask` (`src/ClaudeDo.Worker/Hub/WorkerHub.cs:818`), sendet unmittelbar danach `Clients.All.SendAsync("TaskUpdated", taskId)`. Das kam mit Commit `c07c1f7` (2026-08-05) und ist durch `MergeHelperTaskHubTests.CreateMergeHelperTask_CreatesIdleManualTask_StampsBaseCommit_Broadcasts` abgesichert. Der UI-Pfad (`MissionControlViewModel` → `WorkerClient.CreateMergeHelperTaskAsync` → Hub) führt ausschließlich über diesen Aufrufer. + +Den Broadcast zusätzlich in den Service zu legen hätte ihn **verdoppelt**. Ihn dorthin zu *verschieben* wäre ein reiner Konsistenz-Refactor ohne Verhaltensänderung — bewusst nicht gemacht. + +Lehre für den Rest des Plans: Ein DB-Write ohne unmittelbar folgenden Broadcast ist erst dann ein Loch, wenn auch **alle Aufrufer** geprüft sind. Bei `WorktreeManager.cs:103` (Task 5) war das Loch echt, hier nicht. + +
+Ursprünglicher Task-Text (nicht umgesetzt) **Files:** - Modify: `src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs` @@ -952,6 +961,8 @@ git add src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs tests/ClaudeD git commit -m "fix(worker): broadcast TaskUpdated when the list-handler task is created" -- src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs tests/ClaudeDo.Worker.Tests/Hub/MergeHelperTaskHubTests.cs ``` +
+ --- ### Task 8: Totes Event `RunCreated` entfernen diff --git a/docs/superpowers/specs/2026-08-07-ui-reaktivitaet-und-listen-performance-design.md b/docs/superpowers/specs/2026-08-07-ui-reaktivitaet-und-listen-performance-design.md index 01275a0e..ad4e994e 100644 --- a/docs/superpowers/specs/2026-08-07-ui-reaktivitaet-und-listen-performance-design.md +++ b/docs/superpowers/specs/2026-08-07-ui-reaktivitaet-und-listen-performance-design.md @@ -22,7 +22,9 @@ Das eigentliche Problem: **ein einziger verlorener Event ist permanent.** Der ei |---|---|---| | 1 | Blankes `catch { }` um den gesamten Delta-Pfad. Eine einzige transiente Exception (z.B. `SQLITE_BUSY`) lässt die Zeile dauerhaft auf dem alten Stand — ohne Log, ohne Retry. | `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs:224` | | 2 | `QueuePicker.ClaimNextAsync` committet `status='running'` sofort. Wirft danach etwas in `RunInSlotAsync` oder im ungeschützten Setup-Block von `TaskRunner.ContinueAsync` (Zeilen 218–238 liegen außerhalb jedes `try`), fängt der Catch das ab und **loggt nur** — kein `FailAsync`, kein Broadcast. DB sagt Running, die UI erfährt es nie. | `src/ClaudeDo.Worker/Queue/QueueService.cs:349-352` | -| 3 | DB-Writes ohne Broadcast. | `src/ClaudeDo.Worker/Runner/WorktreeManager.cs:103`, `src/ClaudeDo.Worker/Online/OnlineSyncService.cs:131`, `src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs:447` | +| 3 | DB-Writes ohne Broadcast. | `src/ClaudeDo.Worker/Runner/WorktreeManager.cs:103`, `src/ClaudeDo.Worker/Online/OnlineSyncService.cs:131` | + +**Korrektur (2026-08-07, bei der Umsetzung gefunden):** `InteractiveLaunchSpecService.cs:447` stand hier ursprünglich als drittes Loch. Das war falsch. Die Service-Methode broadcastet zwar selbst nicht, aber ihr einziger Produktions-Aufrufer `WorkerHub.CreateMergeHelperTask` (`src/ClaudeDo.Worker/Hub/WorkerHub.cs:818`) sendet direkt danach `TaskUpdated` — seit Commit `c07c1f7` vom 2026-08-05, abgesichert durch `MergeHelperTaskHubTests.CreateMergeHelperTask_CreatesIdleManualTask_StampsBaseCommit_Broadcasts`. Der ursprüngliche Befund hatte den DB-Write gesehen, aber den Aufrufer nicht geprüft. Ein Broadcast im Service wäre ein Duplikat gewesen. Dazu zwei kleinere Befunde: @@ -99,7 +101,6 @@ Unabhängig von Phase 2 und 3, kann sofort starten. | Catch-Block ruft `_state.FailAsync` (das selbst broadcastet), statt nur zu loggen; `OperationCanceledException` bleibt ausgenommen | `QueueService.cs:349-352` | | `WorktreeUpdated` nach dem Insert broadcasten | `Runner/WorktreeManager.cs:103` | | `TaskUpdated` nach dem Insert broadcasten | `Online/OnlineSyncService.cs:131` | -| `TaskUpdated` nach dem Insert broadcasten | `Runner/InteractiveLaunchSpecService.cs:447` | | Monotone Sequenznummer pro TaskId im Delta-Pfad; Ergebnisse mit veralteter Sequenz verwerfen | `OnWorkerTaskUpdated` | | `RunCreated` ersatzlos entfernen (totes Event ohne Abonnent) | `HubBroadcaster`, `TaskRunner.cs:358` | From 66630d5ce24ec085fc19d08e0f82f6e4b5d3d324 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Fri, 7 Aug 2026 09:55:21 +0200 Subject: [PATCH 08/11] chore(worker): drop the unsubscribed RunCreated broadcast --- src/ClaudeDo.Worker/CLAUDE.md | 1 - src/ClaudeDo.Worker/Hub/HubBroadcaster.cs | 3 --- src/ClaudeDo.Worker/Runner/TaskRunner.cs | 2 -- 3 files changed, 6 deletions(-) diff --git a/src/ClaudeDo.Worker/CLAUDE.md b/src/ClaudeDo.Worker/CLAUDE.md index 35fc3ca5..ba4f16a7 100644 --- a/src/ClaudeDo.Worker/CLAUDE.md +++ b/src/ClaudeDo.Worker/CLAUDE.md @@ -159,7 +159,6 @@ launch specs · worktrees · agents/settings/lists · reports/notes/prep · diag - `TaskMessage` - `WorktreeUpdated` - `TaskUpdated` -- `RunCreated` - `ListUpdated` - `WorkerLog` - `PrimeFired` diff --git a/src/ClaudeDo.Worker/Hub/HubBroadcaster.cs b/src/ClaudeDo.Worker/Hub/HubBroadcaster.cs index f46dabdf..c3828c2d 100644 --- a/src/ClaudeDo.Worker/Hub/HubBroadcaster.cs +++ b/src/ClaudeDo.Worker/Hub/HubBroadcaster.cs @@ -40,9 +40,6 @@ public sealed class HubBroadcaster : IPrimeBroadcaster, IRefineBroadcaster public Task ListUpdated(string listId) => _hub.Clients.All.SendAsync("ListUpdated", listId); - public Task RunCreated(string taskId, int runNumber, bool isRetry) => - _hub.Clients.All.SendAsync("RunCreated", taskId, runNumber, isRetry); - public Task UsageUpdated(UsageSnapshotDto snapshot) => _hub.Clients.All.SendAsync("UsageUpdated", snapshot); diff --git a/src/ClaudeDo.Worker/Runner/TaskRunner.cs b/src/ClaudeDo.Worker/Runner/TaskRunner.cs index a4712b48..1f5b1442 100644 --- a/src/ClaudeDo.Worker/Runner/TaskRunner.cs +++ b/src/ClaudeDo.Worker/Runner/TaskRunner.cs @@ -358,8 +358,6 @@ public sealed class TaskRunner await taskRepo.SetLogPathAsync(taskId, logPath, ct); } - await _broadcaster.RunCreated(taskId, runNumber, isRetry); - var arguments = _argsBuilder.Build(config); await using var logWriter = new LogWriter(logPath); From ac586797ce0cb36240a92489742db8ff0fff34ec Mon Sep 17 00:00:00 2001 From: mika kuns Date: Fri, 7 Aug 2026 10:11:52 +0200 Subject: [PATCH 09/11] Revert "fix(data): give both processes a SQLite busy timeout" This reverts commit f62dbb9239c672ef7b43bb8a42e453e2da38442c. --- src/ClaudeDo.App/Program.cs | 5 +---- src/ClaudeDo.Worker/Program.cs | 3 +-- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/ClaudeDo.App/Program.cs b/src/ClaudeDo.App/Program.cs index fffebcdc..cb3e95aa 100644 --- a/src/ClaudeDo.App/Program.cs +++ b/src/ClaudeDo.App/Program.cs @@ -91,11 +91,8 @@ sealed class Program TrExtension.Localizer = localizer; ClaudeDo.Ui.Localization.Loc.Current = localizer; sc.AddSingleton(localizer); - // Default Timeout maps to SQLite's busy handler. Without it a momentarily locked - // database throws SqliteException immediately instead of waiting out the writer, - // which is what leaves task rows stuck on a stale status. sc.AddDbContextFactory(opt => - opt.UseSqlite($"Data Source={dbPath};Default Timeout=30")); + opt.UseSqlite($"Data Source={dbPath}")); sc.AddScoped(sp => sp.GetRequiredService>().CreateDbContext()); diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs index aa2f06fd..c4fd4ed1 100644 --- a/src/ClaudeDo.Worker/Program.cs +++ b/src/ClaudeDo.Worker/Program.cs @@ -57,9 +57,8 @@ builder.Host.UseSerilog((ctx, lc) => lc .WriteTo.Sink(broadcastSink)); builder.Services.AddSingleton(logBuffer); -// See ClaudeDo.App/Program.cs — Default Timeout maps to SQLite's busy handler. builder.Services.AddDbContextFactory(opt => - opt.UseSqlite($"Data Source={cfg.DbPath};Default Timeout=30")); + opt.UseSqlite($"Data Source={cfg.DbPath}")); builder.Services.AddSingleton(cfg); builder.Services.AddSingleton(); From 1f8f3efc72f473885d74e919de7acdd7659c81ad Mon Sep 17 00:00:00 2001 From: mika kuns Date: Fri, 7 Aug 2026 10:12:27 +0200 Subject: [PATCH 10/11] docs: record that the sqlite busy-timeout finding was wrong, drop stale RunCreated mention --- docs/explore-notes/worker-task-pipeline.md | 2 +- ...2026-08-07-ui-reaktivitaet-und-listen-performance-design.md | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/explore-notes/worker-task-pipeline.md b/docs/explore-notes/worker-task-pipeline.md index 743b46de..306ed028 100644 --- a/docs/explore-notes/worker-task-pipeline.md +++ b/docs/explore-notes/worker-task-pipeline.md @@ -124,7 +124,7 @@ read-only "## Reference files" section. - `TaskMergeService` — conflict resolution for worktree merges. **Hub/** -- `HubBroadcaster` — single SignalR broadcast point (TaskStarted/TaskUpdated/TaskMessage/RunCreated…). +- `HubBroadcaster` — single SignalR broadcast point (TaskStarted/TaskUpdated/TaskMessage/WorktreeUpdated…). - `WorkerHub` — SignalR hub + client methods. **Agents/** diff --git a/docs/superpowers/specs/2026-08-07-ui-reaktivitaet-und-listen-performance-design.md b/docs/superpowers/specs/2026-08-07-ui-reaktivitaet-und-listen-performance-design.md index ad4e994e..0c2fc220 100644 --- a/docs/superpowers/specs/2026-08-07-ui-reaktivitaet-und-listen-performance-design.md +++ b/docs/superpowers/specs/2026-08-07-ui-reaktivitaet-und-listen-performance-design.md @@ -29,7 +29,7 @@ Das eigentliche Problem: **ein einziger verlorener Event ist permanent.** Der ei Dazu zwei kleinere Befunde: - **Race im Delta-Pfad.** `OnWorkerTaskUpdated` ist `async void` und hängt an *zwei* Events (`TaskUpdatedEvent` und `WorktreeUpdatedEvent`, `TasksIslandViewModel.cs:117-118`). Der Full-Reload-Zweig ist per `_loadCts` gegen Überholen abgesichert, der Delta-Zweig nicht — ein älterer Read kann einen neueren überschreiben. -- **Kein Busy-Timeout konfiguriert.** Die Connection-Strings beider Prozesse sind blanke `Data Source=…` (`src/ClaudeDo.App/Program.cs:100-101`, `src/ClaudeDo.Worker/Program.cs:60-61`). Ohne Timeout schlägt ein seltener `SQLITE_BUSY` sofort als Exception durch, statt kurz zu warten — das erhöht die Wahrscheinlichkeit von Loch 1. Achtung: `PRAGMA busy_timeout` ist **per Connection** und wird — anders als `journal_mode=WAL` — *nicht* in der DB-Datei persistiert. Es in `ClaudeDoDbContext.MigrateAndConfigure` zu setzen würde nur die Startup-Connection betreffen und wäre wirkungslos; es gehört in den Connection-String (`Default Timeout=`), den Microsoft.Data.Sqlite auf den Busy-Handler abbildet. +- ~~**Kein Busy-Timeout konfiguriert.**~~ **Widerlegt (2026-08-07, empirisch geprüft).** Die Vermutung war, die blanken Connection-Strings (`src/ClaudeDo.App/Program.cs:95`, `src/ClaudeDo.Worker/Program.cs:61`) ließen einen `SQLITE_BUSY` sofort durchschlagen. Das stimmt nicht: Microsoft.Data.Sqlite 8.0.11 setzt `DefaultTimeout` **von sich aus auf 30 Sekunden**, mit oder ohne das Keyword — gemessen an `SqliteConnectionStringBuilder("Data Source=x.db").DefaultTimeout` → `30`, ebenso `SqliteConnection.DefaultTimeout` und `SqliteCommand.CommandTimeout`. Ein Contention-Test (Writer hält 2s, zweiter Writer parallel) zeigt, dass der zweite wartet und nach ~2030 ms durchkommt, statt zu werfen. Der ursprünglich dafür gemachte Commit `f62dbb9` war ein No-op mit irreführendem Kommentar und wurde mit `ac58679` zurückgenommen. Die tatsächliche Absicherung gegen transiente Lesefehler leistet der Retry im Delta-Pfad, nicht ein Timeout. - **`RunCreated` ist ein totes Event.** Wird in `TaskRunner.cs:358` gesendet, hat aber keinen einzigen Abonnenten in der UI. ### Performance: der Engpass ist das Rendering, nicht die Datenbank @@ -97,7 +97,6 @@ Unabhängig von Phase 2 und 3, kann sofort starten. | Fix | Ort | |---|---| | `catch { }` ersetzen durch Log + einmaligen Retry. **Kein** Footer-Error — das ist ein Hintergrund-Refresh, keine Nutzeraktion. | `TasksIslandViewModel.cs:224` | -| `Default Timeout=30` in beide Connection-Strings (nicht als PRAGMA — siehe Analyse) | `App/Program.cs:100-101`, `Worker/Program.cs:60-61` | | Catch-Block ruft `_state.FailAsync` (das selbst broadcastet), statt nur zu loggen; `OperationCanceledException` bleibt ausgenommen | `QueueService.cs:349-352` | | `WorktreeUpdated` nach dem Insert broadcasten | `Runner/WorktreeManager.cs:103` | | `TaskUpdated` nach dem Insert broadcasten | `Online/OnlineSyncService.cs:131` | From c6d1fff8b1119dd605e5e5d34e309352ca04f6c2 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Fri, 7 Aug 2026 10:19:54 +0200 Subject: [PATCH 11/11] fix(worker-tests): close TOCTOU race in slot-failure broadcast poll QueueServiceSlotFailureTests's throwing-slot test broke its poll loop the instant the DB read observed Status==Failed, but TaskStateService.FailAsync commits the status flip before calling the broadcaster's TaskUpdated, so the assertion could race ahead of the broadcast landing in hub.Proxy.Calls (~1-in-5 failures in isolation). Wait for both signals before breaking. --- .../Services/QueueServiceSlotFailureTests.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/ClaudeDo.Worker.Tests/Services/QueueServiceSlotFailureTests.cs b/tests/ClaudeDo.Worker.Tests/Services/QueueServiceSlotFailureTests.cs index 82558c39..bf604e78 100644 --- a/tests/ClaudeDo.Worker.Tests/Services/QueueServiceSlotFailureTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Services/QueueServiceSlotFailureTests.cs @@ -132,13 +132,20 @@ public sealed class QueueServiceSlotFailureTests : IDisposable await service.StartAsync(cts.Token); waker.Wake(); + // FailAsync (TaskStateService.cs:236-249) commits the DB status flip via + // ExecuteUpdateAsync *before* it calls the broadcaster's TaskUpdated — so a poll that + // breaks the instant it observes Status==Failed can race ahead of the broadcast still + // landing in hub.Proxy.Calls. Wait for both signals together so the assertions below + // never sample a genuinely-not-yet-broadcast window as a failure. TaskEntity? reloaded = null; var deadline = DateTime.UtcNow.AddSeconds(10); while (DateTime.UtcNow < deadline) { using var verify = _db.CreateContext(); reloaded = await new TaskRepository(verify).GetByIdAsync(taskId); - if (reloaded!.Status == TaskStatus.Failed) break; + var broadcastSeen = hub.Proxy.Calls.Any( + c => c.Method == "TaskUpdated" && (string)c.Args[0]! == taskId); + if (reloaded!.Status == TaskStatus.Failed && broadcastSeen) break; await Task.Delay(25); } cts.Cancel();