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
@@ -102,6 +102,17 @@ public sealed class TaskStateServiceTests : IDisposable
return await new TaskRepository(ctx).GetByIdAsync(id) ?? throw new InvalidOperationException($"task {id} not found");
}
private async Task<List<string>> GetDependsOnAsync(string id)
{
await using var ctx = _factory.CreateDbContext();
return await ctx.TaskDependencies.Where(d => d.TaskId == id)
.Select(d => d.DependsOnTaskId).ToListAsync();
}
// The dependency set has no defined order, and the ids are random GUIDs -- sort both sides.
private static List<string> Sorted(params string[] ids) => ids.OrderBy(x => x).ToList();
private static List<string> Sorted(IEnumerable<string> ids) => ids.OrderBy(x => x).ToList();
// ─── EnqueueAsync ─────────────────────────────────────────────────────
[Fact]
@@ -659,31 +670,55 @@ public sealed class TaskStateServiceTests : IDisposable
// ─── SetDependsOnAsync ────────────────────────────────────────────────
[Fact]
public async Task SetDependsOnAsync_SetsDependsOnTaskId()
public async Task SetDependsOnAsync_SetsDependsOnTaskIds()
{
var predecessor = await SeedTaskAsync(TaskStatus.Idle);
var task = await SeedTaskAsync(TaskStatus.Queued);
var result = await _sut.SetDependsOnAsync(task, predecessor, default);
var result = await _sut.SetDependsOnAsync(task, [predecessor], default);
Assert.True(result.Ok);
var t = await GetTaskAsync(task);
Assert.Equal(predecessor, t.DependsOnTaskId);
Assert.Equal([predecessor], await GetDependsOnAsync(task));
}
[Fact]
public async Task SetDependsOnAsync_Null_ClearsDependsOnTaskId_AndWakesQueue()
public async Task SetDependsOnAsync_SetsSeveralPredecessors()
{
var a = await SeedTaskAsync(TaskStatus.Idle);
var b = await SeedTaskAsync(TaskStatus.Idle);
var task = await SeedTaskAsync(TaskStatus.Queued);
Assert.True((await _sut.SetDependsOnAsync(task, [a, b], default)).Ok);
Assert.Equal(Sorted(a, b), Sorted(await GetDependsOnAsync(task)));
}
[Fact]
public async Task SetDependsOnAsync_ReplacingSet_KeepsOverlapAndDropsTheRest()
{
var a = await SeedTaskAsync(TaskStatus.Idle);
var b = await SeedTaskAsync(TaskStatus.Idle);
var c = await SeedTaskAsync(TaskStatus.Idle);
var task = await SeedTaskAsync(TaskStatus.Queued);
Assert.True((await _sut.SetDependsOnAsync(task, [a, b], default)).Ok);
Assert.True((await _sut.SetDependsOnAsync(task, [b, c], default)).Ok);
Assert.Equal(Sorted(b, c), Sorted(await GetDependsOnAsync(task)));
}
[Fact]
public async Task SetDependsOnAsync_Empty_ClearsDependsOnTaskIds_AndWakesQueue()
{
var predecessor = await SeedTaskAsync(TaskStatus.Idle);
var task = await SeedTaskAsync(TaskStatus.Queued);
await _sut.SetDependsOnAsync(task, predecessor, default);
await _sut.SetDependsOnAsync(task, [predecessor], default);
var wakesBefore = _built.WakeCount();
var result = await _sut.SetDependsOnAsync(task, null, default);
var result = await _sut.SetDependsOnAsync(task, [], default);
Assert.True(result.Ok);
var t = await GetTaskAsync(task);
Assert.Null(t.DependsOnTaskId);
Assert.Empty(await GetDependsOnAsync(task));
Assert.True(_built.WakeCount() > wakesBefore);
}
@@ -692,11 +727,10 @@ public sealed class TaskStateServiceTests : IDisposable
{
var task = await SeedTaskAsync(TaskStatus.Queued);
var result = await _sut.SetDependsOnAsync(task, task, default);
var result = await _sut.SetDependsOnAsync(task, [task], default);
Assert.False(result.Ok);
var t = await GetTaskAsync(task);
Assert.Null(t.DependsOnTaskId);
Assert.Empty(await GetDependsOnAsync(task));
}
[Fact]
@@ -704,7 +738,7 @@ public sealed class TaskStateServiceTests : IDisposable
{
var task = await SeedTaskAsync(TaskStatus.Queued);
var result = await _sut.SetDependsOnAsync(task, "does-not-exist", default);
var result = await _sut.SetDependsOnAsync(task, ["does-not-exist"], default);
Assert.False(result.Ok);
}
@@ -714,7 +748,7 @@ public sealed class TaskStateServiceTests : IDisposable
{
var predecessor = await SeedTaskAsync(TaskStatus.Idle);
var result = await _sut.SetDependsOnAsync("does-not-exist", predecessor, default);
var result = await _sut.SetDependsOnAsync("does-not-exist", [predecessor], default);
Assert.False(result.Ok);
}
@@ -724,15 +758,14 @@ public sealed class TaskStateServiceTests : IDisposable
{
var a = await SeedTaskAsync(TaskStatus.Queued);
var b = await SeedTaskAsync(TaskStatus.Queued);
var setup = await _sut.SetDependsOnAsync(b, a, default);
var setup = await _sut.SetDependsOnAsync(b, [a], default);
Assert.True(setup.Ok);
// a -> b would close the loop a -> b -> a.
var result = await _sut.SetDependsOnAsync(a, b, default);
var result = await _sut.SetDependsOnAsync(a, [b], default);
Assert.False(result.Ok);
var t = await GetTaskAsync(a);
Assert.Null(t.DependsOnTaskId);
Assert.Empty(await GetDependsOnAsync(a));
}
[Fact]
@@ -741,15 +774,31 @@ public sealed class TaskStateServiceTests : IDisposable
var a = await SeedTaskAsync(TaskStatus.Queued);
var b = await SeedTaskAsync(TaskStatus.Queued);
var c = await SeedTaskAsync(TaskStatus.Queued);
Assert.True((await _sut.SetDependsOnAsync(b, a, default)).Ok);
Assert.True((await _sut.SetDependsOnAsync(c, b, default)).Ok);
Assert.True((await _sut.SetDependsOnAsync(b, [a], default)).Ok);
Assert.True((await _sut.SetDependsOnAsync(c, [b], default)).Ok);
// a -> c would close the loop a -> c -> b -> a.
var result = await _sut.SetDependsOnAsync(a, c, default);
var result = await _sut.SetDependsOnAsync(a, [c], default);
Assert.False(result.Ok);
}
[Fact]
public async Task SetDependsOnAsync_CycleViaOneOfSeveralProposedEdges_Rejected()
{
// Only the second proposed edge closes a loop -- the check must look at every one of
// them, not just the first, and must leave the whole set unwritten when one fails.
var a = await SeedTaskAsync(TaskStatus.Queued);
var b = await SeedTaskAsync(TaskStatus.Queued);
var innocent = await SeedTaskAsync(TaskStatus.Idle);
Assert.True((await _sut.SetDependsOnAsync(b, [a], default)).Ok);
var result = await _sut.SetDependsOnAsync(a, [innocent, b], default);
Assert.False(result.Ok);
Assert.Empty(await GetDependsOnAsync(a));
}
// ─── RecoverStaleRunningAsync ─────────────────────────────────────────
[Fact]