feat(claude-do): depends_on als Menge statt Einzelfeld

Ein Task kann jetzt auf beliebig viele Vorgaenger warten. Die Einzelspalte
tasks.depends_on_task_id wird durch die Beziehungstabelle task_dependencies
ersetzt (Composite-PK, beide FKs ON DELETE CASCADE -- das reproduziert das
alte ON DELETE SET NULL: geloeschter Vorgaenger = Kante weg = Task wieder
pickbar). BlockedByTaskId bleibt unberuehrt.

- Migration AddTaskDependencies kopiert jede bestehende depends_on_task_id
  verlustfrei in die Tabelle, bevor die Spalte fallengelassen wird
- QueuePicker gatet per doppelt negiertem NOT EXISTS: kein Task mit 0 Kanten
  wird blockiert, alle Kanten muessen done sein, eine Kante auf einen
  verschwundenen Task blockiert weiter
- SetDependsOnAsync ersetzt die ganze Menge; Zykluspruefung ist jetzt eine
  BFS ueber den Graphen, faengt also auch A->B->C->A
- blockedReason nennt alle offenen Vorgaenger (External/TaskBlocking, von
  ExternalMcpService und TaskWaitMcpTools gemeinsam genutzt)
- add_task/update_task nehmen dependsOnTaskIds; die alte Skalarform bleibt
  als Ein-Element-Menge inkl. ""-Clear-Sentinel gueltig
- UI: Fan-in-Zeile bleibt flach und listet alle Vorgaenger im After-Label,
  die lineare Kette rendert unveraendert
