feat(claude-do): depends_on als Menge statt Einzelfeld

Ein Task kann jetzt auf beliebig viele Vorgaenger warten. Die Einzelspalte
tasks.depends_on_task_id wird durch die Beziehungstabelle task_dependencies
ersetzt (Composite-PK, beide FKs ON DELETE CASCADE -- das reproduziert das
alte ON DELETE SET NULL: geloeschter Vorgaenger = Kante weg = Task wieder
pickbar). BlockedByTaskId bleibt unberuehrt.

- Migration AddTaskDependencies kopiert jede bestehende depends_on_task_id
  verlustfrei in die Tabelle, bevor die Spalte fallengelassen wird
- QueuePicker gatet per doppelt negiertem NOT EXISTS: kein Task mit 0 Kanten
  wird blockiert, alle Kanten muessen done sein, eine Kante auf einen
  verschwundenen Task blockiert weiter
- SetDependsOnAsync ersetzt die ganze Menge; Zykluspruefung ist jetzt eine
  BFS ueber den Graphen, faengt also auch A->B->C->A
- blockedReason nennt alle offenen Vorgaenger (External/TaskBlocking, von
  ExternalMcpService und TaskWaitMcpTools gemeinsam genutzt)
- add_task/update_task nehmen dependsOnTaskIds; die alte Skalarform bleibt
  als Ein-Element-Menge inkl. ""-Clear-Sentinel gueltig
- UI: Fan-in-Zeile bleibt flach und listet alle Vorgaenger im After-Label,
  die lineare Kette rendert unveraendert
This commit is contained in:
Mika Kuns
2026-09-02 15:08:05 +02:00
parent 84d955dfc7
commit c25732cff8
31 changed files with 1893 additions and 206 deletions
@@ -34,7 +34,10 @@ public sealed class TaskRepository
var tracked = _context.Tasks.Local.FirstOrDefault(t => t.Id == entity.Id);
if (tracked is not null && !ReferenceEquals(tracked, entity))
_context.Entry(tracked).State = Microsoft.EntityFrameworkCore.EntityState.Detached;
_context.Tasks.Update(entity);
// Entry().State instead of Update(): Update marks the whole loaded graph Modified, which
// for the all-key task_dependencies rows means an UPDATE with nothing to set. Dependency
// edges are written only by TaskStateService.SetDependsOnAsync.
_context.Entry(entity).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
await _context.SaveChangesAsync(ct);
}
@@ -46,17 +49,20 @@ public sealed class TaskRepository
public async Task<TaskEntity?> GetByIdAsync(string taskId, CancellationToken ct = default)
{
return await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId, ct);
return await _context.Tasks.AsNoTracking().Include(t => t.Dependencies)
.FirstOrDefaultAsync(t => t.Id == taskId, ct);
}
public async Task<TaskEntity?> GetByNumberAsync(int number, CancellationToken ct = default)
{
return await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Number == number, ct);
return await _context.Tasks.AsNoTracking().Include(t => t.Dependencies)
.FirstOrDefaultAsync(t => t.Number == number, ct);
}
public async Task<List<TaskEntity>> GetByListIdAsync(string listId, CancellationToken ct = default)
{
return await _context.Tasks
.Include(t => t.Dependencies)
.Where(t => t.ListId == listId)
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
.ToListAsync(ct);