fix: resolve critical bugs and improve reliability across worker, data, UI

- Fix worker using wrong DB by defaulting to CurrentUser service account
  and expanding ~ to absolute paths at install time
- Fix DbContext disposed before fire-and-forget by passing taskId instead
  of TaskEntity into RunInSlotAsync, which creates its own context
- Fix ActiveTaskDto property casing mismatch between hub and client
- Move WAL mode PRAGMA before migrations to prevent concurrent lock issues
- Replace FirstAsync with FirstOrDefaultAsync + null guards in tag operations
- Add delete confirmation flow for lists
- Log fire-and-forget exceptions instead of swallowing them
- Broadcast RunCreated event from WorkerHub.RunNow
- Add IDisposable to MainWindowViewModel for event handler cleanup
- Preserve subtask CreatedAt on updates instead of overwriting
- Replace bare catch blocks with Debug.WriteLine logging

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
mika kuns
2026-04-16 13:12:59 +02:00
parent fca2bdb596
commit 3423919655
11 changed files with 154 additions and 61 deletions

View File

@@ -5,6 +5,8 @@ using Microsoft.AspNetCore.SignalR;
namespace ClaudeDo.Worker.Hub;
public record ActiveTaskDto(string Slot, string TaskId, DateTime StartedAt);
public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
{
private static readonly string Version =
@@ -12,19 +14,21 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
private readonly QueueService _queue;
private readonly AgentFileService _agentService;
private readonly HubBroadcaster _broadcaster;
public WorkerHub(QueueService queue, AgentFileService agentService)
public WorkerHub(QueueService queue, AgentFileService agentService, HubBroadcaster broadcaster)
{
_queue = queue;
_agentService = agentService;
_broadcaster = broadcaster;
}
public string Ping() => $"pong v{Version}";
public IReadOnlyList<object> GetActive()
public IReadOnlyList<ActiveTaskDto> GetActive()
{
return _queue.GetActive()
.Select(a => (object)new { slot = a.slot, taskId = a.taskId, startedAt = a.startedAt })
.Select(a => new ActiveTaskDto(a.slot, a.taskId, a.startedAt))
.ToList();
}
@@ -33,6 +37,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
try
{
await _queue.RunNow(taskId);
await _broadcaster.RunCreated(taskId, 1, false);
}
catch (InvalidOperationException)
{

View File

@@ -58,11 +58,13 @@ public sealed class QueueService : BackgroundService
public async Task RunNow(string taskId)
{
using var context = _dbFactory.CreateDbContext();
var taskRepo = new TaskRepository(context);
var task = await taskRepo.GetByIdAsync(taskId);
if (task is null)
throw new KeyNotFoundException($"Task '{taskId}' not found.");
using (var context = _dbFactory.CreateDbContext())
{
var taskRepo = new TaskRepository(context);
var exists = await taskRepo.GetByIdAsync(taskId);
if (exists is null)
throw new KeyNotFoundException($"Task '{taskId}' not found.");
}
lock (_lock)
{
@@ -72,8 +74,10 @@ public sealed class QueueService : BackgroundService
var cts = new CancellationTokenSource();
_overrideSlot = new QueueSlotState { TaskId = taskId, StartedAt = DateTime.UtcNow, Cts = cts };
_ = RunInSlotAsync(task, "override", cts.Token).ContinueWith(_ =>
_ = RunInSlotAsync(taskId, "override", cts.Token).ContinueWith(t =>
{
if (t.IsFaulted)
_logger.LogError(t.Exception, "RunInSlotAsync failed for task {TaskId}", taskId);
lock (_lock) { _overrideSlot = null; }
cts.Dispose();
}, TaskScheduler.Default);
@@ -98,8 +102,10 @@ public sealed class QueueService : BackgroundService
var cts = new CancellationTokenSource();
_overrideSlot = new QueueSlotState { TaskId = taskId, StartedAt = DateTime.UtcNow, Cts = cts };
_ = RunContinueInSlotAsync(taskId, followUpPrompt, cts.Token).ContinueWith(_ =>
_ = RunContinueInSlotAsync(taskId, followUpPrompt, cts.Token).ContinueWith(t =>
{
if (t.IsFaulted)
_logger.LogError(t.Exception, "RunContinueInSlotAsync failed for task {TaskId}", taskId);
lock (_lock) { _overrideSlot = null; }
cts.Dispose();
}, TaskScheduler.Default);
@@ -165,8 +171,10 @@ public sealed class QueueService : BackgroundService
var cts = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
_queueSlot = new QueueSlotState { TaskId = task.Id, StartedAt = DateTime.UtcNow, Cts = cts };
_ = RunInSlotAsync(task, "queue", cts.Token).ContinueWith(_ =>
_ = RunInSlotAsync(task.Id, "queue", cts.Token).ContinueWith(t =>
{
if (t.IsFaulted)
_logger.LogError(t.Exception, "RunInSlotAsync failed for task {TaskId} in queue slot", task.Id);
lock (_lock) { _queueSlot = null; }
cts.Dispose();
WakeQueue(); // Check for next task immediately.
@@ -186,16 +194,25 @@ public sealed class QueueService : BackgroundService
_logger.LogInformation("QueueService stopping");
}
private async Task RunInSlotAsync(TaskEntity task, string slot, CancellationToken ct)
private async Task RunInSlotAsync(string taskId, string slot, CancellationToken ct)
{
try
{
_logger.LogInformation("Starting task {TaskId} in {Slot} slot", task.Id, slot);
_logger.LogInformation("Starting task {TaskId} in {Slot} slot", taskId, slot);
TaskEntity task;
using (var context = _dbFactory.CreateDbContext())
{
var taskRepo = new TaskRepository(context);
task = await taskRepo.GetByIdAsync(taskId, ct)
?? throw new KeyNotFoundException($"Task '{taskId}' not found.");
}
await _runner.RunAsync(task, slot, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Slot runner error for task {TaskId}", task.Id);
_logger.LogError(ex, "Slot runner error for task {TaskId}", taskId);
}
}