This commit is contained in:
Mika Kuns
2026-09-02 15:08:05 +02:00
parent 84d955dfc7
commit c25732cff8
31 changed files with 1893 additions and 206 deletions
+14 -8
View File
@@ -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 `<provider>:<id>` (currently always `bandel:<ticketId>`). 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<TaskDependencyEntity>`) + 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
+1
View File
@@ -41,6 +41,7 @@ public class ClaudeDoDbContext : DbContext
=> optionsBuilder.AddInterceptors(SqliteForeignKeyInterceptor.Instance);
public DbSet<TaskEntity> Tasks => Set<TaskEntity>();
public DbSet<TaskDependencyEntity> TaskDependencies => Set<TaskDependencyEntity>();
public DbSet<ListEntity> Lists => Set<ListEntity>();
public DbSet<ListConfigEntity> ListConfigs => Set<ListConfigEntity>();
public DbSet<WorktreeEntity> Worktrees => Set<WorktreeEntity>();
@@ -0,0 +1,32 @@
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ClaudeDo.Data.Configuration;
public class TaskDependencyEntityConfiguration : IEntityTypeConfiguration<TaskDependencyEntity>
{
public void Configure(EntityTypeBuilder<TaskDependencyEntity> 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<TaskEntity>()
.WithMany(t => t.Dependencies)
.HasForeignKey(d => d.TaskId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasOne<TaskEntity>()
.WithMany()
.HasForeignKey(d => d.DependsOnTaskId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasIndex(d => d.DependsOnTaskId).HasDatabaseName("idx_task_dependencies_depends_on");
}
}
@@ -75,7 +75,6 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
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<TaskEntity>
.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<TaskEntity>()
.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<TaskEntity>
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");
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,104 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <summary>
/// Turns the single <c>tasks.depends_on_task_id</c> column into a set: one row per declared
/// "run after" edge in <c>task_dependencies</c>. 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.
/// </summary>
public partial class AddTaskDependencies : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "task_dependencies",
columns: table => new
{
task_id = table.Column<string>(type: "TEXT", nullable: false),
depends_on_task_id = table.Column<string>(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");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
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);
}
}
}
@@ -475,6 +475,24 @@ namespace ClaudeDo.Data.Migrations
b.ToTable("task_attachments", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskDependencyEntity", b =>
{
b.Property<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("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<string>("Id")
@@ -504,10 +522,6 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT")
.HasColumnName("created_by");
b.Property<string>("DependsOnTaskId")
.HasColumnType("TEXT")
.HasColumnName("depends_on_task_id");
b.Property<string>("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");
@@ -0,0 +1,13 @@
namespace ClaudeDo.Data.Models;
/// <summary>
/// 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 <see cref="TaskStatus.Done"/>.
/// Deliberately separate from <see cref="TaskEntity.BlockedByTaskId"/>, the planning chain's own
/// internal single link — see ClaudeDo.Worker/CLAUDE.md → Status Model.
/// </summary>
public sealed class TaskDependencyEntity
{
public required string TaskId { get; init; }
public required string DependsOnTaskId { get; init; }
}
+13 -6
View File
@@ -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<TaskDependencyEntity> Dependencies { get; set; } = new List<TaskDependencyEntity>();
[NotMapped]
public IReadOnlyList<string> DependsOnTaskIds =>
Dependencies.Select(d => d.DependsOnTaskId).ToList();
public DateTime? ScheduledFor { get; set; }
public string? Result { get; set; }
public string? ReviewFeedback { get; set; }
@@ -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<TaskEntity?> 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<TaskEntity?> 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<List<TaskEntity>> 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);
@@ -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<string> _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;
@@ -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<TaskEntity>()
@@ -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<string, TaskRowViewModel> 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);
}
+3 -3
View File
@@ -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. |
+67 -39
View File
@@ -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<string>? 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<string>? 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<Dictionary<string, (bool Blocked, string? Reason)>> ComputeBlockedInfoAsync(
IReadOnlyList<TaskEntity> 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<IReadOnlyList<string>?> ResolveDependsOnAsync(
string? dependsOnTaskId, string[]? dependsOnTaskIds, CancellationToken ct)
{
if (dependsOnTaskIds is not null)
{
var resolved = new List<string>();
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,
+42
View File
@@ -0,0 +1,42 @@
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.External;
/// <summary>
/// Renders the `blockedReason` string shared by <see cref="ExternalMcpService"/> and
/// <see cref="TaskWaitMcpTools"/>. Lives here so the two can't drift on the wording an agent
/// parses.
/// </summary>
internal static class TaskBlocking
{
/// <summary>
/// The predecessors of <paramref name="dependencyIds"/> that have not reached Done — an id
/// missing from <paramref name="statuses"/> counts as unmet (the picker treats a dangling
/// edge as blocking too).
/// </summary>
public static List<(string Id, TaskStatus? Status)> Unmet(
IEnumerable<string> dependencyIds, IReadOnlyDictionary<string, TaskStatus> 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;
}
/// <summary>Names every open predecessor, not just the first — a task can wait on n of them.</summary>
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)}.";
}
}
+16 -13
View File
@@ -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;
}
}
+14 -8
View File
@@ -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
)
@@ -37,9 +37,10 @@ public interface ITaskStateService
Task<TransitionResult> BlockOnAsync(string taskId, string predecessorTaskId, CancellationToken ct);
Task<TransitionResult> 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<TransitionResult> 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<TransitionResult> SetDependsOnAsync(string taskId, IReadOnlyList<string> 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,
+74 -35
View File
@@ -501,50 +501,89 @@ public sealed class TaskStateService : ITaskStateService
return new TransitionResult(true, null);
}
public async Task<TransitionResult> SetDependsOnAsync(string taskId, string? dependsOnTaskId, CancellationToken ct)
public async Task<TransitionResult> SetDependsOnAsync(
string taskId, IReadOnlyList<string> 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<string>();
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<bool> WouldCycleAsync(
ClaudeDoDbContext ctx, string taskId, IReadOnlyList<string> 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<string>(StringComparer.Ordinal);
var frontier = new Queue<string>(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<int> RecoverStaleRunningAsync(string reason, CancellationToken ct)
{
var resultText = "[stale] " + reason;
+11 -4
View File
@@ -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);
}
}
@@ -0,0 +1,100 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
namespace ClaudeDo.Data.Tests;
/// <summary>
/// AddTaskDependencies moves every <c>tasks.depends_on_task_id</c> value into the new
/// <c>task_dependencies</c> table and then drops the column. Runs a real <c>Migrate()</c> — the
/// EnsureCreated fixtures every other Data.Tests file uses skip migrations entirely and would
/// never notice a data copy that silently dropped rows (see
/// <c>.claudedo/traps/ef-migration-parallel-designer-collision.md</c>).
/// </summary>
public sealed class TaskDependencyMigrationTests : IDisposable
{
private const string PreviousMigrationId = "20260902102003_AddExpectsNoDiff";
private readonly string _dbPath;
private readonly DbContextOptions<ClaudeDoDbContext> _options;
public TaskDependencyMigrationTests()
{
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_deps_{Guid.NewGuid():N}.db");
_options = new DbContextOptionsBuilder<ClaudeDoDbContext>()
.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<IMigrator>().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();
}
}
@@ -74,10 +74,15 @@ public class TasksIslandChainTests : IDisposable
Title = t.Id,
CreatedAt = DateTime.UtcNow,
Status = t.Status,
DependsOnTaskId = t.DependsOnTaskId,
ParentTaskId = t.ParentTaskId,
SortOrder = t.SortOrder,
});
if (t.DependsOnTaskId is not null)
db.TaskDependencies.Add(new TaskDependencyEntity
{
TaskId = t.Id,
DependsOnTaskId = t.DependsOnTaskId,
});
}
await db.SaveChangesAsync();
return numbers;
@@ -225,8 +230,7 @@ public class TasksIslandChainTests : IDisposable
await using (var db = NewContext())
{
var entity = await db.Tasks.FirstAsync(t => t.Id == "later");
entity.DependsOnTaskId = "head";
db.TaskDependencies.Add(new TaskDependencyEntity { TaskId = "later", DependsOnTaskId = "head" });
await db.SaveChangesAsync();
}
@@ -236,4 +240,30 @@ public class TasksIslandChainTests : IDisposable
Assert.True(updated.ShowAsChainMember);
Assert.Equal(1, updated.ChainStep);
}
[Fact]
public async Task FanIn_RendersFlatWithEveryPredecessorInTheLabel()
{
// Two independent predecessors is not a chain -- the rail can only draw one line, so the
// row stays flat and the label has to name both, or it misreports what it waits for.
var numbers = await SeedAsync(
("a", TaskStatus.Idle, null, null, 0),
("b", TaskStatus.Idle, null, null, 1),
("wiring", TaskStatus.Idle, "a", null, 2));
await using (var db = NewContext())
{
db.TaskDependencies.Add(new TaskDependencyEntity { TaskId = "wiring", DependsOnTaskId = "b" });
await db.SaveChangesAsync();
}
var vm = BuildViewModel();
await LoadAndWaitAsync(vm, UserList("list1", "Default"));
var wiring = vm.Items.First(r => r.Id == "wiring");
Assert.False(wiring.ShowAsChainMember);
Assert.Null(wiring.ChainStep);
Assert.NotNull(wiring.ChainAfterLabel);
Assert.Contains(numbers["a"].ToString(), wiring.ChainAfterLabel);
Assert.Contains(numbers["b"].ToString(), wiring.ChainAfterLabel);
}
}
@@ -73,9 +73,14 @@ public class TasksIslandClassifyItemsTests : IDisposable
Title = t.Id,
CreatedAt = DateTime.UtcNow,
Status = t.Status,
DependsOnTaskId = t.DependsOnTaskId,
SortOrder = t.SortOrder,
});
if (t.DependsOnTaskId is not null)
db.TaskDependencies.Add(new TaskDependencyEntity
{
TaskId = t.Id,
DependsOnTaskId = t.DependsOnTaskId,
});
}
await db.SaveChangesAsync();
}
@@ -266,8 +266,7 @@ public class TasksIslandReconcileTickTests : IDisposable
await using (var db = NewContext())
{
var t = await db.Tasks.FirstAsync(x => x.Id == "DEP");
t.DependsOnTaskId = "HEAD";
db.TaskDependencies.Add(new TaskDependencyEntity { TaskId = "DEP", DependsOnTaskId = "HEAD" });
await db.SaveChangesAsync();
}
+1 -2
View File
@@ -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);
@@ -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<string> Sorted(params string[] ids) => ids.OrderBy(x => x).ToList();
private static List<string> Sorted(IEnumerable<string> ids) => ids.OrderBy(x => x).ToList();
private async Task<TaskEntity> 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<InvalidOperationException>(() =>
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]
@@ -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();
@@ -0,0 +1,20 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
namespace ClaudeDo.Worker.Tests.Infrastructure;
internal static class TestDependencies
{
/// <summary>
/// Seeds task_dependencies rows straight through the context. The production writer is
/// <c>TaskStateService.SetDependsOnAsync</c>, which most read-path fixtures don't build —
/// this is the "arrange" shortcut, not a second writer.
/// </summary>
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();
}
}
@@ -908,7 +908,7 @@ file sealed class ApproveObservingTaskStateService : ITaskStateService
public Task<TransitionResult> FinalizePlanningAsync(string parentId, CancellationToken ct) => _inner.FinalizePlanningAsync(parentId, ct);
public Task<TransitionResult> BlockOnAsync(string taskId, string predecessorTaskId, CancellationToken ct) => _inner.BlockOnAsync(taskId, predecessorTaskId, ct);
public Task<TransitionResult> UnblockAsync(string taskId, CancellationToken ct) => _inner.UnblockAsync(taskId, ct);
public Task<TransitionResult> SetDependsOnAsync(string taskId, string? dependsOnTaskId, CancellationToken ct) => _inner.SetDependsOnAsync(taskId, dependsOnTaskId, ct);
public Task<TransitionResult> SetDependsOnAsync(string taskId, IReadOnlyList<string> dependsOnTaskIds, CancellationToken ct) => _inner.SetDependsOnAsync(taskId, dependsOnTaskIds, ct);
public Task TryAdvanceParentAsync(string parentId) => _inner.TryAdvanceParentAsync(parentId);
public Task<int> RecoverStaleRunningAsync(string reason, CancellationToken ct) => _inner.RecoverStaleRunningAsync(reason, ct);
}
@@ -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()
{
@@ -102,6 +102,17 @@ public sealed class TaskStateServiceTests : IDisposable
return await new TaskRepository(ctx).GetByIdAsync(id) ?? throw new InvalidOperationException($"task {id} not found");
}
private async Task<List<string>> GetDependsOnAsync(string id)
{
await using var ctx = _factory.CreateDbContext();
return await ctx.TaskDependencies.Where(d => d.TaskId == id)
.Select(d => d.DependsOnTaskId).ToListAsync();
}
// The dependency set has no defined order, and the ids are random GUIDs -- sort both sides.
private static List<string> Sorted(params string[] ids) => ids.OrderBy(x => x).ToList();
private static List<string> Sorted(IEnumerable<string> ids) => ids.OrderBy(x => x).ToList();
// ─── EnqueueAsync ─────────────────────────────────────────────────────
[Fact]
@@ -659,31 +670,55 @@ public sealed class TaskStateServiceTests : IDisposable
// ─── SetDependsOnAsync ────────────────────────────────────────────────
[Fact]
public async Task SetDependsOnAsync_SetsDependsOnTaskId()
public async Task SetDependsOnAsync_SetsDependsOnTaskIds()
{
var predecessor = await SeedTaskAsync(TaskStatus.Idle);
var task = await SeedTaskAsync(TaskStatus.Queued);
var result = await _sut.SetDependsOnAsync(task, predecessor, default);
var result = await _sut.SetDependsOnAsync(task, [predecessor], default);
Assert.True(result.Ok);
var t = await GetTaskAsync(task);
Assert.Equal(predecessor, t.DependsOnTaskId);
Assert.Equal([predecessor], await GetDependsOnAsync(task));
}
[Fact]
public async Task SetDependsOnAsync_Null_ClearsDependsOnTaskId_AndWakesQueue()
public async Task SetDependsOnAsync_SetsSeveralPredecessors()
{
var a = await SeedTaskAsync(TaskStatus.Idle);
var b = await SeedTaskAsync(TaskStatus.Idle);
var task = await SeedTaskAsync(TaskStatus.Queued);
Assert.True((await _sut.SetDependsOnAsync(task, [a, b], default)).Ok);
Assert.Equal(Sorted(a, b), Sorted(await GetDependsOnAsync(task)));
}
[Fact]
public async Task SetDependsOnAsync_ReplacingSet_KeepsOverlapAndDropsTheRest()
{
var a = await SeedTaskAsync(TaskStatus.Idle);
var b = await SeedTaskAsync(TaskStatus.Idle);
var c = await SeedTaskAsync(TaskStatus.Idle);
var task = await SeedTaskAsync(TaskStatus.Queued);
Assert.True((await _sut.SetDependsOnAsync(task, [a, b], default)).Ok);
Assert.True((await _sut.SetDependsOnAsync(task, [b, c], default)).Ok);
Assert.Equal(Sorted(b, c), Sorted(await GetDependsOnAsync(task)));
}
[Fact]
public async Task SetDependsOnAsync_Empty_ClearsDependsOnTaskIds_AndWakesQueue()
{
var predecessor = await SeedTaskAsync(TaskStatus.Idle);
var task = await SeedTaskAsync(TaskStatus.Queued);
await _sut.SetDependsOnAsync(task, predecessor, default);
await _sut.SetDependsOnAsync(task, [predecessor], default);
var wakesBefore = _built.WakeCount();
var result = await _sut.SetDependsOnAsync(task, null, default);
var result = await _sut.SetDependsOnAsync(task, [], default);
Assert.True(result.Ok);
var t = await GetTaskAsync(task);
Assert.Null(t.DependsOnTaskId);
Assert.Empty(await GetDependsOnAsync(task));
Assert.True(_built.WakeCount() > wakesBefore);
}
@@ -692,11 +727,10 @@ public sealed class TaskStateServiceTests : IDisposable
{
var task = await SeedTaskAsync(TaskStatus.Queued);
var result = await _sut.SetDependsOnAsync(task, task, default);
var result = await _sut.SetDependsOnAsync(task, [task], default);
Assert.False(result.Ok);
var t = await GetTaskAsync(task);
Assert.Null(t.DependsOnTaskId);
Assert.Empty(await GetDependsOnAsync(task));
}
[Fact]
@@ -704,7 +738,7 @@ public sealed class TaskStateServiceTests : IDisposable
{
var task = await SeedTaskAsync(TaskStatus.Queued);
var result = await _sut.SetDependsOnAsync(task, "does-not-exist", default);
var result = await _sut.SetDependsOnAsync(task, ["does-not-exist"], default);
Assert.False(result.Ok);
}
@@ -714,7 +748,7 @@ public sealed class TaskStateServiceTests : IDisposable
{
var predecessor = await SeedTaskAsync(TaskStatus.Idle);
var result = await _sut.SetDependsOnAsync("does-not-exist", predecessor, default);
var result = await _sut.SetDependsOnAsync("does-not-exist", [predecessor], default);
Assert.False(result.Ok);
}
@@ -724,15 +758,14 @@ public sealed class TaskStateServiceTests : IDisposable
{
var a = await SeedTaskAsync(TaskStatus.Queued);
var b = await SeedTaskAsync(TaskStatus.Queued);
var setup = await _sut.SetDependsOnAsync(b, a, default);
var setup = await _sut.SetDependsOnAsync(b, [a], default);
Assert.True(setup.Ok);
// a -> b would close the loop a -> b -> a.
var result = await _sut.SetDependsOnAsync(a, b, default);
var result = await _sut.SetDependsOnAsync(a, [b], default);
Assert.False(result.Ok);
var t = await GetTaskAsync(a);
Assert.Null(t.DependsOnTaskId);
Assert.Empty(await GetDependsOnAsync(a));
}
[Fact]
@@ -741,15 +774,31 @@ public sealed class TaskStateServiceTests : IDisposable
var a = await SeedTaskAsync(TaskStatus.Queued);
var b = await SeedTaskAsync(TaskStatus.Queued);
var c = await SeedTaskAsync(TaskStatus.Queued);
Assert.True((await _sut.SetDependsOnAsync(b, a, default)).Ok);
Assert.True((await _sut.SetDependsOnAsync(c, b, default)).Ok);
Assert.True((await _sut.SetDependsOnAsync(b, [a], default)).Ok);
Assert.True((await _sut.SetDependsOnAsync(c, [b], default)).Ok);
// a -> c would close the loop a -> c -> b -> a.
var result = await _sut.SetDependsOnAsync(a, c, default);
var result = await _sut.SetDependsOnAsync(a, [c], default);
Assert.False(result.Ok);
}
[Fact]
public async Task SetDependsOnAsync_CycleViaOneOfSeveralProposedEdges_Rejected()
{
// Only the second proposed edge closes a loop -- the check must look at every one of
// them, not just the first, and must leave the whole set unwritten when one fails.
var a = await SeedTaskAsync(TaskStatus.Queued);
var b = await SeedTaskAsync(TaskStatus.Queued);
var innocent = await SeedTaskAsync(TaskStatus.Idle);
Assert.True((await _sut.SetDependsOnAsync(b, [a], default)).Ok);
var result = await _sut.SetDependsOnAsync(a, [innocent, b], default);
Assert.False(result.Ok);
Assert.Empty(await GetDependsOnAsync(a));
}
// ─── RecoverStaleRunningAsync ─────────────────────────────────────────
[Fact]