From 84d955dfc70a64c0b9f5777fb071161db0d4a357 Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Wed, 2 Sep 2026 14:15:39 +0200 Subject: [PATCH 1/4] docs(claude-do): plan for multi-dependency depends_on relation table --- .../plans/2026-09-02-multi-dependencies.md | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-02-multi-dependencies.md diff --git a/docs/superpowers/plans/2026-09-02-multi-dependencies.md b/docs/superpowers/plans/2026-09-02-multi-dependencies.md new file mode 100644 index 00000000..ec03d7e7 --- /dev/null +++ b/docs/superpowers/plans/2026-09-02-multi-dependencies.md @@ -0,0 +1,147 @@ +# 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/_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` +- `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`), `[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 + 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`). 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. From 81a5fc5d33c10a8ca33f29232b47446008ddff47 Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Wed, 2 Sep 2026 14:26:56 +0200 Subject: [PATCH 2/4] feat(worker): surface the handoff round budget in the merge-helper handoff brief A handler session only learned the chain was capped when handoff_list_handler threw at the end of its work, stranding open tasks. The round count already lived in HandoffRoundTracker the whole time -- it just never reached the brief. InteractiveLaunchSpecService now reads HandoffRoundTracker.GetState (read-only, doesn't advance the round) and renders round N of MaxHandoffs plus the remaining count into the handoff brief, replacing the old one-line finalNote. The rejection error also names the next action (start a new handler run) instead of just failing. --- src/ClaudeDo.Data/PromptFiles.cs | 2 +- .../External/HandoffRoundTracker.cs | 18 +++++- src/ClaudeDo.Worker/Program.cs | 3 +- .../Runner/InteractiveLaunchSpecService.cs | 28 ++++++--- .../External/HandoffMcpToolsTests.cs | 57 +++++++++++++++++++ .../Hub/MergeHelperTaskHubTests.cs | 2 +- .../InteractiveLaunchSpecServiceTests.cs | 18 +++++- 7 files changed, 110 insertions(+), 18 deletions(-) diff --git a/src/ClaudeDo.Data/PromptFiles.cs b/src/ClaudeDo.Data/PromptFiles.cs index 0c67850a..fed4518c 100644 --- a/src/ClaudeDo.Data/PromptFiles.cs +++ b/src/ClaudeDo.Data/PromptFiles.cs @@ -617,7 +617,7 @@ public static class PromptFiles Start with phase 3 (run), continuing through review/merge and the summary as your instructions describe. - {finalNote} + {roundInfo} """; private const string WeeklyReportDefault = """ diff --git a/src/ClaudeDo.Worker/External/HandoffRoundTracker.cs b/src/ClaudeDo.Worker/External/HandoffRoundTracker.cs index 56516242..92f6f63e 100644 --- a/src/ClaudeDo.Worker/External/HandoffRoundTracker.cs +++ b/src/ClaudeDo.Worker/External/HandoffRoundTracker.cs @@ -5,8 +5,8 @@ namespace ClaudeDo.Worker.External; // Bounds the list-handler wait<->merge handoff chain server-side, so a model that ignores the // cooperative "this is the FINAL round" line in handoff.md (InteractiveLaunchSpecService's -// finalNote) cannot loop wait->merge->wait->merge forever, each round spawning a fresh ConPTY -// session. Keyed by handler task id -- a "Let Claude handle it" run always creates a fresh +// roundInfo, read back via GetState) cannot loop wait->merge->wait->merge forever, each round +// spawning a fresh ConPTY session. Keyed by handler task id -- a "Let Claude handle it" run always creates a fresh // handler task (InteractiveLaunchSpecService.CreateMergeHelperTaskAsync), so the counter is // per-run by construction: no reset logic needed, and a later run on the same list starts at 0 // on its own. In-memory only, same tradeoff as TaskRunTokenRegistry -- a worker restart mid-run @@ -26,6 +26,17 @@ public sealed class HandoffRoundTracker public sealed record Result(bool Rejected, bool Coerced, string EffectivePhase, string? Error); + /// Read-only snapshot for a handler task -- does NOT register a round. Used to report + /// the current round/remaining budget (e.g. in the handoff brief) without mutating state that + /// only Register should advance. Count is 0 for a task that hasn't handed off yet. + public (int Count, bool Sealed) GetState(string taskId) + { + if (!_states.TryGetValue(taskId, out var state)) + return (0, false); + lock (state) + return (state.Count, state.Sealed); + } + public Result Register(string taskId, string nextPhase) { var state = _states.GetOrAdd(taskId, _ => new TaskState()); @@ -33,7 +44,8 @@ public sealed class HandoffRoundTracker { if (state.Sealed) return new Result(true, false, nextPhase, - $"Task {taskId} already completed its merge_final round -- no further handoff is allowed."); + $"Task {taskId} already completed its merge_final round -- no further handoff is allowed. " + + "Start a new handler run (\"Let Claude handle it\") on this list instead."); state.Count++; var isFinal = nextPhase is MergeHelperPhase.WaitFinal or MergeHelperPhase.MergeFinal; diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs index 942e2d83..36d68698 100644 --- a/src/ClaudeDo.Worker/Program.cs +++ b/src/ClaudeDo.Worker/Program.cs @@ -94,6 +94,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -335,6 +336,7 @@ if (cfg.ExternalMcpPort > 0) Share(); Share(); Share(); + Share(); externalBuilder.Services.AddScoped(sp => sp.GetRequiredService>().CreateDbContext()); @@ -342,7 +344,6 @@ if (cfg.ExternalMcpPort > 0) externalBuilder.Services.AddScoped(); externalBuilder.Services.AddScoped(); externalBuilder.Services.AddScoped(); - externalBuilder.Services.AddSingleton(); externalBuilder.Services.AddScoped(); externalBuilder.Services.AddScoped(); externalBuilder.Services.AddScoped(); diff --git a/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs b/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs index e0f65201..f927828d 100644 --- a/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs +++ b/src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs @@ -4,6 +4,7 @@ using ClaudeDo.Data.Git; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Config; +using ClaudeDo.Worker.External; using ClaudeDo.Worker.Planning; using ClaudeDo.Worker.Skills; using Microsoft.EntityFrameworkCore; @@ -42,6 +43,7 @@ public sealed class InteractiveLaunchSpecService private readonly WorktreeManager _wtManager; private readonly GitService _git; private readonly string _claudePath; + private readonly HandoffRoundTracker _handoffRounds; public InteractiveLaunchSpecService( IDbContextFactory dbFactory, @@ -49,7 +51,8 @@ public sealed class InteractiveLaunchSpecService ISessionSkillRegistry skillRegistry, WorktreeManager wtManager, GitService git, - WorkerConfig cfg) + WorkerConfig cfg, + HandoffRoundTracker handoffRounds) { _dbFactory = dbFactory; _skillSeeder = skillSeeder; @@ -57,6 +60,7 @@ public sealed class InteractiveLaunchSpecService _wtManager = wtManager; _git = git; _claudePath = cfg.ClaudeBin; + _handoffRounds = handoffRounds; } /// Builds a LaunchSpec for opening an interactive session in a task's worktree. @@ -393,13 +397,19 @@ public sealed class InteractiveLaunchSpecService var systemPromptPath = Path.Combine(sessionDir, "system-prompt.md"); await File.WriteAllTextAsync(systemPromptPath, PromptFiles.ReadOrDefault(phase.PromptKind), ct); - // A "_final" round differs from its non-final counterpart in exactly one rendered line -- - // the same system prompt and model drive both -- marking the final round and forbidding a - // further rerun, so the chain is bounded structurally rather than by prompt appeal alone. - var finalNote = phase.IsFinal - ? "\nThis is the FINAL round of this list-handler run -- do not start any further reruns; " + - "merge what you can, report the rest, and print the summary." - : ""; + // The round count/cap come from HandoffRoundTracker -- the same instance HandoffMcpTools + // registered this handoff against -- never recomputed here, so the brief can't drift from + // the server-side cap that actually enforces the chain. A "_final" round adds one more line + // forbidding a further rerun, so the chain is bounded structurally rather than by prompt + // appeal alone. + var round = _handoffRounds.GetState(taskId); + var remaining = Math.Max(HandoffRoundTracker.MaxHandoffs - round.Count, 0); + var roundInfo = $"Handoff budget: this is round {round.Count} of " + + $"{HandoffRoundTracker.MaxHandoffs} possible handoffs for this list-handler run " + + $"({remaining} more possible after this one)."; + if (phase.IsFinal) + roundInfo += "\nThis is the FINAL round of this list-handler run -- do not start any further reruns; " + + "merge what you can, report the rest, and print the summary."; var briefPath = Path.Combine(sessionDir, "handoff.md"); await File.WriteAllTextAsync(briefPath, PromptFiles.Render(PromptKind.MergeHelperHandoff, @@ -409,7 +419,7 @@ public sealed class InteractiveLaunchSpecService ["repo"] = repoDir, ["handlerTaskId"] = taskId, ["tasks"] = RenderGroupedBrief(briefEntries), - ["finalNote"] = finalNote, + ["roundInfo"] = roundInfo, }), ct); var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath) diff --git a/tests/ClaudeDo.Worker.Tests/External/HandoffMcpToolsTests.cs b/tests/ClaudeDo.Worker.Tests/External/HandoffMcpToolsTests.cs index f7f6450d..4767b5dd 100644 --- a/tests/ClaudeDo.Worker.Tests/External/HandoffMcpToolsTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/HandoffMcpToolsTests.cs @@ -198,6 +198,63 @@ public sealed class HandoffMcpToolsTests : IDisposable secondSut.HandoffListHandler(handlerTask.Id, survivors, "wait", CancellationToken.None)); } + [Theory] + [InlineData(1, 3)] + [InlineData(2, 2)] + [InlineData(4, 0)] + public void HandoffRoundTracker_GetState_ReportsCountAfterNRegistrations(int registrations, int expectedRemaining) + { + var tracker = new HandoffRoundTracker(); + var taskId = Guid.NewGuid().ToString(); + + for (var i = 0; i < registrations; i++) + tracker.Register(taskId, "wait"); + + var (count, sealedState) = tracker.GetState(taskId); + Assert.Equal(registrations, count); + Assert.Equal(expectedRemaining, HandoffRoundTracker.MaxHandoffs - count); + Assert.False(sealedState); + } + + [Fact] + public void HandoffRoundTracker_GetState_UnregisteredTask_ReturnsZero() + { + var tracker = new HandoffRoundTracker(); + var (count, sealedState) = tracker.GetState(Guid.NewGuid().ToString()); + Assert.Equal(0, count); + Assert.False(sealedState); + } + + [Fact] + public void HandoffRoundTracker_Register_DoesNotMutateStateVisibleFromGetState_BeyondItsOwnCall() + { + var tracker = new HandoffRoundTracker(); + var taskId = Guid.NewGuid().ToString(); + + tracker.Register(taskId, "wait"); + var (beforeCount, _) = tracker.GetState(taskId); + Assert.Equal(1, beforeCount); + + // GetState itself must be read-only -- calling it repeatedly must not advance the round. + tracker.GetState(taskId); + tracker.GetState(taskId); + var (afterCount, _) = tracker.GetState(taskId); + Assert.Equal(beforeCount, afterCount); + } + + [Fact] + public void HandoffRoundTracker_RejectedHandoff_ErrorNamesTheNextAction() + { + var tracker = new HandoffRoundTracker(); + var taskId = Guid.NewGuid().ToString(); + tracker.Register(taskId, "merge_final"); + + var result = tracker.Register(taskId, "wait"); + + Assert.True(result.Rejected); + Assert.Contains("new handler run", result.Error, StringComparison.OrdinalIgnoreCase); + } + [Fact] public async Task HandoffListHandler_TwoHandlerTasks_DoNotShareACounter() { diff --git a/tests/ClaudeDo.Worker.Tests/Hub/MergeHelperTaskHubTests.cs b/tests/ClaudeDo.Worker.Tests/Hub/MergeHelperTaskHubTests.cs index ed3535c3..91b919d9 100644 --- a/tests/ClaudeDo.Worker.Tests/Hub/MergeHelperTaskHubTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Hub/MergeHelperTaskHubTests.cs @@ -64,7 +64,7 @@ public sealed class MergeHelperTaskHubTests : IDisposable var wtManager = new WorktreeManager(git, factory, new WorkerConfig(), NullLogger.Instance); var interactiveLaunchSpec = new InteractiveLaunchSpecService( factory, new FakeSessionSkillSeeder(), new FakeSessionSkillRegistry(), wtManager, git, - new WorkerConfig { ClaudeBin = "claude" }); + new WorkerConfig { ClaudeBin = "claude" }, new ClaudeDo.Worker.External.HandoffRoundTracker()); var built = TaskStateServiceBuilder.Build(factory); var reviewSubmission = new InteractiveReviewSubmissionService(factory, git, built.State, wtManager); diff --git a/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs index cf433340..cc2a3e5a 100644 --- a/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs @@ -3,6 +3,7 @@ using ClaudeDo.Data.Git; using ClaudeDo.Data.Models; using ClaudeDo.Data.Repositories; using ClaudeDo.Worker.Config; +using ClaudeDo.Worker.External; using ClaudeDo.Worker.Planning; using ClaudeDo.Worker.Runner; using ClaudeDo.Worker.Skills; @@ -72,11 +73,12 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable => Task.FromResult>(Installed); } - private InteractiveLaunchSpecService BuildService() => + private InteractiveLaunchSpecService BuildService(HandoffRoundTracker? handoffRounds = null) => new(_db.CreateFactory(), _seeder, _registry, new WorktreeManager(new GitService(), _db.CreateFactory(), new WorkerConfig(), NullLogger.Instance), new GitService(), - new WorkerConfig { ClaudeBin = _claudeStubPath }); + new WorkerConfig { ClaudeBin = _claudeStubPath }, + handoffRounds ?? new HandoffRoundTracker()); private async Task SeedListAsync(string? workingDir = null, string name = "L") { @@ -1043,7 +1045,13 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable var beforeCount = await CountTasksAsync(); - var svc = BuildService(); + // In production, HandoffMcpTools.HandoffListHandler calls Register BEFORE the hub asks + // this service to build the spec -- simulate that here so the brief's round info is + // demonstrably read from HandoffRoundTracker, not recomputed. + var tracker = new HandoffRoundTracker(); + tracker.Register(handlerTaskId, nextPhase); + + var svc = BuildService(tracker); var spec = await svc.BuildForMergeHelperHandoffAsync(handlerTaskId, new[] { survivor }, nextPhase, CancellationToken.None); var sessionDir = TrackSessionDir(spec); @@ -1090,6 +1098,10 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable Assert.Contains(survivor, handoffBrief); Assert.Contains("phase 3", handoffBrief, StringComparison.OrdinalIgnoreCase); + // Round info always names the current round and the total cap -- this is the ONE + // Register call above, so round 1 of MaxHandoffs. + Assert.Contains($"round 1 of {HandoffRoundTracker.MaxHandoffs}", handoffBrief); + if (expectFinalNote) { Assert.Contains("FINAL round", handoffBrief); From 2330b426c675ee2ec570ac4f0dbe2f089e1938eb Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Wed, 2 Sep 2026 14:43:44 +0200 Subject: [PATCH 3/4] =?UTF-8?q?=EF=BB=BFfeat(claude-do):=20Expose=20Handof?= =?UTF-8?q?fRoundTracker=20budget=20via=20an=20MCP=20tool=20(e.g.=20get=5F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The handoff-budget task (round/remaining/final now surfaced in the handoff.md brief via HandoffRoundTracker.GetStatus, wired through InteractiveLaunchSpecService.BuildForMergeHelperHandoffAsync) only reaches the session at the start of a handoff round. There is no way to query the current round/remaining budget for a handler task mid-session or from outside (e.g. the UI, or a script). Consider add ClaudeDo-Task: 0a185f8e-823d-42fc-9a5b-97c5eb91c616 --- .../External/HandoffMcpTools.cs | 22 +++++++ .../External/HandoffRoundTracker.cs | 14 +++++ .../External/ExternalMcpToolSchemaTests.cs | 2 +- .../External/HandoffMcpToolsTests.cs | 60 +++++++++++++++++++ 4 files changed, 97 insertions(+), 1 deletion(-) diff --git a/src/ClaudeDo.Worker/External/HandoffMcpTools.cs b/src/ClaudeDo.Worker/External/HandoffMcpTools.cs index 434a7fe7..aaf9ad3a 100644 --- a/src/ClaudeDo.Worker/External/HandoffMcpTools.cs +++ b/src/ClaudeDo.Worker/External/HandoffMcpTools.cs @@ -11,6 +11,8 @@ namespace ClaudeDo.Worker.External; // reflects the effective (post-coercion) value in that case. public sealed record HandoffListHandlerResult(bool Requested, string TaskId, int Number, int SurvivingCount, string NextPhase, bool Coerced = false); +public sealed record HandoffStatusResult(string TaskId, int Number, int Round, int Remaining, bool Final); + [McpServerToolType] public sealed class HandoffMcpTools { @@ -66,4 +68,24 @@ public sealed class HandoffMcpTools await _broadcaster.HandoffRequested(taskId, survivingTaskIds, round.EffectivePhase); return new HandoffListHandlerResult(true, taskId, task.Number, survivingTaskIds.Count, round.EffectivePhase, round.Coerced); } + + [McpServerTool, Description( + "Read-only lookup of a handler task's handoff-round budget (round already used, rounds " + + "remaining before handoff_list_handler starts coercing to a _final phase, and whether the " + + "chain is already sealed by a completed merge_final round) -- for checking the budget mid-" + + "session or from outside a handoff, instead of only seeing it once at the start of a round. " + + "A task that never called handoff_list_handler reports round 0, its full remaining budget, " + + "and final=false. In-memory only, same as the tracker itself: a worker restart resets it." + + McpToolDocs.TaskNumberHint)] + public async Task GetHandoffStatus( + [Description("The handler task's own id.")] string taskId, + CancellationToken cancellationToken = default) + { + taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken); + var task = await _tasks.GetByIdAsync(taskId, cancellationToken) + ?? throw new InvalidOperationException($"Task {taskId} not found."); + + var status = _rounds.GetStatus(taskId); + return new HandoffStatusResult(taskId, task.Number, status.Round, status.Remaining, status.Final); + } } diff --git a/src/ClaudeDo.Worker/External/HandoffRoundTracker.cs b/src/ClaudeDo.Worker/External/HandoffRoundTracker.cs index 56516242..4d6b6721 100644 --- a/src/ClaudeDo.Worker/External/HandoffRoundTracker.cs +++ b/src/ClaudeDo.Worker/External/HandoffRoundTracker.cs @@ -26,6 +26,20 @@ public sealed class HandoffRoundTracker public sealed record Result(bool Rejected, bool Coerced, string EffectivePhase, string? Error); + // Read-only budget lookup for a handler task that hasn't (yet) called HandoffListHandler -- + // Round/Remaining/Final all report their untouched defaults instead of throwing, since "no + // handoff yet" is a normal state, not an error. + public sealed record Status(int Round, int Remaining, bool Final); + + public Status GetStatus(string taskId) + { + if (!_states.TryGetValue(taskId, out var state)) + return new Status(0, MaxHandoffs, false); + + lock (state) + return new Status(state.Count, Math.Max(0, MaxHandoffs - state.Count), state.Sealed); + } + public Result Register(string taskId, string nextPhase) { var state = _states.GetOrAdd(taskId, _ => new TaskState()); diff --git a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpToolSchemaTests.cs b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpToolSchemaTests.cs index 9f6ba4ee..c35ac144 100644 --- a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpToolSchemaTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpToolSchemaTests.cs @@ -106,7 +106,7 @@ public sealed class ExternalMcpToolSchemaTests "list_runs", "get_run", "get_task_log", "list_agents", "reset_failed_task", "submit_task_for_review", - "handoff_list_handler", + "handoff_list_handler", "get_handoff_status", "get_app_settings", "wait_for_task_change", "get_queue_state", diff --git a/tests/ClaudeDo.Worker.Tests/External/HandoffMcpToolsTests.cs b/tests/ClaudeDo.Worker.Tests/External/HandoffMcpToolsTests.cs index f7f6450d..c7847300 100644 --- a/tests/ClaudeDo.Worker.Tests/External/HandoffMcpToolsTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/HandoffMcpToolsTests.cs @@ -220,4 +220,64 @@ public sealed class HandoffMcpToolsTests : IDisposable Assert.False(resultB.Coerced); Assert.Equal("wait", resultB.NextPhase); } + + [Fact] + public async Task GetHandoffStatus_NeverHandedOff_ReportsZeroRoundFullBudget() + { + var listId = await SeedListAsync(); + var handlerTask = await SeedTaskAsync(listId); + + var sut = BuildSut(); + var status = await sut.GetHandoffStatus(handlerTask.Id, CancellationToken.None); + + Assert.Equal(handlerTask.Id, status.TaskId); + Assert.Equal(handlerTask.Number, status.Number); + Assert.Equal(0, status.Round); + Assert.Equal(HandoffRoundTracker.MaxHandoffs, status.Remaining); + Assert.False(status.Final); + } + + [Fact] + public async Task GetHandoffStatus_AfterHandoffs_ReflectsTrackerState() + { + var listId = await SeedListAsync(); + var handlerTask = await SeedTaskAsync(listId); + var survivor = await SeedTaskAsync(listId, TaskStatus.WaitingForReview, title: "Survivor"); + var tracker = new HandoffRoundTracker(); + var survivors = new[] { survivor.Id }; + + await BuildSut(tracker).HandoffListHandler(handlerTask.Id, survivors, "wait", CancellationToken.None); + await BuildSut(tracker).HandoffListHandler(handlerTask.Id, survivors, "merge", CancellationToken.None); + + var status = await BuildSut(tracker).GetHandoffStatus(handlerTask.Id, CancellationToken.None); + + Assert.Equal(2, status.Round); + Assert.Equal(HandoffRoundTracker.MaxHandoffs - 2, status.Remaining); + Assert.False(status.Final); + } + + [Fact] + public async Task GetHandoffStatus_AfterMergeFinal_ReportsFinal() + { + var listId = await SeedListAsync(); + var handlerTask = await SeedTaskAsync(listId); + var survivor = await SeedTaskAsync(listId, TaskStatus.WaitingForReview, title: "Survivor"); + var tracker = new HandoffRoundTracker(); + var survivors = new[] { survivor.Id }; + + await BuildSut(tracker).HandoffListHandler(handlerTask.Id, survivors, "merge_final", CancellationToken.None); + + var status = await BuildSut(tracker).GetHandoffStatus(handlerTask.Id, CancellationToken.None); + + Assert.True(status.Final); + Assert.Equal(1, status.Round); + } + + [Fact] + public async Task GetHandoffStatus_UnknownTask_Throws() + { + var sut = BuildSut(); + await Assert.ThrowsAsync(() => + sut.GetHandoffStatus("missing", CancellationToken.None)); + } } From c25732cff8d1921f22fbf4934a7d2db8ebfcf1ba Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Wed, 2 Sep 2026 15:08:05 +0200 Subject: [PATCH 4/4] 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 --- src/ClaudeDo.Data/CLAUDE.md | 22 +- src/ClaudeDo.Data/ClaudeDoDbContext.cs | 1 + .../TaskDependencyEntityConfiguration.cs | 32 + .../Configuration/TaskEntityConfiguration.cs | 10 +- ...0902121712_AddTaskDependencies.Designer.cs | 1011 +++++++++++++++++ .../20260902121712_AddTaskDependencies.cs | 104 ++ .../ClaudeDoDbContextModelSnapshot.cs | 47 +- .../Models/TaskDependencyEntity.cs | 13 + src/ClaudeDo.Data/Models/TaskEntity.cs | 19 +- .../Repositories/TaskRepository.cs | 12 +- .../ViewModels/Islands/TaskRowViewModel.cs | 14 +- .../Islands/TasksIslandViewModel.cs | 34 +- src/ClaudeDo.Worker/CLAUDE.md | 6 +- .../External/ExternalMcpService.cs | 106 +- src/ClaudeDo.Worker/External/TaskBlocking.cs | 42 + .../External/TaskWaitMcpTools.cs | 29 +- src/ClaudeDo.Worker/Queue/QueuePicker.cs | 22 +- .../State/Interfaces/ITaskStateService.cs | 7 +- src/ClaudeDo.Worker/State/TaskStateService.cs | 109 +- tests/ClaudeDo.Data.Tests/ForeignKeyTests.cs | 15 +- .../TaskDependencyMigrationTests.cs | 100 ++ .../ViewModels/TasksIslandChainTests.cs | 36 +- .../TasksIslandClassifyItemsTests.cs | 7 +- .../TasksIslandReconcileTickTests.cs | 3 +- .../External/BatchMcpToolsTests.cs | 3 +- .../External/ExternalMcpServiceTests.cs | 116 +- .../External/TaskWaitMcpToolsTests.cs | 6 +- .../Infrastructure/TestDependencies.cs | 20 + .../PlanningMergeOrchestratorTests.cs | 2 +- .../Queue/QueuePickerTests.cs | 60 +- .../State/TaskStateServiceTests.cs | 91 +- 31 files changed, 1893 insertions(+), 206 deletions(-) create mode 100644 src/ClaudeDo.Data/Configuration/TaskDependencyEntityConfiguration.cs create mode 100644 src/ClaudeDo.Data/Migrations/20260902121712_AddTaskDependencies.Designer.cs create mode 100644 src/ClaudeDo.Data/Migrations/20260902121712_AddTaskDependencies.cs create mode 100644 src/ClaudeDo.Data/Models/TaskDependencyEntity.cs create mode 100644 src/ClaudeDo.Worker/External/TaskBlocking.cs create mode 100644 tests/ClaudeDo.Data.Tests/TaskDependencyMigrationTests.cs create mode 100644 tests/ClaudeDo.Worker.Tests/Infrastructure/TestDependencies.cs diff --git a/src/ClaudeDo.Data/CLAUDE.md b/src/ClaudeDo.Data/CLAUDE.md index 600972f5..f843e3f4 100644 --- a/src/ClaudeDo.Data/CLAUDE.md +++ b/src/ClaudeDo.Data/CLAUDE.md @@ -4,9 +4,10 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio ## Models -- **TaskEntity** — Id, ListId, Title, Description, Status, PlanningPhase, BlockedByTaskId (FK to predecessor in a chain), DependsOnTaskId (FK to a user/MCP-declared predecessor, distinct from BlockedByTaskId), ScheduledFor, Result, ReviewFeedback, LogPath, timestamps, CommitType, Model / SystemPrompt / AgentPath / MaxTurns / PermissionMode (nullable overrides), IsStarred, IsMyDay, IsManual, Notes, ParentTaskId, PlanningSessionId / PlanningSessionToken / PlanningFinalizedAt, CreatedBy, HandlerBaseCommit / HandlerHeadCommit, InteractiveSessionId, TicketRef, Number (global immutable human-readable alias). +- **TaskEntity** — Id, ListId, Title, Description, Status, PlanningPhase, BlockedByTaskId (FK to predecessor in a chain), Dependencies (n user/MCP-declared predecessors via `task_dependencies`, distinct from BlockedByTaskId), ScheduledFor, Result, ReviewFeedback, LogPath, timestamps, CommitType, Model / SystemPrompt / AgentPath / MaxTurns / PermissionMode (nullable overrides), IsStarred, IsMyDay, IsManual, Notes, ParentTaskId, PlanningSessionId / PlanningSessionToken / PlanningFinalizedAt, CreatedBy, HandlerBaseCommit / HandlerHeadCommit, InteractiveSessionId, TicketRef, Number (global immutable human-readable alias). - `TicketRef` (`ticket_ref`, nullable string) — link to an external ticket, format `:` (currently always `bandel:`). Set by the ticket import, otherwise null. → `ClaudeDo.Worker/CLAUDE.md` Tickets section. - - Status / PlanningPhase / BlockedByTaskId / DependsOnTaskId semantics + allowed transitions: `ClaudeDo.Worker/CLAUDE.md` → Status Model. + - Status / PlanningPhase / BlockedByTaskId / dependency-set semantics + allowed transitions: `ClaudeDo.Worker/CLAUDE.md` → Status Model. + - `Dependencies` (`ICollection`) + the `[NotMapped] DependsOnTaskIds` id projection. **Only populated where the query `Include`s it** — `TaskRepository.GetByIdAsync`/`GetByNumberAsync`/`GetByListIdAsync` do, and so do the `TasksIslandViewModel` row-hydration queries. The queue gate itself is raw SQL in `QueuePicker` and never reads the nav, so a forgotten Include misreports the UI/MCP `blocked` flag but can never let a blocked task run. ⚠️ `TaskRepository.UpdateAsync` marks **only the root entity** Modified (`Entry().State`, not `Update()`) — `Update()` marks the loaded graph, and an all-key `task_dependencies` row has nothing to SET. - `HandlerBaseCommit`/`HandlerHeadCommit` = the review range for a **worktree-less "list handler" host task** ("Let Claude handle it"), which commits straight into the list's working dir instead of a per-task worktree. Everything that reads a task's diff falls back to this pair whenever `Worktree` is null → [conpty-sessions](../../docs/explore-notes/conpty-sessions.md). - `InteractiveSessionId` = the claude session id an embedded ConPTY interactive task session runs under, persisted by `InteractiveLaunchSpecService` before launch so a closed/aborted session can be resumed → [conpty-sessions](../../docs/explore-notes/conpty-sessions.md). - `Number` (INTEGER NOT NULL, unique index `idx_tasks_number`) — a global, monotonically increasing display alias for tasks. **Never reused**: deleting a task leaves a gap in numbering, ensuring that a task number that appears in a log or report always points to the same task (if it exists). Allocated by `TaskNumberAllocator.AddWithNumberAsync` on every insert via a persistent counter (`app_settings.next_task_number`). **Why not `MAX(number) + 1`:** deleting the highest-numbered task would free its number for reuse, breaking the immutability guarantee. The allocator uses an `UPDATE…RETURNING` statement to claim numbers atomically, retrying up to 5 times if a uniqueness collision occurs (the counter advances on each attempt, so retries always allocate fresh numbers). The one-time backfill for pre-existing rows lived in the `AddTaskNumbers` migration, squashed away 2026-08-26. @@ -20,6 +21,7 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio - **DailyNoteEntity** — Id, Date (DateOnly), Text, SortOrder, CreatedAt → `daily_notes` - **WeekReportEntity** — Id, StartDate/EndDate (DateOnly), Markdown, GeneratedAt → `week_reports`, unique index on (start_date, end_date) - **TaskAttachmentEntity** — Id, TaskId (FK, ON DELETE CASCADE), FileName, ByteSize, CreatedAt → `task_attachments` +- **TaskDependencyEntity** — one declared "run after" edge: TaskId + DependsOnTaskId, composite PK, table `task_dependencies`. Both FKs are `ON DELETE CASCADE`, which is what the old single `depends_on_task_id` column got from `ON DELETE SET NULL`: deleting a predecessor drops the edge so the dependent becomes pickable instead of blocked on a task that's gone. Written **only** by `TaskStateService.SetDependsOnAsync`. - **SubtaskEntity**, **AgentInfo** — subtasks / record for scanned agent files ### AppSettingsEntity @@ -103,6 +105,7 @@ Tables (one per line so parallel migrations don't collide on the same line): - `daily_notes` - `week_reports` - `task_attachments` +- `task_dependencies` Managed by EF Core migrations in `Migrations/`. **The 40 incremental migrations were squashed into a single `20260826094154_InitialCreate` on 2026-08-26** — the schema history is git history now, @@ -112,8 +115,9 @@ not a migration chain. Consequences: (pre-EF databases without a history, and databases whose history still lists the old chain ending at `20260825063230_AddPrimeActionKind`). A database that stopped *mid*-chain can no longer be upgraded and throws with a reinstall message instead of being stamped onto a schema it lacks. - Covered by `MigrationBaselineTests` — the only test that runs a real `Migrate()` (every other - Data.Tests fixture uses `EnsureCreated`, which skips migrations entirely). Its fixture builds the + Covered by `MigrationBaselineTests` — one of only two tests that run a real `Migrate()` + (`TaskDependencyMigrationTests` is the other; every other Data.Tests fixture uses + `EnsureCreated`, which skips migrations entirely). Its fixture builds the legacy database by migrating **to** `InitialCreate` and seeding rows with raw SQL: `EnsureCreated` would build *today's* schema, so every post-squash migration would then re-add an existing column ("duplicate column name"). Assert "nothing pending", never "InitialCreate is the only history row". @@ -122,10 +126,12 @@ not a migration chain. Consequences: Release` — `Data` is its own startup project (it holds the `Design` package and a design-time factory), and `--configuration Release` is needed because a running Worker locks `Debug`. -`tasks` holds `status`, `planning_phase` (default `none`), -`blocked_by_task_id` (FK to `tasks.id`, `ON DELETE SET NULL`), and `depends_on_task_id` (same FK -shape, but a separate column — see Worker/CLAUDE.md → Status Model for why it isn't unified with -`blocked_by_task_id`). +`tasks` holds `status`, `planning_phase` (default `none`) and `blocked_by_task_id` (FK to +`tasks.id`, `ON DELETE SET NULL`). The user/MCP-declared predecessors live in `task_dependencies` +instead — a **set**, so a task can wait on n of them; see Worker/CLAUDE.md → Status Model for why +it isn't unified with `blocked_by_task_id`. `AddTaskDependencies` (2026-09-02) migrated the former +single `tasks.depends_on_task_id` column into that table (each value became a one-element set) and +dropped it; the `Down` direction is inherently lossy for more than one edge. ## Conventions diff --git a/src/ClaudeDo.Data/ClaudeDoDbContext.cs b/src/ClaudeDo.Data/ClaudeDoDbContext.cs index 9e466fb4..901d0838 100644 --- a/src/ClaudeDo.Data/ClaudeDoDbContext.cs +++ b/src/ClaudeDo.Data/ClaudeDoDbContext.cs @@ -41,6 +41,7 @@ public class ClaudeDoDbContext : DbContext => optionsBuilder.AddInterceptors(SqliteForeignKeyInterceptor.Instance); public DbSet Tasks => Set(); + public DbSet TaskDependencies => Set(); public DbSet Lists => Set(); public DbSet ListConfigs => Set(); public DbSet Worktrees => Set(); diff --git a/src/ClaudeDo.Data/Configuration/TaskDependencyEntityConfiguration.cs b/src/ClaudeDo.Data/Configuration/TaskDependencyEntityConfiguration.cs new file mode 100644 index 00000000..fa1036bb --- /dev/null +++ b/src/ClaudeDo.Data/Configuration/TaskDependencyEntityConfiguration.cs @@ -0,0 +1,32 @@ +using ClaudeDo.Data.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace ClaudeDo.Data.Configuration; + +public class TaskDependencyEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("task_dependencies"); + + builder.HasKey(d => new { d.TaskId, d.DependsOnTaskId }); + builder.Property(d => d.TaskId).HasColumnName("task_id"); + builder.Property(d => d.DependsOnTaskId).HasColumnName("depends_on_task_id"); + + // Cascade on both sides reproduces what the old single depends_on_task_id column got from + // ON DELETE SET NULL: deleting the predecessor drops the edge, so the dependent becomes + // pickable rather than blocked forever on a task that no longer exists. + builder.HasOne() + .WithMany(t => t.Dependencies) + .HasForeignKey(d => d.TaskId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasOne() + .WithMany() + .HasForeignKey(d => d.DependsOnTaskId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasIndex(d => d.DependsOnTaskId).HasDatabaseName("idx_task_dependencies_depends_on"); + } +} diff --git a/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs b/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs index cc48831a..6436706f 100644 --- a/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs +++ b/src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs @@ -75,7 +75,6 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration builder.Property(t => t.PlanningPhase).HasColumnName("planning_phase").IsRequired() .HasConversion(PhaseConverter).HasDefaultValue(PlanningPhase.None); builder.Property(t => t.BlockedByTaskId).HasColumnName("blocked_by_task_id"); - builder.Property(t => t.DependsOnTaskId).HasColumnName("depends_on_task_id"); builder.Property(t => t.ScheduledFor).HasColumnName("scheduled_for"); builder.Property(t => t.Result).HasColumnName("result"); builder.Property(t => t.ReviewFeedback).HasColumnName("review_feedback"); @@ -124,12 +123,8 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration .HasForeignKey(t => t.BlockedByTaskId) .OnDelete(DeleteBehavior.SetNull); - // DependsOn: user/MCP-declared predecessor. SetNull on delete so the dependent becomes - // pickable rather than blocked forever on a task that no longer exists. - builder.HasOne() - .WithMany() - .HasForeignKey(t => t.DependsOnTaskId) - .OnDelete(DeleteBehavior.SetNull); + // DependsOn edges live in task_dependencies (a task can declare n predecessors) — + // configured in TaskDependencyEntityConfiguration, which also owns the Dependencies nav. builder.HasOne(t => t.List) .WithMany(l => l.Tasks) @@ -146,6 +141,5 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration builder.HasIndex(t => new { t.ListId, t.SortOrder }).HasDatabaseName("idx_tasks_list_sort"); builder.HasIndex(t => t.ParentTaskId).HasDatabaseName("idx_tasks_parent_task_id"); builder.HasIndex(t => t.BlockedByTaskId).HasDatabaseName("idx_tasks_blocked_by"); - builder.HasIndex(t => t.DependsOnTaskId).HasDatabaseName("idx_tasks_depends_on"); } } diff --git a/src/ClaudeDo.Data/Migrations/20260902121712_AddTaskDependencies.Designer.cs b/src/ClaudeDo.Data/Migrations/20260902121712_AddTaskDependencies.Designer.cs new file mode 100644 index 00000000..4313e98c --- /dev/null +++ b/src/ClaudeDo.Data/Migrations/20260902121712_AddTaskDependencies.Designer.cs @@ -0,0 +1,1011 @@ +// +using System; +using ClaudeDo.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace ClaudeDo.Data.Migrations +{ + [DbContext(typeof(ClaudeDoDbContext))] + [Migration("20260902121712_AddTaskDependencies")] + partial class AddTaskDependencies + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.11"); + + modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b => + { + b.Property("Id") + .HasColumnType("INTEGER") + .HasColumnName("id"); + + b.Property("AutoContinueOnUsageLimit") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("auto_continue_on_usage_limit"); + + b.Property("CentralWorktreeRoot") + .HasColumnType("TEXT") + .HasColumnName("central_worktree_root"); + + b.Property("DailyPrepMaxTasks") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5) + .HasColumnName("daily_prep_max_tasks"); + + b.Property("DefaultClaudeInstructions") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("") + .HasColumnName("default_claude_instructions"); + + b.Property("DefaultMaxTurns") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(40) + .HasColumnName("default_max_turns"); + + b.Property("DefaultModel") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("sonnet") + .HasColumnName("default_model"); + + b.Property("DefaultPermissionMode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("bypassPermissions") + .HasColumnName("default_permission_mode"); + + b.Property("MaxParallelExecutions") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1) + .HasColumnName("max_parallel_executions"); + + b.Property("MaxTurnsCeiling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(80) + .HasColumnName("max_turns_ceiling"); + + b.Property("ModelPresets") + .HasColumnType("TEXT") + .HasColumnName("model_presets"); + + b.Property("NextTaskNumber") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(1) + .HasColumnName("next_task_number"); + + b.Property("RepoImportFolders") + .HasColumnType("TEXT") + .HasColumnName("repo_import_folders"); + + b.Property("ReportExcludedPaths") + .HasColumnType("TEXT") + .HasColumnName("report_excluded_paths"); + + b.Property("SessionSkills") + .HasColumnType("TEXT") + .HasColumnName("session_skills"); + + b.Property("StandupWeekday") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(3) + .HasColumnName("standup_weekday"); + + b.Property("UsageGateFiveHourPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(80) + .HasColumnName("usage_gate_five_hour_pct"); + + b.Property("UsageGateSevenDayPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(90) + .HasColumnName("usage_gate_seven_day_pct"); + + b.Property("UsageThrottleFiveHourHardPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(65) + .HasColumnName("usage_throttle_five_hour_hard_pct"); + + b.Property("UsageThrottleFiveHourSoftPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(50) + .HasColumnName("usage_throttle_five_hour_soft_pct"); + + b.Property("UsageThrottleSevenDayHardPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(65) + .HasColumnName("usage_throttle_seven_day_hard_pct"); + + b.Property("UsageThrottleSevenDaySoftPct") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(50) + .HasColumnName("usage_throttle_seven_day_soft_pct"); + + b.Property("WorktreeAutoCleanupDays") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(7) + .HasColumnName("worktree_auto_cleanup_days"); + + b.Property("WorktreeAutoCleanupEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("worktree_auto_cleanup_enabled"); + + b.Property("WorktreeStrategy") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("sibling") + .HasColumnName("worktree_strategy"); + + b.HasKey("Id"); + + b.ToTable("app_settings", (string)null); + + b.HasData( + new + { + Id = 1, + AutoContinueOnUsageLimit = false, + DailyPrepMaxTasks = 5, + DefaultClaudeInstructions = "", + DefaultMaxTurns = 40, + DefaultModel = "sonnet", + DefaultPermissionMode = "auto", + MaxParallelExecutions = 1, + MaxTurnsCeiling = 80, + NextTaskNumber = 1, + StandupWeekday = 3, + UsageGateFiveHourPct = 80, + UsageGateSevenDayPct = 90, + UsageThrottleFiveHourHardPct = 65, + UsageThrottleFiveHourSoftPct = 50, + UsageThrottleSevenDayHardPct = 65, + UsageThrottleSevenDaySoftPct = 50, + WorktreeAutoCleanupDays = 7, + WorktreeAutoCleanupEnabled = false, + WorktreeStrategy = "sibling" + }); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Date") + .HasColumnType("TEXT") + .HasColumnName("note_date"); + + b.Property("FromAgent") + .HasColumnType("INTEGER") + .HasColumnName("from_agent"); + + b.Property("SortOrder") + .HasColumnType("INTEGER") + .HasColumnName("sort_order"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("text"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.ToTable("daily_notes", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b => + { + b.Property("ListId") + .HasColumnType("TEXT") + .HasColumnName("list_id"); + + b.Property("AgentPath") + .HasColumnType("TEXT") + .HasColumnName("agent_path"); + + b.Property("MaxTurns") + .HasColumnType("INTEGER") + .HasColumnName("max_turns"); + + b.Property("Model") + .HasColumnType("TEXT") + .HasColumnName("model"); + + b.Property("PermissionMode") + .HasColumnType("TEXT") + .HasColumnName("permission_mode"); + + b.Property("SessionSkills") + .HasColumnType("TEXT") + .HasColumnName("session_skills"); + + b.Property("SystemPrompt") + .HasColumnType("TEXT") + .HasColumnName("system_prompt"); + + b.Property("TicketProjectId") + .HasColumnType("INTEGER") + .HasColumnName("ticket_project_id"); + + b.Property("VerifyCommand") + .HasColumnType("TEXT") + .HasColumnName("verify_command"); + + b.HasKey("ListId"); + + b.ToTable("list_config", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("DefaultCommitType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("chore") + .HasColumnName("default_commit_type"); + + b.Property("FindingsTracked") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("findings_tracked"); + + b.Property("Icon") + .HasColumnType("TEXT") + .HasColumnName("icon"); + + b.Property("IsManual") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_manual"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0) + .HasColumnName("sort_order"); + + b.Property("WorkingDir") + .HasColumnType("TEXT") + .HasColumnName("working_dir"); + + b.HasKey("Id"); + + b.HasIndex("SortOrder") + .HasDatabaseName("idx_lists_sort"); + + b.ToTable("lists", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("Days") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(31) + .HasColumnName("days_of_week"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true) + .HasColumnName("enabled"); + + b.Property("Kind") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("ping") + .HasColumnName("action_kind"); + + b.Property("LastRunAt") + .HasColumnType("TEXT") + .HasColumnName("last_run_at"); + + b.Property("PromptOverride") + .HasColumnType("TEXT") + .HasColumnName("prompt_override"); + + b.Property("TimeOfDay") + .HasColumnType("TEXT") + .HasColumnName("time_of_day"); + + b.HasKey("Id"); + + b.ToTable("prime_schedules", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.SessionSkillEntity", b => + { + b.Property("Name") + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("AddedAt") + .HasColumnType("TEXT") + .HasColumnName("added_at"); + + b.Property("Description") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("description"); + + b.Property("PinnedRef") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("pinned_ref"); + + b.Property("SourceUrl") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("source_url"); + + b.Property("Subpath") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("subpath"); + + b.HasKey("Name"); + + b.ToTable("session_skills", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Completed") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("completed"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("OrderNum") + .HasColumnType("INTEGER") + .HasColumnName("order_num"); + + b.Property("TaskId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.HasKey("Id"); + + b.HasIndex("TaskId") + .HasDatabaseName("idx_subtasks_task_id"); + + b.ToTable("subtasks", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("ByteSize") + .HasColumnType("INTEGER") + .HasColumnName("byte_size"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("file_name"); + + b.Property("TaskId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.HasKey("Id"); + + b.HasIndex("TaskId") + .HasDatabaseName("idx_task_attachments_task_id"); + + b.ToTable("task_attachments", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskDependencyEntity", b => + { + b.Property("TaskId") + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("DependsOnTaskId") + .HasColumnType("TEXT") + .HasColumnName("depends_on_task_id"); + + b.HasKey("TaskId", "DependsOnTaskId"); + + b.HasIndex("DependsOnTaskId") + .HasDatabaseName("idx_task_dependencies_depends_on"); + + b.ToTable("task_dependencies", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("AgentPath") + .HasColumnType("TEXT") + .HasColumnName("agent_path"); + + b.Property("BlockedByTaskId") + .HasColumnType("TEXT") + .HasColumnName("blocked_by_task_id"); + + b.Property("CommitType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("chore") + .HasColumnName("commit_type"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("CreatedBy") + .HasColumnType("TEXT") + .HasColumnName("created_by"); + + b.Property("Description") + .HasColumnType("TEXT") + .HasColumnName("description"); + + b.Property("ExpectsNoDiff") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("expects_no_diff"); + + b.Property("FailureMaxTurns") + .HasColumnType("INTEGER") + .HasColumnName("failure_max_turns"); + + b.Property("FailureReason") + .HasColumnType("TEXT") + .HasColumnName("failure_reason"); + + b.Property("FailureTurnsUsed") + .HasColumnType("INTEGER") + .HasColumnName("failure_turns_used"); + + b.Property("FinishedAt") + .HasColumnType("TEXT") + .HasColumnName("finished_at"); + + b.Property("HandlerBaseCommit") + .HasColumnType("TEXT") + .HasColumnName("handler_base_commit"); + + b.Property("HandlerHeadCommit") + .HasColumnType("TEXT") + .HasColumnName("handler_head_commit"); + + b.Property("InteractiveSessionId") + .HasColumnType("TEXT") + .HasColumnName("interactive_session_id"); + + b.Property("IsManual") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_manual"); + + b.Property("IsMyDay") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_my_day"); + + b.Property("IsStarred") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_starred"); + + b.Property("ListId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("list_id"); + + b.Property("LogPath") + .HasColumnType("TEXT") + .HasColumnName("log_path"); + + b.Property("MaxTurns") + .HasColumnType("INTEGER") + .HasColumnName("max_turns"); + + b.Property("Model") + .HasColumnType("TEXT") + .HasColumnName("model"); + + b.Property("Notes") + .HasColumnType("TEXT") + .HasColumnName("notes"); + + b.Property("Number") + .HasColumnType("INTEGER") + .HasColumnName("number"); + + b.Property("ParentTaskId") + .HasColumnType("TEXT") + .HasColumnName("parent_task_id"); + + b.Property("PermissionMode") + .HasColumnType("TEXT") + .HasColumnName("permission_mode"); + + b.Property("PlanningFinalizedAt") + .HasColumnType("TEXT") + .HasColumnName("planning_finalized_at"); + + b.Property("PlanningPhase") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("none") + .HasColumnName("planning_phase"); + + b.Property("PlanningSessionId") + .HasColumnType("TEXT") + .HasColumnName("planning_session_id"); + + b.Property("PlanningSessionToken") + .HasColumnType("TEXT") + .HasColumnName("planning_session_token"); + + b.Property("Result") + .HasColumnType("TEXT") + .HasColumnName("result"); + + b.Property("ReviewFeedback") + .HasColumnType("TEXT") + .HasColumnName("review_feedback"); + + b.Property("RoadblockCount") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0) + .HasColumnName("roadblock_count"); + + b.Property("ScheduledFor") + .HasColumnType("TEXT") + .HasColumnName("scheduled_for"); + + b.Property("SessionSkills") + .HasColumnType("TEXT") + .HasColumnName("session_skills"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0) + .HasColumnName("sort_order"); + + b.Property("StartedAt") + .HasColumnType("TEXT") + .HasColumnName("started_at"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("status"); + + b.Property("SystemPrompt") + .HasColumnType("TEXT") + .HasColumnName("system_prompt"); + + b.Property("TicketRef") + .HasColumnType("TEXT") + .HasColumnName("ticket_ref"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("title"); + + b.Property("UsageLimitAutoContinuedAt") + .HasColumnType("TEXT") + .HasColumnName("usage_limit_auto_continued_at"); + + b.HasKey("Id"); + + b.HasIndex("BlockedByTaskId") + .HasDatabaseName("idx_tasks_blocked_by"); + + b.HasIndex("ListId") + .HasDatabaseName("idx_tasks_list_id"); + + b.HasIndex("Number") + .IsUnique() + .HasDatabaseName("idx_tasks_number"); + + b.HasIndex("ParentTaskId") + .HasDatabaseName("idx_tasks_parent_task_id"); + + b.HasIndex("Status") + .HasDatabaseName("idx_tasks_status"); + + b.HasIndex("ListId", "SortOrder") + .HasDatabaseName("idx_tasks_list_sort"); + + b.ToTable("tasks", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CacheReadTokens") + .HasColumnType("INTEGER") + .HasColumnName("cache_read_tokens"); + + b.Property("CacheWriteTokens") + .HasColumnType("INTEGER") + .HasColumnName("cache_write_tokens"); + + b.Property("ErrorMarkdown") + .HasColumnType("TEXT") + .HasColumnName("error_markdown"); + + b.Property("Errors") + .HasColumnType("TEXT") + .HasColumnName("errors"); + + b.Property("ExitCode") + .HasColumnType("INTEGER") + .HasColumnName("exit_code"); + + b.Property("FinishedAt") + .HasColumnType("TEXT") + .HasColumnName("finished_at"); + + b.Property("IsRetry") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false) + .HasColumnName("is_retry"); + + b.Property("LogPath") + .HasColumnType("TEXT") + .HasColumnName("log_path"); + + b.Property("Model") + .HasColumnType("TEXT") + .HasColumnName("model"); + + b.Property("Prompt") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("prompt"); + + b.Property("ResultMarkdown") + .HasColumnType("TEXT") + .HasColumnName("result_markdown"); + + b.Property("ResultSubtype") + .HasColumnType("TEXT") + .HasColumnName("result_subtype"); + + b.Property("RunNumber") + .HasColumnType("INTEGER") + .HasColumnName("run_number"); + + b.Property("SessionId") + .HasColumnType("TEXT") + .HasColumnName("session_id"); + + b.Property("StartedAt") + .HasColumnType("TEXT") + .HasColumnName("started_at"); + + b.Property("StructuredOutputJson") + .HasColumnType("TEXT") + .HasColumnName("structured_output"); + + b.Property("TaskId") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("TerminalReason") + .HasColumnType("TEXT") + .HasColumnName("terminal_reason"); + + b.Property("TokensIn") + .HasColumnType("INTEGER") + .HasColumnName("tokens_in"); + + b.Property("TokensOut") + .HasColumnType("INTEGER") + .HasColumnName("tokens_out"); + + b.Property("TurnCount") + .HasColumnType("INTEGER") + .HasColumnName("turn_count"); + + b.HasKey("Id"); + + b.HasIndex("TaskId") + .HasDatabaseName("idx_task_runs_task_id"); + + b.ToTable("task_runs", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b => + { + b.Property("Id") + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("EndDate") + .HasColumnType("TEXT") + .HasColumnName("end_date"); + + b.Property("GeneratedAt") + .HasColumnType("TEXT") + .HasColumnName("generated_at"); + + b.Property("Markdown") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("markdown"); + + b.Property("StartDate") + .HasColumnType("TEXT") + .HasColumnName("start_date"); + + b.HasKey("Id"); + + b.HasIndex("StartDate", "EndDate") + .IsUnique(); + + b.ToTable("week_reports", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b => + { + b.Property("TaskId") + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("BaseCommit") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("base_commit"); + + b.Property("BranchName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("branch_name"); + + b.Property("CreatedAt") + .HasColumnType("TEXT") + .HasColumnName("created_at"); + + b.Property("DiffStat") + .HasColumnType("TEXT") + .HasColumnName("diff_stat"); + + b.Property("HeadCommit") + .HasColumnType("TEXT") + .HasColumnName("head_commit"); + + b.Property("MergeCommit") + .HasColumnType("TEXT") + .HasColumnName("merge_commit"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("path"); + + b.Property("State") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("active") + .HasColumnName("state"); + + b.HasKey("TaskId"); + + b.ToTable("worktrees", (string)null); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.ListEntity", "List") + .WithOne("Config") + .HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("List"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task") + .WithMany("Subtasks") + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task") + .WithMany() + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskDependencyEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", null) + .WithMany() + .HasForeignKey("DependsOnTaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ClaudeDo.Data.Models.TaskEntity", null) + .WithMany("Dependencies") + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", null) + .WithMany() + .HasForeignKey("BlockedByTaskId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("ClaudeDo.Data.Models.ListEntity", "List") + .WithMany("Tasks") + .HasForeignKey("ListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent") + .WithMany("Children") + .HasForeignKey("ParentTaskId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("List"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task") + .WithMany("Runs") + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task") + .WithOne("Worktree") + .HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Task"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b => + { + b.Navigation("Config"); + + b.Navigation("Tasks"); + }); + + modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b => + { + b.Navigation("Children"); + + b.Navigation("Dependencies"); + + b.Navigation("Runs"); + + b.Navigation("Subtasks"); + + b.Navigation("Worktree"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/ClaudeDo.Data/Migrations/20260902121712_AddTaskDependencies.cs b/src/ClaudeDo.Data/Migrations/20260902121712_AddTaskDependencies.cs new file mode 100644 index 00000000..55c090a3 --- /dev/null +++ b/src/ClaudeDo.Data/Migrations/20260902121712_AddTaskDependencies.cs @@ -0,0 +1,104 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ClaudeDo.Data.Migrations +{ + /// + /// Turns the single tasks.depends_on_task_id column into a set: one row per declared + /// "run after" edge in task_dependencies. Hand-edited after scaffolding so the data copy + /// sits between CreateTable and DropColumn — the scaffolded order dropped the column first and + /// would have discarded every existing link. + /// + public partial class AddTaskDependencies : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "task_dependencies", + columns: table => new + { + task_id = table.Column(type: "TEXT", nullable: false), + depends_on_task_id = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_task_dependencies", x => new { x.task_id, x.depends_on_task_id }); + table.ForeignKey( + name: "FK_task_dependencies_tasks_depends_on_task_id", + column: x => x.depends_on_task_id, + principalTable: "tasks", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_task_dependencies_tasks_task_id", + column: x => x.task_id, + principalTable: "tasks", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "idx_task_dependencies_depends_on", + table: "task_dependencies", + column: "depends_on_task_id"); + + // Every existing link becomes a one-element set. The old FK was ON DELETE SET NULL, so + // a surviving value always points at an existing task and can't violate the new FK. + migrationBuilder.Sql(""" + 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 + """); + + migrationBuilder.DropForeignKey( + name: "FK_tasks_tasks_depends_on_task_id", + table: "tasks"); + + migrationBuilder.DropIndex( + name: "idx_tasks_depends_on", + table: "tasks"); + + migrationBuilder.DropColumn( + name: "depends_on_task_id", + table: "tasks"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "depends_on_task_id", + table: "tasks", + type: "TEXT", + nullable: true); + + // Inherently lossy: a task with several predecessors keeps only one. There is no + // single-column shape that could hold the rest. + migrationBuilder.Sql(""" + UPDATE tasks SET depends_on_task_id = ( + SELECT td.depends_on_task_id FROM task_dependencies td + WHERE td.task_id = tasks.id + ORDER BY td.depends_on_task_id + LIMIT 1 + ) + """); + + migrationBuilder.DropTable( + name: "task_dependencies"); + + migrationBuilder.CreateIndex( + name: "idx_tasks_depends_on", + table: "tasks", + column: "depends_on_task_id"); + + migrationBuilder.AddForeignKey( + name: "FK_tasks_tasks_depends_on_task_id", + table: "tasks", + column: "depends_on_task_id", + principalTable: "tasks", + principalColumn: "id", + onDelete: ReferentialAction.SetNull); + } + } +} diff --git a/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs b/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs index 0f2ef993..b028f11a 100644 --- a/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs +++ b/src/ClaudeDo.Data/Migrations/ClaudeDoDbContextModelSnapshot.cs @@ -475,6 +475,24 @@ namespace ClaudeDo.Data.Migrations b.ToTable("task_attachments", (string)null); }); + modelBuilder.Entity("ClaudeDo.Data.Models.TaskDependencyEntity", b => + { + b.Property("TaskId") + .HasColumnType("TEXT") + .HasColumnName("task_id"); + + b.Property("DependsOnTaskId") + .HasColumnType("TEXT") + .HasColumnName("depends_on_task_id"); + + b.HasKey("TaskId", "DependsOnTaskId"); + + b.HasIndex("DependsOnTaskId") + .HasDatabaseName("idx_task_dependencies_depends_on"); + + b.ToTable("task_dependencies", (string)null); + }); + modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b => { b.Property("Id") @@ -504,10 +522,6 @@ namespace ClaudeDo.Data.Migrations .HasColumnType("TEXT") .HasColumnName("created_by"); - b.Property("DependsOnTaskId") - .HasColumnType("TEXT") - .HasColumnName("depends_on_task_id"); - b.Property("Description") .HasColumnType("TEXT") .HasColumnName("description"); @@ -675,9 +689,6 @@ namespace ClaudeDo.Data.Migrations b.HasIndex("BlockedByTaskId") .HasDatabaseName("idx_tasks_blocked_by"); - b.HasIndex("DependsOnTaskId") - .HasDatabaseName("idx_tasks_depends_on"); - b.HasIndex("ListId") .HasDatabaseName("idx_tasks_list_id"); @@ -912,6 +923,21 @@ namespace ClaudeDo.Data.Migrations b.Navigation("Task"); }); + modelBuilder.Entity("ClaudeDo.Data.Models.TaskDependencyEntity", b => + { + b.HasOne("ClaudeDo.Data.Models.TaskEntity", null) + .WithMany() + .HasForeignKey("DependsOnTaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ClaudeDo.Data.Models.TaskEntity", null) + .WithMany("Dependencies") + .HasForeignKey("TaskId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b => { b.HasOne("ClaudeDo.Data.Models.TaskEntity", null) @@ -919,11 +945,6 @@ namespace ClaudeDo.Data.Migrations .HasForeignKey("BlockedByTaskId") .OnDelete(DeleteBehavior.SetNull); - b.HasOne("ClaudeDo.Data.Models.TaskEntity", null) - .WithMany() - .HasForeignKey("DependsOnTaskId") - .OnDelete(DeleteBehavior.SetNull); - b.HasOne("ClaudeDo.Data.Models.ListEntity", "List") .WithMany("Tasks") .HasForeignKey("ListId") @@ -973,6 +994,8 @@ namespace ClaudeDo.Data.Migrations { b.Navigation("Children"); + b.Navigation("Dependencies"); + b.Navigation("Runs"); b.Navigation("Subtasks"); diff --git a/src/ClaudeDo.Data/Models/TaskDependencyEntity.cs b/src/ClaudeDo.Data/Models/TaskDependencyEntity.cs new file mode 100644 index 00000000..5449bf0e --- /dev/null +++ b/src/ClaudeDo.Data/Models/TaskDependencyEntity.cs @@ -0,0 +1,13 @@ +namespace ClaudeDo.Data.Models; + +/// +/// One declared "task must run after task" edge. A task can carry n of them; the queue picker +/// claims it only once every predecessor reached . +/// Deliberately separate from , the planning chain's own +/// internal single link — see ClaudeDo.Worker/CLAUDE.md → Status Model. +/// +public sealed class TaskDependencyEntity +{ + public required string TaskId { get; init; } + public required string DependsOnTaskId { get; init; } +} diff --git a/src/ClaudeDo.Data/Models/TaskEntity.cs b/src/ClaudeDo.Data/Models/TaskEntity.cs index e7a852f0..1940bab0 100644 --- a/src/ClaudeDo.Data/Models/TaskEntity.cs +++ b/src/ClaudeDo.Data/Models/TaskEntity.cs @@ -1,3 +1,5 @@ +using System.ComponentModel.DataAnnotations.Schema; + namespace ClaudeDo.Data.Models; public enum TaskStatus @@ -33,12 +35,17 @@ public sealed class TaskEntity public PlanningPhase PlanningPhase { get; set; } = PlanningPhase.None; public string? BlockedByTaskId { get; set; } - // A user/MCP-declared predecessor, distinct from BlockedByTaskId (the planning chain's own - // internal link): the picker also skips a Queued task while this is set and the referenced - // task's Status isn't Done. Unlike the chain, a Failed/Cancelled dependency does NOT cascade - // or auto-resolve -- the dependent just stays blocked and reports why (see TaskStateService. - // SetDependsOnAsync and QueuePicker). - public string? DependsOnTaskId { get; set; } + // User/MCP-declared predecessors, distinct from BlockedByTaskId (the planning chain's own + // internal link): the picker skips a Queued task until EVERY predecessor reached Done. + // Unlike the chain, a Failed/Cancelled dependency does NOT cascade or auto-resolve -- the + // dependent just stays blocked and reports why (see TaskStateService.SetDependsOnAsync and + // QueuePicker). Only loaded where a read path Includes it; the picker gates in raw SQL and + // never relies on this collection. + public ICollection Dependencies { get; set; } = new List(); + + [NotMapped] + public IReadOnlyList DependsOnTaskIds => + Dependencies.Select(d => d.DependsOnTaskId).ToList(); public DateTime? ScheduledFor { get; set; } public string? Result { get; set; } public string? ReviewFeedback { get; set; } diff --git a/src/ClaudeDo.Data/Repositories/TaskRepository.cs b/src/ClaudeDo.Data/Repositories/TaskRepository.cs index 2a4dacf8..37f3f6fc 100644 --- a/src/ClaudeDo.Data/Repositories/TaskRepository.cs +++ b/src/ClaudeDo.Data/Repositories/TaskRepository.cs @@ -34,7 +34,10 @@ public sealed class TaskRepository var tracked = _context.Tasks.Local.FirstOrDefault(t => t.Id == entity.Id); if (tracked is not null && !ReferenceEquals(tracked, entity)) _context.Entry(tracked).State = Microsoft.EntityFrameworkCore.EntityState.Detached; - _context.Tasks.Update(entity); + // Entry().State instead of Update(): Update marks the whole loaded graph Modified, which + // for the all-key task_dependencies rows means an UPDATE with nothing to set. Dependency + // edges are written only by TaskStateService.SetDependsOnAsync. + _context.Entry(entity).State = Microsoft.EntityFrameworkCore.EntityState.Modified; await _context.SaveChangesAsync(ct); } @@ -46,17 +49,20 @@ public sealed class TaskRepository public async Task GetByIdAsync(string taskId, CancellationToken ct = default) { - return await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId, ct); + return await _context.Tasks.AsNoTracking().Include(t => t.Dependencies) + .FirstOrDefaultAsync(t => t.Id == taskId, ct); } public async Task GetByNumberAsync(int number, CancellationToken ct = default) { - return await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Number == number, ct); + return await _context.Tasks.AsNoTracking().Include(t => t.Dependencies) + .FirstOrDefaultAsync(t => t.Number == number, ct); } public async Task> GetByListIdAsync(string listId, CancellationToken ct = default) { return await _context.Tasks + .Include(t => t.Dependencies) .Where(t => t.ListId == listId) .OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt) .ToListAsync(ct); diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs index 1341439e..e5cb1da5 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs @@ -113,7 +113,14 @@ public sealed partial class TaskRowViewModel : ViewModelBase [NotifyPropertyChangedFor(nameof(IsWaiting))] [NotifyPropertyChangedFor(nameof(StatusChipClass))] private string? _blockedByTaskId; - [ObservableProperty] private string? _dependsOnTaskId; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(DependsOnTaskId))] + private IReadOnlyList _dependsOnTaskIds = []; + + // The single predecessor, or null when the task has none or several. The chain rail can only + // draw a line, so a task waiting on n predecessors gets a ChainAfterLabel instead — see + // TasksIslandViewModel.ApplyChainGrouping. + public string? DependsOnTaskId => DependsOnTaskIds.Count == 1 ? DependsOnTaskIds[0] : null; [ObservableProperty] private bool _isExpanded = true; [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsPlanningParent))] @@ -488,7 +495,10 @@ public sealed partial class TaskRowViewModel : ViewModelBase ParentTaskId = t.ParentTaskId; CreatedBy = t.CreatedBy; BlockedByTaskId = t.BlockedByTaskId; - DependsOnTaskId = t.DependsOnTaskId; + // Guarded: TaskEntity.DependsOnTaskIds projects a fresh List every read, so a plain + // assignment would fire PropertyChanged on every reconcile tick even when nothing moved. + var deps = t.DependsOnTaskIds; + if (!DependsOnTaskIds.SequenceEqual(deps)) DependsOnTaskIds = deps; RoadblockCount = t.RoadblockCount; FailureReason = t.FailureReason; FailureTurnsUsed = t.FailureTurnsUsed; diff --git a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs index 1f777c46..54d3f4ff 100644 --- a/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs @@ -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() @@ -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 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); } diff --git a/src/ClaudeDo.Worker/CLAUDE.md b/src/ClaudeDo.Worker/CLAUDE.md index bb3be982..0d1dbf40 100644 --- a/src/ClaudeDo.Worker/CLAUDE.md +++ b/src/ClaudeDo.Worker/CLAUDE.md @@ -50,8 +50,8 @@ subfolder within their area; the namespace stays the area namespace. ## Architecture - **Program.cs** — loads config, inits schema, registers DI, configures SignalR on `/hub`, binds to `127.0.0.1:47821` -- **TaskStateService** — the **only** component that writes `Status`, `PlanningPhase`, `BlockedByTaskId`, `DependsOnTaskId`. All transitions return a `TransitionResult` (no exceptions on invalid moves). Wakes the queue and advances the planning chain on child terminal transitions. Every transition broadcasts via a private `NotifyAsync(taskId)` — never call `_broadcaster.TaskUpdated` directly from this class, `NotifyAsync` also drives `TicketStatusSync.SyncAsync` (see Tickets below) and a direct call skips that transition's ticket write-back. `SetDependsOnAsync` rejects a self-reference, an unknown dependency id, or a link that would create a cycle (walks the proposed predecessor's own `DependsOnTaskId` chain). -- **IQueueWaker / IQueuePicker / QueueService** — waker is a singleton `SemaphoreSlim`; picker performs the atomic `Queued → Running` claim filtered by `BlockedByTaskId IS NULL`, `is_manual = 0`, schedule, and (`DependsOnTaskId IS NULL` OR the dependency's `Status = 'done'`); QueueService is a thin `BackgroundService` looping on the waker, dispatching via `TaskRunner`. Per tick it also applies the usage throttle and gate → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md). +- **TaskStateService** — the **only** component that writes `Status`, `PlanningPhase`, `BlockedByTaskId`, and the `task_dependencies` edges. All transitions return a `TransitionResult` (no exceptions on invalid moves). Wakes the queue and advances the planning chain on child terminal transitions. Every transition broadcasts via a private `NotifyAsync(taskId)` — never call `_broadcaster.TaskUpdated` directly from this class, `NotifyAsync` also drives `TicketStatusSync.SyncAsync` (see Tickets below) and a direct call skips that transition's ticket write-back. `SetDependsOnAsync` replaces the task's whole dependency **set** (empty list clears it) and rejects a self-reference, an unknown dependency id, or any link that would create a cycle — a BFS over the whole `task_dependencies` edge set from every proposed predecessor, so a multi-edge loop (A→B→C→A) is caught too, and one bad edge leaves the entire set unwritten. +- **IQueueWaker / IQueuePicker / QueueService** — waker is a singleton `SemaphoreSlim`; picker performs the atomic `Queued → Running` claim filtered by `BlockedByTaskId IS NULL`, `is_manual = 0`, schedule, and "no `task_dependencies` edge whose predecessor isn't `done`" (doubly negated on purpose: zero edges stays eligible, all edges must be done, and an edge pointing at a vanished task keeps blocking); QueueService is a thin `BackgroundService` looping on the waker, dispatching via `TaskRunner`. Per tick it also applies the usage throttle and gate → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md). - **RunCancellationRegistry** — taskId → running-run CTS. Lets `TaskStateService.CancelAsync` kill a cancelled task's process without a DI cycle. - **OverrideSlotService** — owns `RunNow` / `ContinueTask`; goes through `TaskStateService.StartRunningAsync` (caller-driven, serialized by slot lock). - **StaleTaskRecovery** — startup-only; calls `TaskStateService.RecoverStaleRunningAsync` to flip orphaned `Running` rows to `Failed`. @@ -67,7 +67,7 @@ not conflated. | `Status` | `Idle`, `Queued`, `Running`, `WaitingForChildren`, `WaitingForReview`, `Done`, `Failed`, `Cancelled` | Lifecycle only. `WaitingForChildren` = parent's own work done, waiting on children. | | `PlanningPhase` | `None`, `Active`, `Finalized` | Parent-only marker. `Active` ≈ legacy `Planning`; `Finalized` ≈ legacy `Planned`. | | `BlockedByTaskId` | nullable FK | Replaces legacy `Waiting`. A queued row with a non-null value is skipped by the picker. Internal to `PlanningChainCoordinator` — resolves (or cascades) on ANY terminal state of the predecessor. Because it self-resolves, `wait_for_task_change` deliberately keeps **waiting** through it instead of reporting `"Blocked"`. | -| `DependsOnTaskId` | nullable FK | User/MCP-declared predecessor (`add_task`/`update_task`), separate from `BlockedByTaskId` because the semantics differ: the picker only skips a queued row while the dependency's `Status` isn't `Done` -- a Failed/Cancelled dependency does **not** cascade or auto-resolve, the dependent just stays blocked (see `QueuePicker`, `TaskStateService.SetDependsOnAsync`). `get_task`/`list_tasks`/`batch_get_tasks` surface this as `blocked`/`blockedReason`; `wait_for_task_change` reports `"Blocked"` instead of silently running out its timeout. | +| `Dependencies` | 0..n rows in `task_dependencies` | User/MCP-declared predecessors (`add_task`/`update_task`), separate from `BlockedByTaskId` because the semantics differ: the picker skips a queued row until **every** dependency reached `Done` -- a Failed/Cancelled dependency does **not** cascade or auto-resolve, the dependent just stays blocked (see `QueuePicker`, `TaskStateService.SetDependsOnAsync`). `get_task`/`list_tasks`/`batch_get_tasks` surface this as `dependsOnTaskIds` + `blocked`/`blockedReason` (the reason names **all** open predecessors, via `External/TaskBlocking`); `wait_for_task_change` reports `"Blocked"` instead of silently running out its timeout. | | `IsManual` | bool | Reminder only the user can do. `EnqueueAsync`/`StartRunningAsync` refuse it, the picker skips it, `GetDailyPrepCandidates` never offers it. An interactive ConPTY session is still allowed. | | `ReviewFeedback` | nullable string | Reviewer's rejection comment; consumed and cleared by `QueueService` on the next re-run. | diff --git a/src/ClaudeDo.Worker/External/ExternalMcpService.cs b/src/ClaudeDo.Worker/External/ExternalMcpService.cs index 8eb825b4..9d9bb886 100644 --- a/src/ClaudeDo.Worker/External/ExternalMcpService.cs +++ b/src/ClaudeDo.Worker/External/ExternalMcpService.cs @@ -1,4 +1,4 @@ -using System.ComponentModel; +using System.ComponentModel; using System.Diagnostics; using System.Globalization; using System.Text; @@ -55,11 +55,11 @@ public sealed record TaskDto( string? FailureReason = null, int? FailureTurnsUsed = null, int? FailureMaxTurns = null, - // A user/MCP-declared predecessor (set via add_task/update_task), distinct from the - // planning chain's own internal BlockedByTaskId link. - string? DependsOnTaskId = null, + // User/MCP-declared predecessors (set via add_task/update_task), distinct from the planning + // chain's own internal BlockedByTaskId link. Empty unless the task declares dependencies. + IReadOnlyList? DependsOnTaskIds = null, // True only while Status is Queued and the picker will not claim this task yet -- either a - // planning-chain predecessor or DependsOnTaskId hasn't reached Done. See BlockedReason. + // planning-chain predecessor or some dependsOnTaskIds entry hasn't reached Done. See BlockedReason. bool Blocked = false, string? BlockedReason = null, bool IsManual = false, @@ -82,7 +82,7 @@ public sealed record TaskRefDto( string? FailureReason = null, int? FailureTurnsUsed = null, int? FailureMaxTurns = null, - string? DependsOnTaskId = null, + IReadOnlyList? DependsOnTaskIds = null, bool Blocked = false, string? BlockedReason = null, DirtyBaseWarning? BaseDirty = null, @@ -205,14 +205,14 @@ public sealed class ExternalMcpService } // Batch-resolves, for each task, whether the picker is currently skipping it (Queued with - // either a planning-chain BlockedByTaskId or an unmet DependsOnTaskId) and why. Only Queued + // either a planning-chain BlockedByTaskId or an unmet dependency) and why. Only Queued // tasks can be blocked -- once a task has run, or hasn't been queued yet, blocking is moot. private async Task> ComputeBlockedInfoAsync( IReadOnlyList tasks, CancellationToken ct) { var dependencyIds = tasks - .Where(t => t.Status == TaskStatus.Queued && t.BlockedByTaskId is null && t.DependsOnTaskId is not null) - .Select(t => t.DependsOnTaskId!) + .Where(t => t.Status == TaskStatus.Queued && t.BlockedByTaskId is null) + .SelectMany(t => t.DependsOnTaskIds) .Distinct() .ToList(); @@ -237,17 +237,10 @@ public sealed class ExternalMcpService { result[t.Id] = (true, $"Blocked by planning-chain predecessor {t.BlockedByTaskId}."); } - else if (t.DependsOnTaskId is not null) - { - var known = dependencyStatuses.TryGetValue(t.DependsOnTaskId, out var depStatus); - result[t.Id] = known && depStatus == TaskStatus.Done - ? (false, null) - : (true, $"Blocked: depends on task {t.DependsOnTaskId} (status: " + - (known ? depStatus.ToString() : "not found") + ")."); - } else { - result[t.Id] = (false, null); + var unmet = TaskBlocking.Unmet(t.DependsOnTaskIds, dependencyStatuses); + result[t.Id] = unmet.Count == 0 ? (false, null) : (true, TaskBlocking.Describe(unmet)); } } return result; @@ -298,10 +291,15 @@ public sealed class ExternalMcpService "list/global default (normally sonnet).")] string? model = null, [Description("Id of a task that must reach Done (i.e. be merged) before the picker will claim this one, " + - "even once queued. Rejected if it doesn't exist, is this task's own id, or would create a " + + "even once queued. Shorthand for a one-element dependsOnTaskIds; ignored when that is given. " + + "Rejected if it doesn't exist, is this task's own id, or would create a " + "dependency cycle. If that predecessor instead ends up Failed or Cancelled, this task simply " + "stays blocked rather than starving silently -- check get_task/list_tasks' blocked field.")] string? dependsOnTaskId = null, + [Description("Ids of ALL tasks that must reach Done before the picker will claim this one. Use this when a " + + "task wires together, or verifies, the results of several independent tasks -- one waiting on " + + "A and B is the normal shape of a wave-style plan. Same rejection rules as dependsOnTaskId.")] + string[]? dependsOnTaskIds = null, [Description("true: this task's correct outcome may be an empty diff (verification/audit/research work " + "that doesn't change code) — the merge helper won't treat a 0-changed-files result as a red " + "flag. false (default): an empty diff is treated as suspicious, not a clean success.")] @@ -316,7 +314,7 @@ public sealed class ExternalMcpService var list = await _lists.GetByIdAsync(listId, cancellationToken) ?? throw new InvalidOperationException($"List {listId} not found."); - dependsOnTaskId = await TaskIdResolver.ResolveOptionalAsync(_tasks, dependsOnTaskId, cancellationToken); + var dependsOn = await ResolveDependsOnAsync(dependsOnTaskId, dependsOnTaskIds, cancellationToken); var possibleDuplicates = await FindPossibleDuplicatesAsync(listId, title, cancellationToken); @@ -337,12 +335,11 @@ public sealed class ExternalMcpService }; await _tasks.AddAsync(entity, cancellationToken); - if (dependsOnTaskId is not null) + if (dependsOn is { Count: > 0 }) { - var dependsResult = await _state.SetDependsOnAsync(entity.Id, dependsOnTaskId, cancellationToken); + var dependsResult = await _state.SetDependsOnAsync(entity.Id, dependsOn, cancellationToken); if (!dependsResult.Ok) - throw new InvalidOperationException(dependsResult.Reason ?? "Cannot set dependsOnTaskId."); - entity.DependsOnTaskId = dependsOnTaskId; + throw new InvalidOperationException(dependsResult.Reason ?? "Cannot set dependsOnTaskIds."); } if (queueImmediately) @@ -350,12 +347,39 @@ public sealed class ExternalMcpService var enqueue = await _state.EnqueueAsync(entity.Id, cancellationToken); if (!enqueue.Ok) throw new InvalidOperationException(enqueue.Reason ?? "Cannot enqueue task."); - entity.Status = TaskStatus.Queued; } await _broadcaster.TaskUpdated(entity.Id); - var blocked = await ComputeBlockedInfoAsync([entity], cancellationToken); - return new AddTaskResult(ToRefDto(entity, blocked[entity.Id].Blocked, blocked[entity.Id].Reason), possibleDuplicates); + // Reload rather than patching `entity`: SetDependsOnAsync and EnqueueAsync both write + // through their own context, and adding the edges to this still-tracked entity's nav + // would queue a duplicate INSERT on the next SaveChanges. + var reload = (await _tasks.GetByIdAsync(entity.Id, cancellationToken))!; + var blocked = await ComputeBlockedInfoAsync([reload], cancellationToken); + return new AddTaskResult(ToRefDto(reload, blocked[reload.Id].Blocked, blocked[reload.Id].Reason), possibleDuplicates); + } + + // Folds add_task/update_task's two dependency parameters into one resolved set. + // `dependsOnTaskIds` wins when given; otherwise the legacy scalar form is treated as a + // one-element set, with the empty string still its documented "clear it" sentinel. + // Returns null only when neither parameter was passed = leave the task's set untouched. + private async Task?> ResolveDependsOnAsync( + string? dependsOnTaskId, string[]? dependsOnTaskIds, CancellationToken ct) + { + if (dependsOnTaskIds is not null) + { + var resolved = new List(); + foreach (var raw in dependsOnTaskIds) + { + var id = await TaskIdResolver.ResolveOptionalAsync(_tasks, raw, ct); + if (id.NullIfBlank() is { } v) resolved.Add(v); + } + return resolved; + } + + if (dependsOnTaskId is null) return null; + // TaskIdResolver passes "" through untouched, so NullIfBlank still yields the clear signal. + var single = (await TaskIdResolver.ResolveOptionalAsync(_tasks, dependsOnTaskId, ct)).NullIfBlank(); + return single is null ? [] : [single]; } // Non-terminal: a task still open enough that a new, similarly-titled task might be a duplicate @@ -436,10 +460,15 @@ public sealed class ExternalMcpService string? title = null, string? description = null, string? commitType = null, - [Description("Id of a task that must reach Done before the picker will claim this one. Pass an empty " + - "string to clear an existing link; null leaves it unchanged. Rejected if it doesn't exist, " + + [Description("Id of a task that must reach Done before the picker will claim this one. Shorthand for a " + + "one-element dependsOnTaskIds; ignored when that is given. Pass an empty " + + "string to clear all existing links; null leaves them unchanged. Rejected if it doesn't exist, " + "is this task's own id, or would create a dependency cycle.")] string? dependsOnTaskId = null, + [Description("Ids of ALL tasks that must reach Done before the picker will claim this one, replacing the " + + "task's current set. Pass an empty array to clear it; null leaves it unchanged. Use this when " + + "a task wires together, or verifies, the results of several independent tasks.")] + string[]? dependsOnTaskIds = null, [Description("true: mark this task as a manual reminder only the user can complete — the queue picker, " + "daily prep, and the list handler all skip it (a ConPTY session may still touch it). false: " + "clear the flag. null (default): leave unchanged.")] @@ -463,15 +492,12 @@ public sealed class ExternalMcpService if (expectsNoDiff is not null) task.ExpectsNoDiff = expectsNoDiff.Value; await _tasks.UpdateAsync(task, cancellationToken); - if (dependsOnTaskId is not null) + var dependsOn = await ResolveDependsOnAsync(dependsOnTaskId, dependsOnTaskIds, cancellationToken); + if (dependsOn is not null) { - // Empty string is a deliberate sentinel (see the dependsOnTaskId parameter doc) to - // clear the link — TaskIdResolver passes it through untouched, then NullIfBlank - // below still turns it into the clear signal SetDependsOnAsync expects. - dependsOnTaskId = await TaskIdResolver.ResolveOptionalAsync(_tasks, dependsOnTaskId, cancellationToken); - var dependsResult = await _state.SetDependsOnAsync(taskId, dependsOnTaskId.NullIfBlank(), cancellationToken); + var dependsResult = await _state.SetDependsOnAsync(taskId, dependsOn, cancellationToken); if (!dependsResult.Ok) - throw new InvalidOperationException(dependsResult.Reason ?? "Cannot set dependsOnTaskId."); + throw new InvalidOperationException(dependsResult.Reason ?? "Cannot set dependsOnTaskIds."); } var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!; @@ -742,6 +768,7 @@ public sealed class ExternalMcpService var children = await ctx.Tasks .AsNoTracking() .Include(t => t.Worktree) + .Include(t => t.Dependencies) .Where(t => t.ParentTaskId == parentTaskId && t.Status == TaskStatus.Done) .ToListAsync(ct); @@ -935,7 +962,8 @@ public sealed class ExternalMcpService await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken); - var task = await ctx.Tasks.FirstOrDefaultAsync(t => t.Id == taskId, cancellationToken) + var task = await ctx.Tasks.Include(t => t.Dependencies) + .FirstOrDefaultAsync(t => t.Id == taskId, cancellationToken) ?? throw new InvalidOperationException($"Task {taskId} not found."); if (isMyDay && !task.IsMyDay) @@ -995,7 +1023,7 @@ public sealed class ExternalMcpService FailureReasonOf(t), t.Status == TaskStatus.Failed ? t.FailureTurnsUsed : null, t.Status == TaskStatus.Failed ? t.FailureMaxTurns : null, - t.DependsOnTaskId, + t.DependsOnTaskIds, blocked, blockedReason, t.IsManual, @@ -1014,7 +1042,7 @@ public sealed class ExternalMcpService FailureReasonOf(t), t.Status == TaskStatus.Failed ? t.FailureTurnsUsed : null, t.Status == TaskStatus.Failed ? t.FailureMaxTurns : null, - t.DependsOnTaskId, + t.DependsOnTaskIds, blocked, blockedReason, BaseDirty: null, diff --git a/src/ClaudeDo.Worker/External/TaskBlocking.cs b/src/ClaudeDo.Worker/External/TaskBlocking.cs new file mode 100644 index 00000000..16cb1817 --- /dev/null +++ b/src/ClaudeDo.Worker/External/TaskBlocking.cs @@ -0,0 +1,42 @@ +using TaskStatus = ClaudeDo.Data.Models.TaskStatus; + +namespace ClaudeDo.Worker.External; + +/// +/// Renders the `blockedReason` string shared by and +/// . Lives here so the two can't drift on the wording an agent +/// parses. +/// +internal static class TaskBlocking +{ + /// + /// The predecessors of that have not reached Done — an id + /// missing from counts as unmet (the picker treats a dangling + /// edge as blocking too). + /// + public static List<(string Id, TaskStatus? Status)> Unmet( + IEnumerable dependencyIds, IReadOnlyDictionary statuses) + { + var unmet = new List<(string Id, TaskStatus? Status)>(); + foreach (var id in dependencyIds) + { + if (statuses.TryGetValue(id, out var status)) + { + if (status != TaskStatus.Done) unmet.Add((id, status)); + } + else + { + unmet.Add((id, null)); + } + } + return unmet; + } + + /// Names every open predecessor, not just the first — a task can wait on n of them. + public static string Describe(IReadOnlyList<(string Id, TaskStatus? Status)> unmet) + { + var noun = unmet.Count == 1 ? "task" : "tasks"; + var parts = unmet.Select(u => $"{u.Id} (status: {(u.Status?.ToString() ?? "not found")})"); + return $"Blocked: depends on {noun} {string.Join(", ", parts)}."; + } +} diff --git a/src/ClaudeDo.Worker/External/TaskWaitMcpTools.cs b/src/ClaudeDo.Worker/External/TaskWaitMcpTools.cs index 4f2b4ce7..5501fd3d 100644 --- a/src/ClaudeDo.Worker/External/TaskWaitMcpTools.cs +++ b/src/ClaudeDo.Worker/External/TaskWaitMcpTools.cs @@ -132,17 +132,24 @@ public sealed class TaskWaitMcpTools var rows = await ctx.Tasks .AsNoTracking() .Where(t => taskIds.Contains(t.Id)) - .Select(t => new { t.Id, t.Number, t.Status, t.BlockedByTaskId, t.DependsOnTaskId }) + .Select(t => new + { + t.Id, + t.Number, + t.Status, + t.BlockedByTaskId, + DependsOnTaskIds = t.Dependencies.Select(d => d.DependsOnTaskId).ToList(), + }) .ToListAsync(ct); var byId = rows.ToDictionary(r => r.Id, r => r); - // A Queued task with an unmet depends_on link never becomes "changed" by itself -- the + // A Queued task with an unmet depends_on edge never becomes "changed" by itself -- the // picker will not touch it. Batch-resolve those dependencies' statuses once instead of a // query per candidate. var dependencyIds = rows - .Where(r => r.Status == TaskStatus.Queued && r.BlockedByTaskId is null && r.DependsOnTaskId is not null) - .Select(r => r.DependsOnTaskId!) + .Where(r => r.Status == TaskStatus.Queued && r.BlockedByTaskId is null) + .SelectMany(r => r.DependsOnTaskIds) .Distinct() .ToList(); var dependencyStatuses = dependencyIds.Count == 0 @@ -168,16 +175,12 @@ public sealed class TaskWaitMcpTools // every chained child and turn the wait into a one-turn-per-poll busy loop. if (row.Status == TaskStatus.Queued && row.BlockedByTaskId is null) { - if (row.DependsOnTaskId is not null) + var unmet = TaskBlocking.Unmet(row.DependsOnTaskIds, dependencyStatuses); + if (unmet.Count > 0) { - var known = dependencyStatuses.TryGetValue(row.DependsOnTaskId, out var depStatus); - if (!known || depStatus != TaskStatus.Done) - { - result.Add(new TaskStatusChangeDto(id, "Blocked", - $"Blocked: depends on task {row.DependsOnTaskId} (status: " + - (known ? depStatus.ToString() : "not found") + ").", row.Number)); - continue; - } + result.Add(new TaskStatusChangeDto( + id, "Blocked", TaskBlocking.Describe(unmet), row.Number)); + continue; } } diff --git a/src/ClaudeDo.Worker/Queue/QueuePicker.cs b/src/ClaudeDo.Worker/Queue/QueuePicker.cs index 63fcff4c..fdb81088 100644 --- a/src/ClaudeDo.Worker/Queue/QueuePicker.cs +++ b/src/ClaudeDo.Worker/Queue/QueuePicker.cs @@ -21,9 +21,12 @@ public sealed class QueuePicker : IQueuePicker // Atomic queue claim: UPDATE + RETURNING in a single statement prevents TOCTOU races. // Raw SQL because EF cannot express UPDATE...RETURNING. // Eligible task must be Queued, unblocked (chain and depends-on), not manual, and due - // (or unscheduled). depends_on_task_id only gates on the dependency's Status='done' -- - // unlike blocked_by_task_id there is no cascade on the dependency failing, so a task - // with a Failed dependency simply stays here, skipped, until someone intervenes. + // (or unscheduled). A task_dependencies edge only gates on the dependency's Status='done' + // -- unlike blocked_by_task_id there is no cascade on the dependency failing, so a task + // with a Failed dependency simply stays here, skipped, until someone intervenes. The + // depends-on clause is doubly negated on purpose: "no edge whose predecessor isn't done" + // means a task with zero edges is eligible, ALL edges must be done, and an edge pointing + // at a vanished task keeps blocking (same as the old single-column form did). // EF SQLite stores DateTime as "yyyy-MM-dd HH:mm:ss.fffffff" — same format used here for comparison. var rows = await ctx.Tasks.FromSqlRaw(""" UPDATE tasks SET status = 'running', started_at = {1} @@ -33,11 +36,14 @@ public sealed class QueuePicker : IQueuePicker AND t.blocked_by_task_id IS NULL AND t.is_manual = 0 AND (t.scheduled_for IS NULL OR t.scheduled_for <= {0}) - AND (t.depends_on_task_id IS NULL - OR EXISTS ( - SELECT 1 FROM tasks d - WHERE d.id = t.depends_on_task_id AND d.status = 'done' - )) + 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' + ) + ) ORDER BY t.sort_order ASC, t.created_at ASC LIMIT 1 ) diff --git a/src/ClaudeDo.Worker/State/Interfaces/ITaskStateService.cs b/src/ClaudeDo.Worker/State/Interfaces/ITaskStateService.cs index 22aa2d34..d298c479 100644 --- a/src/ClaudeDo.Worker/State/Interfaces/ITaskStateService.cs +++ b/src/ClaudeDo.Worker/State/Interfaces/ITaskStateService.cs @@ -37,9 +37,10 @@ public interface ITaskStateService Task BlockOnAsync(string taskId, string predecessorTaskId, CancellationToken ct); Task UnblockAsync(string taskId, CancellationToken ct); - // dependsOnTaskId null clears the dependency. Rejects self-reference, an unknown dependency - // id, and a link that would create a cycle -- see TaskStateService for the walk. - Task SetDependsOnAsync(string taskId, string? dependsOnTaskId, CancellationToken ct); + // Replaces the task's whole dependency set; an empty list clears it. Rejects a self-reference, + // an unknown dependency id, and any link that would create a cycle in the dependency graph + // -- see TaskStateService for the walk. + Task SetDependsOnAsync(string taskId, IReadOnlyList dependsOnTaskIds, CancellationToken ct); // Surfaces a WaitingForChildren parent for review once all its children are terminal. // Best-effort (swallows and logs failures) — safe to call after any child mutation, diff --git a/src/ClaudeDo.Worker/State/TaskStateService.cs b/src/ClaudeDo.Worker/State/TaskStateService.cs index cec5efc6..8c22f0fa 100644 --- a/src/ClaudeDo.Worker/State/TaskStateService.cs +++ b/src/ClaudeDo.Worker/State/TaskStateService.cs @@ -501,50 +501,89 @@ public sealed class TaskStateService : ITaskStateService return new TransitionResult(true, null); } - public async Task SetDependsOnAsync(string taskId, string? dependsOnTaskId, CancellationToken ct) + public async Task SetDependsOnAsync( + string taskId, IReadOnlyList dependsOnTaskIds, CancellationToken ct) { await using var ctx = await _dbFactory.CreateDbContextAsync(ct); - if (dependsOnTaskId is not null) - { - if (dependsOnTaskId == taskId) - return new TransitionResult(false, "A task cannot depend on itself."); - - if (!await ctx.Tasks.AsNoTracking().AnyAsync(t => t.Id == dependsOnTaskId, ct)) - return new TransitionResult(false, $"Dependency task {dependsOnTaskId} not found."); - - // Walk the proposed predecessor's own chain of dependencies; if it leads back to - // taskId, linking here would create a cycle that starves both tasks forever (the - // picker never claims either). `visited` also stops us looping forever on - // pre-existing bad data unrelated to this write. - var current = dependsOnTaskId; - var visited = new HashSet(); - while (current is not null) - { - if (current == taskId) - return new TransitionResult(false, "Setting this dependency would create a cycle."); - if (!visited.Add(current)) - break; - current = await ctx.Tasks.AsNoTracking() - .Where(t => t.Id == current) - .Select(t => t.DependsOnTaskId) - .FirstOrDefaultAsync(ct); - } - } - - var affected = await ctx.Tasks - .Where(t => t.Id == taskId) - .ExecuteUpdateAsync(s => s.SetProperty(t => t.DependsOnTaskId, dependsOnTaskId), ct); - - if (affected == 0) + if (!await ctx.Tasks.AsNoTracking().AnyAsync(t => t.Id == taskId, ct)) return new TransitionResult(false, "Task not found."); - // Clearing a dependency may free up a Queued task the picker was skipping. - if (dependsOnTaskId is null) _waker.Wake(); + var wanted = dependsOnTaskIds.Distinct(StringComparer.Ordinal).ToList(); + + if (wanted.Count > 0) + { + if (wanted.Contains(taskId, StringComparer.Ordinal)) + return new TransitionResult(false, "A task cannot depend on itself."); + + var known = await ctx.Tasks.AsNoTracking() + .Where(t => wanted.Contains(t.Id)) + .Select(t => t.Id) + .ToListAsync(ct); + var missing = wanted.Except(known, StringComparer.Ordinal).FirstOrDefault(); + if (missing is not null) + return new TransitionResult(false, $"Dependency task {missing} not found."); + + if (await WouldCycleAsync(ctx, taskId, wanted, ct)) + return new TransitionResult(false, "Setting this dependency would create a cycle."); + } + + var existing = await ctx.TaskDependencies.AsNoTracking() + .Where(d => d.TaskId == taskId) + .Select(d => d.DependsOnTaskId) + .ToListAsync(ct); + + var removed = existing.Except(wanted, StringComparer.Ordinal).ToList(); + var added = wanted.Except(existing, StringComparer.Ordinal).ToList(); + if (removed.Count == 0 && added.Count == 0) + return new TransitionResult(true, null); + + if (removed.Count > 0) + await ctx.TaskDependencies + .Where(d => d.TaskId == taskId && removed.Contains(d.DependsOnTaskId)) + .ExecuteDeleteAsync(ct); + + if (added.Count > 0) + { + ctx.TaskDependencies.AddRange(added.Select(id => + new TaskDependencyEntity { TaskId = taskId, DependsOnTaskId = id })); + await ctx.SaveChangesAsync(ct); + } + + // Dropping an edge may free up a Queued task the picker was skipping. + if (removed.Count > 0) _waker.Wake(); await NotifyAsync(taskId); return new TransitionResult(true, null); } + // True when any proposed predecessor can already reach taskId through the dependency graph -- + // linking there would starve both ends forever, since the picker never claims either. + // ponytail: loads the whole edge set (only *declared* dependencies land there, so it stays + // small). Swap in a recursive CTE if task_dependencies ever grows past a few thousand rows. + private static async Task WouldCycleAsync( + ClaudeDoDbContext ctx, string taskId, IReadOnlyList proposed, CancellationToken ct) + { + var edges = await ctx.TaskDependencies.AsNoTracking() + .Select(d => new { d.TaskId, d.DependsOnTaskId }) + .ToListAsync(ct); + var successors = edges + .GroupBy(e => e.TaskId, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.Select(e => e.DependsOnTaskId).ToList(), StringComparer.Ordinal); + + // `visited` also stops the walk looping forever on pre-existing bad data. + var visited = new HashSet(StringComparer.Ordinal); + var frontier = new Queue(proposed); + while (frontier.Count > 0) + { + var current = frontier.Dequeue(); + if (current == taskId) return true; + if (!visited.Add(current)) continue; + if (successors.TryGetValue(current, out var next)) + foreach (var n in next) frontier.Enqueue(n); + } + return false; + } + public async Task RecoverStaleRunningAsync(string reason, CancellationToken ct) { var resultText = "[stale] " + reason; diff --git a/tests/ClaudeDo.Data.Tests/ForeignKeyTests.cs b/tests/ClaudeDo.Data.Tests/ForeignKeyTests.cs index a07bee03..04a439aa 100644 --- a/tests/ClaudeDo.Data.Tests/ForeignKeyTests.cs +++ b/tests/ClaudeDo.Data.Tests/ForeignKeyTests.cs @@ -70,17 +70,23 @@ public sealed class ForeignKeyTests : IDisposable } [Fact] - public async Task DependsOnTaskId_is_nulled_when_predecessor_deleted_on_fresh_context() + public async Task Dependency_edge_is_dropped_when_predecessor_deleted_on_fresh_context() { + // The cascade on task_dependencies is what the old single column got from ON DELETE + // SET NULL: the dependent must come out pickable, not blocked on a task that's gone. var listId = Guid.NewGuid().ToString(); var predecessorId = Guid.NewGuid().ToString(); + var otherId = Guid.NewGuid().ToString(); var dependentId = Guid.NewGuid().ToString(); await using (var ctx = Open()) { ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow }); ctx.Tasks.Add(new TaskEntity { Id = predecessorId, Number = 1, ListId = listId, Title = "Predecessor", Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow }); - ctx.Tasks.Add(new TaskEntity { Id = dependentId, Number = 2, ListId = listId, Title = "Dependent", Status = TaskStatus.Idle, DependsOnTaskId = predecessorId, CreatedAt = DateTime.UtcNow }); + ctx.Tasks.Add(new TaskEntity { Id = otherId, Number = 3, ListId = listId, Title = "Other", Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow }); + ctx.Tasks.Add(new TaskEntity { Id = dependentId, Number = 2, ListId = listId, Title = "Dependent", Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow }); + ctx.TaskDependencies.Add(new TaskDependencyEntity { TaskId = dependentId, DependsOnTaskId = predecessorId }); + ctx.TaskDependencies.Add(new TaskDependencyEntity { TaskId = dependentId, DependsOnTaskId = otherId }); await ctx.SaveChangesAsync(); } @@ -93,8 +99,9 @@ public sealed class ForeignKeyTests : IDisposable await using (var ctx = Open()) { - var dependent = await ctx.Tasks.AsNoTracking().FirstAsync(t => t.Id == dependentId); - Assert.Null(dependent.DependsOnTaskId); + var dependent = await ctx.Tasks.AsNoTracking().Include(t => t.Dependencies) + .FirstAsync(t => t.Id == dependentId); + Assert.Equal([otherId], dependent.DependsOnTaskIds); } } diff --git a/tests/ClaudeDo.Data.Tests/TaskDependencyMigrationTests.cs b/tests/ClaudeDo.Data.Tests/TaskDependencyMigrationTests.cs new file mode 100644 index 00000000..b46e10f7 --- /dev/null +++ b/tests/ClaudeDo.Data.Tests/TaskDependencyMigrationTests.cs @@ -0,0 +1,100 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace ClaudeDo.Data.Tests; + +/// +/// AddTaskDependencies moves every tasks.depends_on_task_id value into the new +/// task_dependencies table and then drops the column. Runs a real Migrate() — the +/// EnsureCreated fixtures every other Data.Tests file uses skip migrations entirely and would +/// never notice a data copy that silently dropped rows (see +/// .claudedo/traps/ef-migration-parallel-designer-collision.md). +/// +public sealed class TaskDependencyMigrationTests : IDisposable +{ + private const string PreviousMigrationId = "20260902102003_AddExpectsNoDiff"; + + private readonly string _dbPath; + private readonly DbContextOptions _options; + + public TaskDependencyMigrationTests() + { + _dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_deps_{Guid.NewGuid():N}.db"); + _options = new DbContextOptionsBuilder() + .UseSqlite($"Data Source={_dbPath}") + .Options; + } + + public void Dispose() + { + Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + foreach (var suffix in new[] { "", "-wal", "-shm" }) + try { File.Delete(_dbPath + suffix); } catch { } + } + + [Fact] + public void Existing_depends_on_links_survive_as_one_element_sets() + { + SeedLegacyDatabase(); + + using (var ctx = new ClaudeDoDbContext(_options)) + ctx.Database.Migrate(); + + using var check = new ClaudeDoDbContext(_options); + Assert.Empty(check.Database.GetPendingMigrations()); + + var edges = check.TaskDependencies.AsNoTracking() + .OrderBy(d => d.TaskId) + .Select(d => new { d.TaskId, d.DependsOnTaskId }) + .ToList(); + Assert.Equal(2, edges.Count); + Assert.Equal(("t2", "t1"), (edges[0].TaskId, edges[0].DependsOnTaskId)); + Assert.Equal(("t3", "t2"), (edges[1].TaskId, edges[1].DependsOnTaskId)); + + // The tasks themselves are untouched, and the independent one gained no edge. + Assert.Equal(4, check.Tasks.Count()); + Assert.DoesNotContain("t4", edges.Select(e => e.TaskId)); + } + + [Fact] + public void Column_is_gone_after_the_migration() + { + SeedLegacyDatabase(); + + using var ctx = new ClaudeDoDbContext(_options); + ctx.Database.Migrate(); + + var conn = ctx.Database.GetDbConnection(); + conn.Open(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT COUNT(*) FROM pragma_table_info('tasks') WHERE name = 'depends_on_task_id'"; + Assert.Equal(0L, Convert.ToInt64(cmd.ExecuteScalar())); + conn.Close(); + } + + // Migrates *to* the migration before this one, then seeds via raw SQL: EnsureCreated would + // build today's schema (no depends_on_task_id column at all), so there'd be nothing to copy. + private void SeedLegacyDatabase() + { + using var seed = new ClaudeDoDbContext(_options); + seed.Database.GetService().Migrate(PreviousMigrationId); + + var conn = seed.Database.GetDbConnection(); + conn.Open(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + INSERT INTO lists (id, name, created_at) VALUES ('l1', 'List', '2026-01-01 00:00:00'); + INSERT INTO tasks (id, number, list_id, title, status, created_at) + VALUES ('t1', 1, 'l1', 'first', 'done', '2026-01-01 00:00:00'); + INSERT INTO tasks (id, number, list_id, title, status, created_at, depends_on_task_id) + VALUES ('t2', 2, 'l1', 'second', 'queued', '2026-01-01 00:00:00', 't1'); + INSERT INTO tasks (id, number, list_id, title, status, created_at, depends_on_task_id) + VALUES ('t3', 3, 'l1', 'third', 'queued', '2026-01-01 00:00:00', 't2'); + INSERT INTO tasks (id, number, list_id, title, status, created_at) + VALUES ('t4', 4, 'l1', 'independent', 'idle', '2026-01-01 00:00:00'); + """; + cmd.ExecuteNonQuery(); + conn.Close(); + } +} diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandChainTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandChainTests.cs index 51c9ae9e..9ba9f8e1 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandChainTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandChainTests.cs @@ -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); + } } diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandClassifyItemsTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandClassifyItemsTests.cs index 211701d2..f7f1eb00 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandClassifyItemsTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandClassifyItemsTests.cs @@ -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(); } diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandReconcileTickTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandReconcileTickTests.cs index 5a19aac7..44c742c6 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandReconcileTickTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/TasksIslandReconcileTickTests.cs @@ -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(); } diff --git a/tests/ClaudeDo.Worker.Tests/External/BatchMcpToolsTests.cs b/tests/ClaudeDo.Worker.Tests/External/BatchMcpToolsTests.cs index 74fced35..5d6a680d 100644 --- a/tests/ClaudeDo.Worker.Tests/External/BatchMcpToolsTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/BatchMcpToolsTests.cs @@ -408,8 +408,7 @@ public sealed class BatchMcpToolsTests : IDisposable var listId = await SeedListAsync(); var predecessor = await SeedTaskAsync(listId, "predecessor", TaskStatus.Idle); var task = await SeedTaskAsync(listId, "blocked", TaskStatus.Queued); - task.DependsOnTaskId = predecessor.Id; - await _tasks.UpdateAsync(task); + await _ctx.SeedDependsOnAsync(task.Id, predecessor.Id); var sut = BuildSut(); var results = await sut.BatchGetTasks(new[] { task.Id }, cancellationToken: CancellationToken.None); diff --git a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs index 45017ed0..02c1850b 100644 --- a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs @@ -118,6 +118,10 @@ public sealed class ExternalMcpServiceTests : IDisposable return id; } + // The dependency set has no defined order, and the ids are random GUIDs -- sort both sides. + private static List Sorted(params string[] ids) => ids.OrderBy(x => x).ToList(); + private static List Sorted(IEnumerable ids) => ids.OrderBy(x => x).ToList(); + private async Task SeedTaskAsync( string listId, string title = "t", TaskStatus status = TaskStatus.Idle, string? parentId = null) { @@ -444,8 +448,7 @@ public sealed class ExternalMcpServiceTests : IDisposable var listId = await SeedListAsync(); var predecessor = await SeedTaskAsync(listId, status: TaskStatus.Idle); var task = await SeedTaskAsync(listId, status: TaskStatus.Queued); - task.DependsOnTaskId = predecessor.Id; - await _tasks.UpdateAsync(task, CancellationToken.None); + await _ctx.SeedDependsOnAsync(task.Id, predecessor.Id); var sut = BuildSut(CreateQueue()); var dto = await sut.GetTask(task.Id, CancellationToken.None); @@ -460,8 +463,7 @@ public sealed class ExternalMcpServiceTests : IDisposable var listId = await SeedListAsync(); var predecessor = await SeedTaskAsync(listId, status: TaskStatus.Done); var task = await SeedTaskAsync(listId, status: TaskStatus.Queued); - task.DependsOnTaskId = predecessor.Id; - await _tasks.UpdateAsync(task, CancellationToken.None); + await _ctx.SeedDependsOnAsync(task.Id, predecessor.Id); var sut = BuildSut(CreateQueue()); var dto = await sut.GetTask(task.Id, CancellationToken.None); @@ -478,8 +480,7 @@ public sealed class ExternalMcpServiceTests : IDisposable var listId = await SeedListAsync(); var predecessor = await SeedTaskAsync(listId, status: TaskStatus.Idle); var task = await SeedTaskAsync(listId, status: TaskStatus.Idle); - task.DependsOnTaskId = predecessor.Id; - await _tasks.UpdateAsync(task, CancellationToken.None); + await _ctx.SeedDependsOnAsync(task.Id, predecessor.Id); var sut = BuildSut(CreateQueue()); var dto = await sut.GetTask(task.Id, CancellationToken.None); @@ -493,8 +494,7 @@ public sealed class ExternalMcpServiceTests : IDisposable var listId = await SeedListAsync(); var predecessor = await SeedTaskAsync(listId, "predecessor", TaskStatus.Idle); var blocked = await SeedTaskAsync(listId, "blocked", TaskStatus.Queued); - blocked.DependsOnTaskId = predecessor.Id; - await _tasks.UpdateAsync(blocked, CancellationToken.None); + await _ctx.SeedDependsOnAsync(blocked.Id, predecessor.Id); var unblocked = await SeedTaskAsync(listId, "unblocked", TaskStatus.Queued); var sut = BuildSut(CreateQueue()); @@ -513,8 +513,7 @@ public sealed class ExternalMcpServiceTests : IDisposable var listId = await SeedListAsync(); var predecessor = await SeedTaskAsync(listId, "predecessor", TaskStatus.Idle); var blocked = await SeedTaskAsync(listId, "blocked", TaskStatus.Queued); - blocked.DependsOnTaskId = predecessor.Id; - await _tasks.UpdateAsync(blocked, CancellationToken.None); + await _ctx.SeedDependsOnAsync(blocked.Id, predecessor.Id); var sut = BuildSut(CreateQueue()); var result = await sut.ListTasks(listId, includeDescription: true, cancellationToken: CancellationToken.None); @@ -555,10 +554,10 @@ public sealed class ExternalMcpServiceTests : IDisposable var dto = await sut.UpdateTask(task.Id, dependsOnTaskId: predecessor.Id, cancellationToken: CancellationToken.None); - Assert.Equal(predecessor.Id, dto.DependsOnTaskId); + Assert.Equal([predecessor.Id], dto.DependsOnTaskIds); Assert.True(dto.Blocked); var loaded = await _tasks.GetByIdAsync(task.Id); - Assert.Equal(predecessor.Id, loaded!.DependsOnTaskId); + Assert.Equal([predecessor.Id], loaded!.DependsOnTaskIds); } [Fact] @@ -572,7 +571,7 @@ public sealed class ExternalMcpServiceTests : IDisposable var dto = await sut.UpdateTask(task.Id, dependsOnTaskId: "", cancellationToken: CancellationToken.None); - Assert.Null(dto.DependsOnTaskId); + Assert.Empty(dto.DependsOnTaskIds!); Assert.False(dto.Blocked); } @@ -589,6 +588,75 @@ public sealed class ExternalMcpServiceTests : IDisposable sut.UpdateTask(a.Id, dependsOnTaskId: b.Id, cancellationToken: CancellationToken.None)); } + [Fact] + public async Task UpdateTask_WithDependsOnTaskIds_SetsSeveralPredecessors() + { + var listId = await SeedListAsync(); + var a = await SeedTaskAsync(listId, "a", TaskStatus.Idle); + var b = await SeedTaskAsync(listId, "b", TaskStatus.Idle); + var task = await SeedTaskAsync(listId, status: TaskStatus.Queued); + var sut = BuildSut(CreateQueue()); + + var dto = await sut.UpdateTask( + task.Id, dependsOnTaskIds: [a.Id, b.Id], cancellationToken: CancellationToken.None); + + Assert.Equal(Sorted(a.Id, b.Id), Sorted(dto.DependsOnTaskIds!)); + Assert.True(dto.Blocked); + // Naming only the first predecessor would send a caller off to unblock the wrong task. + Assert.Contains(a.Id, dto.BlockedReason); + Assert.Contains(b.Id, dto.BlockedReason); + } + + [Fact] + public async Task UpdateTask_WithEmptyDependsOnTaskIds_ClearsTheWholeSet() + { + var listId = await SeedListAsync(); + var a = await SeedTaskAsync(listId, "a", TaskStatus.Idle); + var b = await SeedTaskAsync(listId, "b", TaskStatus.Idle); + var task = await SeedTaskAsync(listId, status: TaskStatus.Queued); + var sut = BuildSut(CreateQueue()); + await sut.UpdateTask(task.Id, dependsOnTaskIds: [a.Id, b.Id], cancellationToken: CancellationToken.None); + + var dto = await sut.UpdateTask(task.Id, dependsOnTaskIds: [], cancellationToken: CancellationToken.None); + + Assert.Empty(dto.DependsOnTaskIds!); + Assert.False(dto.Blocked); + } + + [Fact] + public async Task UpdateTask_LegacyScalarDependsOnTaskId_StillReplacesTheWholeSet() + { + // Back-compat: existing callers and prompts only know the scalar form. It has to keep + // behaving as "this is the one predecessor", not append to whatever is already there. + var listId = await SeedListAsync(); + var a = await SeedTaskAsync(listId, "a", TaskStatus.Idle); + var b = await SeedTaskAsync(listId, "b", TaskStatus.Idle); + var task = await SeedTaskAsync(listId, status: TaskStatus.Queued); + var sut = BuildSut(CreateQueue()); + await sut.UpdateTask(task.Id, dependsOnTaskIds: [a.Id, b.Id], cancellationToken: CancellationToken.None); + + var dto = await sut.UpdateTask(task.Id, dependsOnTaskId: a.Id, cancellationToken: CancellationToken.None); + + Assert.Equal([a.Id], dto.DependsOnTaskIds); + } + + [Fact] + public async Task UpdateTask_WithCyclicDependsOnTaskIds_Throws() + { + var listId = await SeedListAsync(); + var a = await SeedTaskAsync(listId, "a", TaskStatus.Idle); + var b = await SeedTaskAsync(listId, "b", TaskStatus.Idle); + var c = await SeedTaskAsync(listId, "c", TaskStatus.Idle); + var innocent = await SeedTaskAsync(listId, "innocent", TaskStatus.Idle); + var sut = BuildSut(CreateQueue()); + await sut.UpdateTask(b.Id, dependsOnTaskId: a.Id, cancellationToken: CancellationToken.None); + await sut.UpdateTask(c.Id, dependsOnTaskId: b.Id, cancellationToken: CancellationToken.None); + + // a -> {innocent, c} closes a -> c -> b -> a over three edges. + await Assert.ThrowsAsync(() => + sut.UpdateTask(a.Id, dependsOnTaskIds: [innocent.Id, c.Id], cancellationToken: CancellationToken.None)); + } + [Fact] public async Task ReviewTask_Approve_SetsDone() { @@ -1488,11 +1556,29 @@ public sealed class ExternalMcpServiceTests : IDisposable var dto = await sut.AddTask( listId, "t", dependsOnTaskId: predecessor.Id, queueImmediately: true, cancellationToken: CancellationToken.None); - Assert.Equal(predecessor.Id, dto.Task.DependsOnTaskId); + Assert.Equal([predecessor.Id], dto.Task.DependsOnTaskIds); Assert.True(dto.Task.Blocked); Assert.Contains(predecessor.Id, dto.Task.BlockedReason); var loaded = await _tasks.GetByIdAsync(dto.Task.Id); - Assert.Equal(predecessor.Id, loaded!.DependsOnTaskId); + Assert.Equal([predecessor.Id], loaded!.DependsOnTaskIds); + } + + [Fact] + public async Task AddTask_WithDependsOnTaskIds_PersistsEveryLink() + { + var listId = await SeedListAsync(); + var a = await SeedTaskAsync(listId, "a", TaskStatus.Idle); + var b = await SeedTaskAsync(listId, "b", TaskStatus.Done); + var sut = NewService(); + + var dto = await sut.AddTask( + listId, "t", dependsOnTaskIds: [a.Id, b.Id], queueImmediately: true, + cancellationToken: CancellationToken.None); + + Assert.Equal(Sorted(a.Id, b.Id), Sorted(dto.Task.DependsOnTaskIds!)); + Assert.True(dto.Task.Blocked); + var loaded = await _tasks.GetByIdAsync(dto.Task.Id); + Assert.Equal(Sorted(a.Id, b.Id), Sorted(loaded!.DependsOnTaskIds)); } [Fact] diff --git a/tests/ClaudeDo.Worker.Tests/External/TaskWaitMcpToolsTests.cs b/tests/ClaudeDo.Worker.Tests/External/TaskWaitMcpToolsTests.cs index 846d7955..dc7387cd 100644 --- a/tests/ClaudeDo.Worker.Tests/External/TaskWaitMcpToolsTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/TaskWaitMcpToolsTests.cs @@ -220,8 +220,7 @@ public sealed class TaskWaitMcpToolsTests : IDisposable { var dependency = await SeedTaskAsync(TaskStatus.Idle); var task = await SeedTaskAsync(TaskStatus.Queued); - task.DependsOnTaskId = dependency.Id; - await _tasks.UpdateAsync(task); + await _ctx.SeedDependsOnAsync(task.Id, dependency.Id); var sut = BuildSut(); var sw = Stopwatch.StartNew(); @@ -240,8 +239,7 @@ public sealed class TaskWaitMcpToolsTests : IDisposable { var dependency = await SeedTaskAsync(TaskStatus.Done); var task = await SeedTaskAsync(TaskStatus.Queued); - task.DependsOnTaskId = dependency.Id; - await _tasks.UpdateAsync(task); + await _ctx.SeedDependsOnAsync(task.Id, dependency.Id); var sut = BuildSut(); var sw = Stopwatch.StartNew(); diff --git a/tests/ClaudeDo.Worker.Tests/Infrastructure/TestDependencies.cs b/tests/ClaudeDo.Worker.Tests/Infrastructure/TestDependencies.cs new file mode 100644 index 00000000..812bc4e2 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Infrastructure/TestDependencies.cs @@ -0,0 +1,20 @@ +using ClaudeDo.Data; +using ClaudeDo.Data.Models; + +namespace ClaudeDo.Worker.Tests.Infrastructure; + +internal static class TestDependencies +{ + /// + /// Seeds task_dependencies rows straight through the context. The production writer is + /// TaskStateService.SetDependsOnAsync, which most read-path fixtures don't build — + /// this is the "arrange" shortcut, not a second writer. + /// + public static async Task SeedDependsOnAsync( + this ClaudeDoDbContext ctx, string taskId, params string[] dependsOnTaskIds) + { + ctx.TaskDependencies.AddRange(dependsOnTaskIds.Select(id => + new TaskDependencyEntity { TaskId = taskId, DependsOnTaskId = id })); + await ctx.SaveChangesAsync(); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Planning/PlanningMergeOrchestratorTests.cs b/tests/ClaudeDo.Worker.Tests/Planning/PlanningMergeOrchestratorTests.cs index a31bd2db..eb2365ec 100644 --- a/tests/ClaudeDo.Worker.Tests/Planning/PlanningMergeOrchestratorTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Planning/PlanningMergeOrchestratorTests.cs @@ -908,7 +908,7 @@ file sealed class ApproveObservingTaskStateService : ITaskStateService public Task FinalizePlanningAsync(string parentId, CancellationToken ct) => _inner.FinalizePlanningAsync(parentId, ct); public Task BlockOnAsync(string taskId, string predecessorTaskId, CancellationToken ct) => _inner.BlockOnAsync(taskId, predecessorTaskId, ct); public Task UnblockAsync(string taskId, CancellationToken ct) => _inner.UnblockAsync(taskId, ct); - public Task SetDependsOnAsync(string taskId, string? dependsOnTaskId, CancellationToken ct) => _inner.SetDependsOnAsync(taskId, dependsOnTaskId, ct); + public Task SetDependsOnAsync(string taskId, IReadOnlyList dependsOnTaskIds, CancellationToken ct) => _inner.SetDependsOnAsync(taskId, dependsOnTaskIds, ct); public Task TryAdvanceParentAsync(string parentId) => _inner.TryAdvanceParentAsync(parentId); public Task RecoverStaleRunningAsync(string reason, CancellationToken ct) => _inner.RecoverStaleRunningAsync(reason, ct); } diff --git a/tests/ClaudeDo.Worker.Tests/Queue/QueuePickerTests.cs b/tests/ClaudeDo.Worker.Tests/Queue/QueuePickerTests.cs index 3789f54d..d1dcf9f2 100644 --- a/tests/ClaudeDo.Worker.Tests/Queue/QueuePickerTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Queue/QueuePickerTests.cs @@ -50,7 +50,7 @@ public sealed class QueuePickerTests : IDisposable bool taskAgentTag = false, int? sortOrder = null, bool isManual = false, - string? dependsOn = null) + params string[] dependsOn) { var task = new TaskEntity { @@ -61,11 +61,12 @@ public sealed class QueuePickerTests : IDisposable CreatedAt = createdAt ?? DateTime.UtcNow, ScheduledFor = scheduledFor, BlockedByTaskId = blockedBy, - DependsOnTaskId = dependsOn, CommitType = "feat", IsManual = isManual, }; await _tasks.AddAsync(task); + if (dependsOn.Length > 0) + await _ctx.SeedDependsOnAsync(task.Id, dependsOn); if (sortOrder is not null) { task.SortOrder = sortOrder.Value; @@ -117,7 +118,7 @@ public sealed class QueuePickerTests : IDisposable { var listId = await CreateListAsync(); var dependency = await SeedAsync(listId, status: TaskStatus.Idle); - await SeedAsync(listId, dependsOn: dependency.Id); + await SeedAsync(listId, dependsOn: [dependency.Id]); // The dependency is only Idle (not Done), so both stay unclaimed: the dependent is // gated on depends_on_task_id, and the dependency itself was never queued. @@ -129,7 +130,7 @@ public sealed class QueuePickerTests : IDisposable { var listId = await CreateListAsync(); var dependency = await SeedAsync(listId, status: TaskStatus.Done); - var dependent = await SeedAsync(listId, dependsOn: dependency.Id); + var dependent = await SeedAsync(listId, dependsOn: [dependency.Id]); var picked = await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None); @@ -142,7 +143,7 @@ public sealed class QueuePickerTests : IDisposable { var listId = await CreateListAsync(); var dependency = await SeedAsync(listId, status: TaskStatus.Failed); - await SeedAsync(listId, dependsOn: dependency.Id); + await SeedAsync(listId, dependsOn: [dependency.Id]); var independent = await SeedAsync(listId, createdAt: DateTime.UtcNow.AddMinutes(1)); // A Failed dependency does not cascade-cancel or auto-unblock the dependent (unlike the @@ -155,6 +156,55 @@ public sealed class QueuePickerTests : IDisposable Assert.Null(second); } + [Fact] + public async Task ClaimNextAsync_Skips_TaskWhoseDependsOnCancelled() + { + var listId = await CreateListAsync(); + var dependency = await SeedAsync(listId, status: TaskStatus.Cancelled); + await SeedAsync(listId, dependsOn: [dependency.Id]); + + Assert.Null(await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None)); + } + + [Fact] + public async Task ClaimNextAsync_Skips_TaskWithOneOfTwoDependenciesUnmet() + { + var listId = await CreateListAsync(); + var done = await SeedAsync(listId, status: TaskStatus.Done); + var running = await SeedAsync(listId, status: TaskStatus.Running); + await SeedAsync(listId, dependsOn: [done.Id, running.Id]); + + Assert.Null(await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None)); + } + + [Fact] + public async Task ClaimNextAsync_Claims_TaskOnceAllDependenciesAreDone() + { + var listId = await CreateListAsync(); + var first = await SeedAsync(listId, status: TaskStatus.Done); + var second = await SeedAsync(listId, status: TaskStatus.Done); + var dependent = await SeedAsync(listId, dependsOn: [first.Id, second.Id]); + + var picked = await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None); + + Assert.NotNull(picked); + Assert.Equal(dependent.Id, picked!.Id); + } + + [Fact] + public async Task ClaimNextAsync_Claims_TaskWithoutAnyDependency() + { + // The gating clause is "no edge whose predecessor isn't done" — a task with zero edges + // must stay eligible, which the doubly-negated form is easy to get wrong. + var listId = await CreateListAsync(); + var task = await SeedAsync(listId); + + var picked = await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None); + + Assert.NotNull(picked); + Assert.Equal(task.Id, picked!.Id); + } + [Fact] public async Task ClaimNextAsync_Picks_TasksWithoutAgentTag() { diff --git a/tests/ClaudeDo.Worker.Tests/State/TaskStateServiceTests.cs b/tests/ClaudeDo.Worker.Tests/State/TaskStateServiceTests.cs index 534a2066..79c7de79 100644 --- a/tests/ClaudeDo.Worker.Tests/State/TaskStateServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/State/TaskStateServiceTests.cs @@ -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> 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 Sorted(params string[] ids) => ids.OrderBy(x => x).ToList(); + private static List Sorted(IEnumerable 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]