Files
ClaudeDo/docs/superpowers/plans/2026-09-02-multi-dependencies.md

7.7 KiB

Mehrfach-Abhängigkeiten (depends_on als Menge) — Implementation Plan

Goal: A task can declare n predecessors instead of exactly one. The queue picker claims it only once all of them are Done.

Architecture: Replace the single tasks.depends_on_task_id column with a task_dependencies(task_id, depends_on_task_id) relation table (composite PK, both FKs to tasks.id, ON DELETE CASCADE). Cascade reproduces today's ON DELETE SET NULL semantics on a join table: deleting a predecessor drops the edge, so the dependent becomes pickable instead of blocked forever. BlockedByTaskId (the internal planning chain) is untouched — the split stays deliberate.

Tech Stack: .NET 8, EF Core (SQLite), SignalR, Avalonia 12, xUnit.

Not in scope: BlockedByTaskId; rewriting the list-handler prompt to use multi-deps; a graph visualizer.


File Structure

Create:

  • src/ClaudeDo.Data/Models/TaskDependencyEntity.cs
  • src/ClaudeDo.Data/Configuration/TaskDependencyEntityConfiguration.cs
  • src/ClaudeDo.Data/Migrations/<ts>_AddTaskDependencies.cs — scaffolded
  • src/ClaudeDo.Worker/External/TaskBlocking.cs — the shared blockedReason builder (two consumers: ExternalMcpService, TaskWaitMcpTools)
  • tests/ClaudeDo.Data.Tests/TaskDependencyMigrationTests.cs — real Migrate() on a DB seeded with legacy depends_on_task_id rows

Modify:

  • src/ClaudeDo.Data/Models/TaskEntity.cs — DependsOnTaskId → Dependencies nav + [NotMapped] DependsOnTaskIds
  • src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs — drop column/FK/index
  • src/ClaudeDo.Data/ClaudeDoDbContext.cs — DbSet<TaskDependencyEntity>
  • src/ClaudeDo.Data/Repositories/TaskRepository.cs — .Include(t => t.Dependencies) on the three read paths; UpdateAsync must not push the nav graph
  • src/ClaudeDo.Worker/Queue/QueuePicker.cs — NOT EXISTS over the relation table
  • src/ClaudeDo.Worker/State/TaskStateService.cs — SetDependsOnAsync takes a set, graph-wide cycle check
  • src/ClaudeDo.Worker/State/Interfaces/ITaskStateService.cs
  • src/ClaudeDo.Worker/External/ExternalMcpService.cs — DTOs, add_task, update_task, ComputeBlockedInfoAsync
  • src/ClaudeDo.Worker/External/TaskWaitMcpTools.cs
  • src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs, TasksIslandViewModel.cs — display of n predecessors
  • src/ClaudeDo.Data/CLAUDE.md, src/ClaudeDo.Worker/CLAUDE.md
  • test files listed per task below

Task 1 — Schema + migration

  • TaskDependencyEntity { string TaskId; string DependsOnTaskId; }, table task_dependencies, composite PK, both FKs cascade, index on depends_on_task_id.
  • TaskEntity.Dependencies (ICollection<TaskDependencyEntity>), [NotMapped] DependsOnTaskIds projection. DependsOnTaskId and its FK/index are removed.
  • Scaffold the migration, then hand-edit the Up body so the data copy sits between create and drop: CreateTable → INSERT INTO task_dependencies (task_id, depends_on_task_id) SELECT id, depends_on_task_id FROM tasks WHERE depends_on_task_id IS NOT NULL → DropColumn. Down reverses it (add column, UPDATE … FROM the first edge, drop table) — lossy for >1 edge, which is inherent and documented in the migration.
  • Verify: dotnet ef database update against a temp DB seeded with legacy rows; pragma_table_info diff. DropColumn forces a SQLite table rebuild — the ef-migration-parallel-designer-collision trap applies: if another migration lands in parallel, hand-check both .Designer.cs snapshots.

