Files
ClaudeDo/src/ClaudeDo.Data/Repositories/TaskRepository.cs
mika kuns 3423919655 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>
2026-04-16 13:12:59 +02:00

189 lines
6.8 KiB
C#

using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Data.Repositories;
public sealed class TaskRepository
{
private readonly ClaudeDoDbContext _context;
public TaskRepository(ClaudeDoDbContext context) => _context = context;
#region CRUD
public async Task AddAsync(TaskEntity entity, CancellationToken ct = default)
{
_context.Tasks.Add(entity);
await _context.SaveChangesAsync(ct);
}
public async Task UpdateAsync(TaskEntity entity, CancellationToken ct = default)
{
_context.Tasks.Update(entity);
await _context.SaveChangesAsync(ct);
}
public async Task DeleteAsync(string taskId, CancellationToken ct = default)
{
await _context.Tasks.Where(t => t.Id == taskId).ExecuteDeleteAsync(ct);
}
public async Task<TaskEntity?> GetByIdAsync(string taskId, CancellationToken ct = default)
{
return await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId, ct);
}
public async Task<List<TaskEntity>> GetByListIdAsync(string listId, CancellationToken ct = default)
{
return await _context.Tasks
.Where(t => t.ListId == listId)
.OrderBy(t => t.CreatedAt)
.ToListAsync(ct);
}
// Kept for backwards-compatibility with callers using the old name.
public Task<List<TaskEntity>> GetByListAsync(string listId, CancellationToken ct = default)
=> GetByListIdAsync(listId, ct);
#endregion
#region Status transitions
public async Task MarkRunningAsync(string taskId, DateTime startedAt, CancellationToken ct = default)
{
await _context.Tasks
.Where(t => t.Id == taskId)
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.Status, TaskStatus.Running)
.SetProperty(t => t.StartedAt, startedAt), ct);
}
public async Task MarkDoneAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct = default)
{
await _context.Tasks
.Where(t => t.Id == taskId)
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.Status, TaskStatus.Done)
.SetProperty(t => t.FinishedAt, finishedAt)
.SetProperty(t => t.Result, result), ct);
}
public async Task MarkFailedAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct = default)
{
await _context.Tasks
.Where(t => t.Id == taskId)
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.Status, TaskStatus.Failed)
.SetProperty(t => t.FinishedAt, finishedAt)
.SetProperty(t => t.Result, result), ct);
}
public async Task SetLogPathAsync(string taskId, string logPath, CancellationToken ct = default)
{
await _context.Tasks
.Where(t => t.Id == taskId)
.ExecuteUpdateAsync(s => s.SetProperty(t => t.LogPath, logPath), ct);
}
public async Task<int> FlipAllRunningToFailedAsync(string reason, CancellationToken ct = default)
{
var resultText = "[stale] " + reason;
var now = DateTime.UtcNow;
return await _context.Tasks
.Where(t => t.Status == TaskStatus.Running)
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.Status, TaskStatus.Failed)
.SetProperty(t => t.FinishedAt, now)
.SetProperty(t => t.Result, resultText), ct);
}
#endregion
#region Tags
public async Task AddTagAsync(string taskId, long tagId, CancellationToken ct = default)
{
var task = await _context.Tasks.Include(t => t.Tags).FirstOrDefaultAsync(t => t.Id == taskId, ct);
if (task is null) return;
var tag = await _context.Tags.FindAsync([tagId], ct);
if (tag is not null && !task.Tags.Any(t => t.Id == tagId))
{
task.Tags.Add(tag);
await _context.SaveChangesAsync(ct);
}
}
public async Task RemoveTagAsync(string taskId, long tagId, CancellationToken ct = default)
{
var task = await _context.Tasks.Include(t => t.Tags).FirstOrDefaultAsync(t => t.Id == taskId, ct);
if (task is null) return;
var tag = task.Tags.FirstOrDefault(t => t.Id == tagId);
if (tag is not null)
{
task.Tags.Remove(tag);
await _context.SaveChangesAsync(ct);
}
}
public async Task<List<TagEntity>> GetTagsAsync(string taskId, CancellationToken ct = default)
{
return await _context.Tasks
.Where(t => t.Id == taskId)
.SelectMany(t => t.Tags)
.ToListAsync(ct);
}
public async Task<List<TagEntity>> GetEffectiveTagsAsync(string taskId, CancellationToken ct = default)
{
var taskTags = _context.Tasks
.Where(t => t.Id == taskId)
.SelectMany(t => t.Tags);
var listTags = _context.Tasks
.Where(t => t.Id == taskId)
.SelectMany(t => t.List.Tags);
return await taskTags.Union(listTags).Distinct().ToListAsync(ct);
}
#endregion
#region Queue selection
public async Task<TaskEntity?> GetNextQueuedAgentTaskAsync(DateTime now, CancellationToken ct = default)
{
// Atomic queue claim: UPDATE + RETURNING in one statement prevents TOCTOU races.
// Uses raw SQL because EF cannot express UPDATE...RETURNING.
// Includes both task-level and list-level "agent" tag so lists tagged "agent"
// automatically enqueue all their tasks without per-task tagging.
// EF SQLite stores DateTime as "yyyy-MM-dd HH:mm:ss.fffffff" — use the same format for comparison.
var nowStr = now.ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss.fffffff");
var result = await _context.Tasks.FromSqlRaw("""
UPDATE tasks SET status = 'running'
WHERE id = (
SELECT t.id FROM tasks t
WHERE t.status = 'queued'
AND (t.scheduled_for IS NULL OR t.scheduled_for <= {0})
AND (
EXISTS (
SELECT 1 FROM task_tags tt
JOIN tags tg ON tg.id = tt.tag_id
WHERE tt.task_id = t.id AND tg.name = 'agent'
)
OR EXISTS (
SELECT 1 FROM list_tags lt
JOIN tags tg ON tg.id = lt.tag_id
WHERE lt.list_id = t.list_id AND tg.name = 'agent'
)
)
ORDER BY t.created_at ASC
LIMIT 1
)
RETURNING *
""", nowStr).ToListAsync(ct);
return result.FirstOrDefault();
}
#endregion
}