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.cssrc/ClaudeDo.Data/Configuration/TaskDependencyEntityConfiguration.cssrc/ClaudeDo.Data/Migrations/<ts>_AddTaskDependencies.cs— scaffoldedsrc/ClaudeDo.Worker/External/TaskBlocking.cs— the sharedblockedReasonbuilder (two consumers:ExternalMcpService,TaskWaitMcpTools)tests/ClaudeDo.Data.Tests/TaskDependencyMigrationTests.cs— realMigrate()on a DB seeded with legacydepends_on_task_idrows
Modify:
src/ClaudeDo.Data/Models/TaskEntity.cs—DependsOnTaskId→Dependenciesnav +[NotMapped] DependsOnTaskIdssrc/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs— drop column/FK/indexsrc/ClaudeDo.Data/ClaudeDoDbContext.cs—DbSet<TaskDependencyEntity>src/ClaudeDo.Data/Repositories/TaskRepository.cs—.Include(t => t.Dependencies)on the three read paths;UpdateAsyncmust not push the nav graphsrc/ClaudeDo.Worker/Queue/QueuePicker.cs—NOT EXISTSover the relation tablesrc/ClaudeDo.Worker/State/TaskStateService.cs—SetDependsOnAsynctakes a set, graph-wide cycle checksrc/ClaudeDo.Worker/State/Interfaces/ITaskStateService.cssrc/ClaudeDo.Worker/External/ExternalMcpService.cs— DTOs,add_task,update_task,ComputeBlockedInfoAsyncsrc/ClaudeDo.Worker/External/TaskWaitMcpTools.cssrc/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs,TasksIslandViewModel.cs— display of n predecessorssrc/ClaudeDo.Data/CLAUDE.md,src/ClaudeDo.Worker/CLAUDE.md- test files listed per task below
Task 1 — Schema + migration
TaskDependencyEntity { string TaskId; string DependsOnTaskId; }, tabletask_dependencies, composite PK, both FKs cascade, index ondepends_on_task_id.TaskEntity.Dependencies(ICollection<TaskDependencyEntity>),[NotMapped] DependsOnTaskIdsprojection.DependsOnTaskIdand 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 … FROMthe first edge, drop table) — lossy for >1 edge, which is inherent and documented in the migration. - Verify:
dotnet ef database updateagainst a temp DB seeded with legacy rows;pragma_table_infodiff.DropColumnforces a SQLite table rebuild — theef-migration-parallel-designer-collisiontrap applies: if another migration lands in parallel, hand-check both.Designer.cssnapshots.
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
QueuePickerreplaces thedepends_on_task_id IS NULL OR EXISTS(...)clause with a doubly-negatedNOT EXISTSovertask_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; reachingtaskIdis a cycle. Visited set guards pre-existing bad data. - Replace rows transactionally (
ExecuteDeletefortaskId+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_taskkeepdependsOnTaskId(string) and gaindependsOnTaskIds(string[]?). Precedence:dependsOnTaskIdsnon-null wins; otherwise the legacy scalar is treated as a one-element set, with""still the clear sentinel. Both run throughTaskIdResolverso#123keeps working.TaskDto/TaskRefDto:DependsOnTaskId→DependsOnTaskIds(IReadOnlyList<string>). Only the input form is contractually frozen.TaskBlocking.Describebuilds one reason naming every unmet predecessor (acceptance #4);ComputeBlockedInfoAsyncandTaskWaitMcpTools.CheckOnceAsyncboth 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;DependsOnTaskIdbecomes a computed "single predecessor or null" so the existing linear-chain rail is unchanged for the 1-dependency case.BuildAfterLabeljoins all resolvable predecessors (#3, #7); a row with >1 predecessor gets the label, never a rail (a rail can only render a line).GroupingKeyfolds the joined ids so the reconcile tick still detects a change..Include(t => t.Dependencies)on theTasksIslandViewModelquery 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) andsrc/ClaudeDo.Worker/CLAUDE.md(Status Model table, picker bullet,TaskStateServicebullet) describe the relation table (acceptance #7).- VERIFY (acceptance #8):
dotnet build src/ClaudeDo.App -c Release,dotnet build src/ClaudeDo.Worker -c Release,dotnet testforClaudeDo.Data.Tests,ClaudeDo.Worker.Tests,ClaudeDo.Ui.Tests(-c Release), report counts.