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
@@ -313,6 +313,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
var entity = await db.Tasks
.Include(t => t.List)
.Include(t => t.Worktree)
.Include(t => t.Dependencies)
.FirstOrDefaultAsync(t => t.Id == taskId);
// A newer refresh for this task started while we were reading — its result is fresher.
@@ -453,7 +454,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
{
await using var db = await _dbFactory.CreateDbContextAsync(ct);
var filter = _filters.Resolve(list.Id);
var baseQuery = db.Tasks.Include(t => t.List).Include(t => t.Worktree);
var baseQuery = db.Tasks.Include(t => t.List).Include(t => t.Worktree).Include(t => t.Dependencies);
var filteredList = filter is null
? new List<TaskEntity>()
@@ -722,12 +723,17 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
}
// Content only — "after " + localization is Slice 2's concern (the view), which is why this
// holds just the identifying fragment rather than an assembled sentence.
// holds just the identifying fragment rather than an assembled sentence. Names every
// predecessor it can resolve: a task can wait on n of them, and hiding all but the first
// would misreport what the row is actually waiting for.
private static string? BuildAfterLabel(TaskRowViewModel row, Dictionary<string, TaskRowViewModel> rowsById)
{
if (string.IsNullOrEmpty(row.DependsOnTaskId)) return null;
if (!rowsById.TryGetValue(row.DependsOnTaskId, out var predecessor)) return null;
return predecessor.Number > 0 ? $"#{predecessor.Number}" : predecessor.Title;
var parts = row.DependsOnTaskIds
.Select(id => rowsById.TryGetValue(id, out var p) ? p : null)
.Where(p => p is not null)
.Select(p => p!.Number > 0 ? $"#{p.Number}" : p.Title)
.ToList();
return parts.Count == 0 ? null : string.Join(", ", parts);
}
// Assigns ShowAsChainMember/ChainStep/ChainAfterLabel for every row in `section`, then
@@ -743,7 +749,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
foreach (var r in section)
{
if (string.IsNullOrEmpty(r.DependsOnTaskId))
if (r.DependsOnTaskIds.Count == 0)
{
r.ShowAsChainMember = false;
r.ChainStep = null;
@@ -751,6 +757,16 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
continue;
}
// Several predecessors aren't a chain — the rail draws one line, so a fan-in row
// only ever gets the label listing everything it waits on.
if (r.DependsOnTaskId is null)
{
r.ShowAsChainMember = false;
r.ChainStep = null;
r.ChainAfterLabel = BuildAfterLabel(r, rowsById);
continue;
}
// A planning child keeps its parent indent — chain membership only ever shows as a
// label for it, never as a second, nested rail (design: "parent wins").
if (r.IsChild)
@@ -1684,6 +1700,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
entities = await db.Tasks
.Include(t => t.List)
.Include(t => t.Worktree)
.Include(t => t.Dependencies)
.Where(t => idSet.Contains(t.Id))
.ToListAsync(ct);
dbOk = true;
@@ -1728,6 +1745,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
// The slice of a row that Regroup reads: which section it lands in, where it sits inside it,
// and what its chain rail/after-chip says. Only fields UpdateFromEntity actually writes —
// IsExpanded and HasPlanningChildren are owned elsewhere and would produce false positives.
private static (bool, DateTime?, string?, string?, PlanningPhase, int, string) GroupingKey(TaskRowViewModel r) =>
(r.Done, r.ScheduledFor, r.ParentTaskId, r.DependsOnTaskId, r.PlanningPhase, r.Number, r.Title);
private static (bool, DateTime?, string?, string, PlanningPhase, int, string) GroupingKey(TaskRowViewModel r) =>
(r.Done, r.ScheduledFor, r.ParentTaskId, string.Join(",", r.DependsOnTaskIds),
r.PlanningPhase, r.Number, r.Title);
}