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
+16 -13
View File
@@ -132,17 +132,24 @@ public sealed class TaskWaitMcpTools
var rows = await ctx.Tasks
.AsNoTracking()
.Where(t => taskIds.Contains(t.Id))
.Select(t => new { t.Id, t.Number, t.Status, t.BlockedByTaskId, t.DependsOnTaskId })
.Select(t => new
{
t.Id,
t.Number,
t.Status,
t.BlockedByTaskId,
DependsOnTaskIds = t.Dependencies.Select(d => d.DependsOnTaskId).ToList(),
})
.ToListAsync(ct);
var byId = rows.ToDictionary(r => r.Id, r => r);
// A Queued task with an unmet depends_on link never becomes "changed" by itself -- the
// A Queued task with an unmet depends_on edge never becomes "changed" by itself -- the
// picker will not touch it. Batch-resolve those dependencies' statuses once instead of a
// query per candidate.
var dependencyIds = rows
.Where(r => r.Status == TaskStatus.Queued && r.BlockedByTaskId is null && r.DependsOnTaskId is not null)
.Select(r => r.DependsOnTaskId!)
.Where(r => r.Status == TaskStatus.Queued && r.BlockedByTaskId is null)
.SelectMany(r => r.DependsOnTaskIds)
.Distinct()
.ToList();
var dependencyStatuses = dependencyIds.Count == 0
@@ -168,16 +175,12 @@ public sealed class TaskWaitMcpTools
// every chained child and turn the wait into a one-turn-per-poll busy loop.
if (row.Status == TaskStatus.Queued && row.BlockedByTaskId is null)
{
if (row.DependsOnTaskId is not null)
var unmet = TaskBlocking.Unmet(row.DependsOnTaskIds, dependencyStatuses);
if (unmet.Count > 0)
{
var known = dependencyStatuses.TryGetValue(row.DependsOnTaskId, out var depStatus);
if (!known || depStatus != TaskStatus.Done)
{
result.Add(new TaskStatusChangeDto(id, "Blocked",
$"Blocked: depends on task {row.DependsOnTaskId} (status: " +
(known ? depStatus.ToString() : "not found") + ").", row.Number));
continue;
}
result.Add(new TaskStatusChangeDto(
id, "Blocked", TaskBlocking.Describe(unmet), row.Number));
continue;
}
}