Test: TaskDependencyMigrationTests — migrate to the previous migration, seed a task with depends_on_task_id via raw SQL, migrate to head, assert the edge is in task_dependencies and nothing else changed. (Acceptance #2.)

Task 2 — Queue gating

  • QueuePicker replaces the depends_on_task_id IS NULL OR EXISTS(...) clause with a doubly-negated NOT EXISTS over task_dependencies, so a dangling edge keeps blocking exactly as today: sql AND NOT EXISTS ( SELECT 1 FROM task_dependencies td WHERE td.task_id = t.id AND NOT EXISTS (SELECT 1 FROM tasks d WHERE d.id = td.depends_on_task_id AND d.status = 'done') )

Tests (QueuePickerTests, real SQLite): n=2 both Done → claimed; one Done one Running → skipped; zero deps → claimed (regression); one dep Failed → still skipped, no cascade (acceptance #6); one dep Cancelled → still skipped.

Task 3 — Write path + cycle detection

  • ITaskStateService.SetDependsOnAsync(string taskId, IReadOnlyList<string> dependsOnTaskIds, CancellationToken) — empty list clears. Rejects self-reference, unknown id, duplicate handling by de-dup.
  • Cycle check walks the graph: load the whole edge set once (it only ever holds declared dependencies, so it stays small — ponytail: note, switch to a recursive CTE if it ever isn't), BFS from every proposed predecessor; reaching taskId is a cycle. Visited set guards pre-existing bad data.
  • Replace rows transactionally (ExecuteDelete for taskId + AddRange), wake the queue when the set shrank.

Tests (TaskStateServiceTests): sets n edges; empty list clears + wakes; self-ref rejected; unknown id rejected; A→B→C→A rejected (acceptance #3); adding a second edge leaves the first intact.

Task 4 — MCP surface

  • add_task / update_task keep dependsOnTaskId (string) and gain dependsOnTaskIds (string[]?). Precedence: dependsOnTaskIds non-null wins; otherwise the legacy scalar is treated as a one-element set, with "" still the clear sentinel. Both run through TaskIdResolver so #123 keeps working.
  • TaskDto/TaskRefDto: DependsOnTaskId → DependsOnTaskIds (IReadOnlyList<string>). Only the input form is contractually frozen.
  • TaskBlocking.Describe builds one reason naming every unmet predecessor (acceptance #4); ComputeBlockedInfoAsync and TaskWaitMcpTools.CheckOnceAsync both use it.

Tests (ExternalMcpServiceTests, TaskWaitMcpToolsTests, BatchMcpToolsTests): legacy scalar still sets + clears the link (acceptance #5, explicit back-compat test); dependsOnTaskIds sets n; cycle via the list form throws; blockedReason names both open predecessors.

Task 5 — UI

  • TaskRowViewModel.DependsOnTaskIds; DependsOnTaskId becomes a computed "single predecessor or null" so the existing linear-chain rail is unchanged for the 1-dependency case.
  • BuildAfterLabel joins all resolvable predecessors (#3, #7); a row with >1 predecessor gets the label, never a rail (a rail can only render a line).
  • GroupingKey folds the joined ids so the reconcile tick still detects a change.
  • .Include(t => t.Dependencies) on the TasksIslandViewModel query sites that hydrate rows.

Tests (TasksIslandChainTests, TasksIslandClassifyItemsTests, TasksIslandReconcileTickTests): existing chain tests keep passing via the 1-dep path; a 2-dep row shows a joined after-label and no rail.

Task 6 — Docs + verify

  • src/ClaudeDo.Data/CLAUDE.md (Models, Schema) and src/ClaudeDo.Worker/CLAUDE.md (Status Model table, picker bullet, TaskStateService bullet) describe the relation table (acceptance #7).
  • VERIFY (acceptance #8): dotnet build src/ClaudeDo.App -c Release, dotnet build src/ClaudeDo.Worker -c Release, dotnet test for ClaudeDo.Data.Tests, ClaudeDo.Worker.Tests, ClaudeDo.Ui.Tests (-c Release), report counts.