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
|
## 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.
|
- `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).
|
- `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).
|
- `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.
|
- `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`
|
- **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)
|
- **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`
|
- **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
|
- **SubtaskEntity**, **AgentInfo** — subtasks / record for scanned agent files
|
||||||
|
|
||||||
### AppSettingsEntity
|
### AppSettingsEntity
|
||||||
@@ -105,6 +107,7 @@ Tables (one per line so parallel migrations don't collide on the same line):
|
|||||||
- `daily_notes`
|
- `daily_notes`
|
||||||
- `week_reports`
|
- `week_reports`
|
||||||
- `task_attachments`
|
- `task_attachments`
|
||||||
|
- `task_dependencies`
|
||||||
|
|
||||||
Managed by EF Core migrations in `Migrations/`. **The 40 incremental migrations were squashed into
|
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,
|
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
|
(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
|
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.
|
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
|
Covered by `MigrationBaselineTests` — one of only two tests that run a real `Migrate()`
|
||||||
Data.Tests fixture uses `EnsureCreated`, which skips migrations entirely). Its fixture builds the
|
(`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`
|
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
|
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".
|
("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
|
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`.
|
factory), and `--configuration Release` is needed because a running Worker locks `Debug`.
|
||||||
|
|
||||||
`tasks` holds `status`, `planning_phase` (default `none`),
|
`tasks` holds `status`, `planning_phase` (default `none`) and `blocked_by_task_id` (FK to
|
||||||
`blocked_by_task_id` (FK to `tasks.id`, `ON DELETE SET NULL`), and `depends_on_task_id` (same FK
|
`tasks.id`, `ON DELETE SET NULL`). The user/MCP-declared predecessors live in `task_dependencies`
|
||||||
shape, but a separate column — see Worker/CLAUDE.md → Status Model for why it isn't unified with
|
instead — a **set**, so a task can wait on n of them; see Worker/CLAUDE.md → Status Model for why
|
||||||
`blocked_by_task_id`).
|
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
|
## Conventions
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ public class ClaudeDoDbContext : DbContext
|
|||||||
=> optionsBuilder.AddInterceptors(SqliteForeignKeyInterceptor.Instance);
|
=> optionsBuilder.AddInterceptors(SqliteForeignKeyInterceptor.Instance);
|
||||||
|
|
||||||
public DbSet<TaskEntity> Tasks => Set<TaskEntity>();
|
public DbSet<TaskEntity> Tasks => Set<TaskEntity>();
|
||||||
|
public DbSet<TaskDependencyEntity> TaskDependencies => Set<TaskDependencyEntity>();
|
||||||
public DbSet<ListEntity> Lists => Set<ListEntity>();
|
public DbSet<ListEntity> Lists => Set<ListEntity>();
|
||||||
public DbSet<ListConfigEntity> ListConfigs => Set<ListConfigEntity>();
|
public DbSet<ListConfigEntity> ListConfigs => Set<ListConfigEntity>();
|
||||||
public DbSet<WorktreeEntity> Worktrees => Set<WorktreeEntity>();
|
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()
|
builder.Property(t => t.PlanningPhase).HasColumnName("planning_phase").IsRequired()
|
||||||
.HasConversion(PhaseConverter).HasDefaultValue(PlanningPhase.None);
|
.HasConversion(PhaseConverter).HasDefaultValue(PlanningPhase.None);
|
||||||
builder.Property(t => t.BlockedByTaskId).HasColumnName("blocked_by_task_id");
|
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.ScheduledFor).HasColumnName("scheduled_for");
|
||||||
builder.Property(t => t.Result).HasColumnName("result");
|
builder.Property(t => t.Result).HasColumnName("result");
|
||||||
builder.Property(t => t.ReviewFeedback).HasColumnName("review_feedback");
|
builder.Property(t => t.ReviewFeedback).HasColumnName("review_feedback");
|
||||||
@@ -124,12 +123,8 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
|
|||||||
.HasForeignKey(t => t.BlockedByTaskId)
|
.HasForeignKey(t => t.BlockedByTaskId)
|
||||||
.OnDelete(DeleteBehavior.SetNull);
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
// DependsOn: user/MCP-declared predecessor. SetNull on delete so the dependent becomes
|
// DependsOn edges live in task_dependencies (a task can declare n predecessors) —
|
||||||
// pickable rather than blocked forever on a task that no longer exists.
|
// configured in TaskDependencyEntityConfiguration, which also owns the Dependencies nav.
|
||||||
builder.HasOne<TaskEntity>()
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey(t => t.DependsOnTaskId)
|
|
||||||
.OnDelete(DeleteBehavior.SetNull);
|
|
||||||
|
|
||||||
builder.HasOne(t => t.List)
|
builder.HasOne(t => t.List)
|
||||||
.WithMany(l => l.Tasks)
|
.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 => 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.ParentTaskId).HasDatabaseName("idx_tasks_parent_task_id");
|
||||||
builder.HasIndex(t => t.BlockedByTaskId).HasDatabaseName("idx_tasks_blocked_by");
|
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);
|
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 =>
|
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||||
{
|
{
|
||||||
b.Property<string>("Id")
|
b.Property<string>("Id")
|
||||||
@@ -522,10 +540,6 @@ namespace ClaudeDo.Data.Migrations
|
|||||||
.HasColumnType("TEXT")
|
.HasColumnType("TEXT")
|
||||||
.HasColumnName("created_by");
|
.HasColumnName("created_by");
|
||||||
|
|
||||||
b.Property<string>("DependsOnTaskId")
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("depends_on_task_id");
|
|
||||||
|
|
||||||
b.Property<string>("Description")
|
b.Property<string>("Description")
|
||||||
.HasColumnType("TEXT")
|
.HasColumnType("TEXT")
|
||||||
.HasColumnName("description");
|
.HasColumnName("description");
|
||||||
@@ -693,9 +707,6 @@ namespace ClaudeDo.Data.Migrations
|
|||||||
b.HasIndex("BlockedByTaskId")
|
b.HasIndex("BlockedByTaskId")
|
||||||
.HasDatabaseName("idx_tasks_blocked_by");
|
.HasDatabaseName("idx_tasks_blocked_by");
|
||||||
|
|
||||||
b.HasIndex("DependsOnTaskId")
|
|
||||||
.HasDatabaseName("idx_tasks_depends_on");
|
|
||||||
|
|
||||||
b.HasIndex("ListId")
|
b.HasIndex("ListId")
|
||||||
.HasDatabaseName("idx_tasks_list_id");
|
.HasDatabaseName("idx_tasks_list_id");
|
||||||
|
|
||||||
@@ -930,6 +941,21 @@ namespace ClaudeDo.Data.Migrations
|
|||||||
b.Navigation("Task");
|
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 =>
|
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
|
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
|
||||||
@@ -937,11 +963,6 @@ namespace ClaudeDo.Data.Migrations
|
|||||||
.HasForeignKey("BlockedByTaskId")
|
.HasForeignKey("BlockedByTaskId")
|
||||||
.OnDelete(DeleteBehavior.SetNull);
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("DependsOnTaskId")
|
|
||||||
.OnDelete(DeleteBehavior.SetNull);
|
|
||||||
|
|
||||||
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
|
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
|
||||||
.WithMany("Tasks")
|
.WithMany("Tasks")
|
||||||
.HasForeignKey("ListId")
|
.HasForeignKey("ListId")
|
||||||
@@ -991,6 +1012,8 @@ namespace ClaudeDo.Data.Migrations
|
|||||||
{
|
{
|
||||||
b.Navigation("Children");
|
b.Navigation("Children");
|
||||||
|
|
||||||
|
b.Navigation("Dependencies");
|
||||||
|
|
||||||
b.Navigation("Runs");
|
b.Navigation("Runs");
|
||||||
|
|
||||||
b.Navigation("Subtasks");
|
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;
|
namespace ClaudeDo.Data.Models;
|
||||||
|
|
||||||
public enum TaskStatus
|
public enum TaskStatus
|
||||||
@@ -33,12 +35,17 @@ public sealed class TaskEntity
|
|||||||
public PlanningPhase PlanningPhase { get; set; } = PlanningPhase.None;
|
public PlanningPhase PlanningPhase { get; set; } = PlanningPhase.None;
|
||||||
public string? BlockedByTaskId { get; set; }
|
public string? BlockedByTaskId { get; set; }
|
||||||
|
|
||||||
// A user/MCP-declared predecessor, distinct from BlockedByTaskId (the planning chain's own
|
// User/MCP-declared predecessors, distinct from BlockedByTaskId (the planning chain's own
|
||||||
// internal link): the picker also skips a Queued task while this is set and the referenced
|
// internal link): the picker skips a Queued task until EVERY predecessor reached Done.
|
||||||
// task's Status isn't Done. Unlike the chain, a Failed/Cancelled dependency does NOT cascade
|
// Unlike the chain, a Failed/Cancelled dependency does NOT cascade or auto-resolve -- the
|
||||||
// or auto-resolve -- the dependent just stays blocked and reports why (see TaskStateService.
|
// dependent just stays blocked and reports why (see TaskStateService.SetDependsOnAsync and
|
||||||
// SetDependsOnAsync and QueuePicker).
|
// QueuePicker). Only loaded where a read path Includes it; the picker gates in raw SQL and
|
||||||
public string? DependsOnTaskId { get; set; }
|
// 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 DateTime? ScheduledFor { get; set; }
|
||||||
public string? Result { get; set; }
|
public string? Result { get; set; }
|
||||||
public string? ReviewFeedback { 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
|
Start with phase 3 (run), continuing through review/merge and the summary as your
|
||||||
instructions describe.
|
instructions describe.
|
||||||
{finalNote}
|
{roundInfo}
|
||||||
""";
|
""";
|
||||||
|
|
||||||
// Appended to the triage/wait/merge system prompt when a run is in PowerUse (global switch AND
|
// 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);
|
var tracked = _context.Tasks.Local.FirstOrDefault(t => t.Id == entity.Id);
|
||||||
if (tracked is not null && !ReferenceEquals(tracked, entity))
|
if (tracked is not null && !ReferenceEquals(tracked, entity))
|
||||||
_context.Entry(tracked).State = Microsoft.EntityFrameworkCore.EntityState.Detached;
|
_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);
|
await _context.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,17 +49,20 @@ public sealed class TaskRepository
|
|||||||
|
|
||||||
public async Task<TaskEntity?> GetByIdAsync(string taskId, CancellationToken ct = default)
|
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)
|
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)
|
public async Task<List<TaskEntity>> GetByListIdAsync(string listId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
return await _context.Tasks
|
return await _context.Tasks
|
||||||
|
.Include(t => t.Dependencies)
|
||||||
.Where(t => t.ListId == listId)
|
.Where(t => t.ListId == listId)
|
||||||
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
|
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|||||||
@@ -113,7 +113,14 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
[NotifyPropertyChangedFor(nameof(IsWaiting))]
|
[NotifyPropertyChangedFor(nameof(IsWaiting))]
|
||||||
[NotifyPropertyChangedFor(nameof(StatusChipClass))]
|
[NotifyPropertyChangedFor(nameof(StatusChipClass))]
|
||||||
private string? _blockedByTaskId;
|
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] private bool _isExpanded = true;
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
[NotifyPropertyChangedFor(nameof(IsPlanningParent))]
|
[NotifyPropertyChangedFor(nameof(IsPlanningParent))]
|
||||||
@@ -488,7 +495,10 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
ParentTaskId = t.ParentTaskId;
|
ParentTaskId = t.ParentTaskId;
|
||||||
CreatedBy = t.CreatedBy;
|
CreatedBy = t.CreatedBy;
|
||||||
BlockedByTaskId = t.BlockedByTaskId;
|
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;
|
RoadblockCount = t.RoadblockCount;
|
||||||
FailureReason = t.FailureReason;
|
FailureReason = t.FailureReason;
|
||||||
FailureTurnsUsed = t.FailureTurnsUsed;
|
FailureTurnsUsed = t.FailureTurnsUsed;
|
||||||
|
|||||||
@@ -313,6 +313,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
var entity = await db.Tasks
|
var entity = await db.Tasks
|
||||||
.Include(t => t.List)
|
.Include(t => t.List)
|
||||||
.Include(t => t.Worktree)
|
.Include(t => t.Worktree)
|
||||||
|
.Include(t => t.Dependencies)
|
||||||
.FirstOrDefaultAsync(t => t.Id == taskId);
|
.FirstOrDefaultAsync(t => t.Id == taskId);
|
||||||
|
|
||||||
// A newer refresh for this task started while we were reading — its result is fresher.
|
// 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);
|
await using var db = await _dbFactory.CreateDbContextAsync(ct);
|
||||||
var filter = _filters.Resolve(list.Id);
|
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
|
var filteredList = filter is null
|
||||||
? new List<TaskEntity>()
|
? 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
|
// 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)
|
private static string? BuildAfterLabel(TaskRowViewModel row, Dictionary<string, TaskRowViewModel> rowsById)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(row.DependsOnTaskId)) return null;
|
var parts = row.DependsOnTaskIds
|
||||||
if (!rowsById.TryGetValue(row.DependsOnTaskId, out var predecessor)) return null;
|
.Select(id => rowsById.TryGetValue(id, out var p) ? p : null)
|
||||||
return predecessor.Number > 0 ? $"#{predecessor.Number}" : predecessor.Title;
|
.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
|
// 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)
|
foreach (var r in section)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(r.DependsOnTaskId))
|
if (r.DependsOnTaskIds.Count == 0)
|
||||||
{
|
{
|
||||||
r.ShowAsChainMember = false;
|
r.ShowAsChainMember = false;
|
||||||
r.ChainStep = null;
|
r.ChainStep = null;
|
||||||
@@ -751,6 +757,16 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
continue;
|
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
|
// 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").
|
// label for it, never as a second, nested rail (design: "parent wins").
|
||||||
if (r.IsChild)
|
if (r.IsChild)
|
||||||
@@ -1684,6 +1700,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
entities = await db.Tasks
|
entities = await db.Tasks
|
||||||
.Include(t => t.List)
|
.Include(t => t.List)
|
||||||
.Include(t => t.Worktree)
|
.Include(t => t.Worktree)
|
||||||
|
.Include(t => t.Dependencies)
|
||||||
.Where(t => idSet.Contains(t.Id))
|
.Where(t => idSet.Contains(t.Id))
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
dbOk = true;
|
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,
|
// 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 —
|
// and what its chain rail/after-chip says. Only fields UpdateFromEntity actually writes —
|
||||||
// IsExpanded and HasPlanningChildren are owned elsewhere and would produce false positives.
|
// IsExpanded and HasPlanningChildren are owned elsewhere and would produce false positives.
|
||||||
private static (bool, DateTime?, string?, string?, PlanningPhase, int, string) GroupingKey(TaskRowViewModel r) =>
|
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);
|
(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
|
## Architecture
|
||||||
|
|
||||||
- **Program.cs** — loads config, inits schema, registers DI, configures SignalR on `/hub`, binds to `127.0.0.1:47821`
|
- **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).
|
- **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 (`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).
|
- **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.
|
- **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).
|
- **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`.
|
- **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. |
|
| `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`. |
|
| `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"`. |
|
| `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. |
|
| `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. |
|
| `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.Diagnostics;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
@@ -55,11 +55,11 @@ public sealed record TaskDto(
|
|||||||
string? FailureReason = null,
|
string? FailureReason = null,
|
||||||
int? FailureTurnsUsed = null,
|
int? FailureTurnsUsed = null,
|
||||||
int? FailureMaxTurns = null,
|
int? FailureMaxTurns = null,
|
||||||
// A user/MCP-declared predecessor (set via add_task/update_task), distinct from the
|
// User/MCP-declared predecessors (set via add_task/update_task), distinct from the planning
|
||||||
// planning chain's own internal BlockedByTaskId link.
|
// chain's own internal BlockedByTaskId link. Empty unless the task declares dependencies.
|
||||||
string? DependsOnTaskId = null,
|
IReadOnlyList<string>? DependsOnTaskIds = null,
|
||||||
// True only while Status is Queued and the picker will not claim this task yet -- either a
|
// 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,
|
bool Blocked = false,
|
||||||
string? BlockedReason = null,
|
string? BlockedReason = null,
|
||||||
bool IsManual = false,
|
bool IsManual = false,
|
||||||
@@ -82,7 +82,7 @@ public sealed record TaskRefDto(
|
|||||||
string? FailureReason = null,
|
string? FailureReason = null,
|
||||||
int? FailureTurnsUsed = null,
|
int? FailureTurnsUsed = null,
|
||||||
int? FailureMaxTurns = null,
|
int? FailureMaxTurns = null,
|
||||||
string? DependsOnTaskId = null,
|
IReadOnlyList<string>? DependsOnTaskIds = null,
|
||||||
bool Blocked = false,
|
bool Blocked = false,
|
||||||
string? BlockedReason = null,
|
string? BlockedReason = null,
|
||||||
DirtyBaseWarning? BaseDirty = 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
|
// 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.
|
// 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(
|
private async Task<Dictionary<string, (bool Blocked, string? Reason)>> ComputeBlockedInfoAsync(
|
||||||
IReadOnlyList<TaskEntity> tasks, CancellationToken ct)
|
IReadOnlyList<TaskEntity> tasks, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var dependencyIds = tasks
|
var dependencyIds = tasks
|
||||||
.Where(t => t.Status == TaskStatus.Queued && t.BlockedByTaskId is null && t.DependsOnTaskId is not null)
|
.Where(t => t.Status == TaskStatus.Queued && t.BlockedByTaskId is null)
|
||||||
.Select(t => t.DependsOnTaskId!)
|
.SelectMany(t => t.DependsOnTaskIds)
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
@@ -237,17 +237,10 @@ public sealed class ExternalMcpService
|
|||||||
{
|
{
|
||||||
result[t.Id] = (true, $"Blocked by planning-chain predecessor {t.BlockedByTaskId}.");
|
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
|
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;
|
return result;
|
||||||
@@ -298,10 +291,15 @@ public sealed class ExternalMcpService
|
|||||||
"list/global default (normally sonnet).")]
|
"list/global default (normally sonnet).")]
|
||||||
string? model = null,
|
string? model = null,
|
||||||
[Description("Id of a task that must reach Done (i.e. be merged) before the picker will claim this one, " +
|
[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 " +
|
"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.")]
|
"stays blocked rather than starving silently -- check get_task/list_tasks' blocked field.")]
|
||||||
string? dependsOnTaskId = null,
|
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 " +
|
[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 " +
|
"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.")]
|
"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)
|
var list = await _lists.GetByIdAsync(listId, cancellationToken)
|
||||||
?? throw new InvalidOperationException($"List {listId} not found.");
|
?? 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);
|
var possibleDuplicates = await FindPossibleDuplicatesAsync(listId, title, cancellationToken);
|
||||||
|
|
||||||
@@ -337,12 +335,11 @@ public sealed class ExternalMcpService
|
|||||||
};
|
};
|
||||||
await _tasks.AddAsync(entity, cancellationToken);
|
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)
|
if (!dependsResult.Ok)
|
||||||
throw new InvalidOperationException(dependsResult.Reason ?? "Cannot set dependsOnTaskId.");
|
throw new InvalidOperationException(dependsResult.Reason ?? "Cannot set dependsOnTaskIds.");
|
||||||
entity.DependsOnTaskId = dependsOnTaskId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (queueImmediately)
|
if (queueImmediately)
|
||||||
@@ -350,12 +347,39 @@ public sealed class ExternalMcpService
|
|||||||
var enqueue = await _state.EnqueueAsync(entity.Id, cancellationToken);
|
var enqueue = await _state.EnqueueAsync(entity.Id, cancellationToken);
|
||||||
if (!enqueue.Ok)
|
if (!enqueue.Ok)
|
||||||
throw new InvalidOperationException(enqueue.Reason ?? "Cannot enqueue task.");
|
throw new InvalidOperationException(enqueue.Reason ?? "Cannot enqueue task.");
|
||||||
entity.Status = TaskStatus.Queued;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await _broadcaster.TaskUpdated(entity.Id);
|
await _broadcaster.TaskUpdated(entity.Id);
|
||||||
var blocked = await ComputeBlockedInfoAsync([entity], cancellationToken);
|
// Reload rather than patching `entity`: SetDependsOnAsync and EnqueueAsync both write
|
||||||
return new AddTaskResult(ToRefDto(entity, blocked[entity.Id].Blocked, blocked[entity.Id].Reason), possibleDuplicates);
|
// 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
|
// 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? title = null,
|
||||||
string? description = null,
|
string? description = null,
|
||||||
string? commitType = null,
|
string? commitType = null,
|
||||||
[Description("Id of a task that must reach Done before the picker will claim this one. Pass an empty " +
|
[Description("Id of a task that must reach Done before the picker will claim this one. Shorthand for a " +
|
||||||
"string to clear an existing link; null leaves it unchanged. Rejected if it doesn't exist, " +
|
"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.")]
|
"is this task's own id, or would create a dependency cycle.")]
|
||||||
string? dependsOnTaskId = null,
|
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, " +
|
[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: " +
|
"daily prep, and the list handler all skip it (a ConPTY session may still touch it). false: " +
|
||||||
"clear the flag. null (default): leave unchanged.")]
|
"clear the flag. null (default): leave unchanged.")]
|
||||||
@@ -463,15 +492,12 @@ public sealed class ExternalMcpService
|
|||||||
if (expectsNoDiff is not null) task.ExpectsNoDiff = expectsNoDiff.Value;
|
if (expectsNoDiff is not null) task.ExpectsNoDiff = expectsNoDiff.Value;
|
||||||
await _tasks.UpdateAsync(task, cancellationToken);
|
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
|
var dependsResult = await _state.SetDependsOnAsync(taskId, dependsOn, cancellationToken);
|
||||||
// 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);
|
|
||||||
if (!dependsResult.Ok)
|
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))!;
|
var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
|
||||||
@@ -742,6 +768,7 @@ public sealed class ExternalMcpService
|
|||||||
var children = await ctx.Tasks
|
var children = await ctx.Tasks
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Include(t => t.Worktree)
|
.Include(t => t.Worktree)
|
||||||
|
.Include(t => t.Dependencies)
|
||||||
.Where(t => t.ParentTaskId == parentTaskId && t.Status == TaskStatus.Done)
|
.Where(t => t.ParentTaskId == parentTaskId && t.Status == TaskStatus.Done)
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
@@ -935,7 +962,8 @@ public sealed class ExternalMcpService
|
|||||||
|
|
||||||
await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
|
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.");
|
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||||
|
|
||||||
if (isMyDay && !task.IsMyDay)
|
if (isMyDay && !task.IsMyDay)
|
||||||
@@ -995,7 +1023,7 @@ public sealed class ExternalMcpService
|
|||||||
FailureReasonOf(t),
|
FailureReasonOf(t),
|
||||||
t.Status == TaskStatus.Failed ? t.FailureTurnsUsed : null,
|
t.Status == TaskStatus.Failed ? t.FailureTurnsUsed : null,
|
||||||
t.Status == TaskStatus.Failed ? t.FailureMaxTurns : null,
|
t.Status == TaskStatus.Failed ? t.FailureMaxTurns : null,
|
||||||
t.DependsOnTaskId,
|
t.DependsOnTaskIds,
|
||||||
blocked,
|
blocked,
|
||||||
blockedReason,
|
blockedReason,
|
||||||
t.IsManual,
|
t.IsManual,
|
||||||
@@ -1014,7 +1042,7 @@ public sealed class ExternalMcpService
|
|||||||
FailureReasonOf(t),
|
FailureReasonOf(t),
|
||||||
t.Status == TaskStatus.Failed ? t.FailureTurnsUsed : null,
|
t.Status == TaskStatus.Failed ? t.FailureTurnsUsed : null,
|
||||||
t.Status == TaskStatus.Failed ? t.FailureMaxTurns : null,
|
t.Status == TaskStatus.Failed ? t.FailureMaxTurns : null,
|
||||||
t.DependsOnTaskId,
|
t.DependsOnTaskIds,
|
||||||
blocked,
|
blocked,
|
||||||
blockedReason,
|
blockedReason,
|
||||||
BaseDirty: null,
|
BaseDirty: null,
|
||||||
|
|||||||
+27
@@ -13,6 +13,8 @@ namespace ClaudeDo.Worker.External;
|
|||||||
// reflects the effective (post-coercion) value in that case.
|
// 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 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]
|
[McpServerToolType]
|
||||||
public sealed class HandoffMcpTools
|
public sealed class HandoffMcpTools
|
||||||
{
|
{
|
||||||
@@ -78,4 +80,29 @@ public sealed class HandoffMcpTools
|
|||||||
await _broadcaster.HandoffRequested(taskId, survivingTaskIds, round.EffectivePhase);
|
await _broadcaster.HandoffRequested(taskId, survivingTaskIds, round.EffectivePhase);
|
||||||
return new HandoffListHandlerResult(true, taskId, task.Number, survivingTaskIds.Count, round.EffectivePhase, round.Coerced);
|
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
|
// 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
|
// 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
|
// roundInfo, read back via GetState) cannot loop wait->merge->wait->merge forever, each round
|
||||||
// session. Keyed by handler task id -- a "Let Claude handle it" run always creates a fresh
|
// 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
|
// 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
|
// 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
|
// 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);
|
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.
|
/// <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
|
/// 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>
|
/// option a caller gets to pick by passing 0.</param>
|
||||||
@@ -39,7 +70,8 @@ public sealed class HandoffRoundTracker
|
|||||||
{
|
{
|
||||||
if (state.Sealed)
|
if (state.Sealed)
|
||||||
return new Result(true, false, nextPhase,
|
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++;
|
state.Count++;
|
||||||
var isFinal = nextPhase is MergeHelperPhase.WaitFinal or MergeHelperPhase.MergeFinal;
|
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
|
var rows = await ctx.Tasks
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(t => taskIds.Contains(t.Id))
|
.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);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
var byId = rows.ToDictionary(r => r.Id, r => r);
|
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
|
// picker will not touch it. Batch-resolve those dependencies' statuses once instead of a
|
||||||
// query per candidate.
|
// query per candidate.
|
||||||
var dependencyIds = rows
|
var dependencyIds = rows
|
||||||
.Where(r => r.Status == TaskStatus.Queued && r.BlockedByTaskId is null && r.DependsOnTaskId is not null)
|
.Where(r => r.Status == TaskStatus.Queued && r.BlockedByTaskId is null)
|
||||||
.Select(r => r.DependsOnTaskId!)
|
.SelectMany(r => r.DependsOnTaskIds)
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.ToList();
|
.ToList();
|
||||||
var dependencyStatuses = dependencyIds.Count == 0
|
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.
|
// 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.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);
|
result.Add(new TaskStatusChangeDto(
|
||||||
if (!known || depStatus != TaskStatus.Done)
|
id, "Blocked", TaskBlocking.Describe(unmet), row.Number));
|
||||||
{
|
continue;
|
||||||
result.Add(new TaskStatusChangeDto(id, "Blocked",
|
|
||||||
$"Blocked: depends on task {row.DependsOnTaskId} (status: " +
|
|
||||||
(known ? depStatus.ToString() : "not found") + ").", row.Number));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ builder.Services.AddSingleton<PendingQuestionRegistry>();
|
|||||||
builder.Services.AddSingleton<IRepoCloner, GitRepoCloner>();
|
builder.Services.AddSingleton<IRepoCloner, GitRepoCloner>();
|
||||||
builder.Services.AddSingleton<ISessionSkillRegistry, SessionSkillRegistry>();
|
builder.Services.AddSingleton<ISessionSkillRegistry, SessionSkillRegistry>();
|
||||||
builder.Services.AddSingleton<ISessionSkillSeeder, SessionSkillSeeder>();
|
builder.Services.AddSingleton<ISessionSkillSeeder, SessionSkillSeeder>();
|
||||||
|
builder.Services.AddSingleton<HandoffRoundTracker>();
|
||||||
builder.Services.AddSingleton<InteractiveLaunchSpecService>();
|
builder.Services.AddSingleton<InteractiveLaunchSpecService>();
|
||||||
builder.Services.AddSingleton<TaskRunner>();
|
builder.Services.AddSingleton<TaskRunner>();
|
||||||
builder.Services.AddSingleton<WorktreeMaintenanceService>();
|
builder.Services.AddSingleton<WorktreeMaintenanceService>();
|
||||||
@@ -335,6 +336,7 @@ if (cfg.ExternalMcpPort > 0)
|
|||||||
Share<InteractiveReviewSubmissionService>();
|
Share<InteractiveReviewSubmissionService>();
|
||||||
Share<AttachmentStore>();
|
Share<AttachmentStore>();
|
||||||
Share<FindingsStore>();
|
Share<FindingsStore>();
|
||||||
|
Share<HandoffRoundTracker>();
|
||||||
|
|
||||||
externalBuilder.Services.AddScoped<ClaudeDoDbContext>(sp =>
|
externalBuilder.Services.AddScoped<ClaudeDoDbContext>(sp =>
|
||||||
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>().CreateDbContext());
|
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>().CreateDbContext());
|
||||||
@@ -342,7 +344,6 @@ if (cfg.ExternalMcpPort > 0)
|
|||||||
externalBuilder.Services.AddScoped<ListRepository>();
|
externalBuilder.Services.AddScoped<ListRepository>();
|
||||||
externalBuilder.Services.AddScoped<TaskRunRepository>();
|
externalBuilder.Services.AddScoped<TaskRunRepository>();
|
||||||
externalBuilder.Services.AddScoped<TaskAttachmentRepository>();
|
externalBuilder.Services.AddScoped<TaskAttachmentRepository>();
|
||||||
externalBuilder.Services.AddSingleton<HandoffRoundTracker>();
|
|
||||||
externalBuilder.Services.AddScoped<FindingsStoreLocator>();
|
externalBuilder.Services.AddScoped<FindingsStoreLocator>();
|
||||||
externalBuilder.Services.AddScoped<ExternalMcpService>();
|
externalBuilder.Services.AddScoped<ExternalMcpService>();
|
||||||
externalBuilder.Services.AddScoped<MergeMcpTools>();
|
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.
|
// Atomic queue claim: UPDATE + RETURNING in a single statement prevents TOCTOU races.
|
||||||
// Raw SQL because EF cannot express UPDATE...RETURNING.
|
// Raw SQL because EF cannot express UPDATE...RETURNING.
|
||||||
// Eligible task must be Queued, unblocked (chain and depends-on), not manual, and due
|
// 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' --
|
// (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
|
// -- 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.
|
// 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.
|
// EF SQLite stores DateTime as "yyyy-MM-dd HH:mm:ss.fffffff" — same format used here for comparison.
|
||||||
var rows = await ctx.Tasks.FromSqlRaw("""
|
var rows = await ctx.Tasks.FromSqlRaw("""
|
||||||
UPDATE tasks SET status = 'running', started_at = {1}
|
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.blocked_by_task_id IS NULL
|
||||||
AND t.is_manual = 0
|
AND t.is_manual = 0
|
||||||
AND (t.scheduled_for IS NULL OR t.scheduled_for <= {0})
|
AND (t.scheduled_for IS NULL OR t.scheduled_for <= {0})
|
||||||
AND (t.depends_on_task_id IS NULL
|
AND NOT EXISTS (
|
||||||
OR EXISTS (
|
SELECT 1 FROM task_dependencies td
|
||||||
SELECT 1 FROM tasks d
|
WHERE td.task_id = t.id
|
||||||
WHERE d.id = t.depends_on_task_id AND d.status = 'done'
|
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
|
ORDER BY t.sort_order ASC, t.created_at ASC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using ClaudeDo.Data.Git;
|
|||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
using ClaudeDo.Data.Repositories;
|
using ClaudeDo.Data.Repositories;
|
||||||
using ClaudeDo.Worker.Config;
|
using ClaudeDo.Worker.Config;
|
||||||
|
using ClaudeDo.Worker.External;
|
||||||
using ClaudeDo.Worker.Planning;
|
using ClaudeDo.Worker.Planning;
|
||||||
using ClaudeDo.Worker.Skills;
|
using ClaudeDo.Worker.Skills;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -42,6 +43,7 @@ public sealed class InteractiveLaunchSpecService
|
|||||||
private readonly WorktreeManager _wtManager;
|
private readonly WorktreeManager _wtManager;
|
||||||
private readonly GitService _git;
|
private readonly GitService _git;
|
||||||
private readonly string _claudePath;
|
private readonly string _claudePath;
|
||||||
|
private readonly HandoffRoundTracker _handoffRounds;
|
||||||
|
|
||||||
public InteractiveLaunchSpecService(
|
public InteractiveLaunchSpecService(
|
||||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||||
@@ -49,7 +51,8 @@ public sealed class InteractiveLaunchSpecService
|
|||||||
ISessionSkillRegistry skillRegistry,
|
ISessionSkillRegistry skillRegistry,
|
||||||
WorktreeManager wtManager,
|
WorktreeManager wtManager,
|
||||||
GitService git,
|
GitService git,
|
||||||
WorkerConfig cfg)
|
WorkerConfig cfg,
|
||||||
|
HandoffRoundTracker handoffRounds)
|
||||||
{
|
{
|
||||||
_dbFactory = dbFactory;
|
_dbFactory = dbFactory;
|
||||||
_skillSeeder = skillSeeder;
|
_skillSeeder = skillSeeder;
|
||||||
@@ -57,6 +60,7 @@ public sealed class InteractiveLaunchSpecService
|
|||||||
_wtManager = wtManager;
|
_wtManager = wtManager;
|
||||||
_git = git;
|
_git = git;
|
||||||
_claudePath = cfg.ClaudeBin;
|
_claudePath = cfg.ClaudeBin;
|
||||||
|
_handoffRounds = handoffRounds;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Builds a LaunchSpec for opening an interactive session in a task's worktree.
|
/// <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 File.WriteAllTextAsync(systemPromptPath,
|
||||||
await ComposeHandlerPromptAsync(ctx, handlerTask.ListId, phase.PromptKind, ct), ct);
|
await ComposeHandlerPromptAsync(ctx, handlerTask.ListId, phase.PromptKind, ct), ct);
|
||||||
|
|
||||||
// A "_final" round differs from its non-final counterpart in exactly one rendered line --
|
// The round count/cap come from HandoffRoundTracker -- the same instance HandoffMcpTools
|
||||||
// the same system prompt and model drive both -- marking the final round and forbidding a
|
// registered this handoff against -- never recomputed here, so the brief can't drift from
|
||||||
// further rerun, so the chain is bounded structurally rather than by prompt appeal alone.
|
// the server-side cap that actually enforces the chain. The cap is the CONFIGURED one
|
||||||
var finalNote = phase.IsFinal
|
// (ListHandlerMaxHandoffs), read through GetStatus so the clamp and the arithmetic live in
|
||||||
? "\nThis is the FINAL round of this list-handler run -- do not start any further reruns; " +
|
// one place; hardcoding the default here would understate a raised budget and tell the model
|
||||||
"merge what you can, report the rest, and print the summary."
|
// 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");
|
var briefPath = Path.Combine(sessionDir, "handoff.md");
|
||||||
await File.WriteAllTextAsync(briefPath, PromptFiles.Render(PromptKind.MergeHelperHandoff,
|
await File.WriteAllTextAsync(briefPath, PromptFiles.Render(PromptKind.MergeHelperHandoff,
|
||||||
@@ -411,7 +424,7 @@ public sealed class InteractiveLaunchSpecService
|
|||||||
["repo"] = repoDir,
|
["repo"] = repoDir,
|
||||||
["handlerTaskId"] = taskId,
|
["handlerTaskId"] = taskId,
|
||||||
["tasks"] = RenderGroupedBrief(briefEntries),
|
["tasks"] = RenderGroupedBrief(briefEntries),
|
||||||
["finalNote"] = finalNote,
|
["roundInfo"] = roundInfo,
|
||||||
}), ct);
|
}), ct);
|
||||||
|
|
||||||
var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath)
|
var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath)
|
||||||
|
|||||||
@@ -37,9 +37,10 @@ public interface ITaskStateService
|
|||||||
Task<TransitionResult> BlockOnAsync(string taskId, string predecessorTaskId, CancellationToken ct);
|
Task<TransitionResult> BlockOnAsync(string taskId, string predecessorTaskId, CancellationToken ct);
|
||||||
Task<TransitionResult> UnblockAsync(string taskId, CancellationToken ct);
|
Task<TransitionResult> UnblockAsync(string taskId, CancellationToken ct);
|
||||||
|
|
||||||
// dependsOnTaskId null clears the dependency. Rejects self-reference, an unknown dependency
|
// Replaces the task's whole dependency set; an empty list clears it. Rejects a self-reference,
|
||||||
// id, and a link that would create a cycle -- see TaskStateService for the walk.
|
// an unknown dependency id, and any link that would create a cycle in the dependency graph
|
||||||
Task<TransitionResult> SetDependsOnAsync(string taskId, string? dependsOnTaskId, CancellationToken ct);
|
// -- 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.
|
// 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,
|
// 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);
|
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);
|
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||||
|
|
||||||
if (dependsOnTaskId is not null)
|
if (!await ctx.Tasks.AsNoTracking().AnyAsync(t => t.Id == taskId, ct))
|
||||||
{
|
|
||||||
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)
|
|
||||||
return new TransitionResult(false, "Task not found.");
|
return new TransitionResult(false, "Task not found.");
|
||||||
|
|
||||||
// Clearing a dependency may free up a Queued task the picker was skipping.
|
var wanted = dependsOnTaskIds.Distinct(StringComparer.Ordinal).ToList();
|
||||||
if (dependsOnTaskId is null) _waker.Wake();
|
|
||||||
|
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);
|
await NotifyAsync(taskId);
|
||||||
return new TransitionResult(true, null);
|
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)
|
public async Task<int> RecoverStaleRunningAsync(string reason, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var resultText = "[stale] " + reason;
|
var resultText = "[stale] " + reason;
|
||||||
|
|||||||
@@ -70,17 +70,23 @@ public sealed class ForeignKeyTests : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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 listId = Guid.NewGuid().ToString();
|
||||||
var predecessorId = Guid.NewGuid().ToString();
|
var predecessorId = Guid.NewGuid().ToString();
|
||||||
|
var otherId = Guid.NewGuid().ToString();
|
||||||
var dependentId = Guid.NewGuid().ToString();
|
var dependentId = Guid.NewGuid().ToString();
|
||||||
|
|
||||||
await using (var ctx = Open())
|
await using (var ctx = Open())
|
||||||
{
|
{
|
||||||
ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
|
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 = 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();
|
await ctx.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,8 +99,9 @@ public sealed class ForeignKeyTests : IDisposable
|
|||||||
|
|
||||||
await using (var ctx = Open())
|
await using (var ctx = Open())
|
||||||
{
|
{
|
||||||
var dependent = await ctx.Tasks.AsNoTracking().FirstAsync(t => t.Id == dependentId);
|
var dependent = await ctx.Tasks.AsNoTracking().Include(t => t.Dependencies)
|
||||||
Assert.Null(dependent.DependsOnTaskId);
|
.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,
|
Title = t.Id,
|
||||||
CreatedAt = DateTime.UtcNow,
|
CreatedAt = DateTime.UtcNow,
|
||||||
Status = t.Status,
|
Status = t.Status,
|
||||||
DependsOnTaskId = t.DependsOnTaskId,
|
|
||||||
ParentTaskId = t.ParentTaskId,
|
ParentTaskId = t.ParentTaskId,
|
||||||
SortOrder = t.SortOrder,
|
SortOrder = t.SortOrder,
|
||||||
});
|
});
|
||||||
|
if (t.DependsOnTaskId is not null)
|
||||||
|
db.TaskDependencies.Add(new TaskDependencyEntity
|
||||||
|
{
|
||||||
|
TaskId = t.Id,
|
||||||
|
DependsOnTaskId = t.DependsOnTaskId,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return numbers;
|
return numbers;
|
||||||
@@ -225,8 +230,7 @@ public class TasksIslandChainTests : IDisposable
|
|||||||
|
|
||||||
await using (var db = NewContext())
|
await using (var db = NewContext())
|
||||||
{
|
{
|
||||||
var entity = await db.Tasks.FirstAsync(t => t.Id == "later");
|
db.TaskDependencies.Add(new TaskDependencyEntity { TaskId = "later", DependsOnTaskId = "head" });
|
||||||
entity.DependsOnTaskId = "head";
|
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,4 +240,30 @@ public class TasksIslandChainTests : IDisposable
|
|||||||
Assert.True(updated.ShowAsChainMember);
|
Assert.True(updated.ShowAsChainMember);
|
||||||
Assert.Equal(1, updated.ChainStep);
|
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,
|
Title = t.Id,
|
||||||
CreatedAt = DateTime.UtcNow,
|
CreatedAt = DateTime.UtcNow,
|
||||||
Status = t.Status,
|
Status = t.Status,
|
||||||
DependsOnTaskId = t.DependsOnTaskId,
|
|
||||||
SortOrder = t.SortOrder,
|
SortOrder = t.SortOrder,
|
||||||
});
|
});
|
||||||
|
if (t.DependsOnTaskId is not null)
|
||||||
|
db.TaskDependencies.Add(new TaskDependencyEntity
|
||||||
|
{
|
||||||
|
TaskId = t.Id,
|
||||||
|
DependsOnTaskId = t.DependsOnTaskId,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -266,8 +266,7 @@ public class TasksIslandReconcileTickTests : IDisposable
|
|||||||
|
|
||||||
await using (var db = NewContext())
|
await using (var db = NewContext())
|
||||||
{
|
{
|
||||||
var t = await db.Tasks.FirstAsync(x => x.Id == "DEP");
|
db.TaskDependencies.Add(new TaskDependencyEntity { TaskId = "DEP", DependsOnTaskId = "HEAD" });
|
||||||
t.DependsOnTaskId = "HEAD";
|
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -408,8 +408,7 @@ public sealed class BatchMcpToolsTests : IDisposable
|
|||||||
var listId = await SeedListAsync();
|
var listId = await SeedListAsync();
|
||||||
var predecessor = await SeedTaskAsync(listId, "predecessor", TaskStatus.Idle);
|
var predecessor = await SeedTaskAsync(listId, "predecessor", TaskStatus.Idle);
|
||||||
var task = await SeedTaskAsync(listId, "blocked", TaskStatus.Queued);
|
var task = await SeedTaskAsync(listId, "blocked", TaskStatus.Queued);
|
||||||
task.DependsOnTaskId = predecessor.Id;
|
await _ctx.SeedDependsOnAsync(task.Id, predecessor.Id);
|
||||||
await _tasks.UpdateAsync(task);
|
|
||||||
var sut = BuildSut();
|
var sut = BuildSut();
|
||||||
|
|
||||||
var results = await sut.BatchGetTasks(new[] { task.Id }, cancellationToken: CancellationToken.None);
|
var results = await sut.BatchGetTasks(new[] { task.Id }, cancellationToken: CancellationToken.None);
|
||||||
|
|||||||
+101
-15
@@ -118,6 +118,10 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
|||||||
return id;
|
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(
|
private async Task<TaskEntity> SeedTaskAsync(
|
||||||
string listId, string title = "t", TaskStatus status = TaskStatus.Idle, string? parentId = null)
|
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 listId = await SeedListAsync();
|
||||||
var predecessor = await SeedTaskAsync(listId, status: TaskStatus.Idle);
|
var predecessor = await SeedTaskAsync(listId, status: TaskStatus.Idle);
|
||||||
var task = await SeedTaskAsync(listId, status: TaskStatus.Queued);
|
var task = await SeedTaskAsync(listId, status: TaskStatus.Queued);
|
||||||
task.DependsOnTaskId = predecessor.Id;
|
await _ctx.SeedDependsOnAsync(task.Id, predecessor.Id);
|
||||||
await _tasks.UpdateAsync(task, CancellationToken.None);
|
|
||||||
var sut = BuildSut(CreateQueue());
|
var sut = BuildSut(CreateQueue());
|
||||||
|
|
||||||
var dto = await sut.GetTask(task.Id, CancellationToken.None);
|
var dto = await sut.GetTask(task.Id, CancellationToken.None);
|
||||||
@@ -460,8 +463,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
|||||||
var listId = await SeedListAsync();
|
var listId = await SeedListAsync();
|
||||||
var predecessor = await SeedTaskAsync(listId, status: TaskStatus.Done);
|
var predecessor = await SeedTaskAsync(listId, status: TaskStatus.Done);
|
||||||
var task = await SeedTaskAsync(listId, status: TaskStatus.Queued);
|
var task = await SeedTaskAsync(listId, status: TaskStatus.Queued);
|
||||||
task.DependsOnTaskId = predecessor.Id;
|
await _ctx.SeedDependsOnAsync(task.Id, predecessor.Id);
|
||||||
await _tasks.UpdateAsync(task, CancellationToken.None);
|
|
||||||
var sut = BuildSut(CreateQueue());
|
var sut = BuildSut(CreateQueue());
|
||||||
|
|
||||||
var dto = await sut.GetTask(task.Id, CancellationToken.None);
|
var dto = await sut.GetTask(task.Id, CancellationToken.None);
|
||||||
@@ -478,8 +480,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
|||||||
var listId = await SeedListAsync();
|
var listId = await SeedListAsync();
|
||||||
var predecessor = await SeedTaskAsync(listId, status: TaskStatus.Idle);
|
var predecessor = await SeedTaskAsync(listId, status: TaskStatus.Idle);
|
||||||
var task = await SeedTaskAsync(listId, status: TaskStatus.Idle);
|
var task = await SeedTaskAsync(listId, status: TaskStatus.Idle);
|
||||||
task.DependsOnTaskId = predecessor.Id;
|
await _ctx.SeedDependsOnAsync(task.Id, predecessor.Id);
|
||||||
await _tasks.UpdateAsync(task, CancellationToken.None);
|
|
||||||
var sut = BuildSut(CreateQueue());
|
var sut = BuildSut(CreateQueue());
|
||||||
|
|
||||||
var dto = await sut.GetTask(task.Id, CancellationToken.None);
|
var dto = await sut.GetTask(task.Id, CancellationToken.None);
|
||||||
@@ -493,8 +494,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
|||||||
var listId = await SeedListAsync();
|
var listId = await SeedListAsync();
|
||||||
var predecessor = await SeedTaskAsync(listId, "predecessor", TaskStatus.Idle);
|
var predecessor = await SeedTaskAsync(listId, "predecessor", TaskStatus.Idle);
|
||||||
var blocked = await SeedTaskAsync(listId, "blocked", TaskStatus.Queued);
|
var blocked = await SeedTaskAsync(listId, "blocked", TaskStatus.Queued);
|
||||||
blocked.DependsOnTaskId = predecessor.Id;
|
await _ctx.SeedDependsOnAsync(blocked.Id, predecessor.Id);
|
||||||
await _tasks.UpdateAsync(blocked, CancellationToken.None);
|
|
||||||
var unblocked = await SeedTaskAsync(listId, "unblocked", TaskStatus.Queued);
|
var unblocked = await SeedTaskAsync(listId, "unblocked", TaskStatus.Queued);
|
||||||
var sut = BuildSut(CreateQueue());
|
var sut = BuildSut(CreateQueue());
|
||||||
|
|
||||||
@@ -513,8 +513,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
|||||||
var listId = await SeedListAsync();
|
var listId = await SeedListAsync();
|
||||||
var predecessor = await SeedTaskAsync(listId, "predecessor", TaskStatus.Idle);
|
var predecessor = await SeedTaskAsync(listId, "predecessor", TaskStatus.Idle);
|
||||||
var blocked = await SeedTaskAsync(listId, "blocked", TaskStatus.Queued);
|
var blocked = await SeedTaskAsync(listId, "blocked", TaskStatus.Queued);
|
||||||
blocked.DependsOnTaskId = predecessor.Id;
|
await _ctx.SeedDependsOnAsync(blocked.Id, predecessor.Id);
|
||||||
await _tasks.UpdateAsync(blocked, CancellationToken.None);
|
|
||||||
var sut = BuildSut(CreateQueue());
|
var sut = BuildSut(CreateQueue());
|
||||||
|
|
||||||
var result = await sut.ListTasks(listId, includeDescription: true, cancellationToken: CancellationToken.None);
|
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);
|
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);
|
Assert.True(dto.Blocked);
|
||||||
var loaded = await _tasks.GetByIdAsync(task.Id);
|
var loaded = await _tasks.GetByIdAsync(task.Id);
|
||||||
Assert.Equal(predecessor.Id, loaded!.DependsOnTaskId);
|
Assert.Equal([predecessor.Id], loaded!.DependsOnTaskIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -572,7 +571,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
|||||||
|
|
||||||
var dto = await sut.UpdateTask(task.Id, dependsOnTaskId: "", cancellationToken: CancellationToken.None);
|
var dto = await sut.UpdateTask(task.Id, dependsOnTaskId: "", cancellationToken: CancellationToken.None);
|
||||||
|
|
||||||
Assert.Null(dto.DependsOnTaskId);
|
Assert.Empty(dto.DependsOnTaskIds!);
|
||||||
Assert.False(dto.Blocked);
|
Assert.False(dto.Blocked);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -589,6 +588,75 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
|||||||
sut.UpdateTask(a.Id, dependsOnTaskId: b.Id, cancellationToken: CancellationToken.None));
|
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]
|
[Fact]
|
||||||
public async Task ReviewTask_Approve_SetsDone()
|
public async Task ReviewTask_Approve_SetsDone()
|
||||||
{
|
{
|
||||||
@@ -1488,11 +1556,29 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
|||||||
var dto = await sut.AddTask(
|
var dto = await sut.AddTask(
|
||||||
listId, "t", dependsOnTaskId: predecessor.Id, queueImmediately: true, cancellationToken: CancellationToken.None);
|
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.True(dto.Task.Blocked);
|
||||||
Assert.Contains(predecessor.Id, dto.Task.BlockedReason);
|
Assert.Contains(predecessor.Id, dto.Task.BlockedReason);
|
||||||
var loaded = await _tasks.GetByIdAsync(dto.Task.Id);
|
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]
|
[Fact]
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ public sealed class ExternalMcpToolSchemaTests
|
|||||||
"list_runs", "get_run", "get_task_log",
|
"list_runs", "get_run", "get_task_log",
|
||||||
"list_agents",
|
"list_agents",
|
||||||
"reset_failed_task", "submit_task_for_review",
|
"reset_failed_task", "submit_task_for_review",
|
||||||
"handoff_list_handler",
|
"handoff_list_handler", "get_handoff_status",
|
||||||
"get_app_settings",
|
"get_app_settings",
|
||||||
"wait_for_task_change",
|
"wait_for_task_change",
|
||||||
"get_queue_state",
|
"get_queue_state",
|
||||||
|
|||||||
@@ -209,6 +209,63 @@ public sealed class HandoffMcpToolsTests : IDisposable
|
|||||||
secondSut.HandoffListHandler(handlerTask.Id, survivors, "wait", CancellationToken.None));
|
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]
|
[Fact]
|
||||||
public async Task HandoffListHandler_RaisedCapSetting_AllowsMoreRoundsBeforeCoercing()
|
public async Task HandoffListHandler_RaisedCapSetting_AllowsMoreRoundsBeforeCoercing()
|
||||||
{
|
{
|
||||||
@@ -258,6 +315,30 @@ public sealed class HandoffMcpToolsTests : IDisposable
|
|||||||
Assert.Equal("merge_final", second.NextPhase);
|
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]
|
[Fact]
|
||||||
public async Task HandoffListHandler_TwoHandlerTasks_DoNotShareACounter()
|
public async Task HandoffListHandler_TwoHandlerTasks_DoNotShareACounter()
|
||||||
{
|
{
|
||||||
@@ -280,4 +361,64 @@ public sealed class HandoffMcpToolsTests : IDisposable
|
|||||||
Assert.False(resultB.Coerced);
|
Assert.False(resultB.Coerced);
|
||||||
Assert.Equal("wait", resultB.NextPhase);
|
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 dependency = await SeedTaskAsync(TaskStatus.Idle);
|
||||||
var task = await SeedTaskAsync(TaskStatus.Queued);
|
var task = await SeedTaskAsync(TaskStatus.Queued);
|
||||||
task.DependsOnTaskId = dependency.Id;
|
await _ctx.SeedDependsOnAsync(task.Id, dependency.Id);
|
||||||
await _tasks.UpdateAsync(task);
|
|
||||||
var sut = BuildSut();
|
var sut = BuildSut();
|
||||||
var sw = Stopwatch.StartNew();
|
var sw = Stopwatch.StartNew();
|
||||||
|
|
||||||
@@ -240,8 +239,7 @@ public sealed class TaskWaitMcpToolsTests : IDisposable
|
|||||||
{
|
{
|
||||||
var dependency = await SeedTaskAsync(TaskStatus.Done);
|
var dependency = await SeedTaskAsync(TaskStatus.Done);
|
||||||
var task = await SeedTaskAsync(TaskStatus.Queued);
|
var task = await SeedTaskAsync(TaskStatus.Queued);
|
||||||
task.DependsOnTaskId = dependency.Id;
|
await _ctx.SeedDependsOnAsync(task.Id, dependency.Id);
|
||||||
await _tasks.UpdateAsync(task);
|
|
||||||
var sut = BuildSut();
|
var sut = BuildSut();
|
||||||
var sw = Stopwatch.StartNew();
|
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 wtManager = new WorktreeManager(git, factory, new WorkerConfig(), NullLogger<WorktreeManager>.Instance);
|
||||||
var interactiveLaunchSpec = new InteractiveLaunchSpecService(
|
var interactiveLaunchSpec = new InteractiveLaunchSpecService(
|
||||||
factory, new FakeSessionSkillSeeder(), new FakeSessionSkillRegistry(), wtManager, git,
|
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 built = TaskStateServiceBuilder.Build(factory);
|
||||||
var reviewSubmission = new InteractiveReviewSubmissionService(factory, git, built.State, wtManager);
|
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> 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> 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> 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 TryAdvanceParentAsync(string parentId) => _inner.TryAdvanceParentAsync(parentId);
|
||||||
public Task<int> RecoverStaleRunningAsync(string reason, CancellationToken ct) => _inner.RecoverStaleRunningAsync(reason, ct);
|
public Task<int> RecoverStaleRunningAsync(string reason, CancellationToken ct) => _inner.RecoverStaleRunningAsync(reason, ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ public sealed class QueuePickerTests : IDisposable
|
|||||||
bool taskAgentTag = false,
|
bool taskAgentTag = false,
|
||||||
int? sortOrder = null,
|
int? sortOrder = null,
|
||||||
bool isManual = false,
|
bool isManual = false,
|
||||||
string? dependsOn = null)
|
params string[] dependsOn)
|
||||||
{
|
{
|
||||||
var task = new TaskEntity
|
var task = new TaskEntity
|
||||||
{
|
{
|
||||||
@@ -61,11 +61,12 @@ public sealed class QueuePickerTests : IDisposable
|
|||||||
CreatedAt = createdAt ?? DateTime.UtcNow,
|
CreatedAt = createdAt ?? DateTime.UtcNow,
|
||||||
ScheduledFor = scheduledFor,
|
ScheduledFor = scheduledFor,
|
||||||
BlockedByTaskId = blockedBy,
|
BlockedByTaskId = blockedBy,
|
||||||
DependsOnTaskId = dependsOn,
|
|
||||||
CommitType = "feat",
|
CommitType = "feat",
|
||||||
IsManual = isManual,
|
IsManual = isManual,
|
||||||
};
|
};
|
||||||
await _tasks.AddAsync(task);
|
await _tasks.AddAsync(task);
|
||||||
|
if (dependsOn.Length > 0)
|
||||||
|
await _ctx.SeedDependsOnAsync(task.Id, dependsOn);
|
||||||
if (sortOrder is not null)
|
if (sortOrder is not null)
|
||||||
{
|
{
|
||||||
task.SortOrder = sortOrder.Value;
|
task.SortOrder = sortOrder.Value;
|
||||||
@@ -117,7 +118,7 @@ public sealed class QueuePickerTests : IDisposable
|
|||||||
{
|
{
|
||||||
var listId = await CreateListAsync();
|
var listId = await CreateListAsync();
|
||||||
var dependency = await SeedAsync(listId, status: TaskStatus.Idle);
|
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
|
// 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.
|
// 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 listId = await CreateListAsync();
|
||||||
var dependency = await SeedAsync(listId, status: TaskStatus.Done);
|
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);
|
var picked = await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None);
|
||||||
|
|
||||||
@@ -142,7 +143,7 @@ public sealed class QueuePickerTests : IDisposable
|
|||||||
{
|
{
|
||||||
var listId = await CreateListAsync();
|
var listId = await CreateListAsync();
|
||||||
var dependency = await SeedAsync(listId, status: TaskStatus.Failed);
|
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));
|
var independent = await SeedAsync(listId, createdAt: DateTime.UtcNow.AddMinutes(1));
|
||||||
|
|
||||||
// A Failed dependency does not cascade-cancel or auto-unblock the dependent (unlike the
|
// 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);
|
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]
|
[Fact]
|
||||||
public async Task ClaimNextAsync_Picks_TasksWithoutAgentTag()
|
public async Task ClaimNextAsync_Picks_TasksWithoutAgentTag()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using ClaudeDo.Data.Git;
|
|||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
using ClaudeDo.Data.Repositories;
|
using ClaudeDo.Data.Repositories;
|
||||||
using ClaudeDo.Worker.Config;
|
using ClaudeDo.Worker.Config;
|
||||||
|
using ClaudeDo.Worker.External;
|
||||||
using ClaudeDo.Worker.Planning;
|
using ClaudeDo.Worker.Planning;
|
||||||
using ClaudeDo.Worker.Runner;
|
using ClaudeDo.Worker.Runner;
|
||||||
using ClaudeDo.Worker.Skills;
|
using ClaudeDo.Worker.Skills;
|
||||||
@@ -72,11 +73,12 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
|||||||
=> Task.FromResult<IReadOnlyList<SessionSkillEntity>>(Installed);
|
=> Task.FromResult<IReadOnlyList<SessionSkillEntity>>(Installed);
|
||||||
}
|
}
|
||||||
|
|
||||||
private InteractiveLaunchSpecService BuildService() =>
|
private InteractiveLaunchSpecService BuildService(HandoffRoundTracker? handoffRounds = null) =>
|
||||||
new(_db.CreateFactory(), _seeder, _registry,
|
new(_db.CreateFactory(), _seeder, _registry,
|
||||||
new WorktreeManager(new GitService(), _db.CreateFactory(), new WorkerConfig(), NullLogger<WorktreeManager>.Instance),
|
new WorktreeManager(new GitService(), _db.CreateFactory(), new WorkerConfig(), NullLogger<WorktreeManager>.Instance),
|
||||||
new GitService(),
|
new GitService(),
|
||||||
new WorkerConfig { ClaudeBin = _claudeStubPath });
|
new WorkerConfig { ClaudeBin = _claudeStubPath },
|
||||||
|
handoffRounds ?? new HandoffRoundTracker());
|
||||||
|
|
||||||
private async Task<string> SeedListAsync(string? workingDir = null, string name = "L")
|
private async Task<string> SeedListAsync(string? workingDir = null, string name = "L")
|
||||||
{
|
{
|
||||||
@@ -636,6 +638,43 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
|||||||
await ctx.SaveChangesAsync();
|
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)
|
private async Task SetPowerUseAllowedAsync(string listId, bool value)
|
||||||
{
|
{
|
||||||
using var ctx = _db.CreateContext();
|
using var ctx = _db.CreateContext();
|
||||||
@@ -1134,7 +1173,13 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
|||||||
|
|
||||||
var beforeCount = await CountTasksAsync();
|
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 spec = await svc.BuildForMergeHelperHandoffAsync(handlerTaskId, new[] { survivor }, nextPhase, CancellationToken.None);
|
||||||
var sessionDir = TrackSessionDir(spec);
|
var sessionDir = TrackSessionDir(spec);
|
||||||
|
|
||||||
@@ -1181,6 +1226,11 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
|||||||
Assert.Contains(survivor, handoffBrief);
|
Assert.Contains(survivor, handoffBrief);
|
||||||
Assert.Contains("phase 3", handoffBrief, StringComparison.OrdinalIgnoreCase);
|
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)
|
if (expectFinalNote)
|
||||||
{
|
{
|
||||||
Assert.Contains("FINAL round", handoffBrief);
|
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");
|
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 ─────────────────────────────────────────────────────
|
// ─── EnqueueAsync ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -659,31 +670,55 @@ public sealed class TaskStateServiceTests : IDisposable
|
|||||||
// ─── SetDependsOnAsync ────────────────────────────────────────────────
|
// ─── SetDependsOnAsync ────────────────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task SetDependsOnAsync_SetsDependsOnTaskId()
|
public async Task SetDependsOnAsync_SetsDependsOnTaskIds()
|
||||||
{
|
{
|
||||||
var predecessor = await SeedTaskAsync(TaskStatus.Idle);
|
var predecessor = await SeedTaskAsync(TaskStatus.Idle);
|
||||||
var task = await SeedTaskAsync(TaskStatus.Queued);
|
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);
|
Assert.True(result.Ok);
|
||||||
var t = await GetTaskAsync(task);
|
Assert.Equal([predecessor], await GetDependsOnAsync(task));
|
||||||
Assert.Equal(predecessor, t.DependsOnTaskId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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 predecessor = await SeedTaskAsync(TaskStatus.Idle);
|
||||||
var task = await SeedTaskAsync(TaskStatus.Queued);
|
var task = await SeedTaskAsync(TaskStatus.Queued);
|
||||||
await _sut.SetDependsOnAsync(task, predecessor, default);
|
await _sut.SetDependsOnAsync(task, [predecessor], default);
|
||||||
var wakesBefore = _built.WakeCount();
|
var wakesBefore = _built.WakeCount();
|
||||||
|
|
||||||
var result = await _sut.SetDependsOnAsync(task, null, default);
|
var result = await _sut.SetDependsOnAsync(task, [], default);
|
||||||
|
|
||||||
Assert.True(result.Ok);
|
Assert.True(result.Ok);
|
||||||
var t = await GetTaskAsync(task);
|
Assert.Empty(await GetDependsOnAsync(task));
|
||||||
Assert.Null(t.DependsOnTaskId);
|
|
||||||
Assert.True(_built.WakeCount() > wakesBefore);
|
Assert.True(_built.WakeCount() > wakesBefore);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -692,11 +727,10 @@ public sealed class TaskStateServiceTests : IDisposable
|
|||||||
{
|
{
|
||||||
var task = await SeedTaskAsync(TaskStatus.Queued);
|
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);
|
Assert.False(result.Ok);
|
||||||
var t = await GetTaskAsync(task);
|
Assert.Empty(await GetDependsOnAsync(task));
|
||||||
Assert.Null(t.DependsOnTaskId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -704,7 +738,7 @@ public sealed class TaskStateServiceTests : IDisposable
|
|||||||
{
|
{
|
||||||
var task = await SeedTaskAsync(TaskStatus.Queued);
|
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);
|
Assert.False(result.Ok);
|
||||||
}
|
}
|
||||||
@@ -714,7 +748,7 @@ public sealed class TaskStateServiceTests : IDisposable
|
|||||||
{
|
{
|
||||||
var predecessor = await SeedTaskAsync(TaskStatus.Idle);
|
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);
|
Assert.False(result.Ok);
|
||||||
}
|
}
|
||||||
@@ -724,15 +758,14 @@ public sealed class TaskStateServiceTests : IDisposable
|
|||||||
{
|
{
|
||||||
var a = await SeedTaskAsync(TaskStatus.Queued);
|
var a = await SeedTaskAsync(TaskStatus.Queued);
|
||||||
var b = 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);
|
Assert.True(setup.Ok);
|
||||||
|
|
||||||
// a -> b would close the loop a -> b -> a.
|
// 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);
|
Assert.False(result.Ok);
|
||||||
var t = await GetTaskAsync(a);
|
Assert.Empty(await GetDependsOnAsync(a));
|
||||||
Assert.Null(t.DependsOnTaskId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -741,15 +774,31 @@ public sealed class TaskStateServiceTests : IDisposable
|
|||||||
var a = await SeedTaskAsync(TaskStatus.Queued);
|
var a = await SeedTaskAsync(TaskStatus.Queued);
|
||||||
var b = await SeedTaskAsync(TaskStatus.Queued);
|
var b = await SeedTaskAsync(TaskStatus.Queued);
|
||||||
var c = await SeedTaskAsync(TaskStatus.Queued);
|
var c = await SeedTaskAsync(TaskStatus.Queued);
|
||||||
Assert.True((await _sut.SetDependsOnAsync(b, a, default)).Ok);
|
Assert.True((await _sut.SetDependsOnAsync(b, [a], default)).Ok);
|
||||||
Assert.True((await _sut.SetDependsOnAsync(c, b, default)).Ok);
|
Assert.True((await _sut.SetDependsOnAsync(c, [b], default)).Ok);
|
||||||
|
|
||||||
// a -> c would close the loop a -> c -> b -> a.
|
// 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);
|
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 ─────────────────────────────────────────
|
// ─── RecoverStaleRunningAsync ─────────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
Reference in New Issue
Block a user