Merge branch 'main' into feat/poweruse-list-handler
Kollision mit #264 (Handoff-Budget im Brief sichtbar machen): beide Seiten haben HandoffRoundTracker, HandoffMcpTools und InteractiveLaunchSpecService angefasst. Der Konflikt war inhaltlich, nicht nur textuell. #264 liest den Cap an zwei Stellen aus der Konstante (GetStatus und das roundInfo-Rendering), waehrend dieser Branch ihn zum Parameter aus app_settings gemacht hat. Ein reiner Text-Merge haette kompiliert und trotzdem gelogen: Register haette den konfigurierten Cap durchgesetzt, waehrend der Brief dem Modell weiter "2 Runden uebrig" gemeldet haette, obwohl es 39 hat. Aufgeloest so, dass der Cap eine einzige Quelle hat: - GetStatus(taskId, maxHandoffs) nimmt den Cap als Parameter, mit demselben Clamp wie Register. Die Rechnerei liegt damit an einer Stelle. - GetHandoffStatus liest das Setting und gibt es weiter. - Das roundInfo-Rendering benutzt GetStatus statt eigener Arithmetik. Zwei Tests nageln genau diesen Drift fest, damit er beim naechsten Merge nicht unbemerkt zurueckkommt: einer auf GetHandoffStatus, einer auf den Brief. MaxHandoffs -> DefaultMaxHandoffs in #264s vier Test-Referenzen; die laufen alle mit Default-Settings, die Semantik bleibt.
This commit is contained in:
@@ -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/<ts>_AddTaskDependencies.cs` — scaffolded
|
||||
- `src/ClaudeDo.Worker/External/TaskBlocking.cs` — the shared `blockedReason` builder
|
||||
(two consumers: `ExternalMcpService`, `TaskWaitMcpTools`)
|
||||
- `tests/ClaudeDo.Data.Tests/TaskDependencyMigrationTests.cs` — real `Migrate()` on a DB
|
||||
seeded with legacy `depends_on_task_id` rows
|
||||
|
||||
**Modify:**
|
||||
- `src/ClaudeDo.Data/Models/TaskEntity.cs` — `DependsOnTaskId` → `Dependencies` nav +
|
||||
`[NotMapped] DependsOnTaskIds`
|
||||
- `src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs` — drop column/FK/index
|
||||
- `src/ClaudeDo.Data/ClaudeDoDbContext.cs` — `DbSet<TaskDependencyEntity>`
|
||||
- `src/ClaudeDo.Data/Repositories/TaskRepository.cs` — `.Include(t => t.Dependencies)` on
|
||||
the three read paths; `UpdateAsync` must not push the nav graph
|
||||
- `src/ClaudeDo.Worker/Queue/QueuePicker.cs` — `NOT EXISTS` over the relation table
|
||||
- `src/ClaudeDo.Worker/State/TaskStateService.cs` — `SetDependsOnAsync` takes a set,
|
||||
graph-wide cycle check
|
||||
- `src/ClaudeDo.Worker/State/Interfaces/ITaskStateService.cs`
|
||||
- `src/ClaudeDo.Worker/External/ExternalMcpService.cs` — DTOs, `add_task`, `update_task`,
|
||||
`ComputeBlockedInfoAsync`
|
||||
- `src/ClaudeDo.Worker/External/TaskWaitMcpTools.cs`
|
||||
- `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs`,
|
||||
`TasksIslandViewModel.cs` — display of *n* predecessors
|
||||
- `src/ClaudeDo.Data/CLAUDE.md`, `src/ClaudeDo.Worker/CLAUDE.md`
|
||||
- test files listed per task below
|
||||
|
||||
---
|
||||
|
||||
## Task 1 — Schema + migration
|
||||
|
||||
- [ ] `TaskDependencyEntity { string TaskId; string DependsOnTaskId; }`, table
|
||||
`task_dependencies`, composite PK, both FKs cascade, index on `depends_on_task_id`.
|
||||
- [ ] `TaskEntity.Dependencies` (`ICollection<TaskDependencyEntity>`), `[NotMapped]
|
||||
DependsOnTaskIds` projection. `DependsOnTaskId` and its FK/index are removed.
|
||||
- [ ] Scaffold the migration, then **hand-edit the Up body** so the data copy sits between
|
||||
create and drop:
|
||||
`CreateTable` → `INSERT INTO task_dependencies (task_id, depends_on_task_id) SELECT
|
||||
id, depends_on_task_id FROM tasks WHERE depends_on_task_id IS NOT NULL` →
|
||||
`DropColumn`. Down reverses it (add column, `UPDATE … FROM` the first edge, drop
|
||||
table) — lossy for >1 edge, which is inherent and documented in the migration.
|
||||
- [ ] Verify: `dotnet ef database update` against a temp DB seeded with legacy rows;
|
||||
`pragma_table_info` diff. **`DropColumn` forces a SQLite table rebuild** — the
|
||||
`ef-migration-parallel-designer-collision` trap applies: if another migration lands
|
||||
in parallel, hand-check both `.Designer.cs` snapshots.
|
||||
|
||||
**Test:** `TaskDependencyMigrationTests` — migrate to the previous migration, seed a task
|
||||
with `depends_on_task_id` via raw SQL, migrate to head, assert the edge is in
|
||||
`task_dependencies` and nothing else changed. (Acceptance #2.)
|
||||
|
||||
## Task 2 — Queue gating
|
||||
|
||||
- [ ] `QueuePicker` replaces the `depends_on_task_id IS NULL OR EXISTS(...)` clause with
|
||||
a doubly-negated `NOT EXISTS` over `task_dependencies`, so a dangling edge keeps
|
||||
blocking exactly as today:
|
||||
```sql
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM task_dependencies td
|
||||
WHERE td.task_id = t.id
|
||||
AND NOT EXISTS (SELECT 1 FROM tasks d
|
||||
WHERE d.id = td.depends_on_task_id AND d.status = 'done')
|
||||
)
|
||||
```
|
||||
|
||||
**Tests** (`QueuePickerTests`, real SQLite): n=2 both `Done` → claimed; one `Done` one
|
||||
`Running` → skipped; zero deps → claimed (regression); one dep `Failed` → still skipped,
|
||||
no cascade (acceptance #6); one dep `Cancelled` → still skipped.
|
||||
|
||||
## Task 3 — Write path + cycle detection
|
||||
|
||||
- [ ] `ITaskStateService.SetDependsOnAsync(string taskId, IReadOnlyList<string>
|
||||
dependsOnTaskIds, CancellationToken)` — empty list clears. Rejects self-reference,
|
||||
unknown id, duplicate handling by de-dup.
|
||||
- [ ] Cycle check walks the **graph**: load the whole edge set once (it only ever holds
|
||||
*declared* dependencies, so it stays small — `ponytail:` note, switch to a recursive
|
||||
CTE if it ever isn't), BFS from every proposed predecessor; reaching `taskId` is a
|
||||
cycle. Visited set guards pre-existing bad data.
|
||||
- [ ] Replace rows transactionally (`ExecuteDelete` for `taskId` + `AddRange`), wake the
|
||||
queue when the set shrank.
|
||||
|
||||
**Tests** (`TaskStateServiceTests`): sets n edges; empty list clears + wakes; self-ref
|
||||
rejected; unknown id rejected; **A→B→C→A rejected** (acceptance #3); adding a second edge
|
||||
leaves the first intact.
|
||||
|
||||
## Task 4 — MCP surface
|
||||
|
||||
- [ ] `add_task` / `update_task` keep `dependsOnTaskId` (string) **and** gain
|
||||
`dependsOnTaskIds` (`string[]?`). Precedence: `dependsOnTaskIds` non-null wins;
|
||||
otherwise the legacy scalar is treated as a one-element set, with `""` still the
|
||||
clear sentinel. Both run through `TaskIdResolver` so `#123` keeps working.
|
||||
- [ ] `TaskDto`/`TaskRefDto`: `DependsOnTaskId` → `DependsOnTaskIds`
|
||||
(`IReadOnlyList<string>`). Only the *input* form is contractually frozen.
|
||||
- [ ] `TaskBlocking.Describe` builds one reason naming **every** unmet predecessor
|
||||
(acceptance #4); `ComputeBlockedInfoAsync` and `TaskWaitMcpTools.CheckOnceAsync`
|
||||
both use it.
|
||||
|
||||
**Tests** (`ExternalMcpServiceTests`, `TaskWaitMcpToolsTests`, `BatchMcpToolsTests`):
|
||||
legacy scalar still sets + clears the link (acceptance #5, explicit back-compat test);
|
||||
`dependsOnTaskIds` sets n; cycle via the list form throws; `blockedReason` names both
|
||||
open predecessors.
|
||||
|
||||
## Task 5 — UI
|
||||
|
||||
- [ ] `TaskRowViewModel.DependsOnTaskIds`; `DependsOnTaskId` becomes a computed
|
||||
"single predecessor or null" so the existing linear-chain rail is unchanged for the
|
||||
1-dependency case.
|
||||
- [ ] `BuildAfterLabel` joins all resolvable predecessors (`#3, #7`); a row with >1
|
||||
predecessor gets the label, never a rail (a rail can only render a line).
|
||||
- [ ] `GroupingKey` folds the joined ids so the reconcile tick still detects a change.
|
||||
- [ ] `.Include(t => t.Dependencies)` on the `TasksIslandViewModel` query sites that
|
||||
hydrate rows.
|
||||
|
||||
**Tests** (`TasksIslandChainTests`, `TasksIslandClassifyItemsTests`,
|
||||
`TasksIslandReconcileTickTests`): existing chain tests keep passing via the 1-dep path;
|
||||
a 2-dep row shows a joined after-label and no rail.
|
||||
|
||||
## Task 6 — Docs + verify
|
||||
|
||||
- [ ] `src/ClaudeDo.Data/CLAUDE.md` (Models, Schema) and
|
||||
`src/ClaudeDo.Worker/CLAUDE.md` (Status Model table, picker bullet,
|
||||
`TaskStateService` bullet) describe the relation table (acceptance #7).
|
||||
- [ ] VERIFY (acceptance #8): `dotnet build src/ClaudeDo.App -c Release`,
|
||||
`dotnet build src/ClaudeDo.Worker -c Release`, `dotnet test` for
|
||||
`ClaudeDo.Data.Tests`, `ClaudeDo.Worker.Tests`, `ClaudeDo.Ui.Tests` (`-c Release`),
|
||||
report counts.
|
||||
@@ -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
|
||||
@@ -105,6 +107,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,
|
||||
@@ -114,8 +117,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".
|
||||
@@ -124,10 +128,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
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
+1011
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -493,6 +493,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")
|
||||
@@ -522,10 +540,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");
|
||||
@@ -693,9 +707,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");
|
||||
|
||||
@@ -930,6 +941,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)
|
||||
@@ -937,11 +963,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")
|
||||
@@ -991,6 +1012,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; }
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -619,7 +619,7 @@ public static class PromptFiles
|
||||
|
||||
Start with phase 3 (run), continuing through review/merge and the summary as your
|
||||
instructions describe.
|
||||
{finalNote}
|
||||
{roundInfo}
|
||||
""";
|
||||
|
||||
// Appended to the triage/wait/merge system prompt when a run is in PowerUse (global switch AND
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
+27
@@ -13,6 +13,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
|
||||
{
|
||||
@@ -78,4 +80,29 @@ 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<HandoffStatusResult> 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.");
|
||||
|
||||
// Same setting HandoffListHandler enforces against — read here too, so a raised cap shows up
|
||||
// in the reported budget instead of the tool confidently understating it.
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
|
||||
var maxHandoffs = (await new AppSettingsRepository(ctx).GetAsync(cancellationToken)).ListHandlerMaxHandoffs;
|
||||
|
||||
var status = _rounds.GetStatus(taskId, maxHandoffs);
|
||||
return new HandoffStatusResult(taskId, task.Number, status.Round, status.Remaining, status.Final);
|
||||
}
|
||||
}
|
||||
|
||||
+35
-3
@@ -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
|
||||
@@ -28,6 +28,37 @@ public sealed class HandoffRoundTracker
|
||||
|
||||
public sealed record Result(bool Rejected, bool Coerced, string EffectivePhase, string? Error);
|
||||
|
||||
/// <summary>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.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
/// <param name="maxHandoffs">The live cap (AppSettingsEntity.ListHandlerMaxHandoffs), passed in
|
||||
/// rather than read off a constant so the REPORTED budget can never drift from the cap Register
|
||||
/// actually enforces. A brief telling the model "2 rounds left" while the chain really allows 40
|
||||
/// is worse than no budget line at all. Clamped like Register's.</param>
|
||||
public Status GetStatus(string taskId, int maxHandoffs = DefaultMaxHandoffs)
|
||||
{
|
||||
var cap = Math.Max(1, maxHandoffs);
|
||||
|
||||
if (!_states.TryGetValue(taskId, out var state))
|
||||
return new Status(0, cap, false);
|
||||
|
||||
lock (state)
|
||||
return new Status(state.Count, Math.Max(0, cap - state.Count), state.Sealed);
|
||||
}
|
||||
|
||||
/// <param name="maxHandoffs">Cap for this chain, normally AppSettingsEntity.ListHandlerMaxHandoffs.
|
||||
/// Values below 1 are treated as 1: the cap exists to bound the loop, so "no cap" is not an
|
||||
/// option a caller gets to pick by passing 0.</param>
|
||||
@@ -39,7 +70,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;
|
||||
|
||||
+42
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -94,6 +94,7 @@ builder.Services.AddSingleton<PendingQuestionRegistry>();
|
||||
builder.Services.AddSingleton<IRepoCloner, GitRepoCloner>();
|
||||
builder.Services.AddSingleton<ISessionSkillRegistry, SessionSkillRegistry>();
|
||||
builder.Services.AddSingleton<ISessionSkillSeeder, SessionSkillSeeder>();
|
||||
builder.Services.AddSingleton<HandoffRoundTracker>();
|
||||
builder.Services.AddSingleton<InteractiveLaunchSpecService>();
|
||||
builder.Services.AddSingleton<TaskRunner>();
|
||||
builder.Services.AddSingleton<WorktreeMaintenanceService>();
|
||||
@@ -335,6 +336,7 @@ if (cfg.ExternalMcpPort > 0)
|
||||
Share<InteractiveReviewSubmissionService>();
|
||||
Share<AttachmentStore>();
|
||||
Share<FindingsStore>();
|
||||
Share<HandoffRoundTracker>();
|
||||
|
||||
externalBuilder.Services.AddScoped<ClaudeDoDbContext>(sp =>
|
||||
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>().CreateDbContext());
|
||||
@@ -342,7 +344,6 @@ if (cfg.ExternalMcpPort > 0)
|
||||
externalBuilder.Services.AddScoped<ListRepository>();
|
||||
externalBuilder.Services.AddScoped<TaskRunRepository>();
|
||||
externalBuilder.Services.AddScoped<TaskAttachmentRepository>();
|
||||
externalBuilder.Services.AddSingleton<HandoffRoundTracker>();
|
||||
externalBuilder.Services.AddScoped<FindingsStoreLocator>();
|
||||
externalBuilder.Services.AddScoped<ExternalMcpService>();
|
||||
externalBuilder.Services.AddScoped<MergeMcpTools>();
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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<ClaudeDoDbContext> 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;
|
||||
}
|
||||
|
||||
/// <summary>Builds a LaunchSpec for opening an interactive session in a task's worktree.
|
||||
@@ -395,13 +399,22 @@ public sealed class InteractiveLaunchSpecService
|
||||
await File.WriteAllTextAsync(systemPromptPath,
|
||||
await ComposeHandlerPromptAsync(ctx, handlerTask.ListId, phase.PromptKind, ct), 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. The cap is the CONFIGURED one
|
||||
// (ListHandlerMaxHandoffs), read through GetStatus so the clamp and the arithmetic live in
|
||||
// one place; hardcoding the default here would understate a raised budget and tell the model
|
||||
// it has two rounds left when it really has dozens. A "_final" round adds one more line
|
||||
// forbidding a further rerun, so the chain is bounded structurally rather than by prompt
|
||||
// appeal alone.
|
||||
var maxHandoffs = (await new AppSettingsRepository(ctx).GetAsync(ct)).ListHandlerMaxHandoffs;
|
||||
var round = _handoffRounds.GetStatus(taskId, maxHandoffs);
|
||||
var roundInfo = $"Handoff budget: this is round {round.Round} of " +
|
||||
$"{Math.Max(1, maxHandoffs)} possible handoffs for this list-handler run " +
|
||||
$"({round.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,
|
||||
@@ -411,7 +424,7 @@ public sealed class InteractiveLaunchSpecService
|
||||
["repo"] = repoDir,
|
||||
["handlerTaskId"] = taskId,
|
||||
["tasks"] = RenderGroupedBrief(briefEntries),
|
||||
["finalNote"] = finalNote,
|
||||
["roundInfo"] = roundInfo,
|
||||
}), ct);
|
||||
|
||||
var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
+101
-15
@@ -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]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -209,6 +209,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.DefaultMaxHandoffs - 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_RaisedCapSetting_AllowsMoreRoundsBeforeCoercing()
|
||||
{
|
||||
@@ -258,6 +315,30 @@ public sealed class HandoffMcpToolsTests : IDisposable
|
||||
Assert.Equal("merge_final", second.NextPhase);
|
||||
}
|
||||
|
||||
/// GetHandoffStatus must report against the SAME cap HandoffListHandler enforces. Reading it
|
||||
/// off the constant would understate a raised budget and make the tool lie about how much room
|
||||
/// the chain has left.
|
||||
[Fact]
|
||||
public async Task GetHandoffStatus_RaisedCap_ReportsConfiguredRemaining()
|
||||
{
|
||||
await SetMaxHandoffsAsync(40);
|
||||
|
||||
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(38, status.Remaining);
|
||||
Assert.False(status.Final);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandoffListHandler_TwoHandlerTasks_DoNotShareACounter()
|
||||
{
|
||||
@@ -280,4 +361,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.DefaultMaxHandoffs, 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.DefaultMaxHandoffs - 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<InvalidOperationException>(() =>
|
||||
sut.GetHandoffStatus("missing", CancellationToken.None));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ public sealed class MergeHelperTaskHubTests : IDisposable
|
||||
var wtManager = new WorktreeManager(git, factory, new WorkerConfig(), NullLogger<WorktreeManager>.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);
|
||||
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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<IReadOnlyList<SessionSkillEntity>>(Installed);
|
||||
}
|
||||
|
||||
private InteractiveLaunchSpecService BuildService() =>
|
||||
private InteractiveLaunchSpecService BuildService(HandoffRoundTracker? handoffRounds = null) =>
|
||||
new(_db.CreateFactory(), _seeder, _registry,
|
||||
new WorktreeManager(new GitService(), _db.CreateFactory(), new WorkerConfig(), NullLogger<WorktreeManager>.Instance),
|
||||
new GitService(),
|
||||
new WorkerConfig { ClaudeBin = _claudeStubPath });
|
||||
new WorkerConfig { ClaudeBin = _claudeStubPath },
|
||||
handoffRounds ?? new HandoffRoundTracker());
|
||||
|
||||
private async Task<string> SeedListAsync(string? workingDir = null, string name = "L")
|
||||
{
|
||||
@@ -636,6 +638,43 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task SetMaxHandoffsAsync(int value)
|
||||
{
|
||||
using var ctx = _db.CreateContext();
|
||||
await new AppSettingsRepository(ctx).GetAsync();
|
||||
var row = await ctx.AppSettings.FirstAsync(s => s.Id == AppSettingsEntity.SingletonId);
|
||||
row.ListHandlerMaxHandoffs = value;
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
/// The handoff brief reports the budget the chain is ACTUALLY bounded by. Recomputing it from
|
||||
/// HandoffRoundTracker.DefaultMaxHandoffs would compile, look fine, and quietly tell the model
|
||||
/// it has 2 rounds left when the configured cap gives it 39 — so pin the configured value.
|
||||
[Fact]
|
||||
public async Task BuildForMergeHelperHandoffAsync_RaisedCap_BriefReportsConfiguredBudget()
|
||||
{
|
||||
var repo = Path.Combine(_tempDir, "capBrief");
|
||||
Directory.CreateDirectory(repo);
|
||||
var listId = await SeedListAsync(workingDir: repo, name: "Alpha");
|
||||
var handler = Guid.NewGuid().ToString();
|
||||
var survivor = Guid.NewGuid().ToString();
|
||||
await SeedTaskAsync(handler, listId, TaskStatus.Idle, title: "List handler: Alpha");
|
||||
await SeedTaskAsync(survivor, listId, TaskStatus.WaitingForReview, title: "Survivor task");
|
||||
|
||||
await SetMaxHandoffsAsync(40);
|
||||
|
||||
var tracker = new HandoffRoundTracker();
|
||||
tracker.Register(handler, "wait", 40);
|
||||
|
||||
var svc = BuildService(tracker);
|
||||
var spec = await svc.BuildForMergeHelperHandoffAsync(handler, new[] { survivor }, "wait", CancellationToken.None);
|
||||
var sessionDir = TrackSessionDir(spec);
|
||||
var brief = await File.ReadAllTextAsync(Path.Combine(sessionDir, "handoff.md"));
|
||||
|
||||
Assert.Contains("round 1 of 40", brief);
|
||||
Assert.Contains("39 more possible", brief);
|
||||
}
|
||||
|
||||
private async Task SetPowerUseAllowedAsync(string listId, bool value)
|
||||
{
|
||||
using var ctx = _db.CreateContext();
|
||||
@@ -1134,7 +1173,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);
|
||||
|
||||
@@ -1181,6 +1226,11 @@ 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, and this test leaves ListHandlerMaxHandoffs at its default,
|
||||
// so round 1 of DefaultMaxHandoffs.
|
||||
Assert.Contains($"round 1 of {HandoffRoundTracker.DefaultMaxHandoffs}", handoffBrief);
|
||||
|
||||
if (expectFinalNote)
|
||||
{
|
||||
Assert.Contains("FINAL round", handoffBrief);
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user