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
@@ -74,10 +74,15 @@ public class TasksIslandChainTests : IDisposable
Title = t.Id,
CreatedAt = DateTime.UtcNow,
Status = t.Status,
DependsOnTaskId = t.DependsOnTaskId,
ParentTaskId = t.ParentTaskId,
SortOrder = t.SortOrder,
});
if (t.DependsOnTaskId is not null)
db.TaskDependencies.Add(new TaskDependencyEntity
{
TaskId = t.Id,
DependsOnTaskId = t.DependsOnTaskId,
});
}
await db.SaveChangesAsync();
return numbers;
@@ -225,8 +230,7 @@ public class TasksIslandChainTests : IDisposable
await using (var db = NewContext())
{
var entity = await db.Tasks.FirstAsync(t => t.Id == "later");
entity.DependsOnTaskId = "head";
db.TaskDependencies.Add(new TaskDependencyEntity { TaskId = "later", DependsOnTaskId = "head" });
await db.SaveChangesAsync();
}
@@ -236,4 +240,30 @@ public class TasksIslandChainTests : IDisposable
Assert.True(updated.ShowAsChainMember);
Assert.Equal(1, updated.ChainStep);
}
[Fact]
public async Task FanIn_RendersFlatWithEveryPredecessorInTheLabel()
{
// Two independent predecessors is not a chain -- the rail can only draw one line, so the
// row stays flat and the label has to name both, or it misreports what it waits for.
var numbers = await SeedAsync(
("a", TaskStatus.Idle, null, null, 0),
("b", TaskStatus.Idle, null, null, 1),
("wiring", TaskStatus.Idle, "a", null, 2));
await using (var db = NewContext())
{
db.TaskDependencies.Add(new TaskDependencyEntity { TaskId = "wiring", DependsOnTaskId = "b" });
await db.SaveChangesAsync();
}
var vm = BuildViewModel();
await LoadAndWaitAsync(vm, UserList("list1", "Default"));
var wiring = vm.Items.First(r => r.Id == "wiring");
Assert.False(wiring.ShowAsChainMember);
Assert.Null(wiring.ChainStep);
Assert.NotNull(wiring.ChainAfterLabel);
Assert.Contains(numbers["a"].ToString(), wiring.ChainAfterLabel);
Assert.Contains(numbers["b"].ToString(), wiring.ChainAfterLabel);
}
}
@@ -73,9 +73,14 @@ public class TasksIslandClassifyItemsTests : IDisposable
Title = t.Id,
CreatedAt = DateTime.UtcNow,
Status = t.Status,
DependsOnTaskId = t.DependsOnTaskId,
SortOrder = t.SortOrder,
});
if (t.DependsOnTaskId is not null)
db.TaskDependencies.Add(new TaskDependencyEntity
{
TaskId = t.Id,
DependsOnTaskId = t.DependsOnTaskId,
});
}
await db.SaveChangesAsync();
}
@@ -266,8 +266,7 @@ public class TasksIslandReconcileTickTests : IDisposable
await using (var db = NewContext())
{
var t = await db.Tasks.FirstAsync(x => x.Id == "DEP");
t.DependsOnTaskId = "HEAD";
db.TaskDependencies.Add(new TaskDependencyEntity { TaskId = "DEP", DependsOnTaskId = "HEAD" });
await db.SaveChangesAsync();
}