- Plan A (Foundation): schema, enum, repos, auto-status hook - Plan B (Worker MCP + Launcher): MCP server, SignalR endpoints, wt.exe launcher - Plan C (UI): context menu, hierarchy rendering, dialog, client methods Plans B and C depend on Plan A merging first (marker: migration file AddPlanningSupport). B and C can run in parallel after A. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1429 lines
45 KiB
Markdown
1429 lines
45 KiB
Markdown
# Planning Sessions — Plan A: Foundation Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Add the database, enum, repository, and auto-status infrastructure that enables planning sessions — no UI, no terminal, no MCP. Everything on top of this plan depends on the schema and repo methods landing cleanly.
|
|
|
|
**Architecture:** Extend `TaskEntity` with four new columns (`ParentTaskId`, `PlanningSessionId`, `PlanningSessionToken`, `PlanningFinalizedAt`), add three enum values (`Planning`, `Planned`, `Draft`), configure a self-referential FK with `DeleteBehavior.Restrict`, generate an EF migration, add seven new repository methods for the planning lifecycle, and wire a parent-auto-completion hook into the TaskRunner so that when a child reaches a terminal state the parent's status updates accordingly.
|
|
|
|
**Tech Stack:** .NET 8, EF Core (SQLite provider), xUnit. Uses existing `DbFixture` (real SQLite per test) and `TaskRepository` sealed-class pattern.
|
|
|
|
**Spec reference:** `docs/superpowers/specs/2026-04-23-planning-sessions-design.md` sections 2, 3, 7.1, 7.2, 8.1.
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
**Modified:**
|
|
- `src/ClaudeDo.Data/Models/TaskEntity.cs` — add four columns + navigation properties, extend `TaskStatus` enum.
|
|
- `src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs` — map new columns, configure self-ref FK, extend status converter.
|
|
- `src/ClaudeDo.Data/Repositories/TaskRepository.cs` — add seven planning methods.
|
|
- `src/ClaudeDo.Worker/Runner/TaskRunner.cs` — call `TryCompleteParentAsync` after `MarkDoneAsync`/`MarkFailedAsync`.
|
|
|
|
**Created:**
|
|
- `src/ClaudeDo.Data/Migrations/<timestamp>_AddPlanningSupport.cs` — EF migration.
|
|
- `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs` — new test class for planning methods.
|
|
- `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryParentCompletionTests.cs` — tests for auto-status hook.
|
|
|
|
**No interface extraction:** existing code uses `TaskRepository` sealed class directly (no `ITaskRepository`); we follow that pattern.
|
|
|
|
**Marker for parallel plans B and C:** the file `src/ClaudeDo.Data/Migrations/<timestamp>_AddPlanningSupport.cs` existing on `main` is the signal that Plan A is merged. Plans B and C should poll for it before starting.
|
|
|
|
---
|
|
|
|
## Task 1: Extend `TaskStatus` enum
|
|
|
|
**Files:**
|
|
- Modify: `src/ClaudeDo.Data/Models/TaskEntity.cs`
|
|
- Modify: `src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs`
|
|
|
|
- [ ] **Step 1: Add enum values**
|
|
|
|
In `src/ClaudeDo.Data/Models/TaskEntity.cs`, extend the enum:
|
|
|
|
```csharp
|
|
public enum TaskStatus
|
|
{
|
|
Manual,
|
|
Queued,
|
|
Running,
|
|
Done,
|
|
Failed,
|
|
Planning,
|
|
Planned,
|
|
Draft,
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Extend status converter**
|
|
|
|
In `src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs`, extend both converter methods to handle the three new values:
|
|
|
|
```csharp
|
|
private static string StatusToString(TaskStatus v)
|
|
=> v == TaskStatus.Manual ? "manual"
|
|
: v == TaskStatus.Queued ? "queued"
|
|
: v == TaskStatus.Running ? "running"
|
|
: v == TaskStatus.Done ? "done"
|
|
: v == TaskStatus.Failed ? "failed"
|
|
: v == TaskStatus.Planning ? "planning"
|
|
: v == TaskStatus.Planned ? "planned"
|
|
: v == TaskStatus.Draft ? "draft"
|
|
: throw new ArgumentOutOfRangeException(nameof(v));
|
|
|
|
private static TaskStatus StatusFromString(string v)
|
|
=> v == "manual" ? TaskStatus.Manual
|
|
: v == "queued" ? TaskStatus.Queued
|
|
: v == "running" ? TaskStatus.Running
|
|
: v == "done" ? TaskStatus.Done
|
|
: v == "failed" ? TaskStatus.Failed
|
|
: v == "planning" ? TaskStatus.Planning
|
|
: v == "planned" ? TaskStatus.Planned
|
|
: v == "draft" ? TaskStatus.Draft
|
|
: throw new ArgumentOutOfRangeException(nameof(v));
|
|
```
|
|
|
|
- [ ] **Step 3: Build**
|
|
|
|
Run: `dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj`
|
|
Expected: build succeeds.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add src/ClaudeDo.Data/Models/TaskEntity.cs src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs
|
|
git commit -m "feat(data): add Planning, Planned, Draft task statuses"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Extend `TaskEntity` with planning columns and navigations
|
|
|
|
**Files:**
|
|
- Modify: `src/ClaudeDo.Data/Models/TaskEntity.cs`
|
|
|
|
- [ ] **Step 1: Add four properties + navigations**
|
|
|
|
In `TaskEntity` class, add (placement: after `SortOrder`, before navigation section):
|
|
|
|
```csharp
|
|
public int SortOrder { get; set; }
|
|
|
|
public string? ParentTaskId { get; set; }
|
|
public string? PlanningSessionId { get; set; }
|
|
public string? PlanningSessionToken { get; set; }
|
|
public DateTime? PlanningFinalizedAt { get; set; }
|
|
|
|
// Navigation properties
|
|
public ListEntity List { get; set; } = null!;
|
|
public WorktreeEntity? Worktree { get; set; }
|
|
public ICollection<TagEntity> Tags { get; set; } = new List<TagEntity>();
|
|
public ICollection<TaskRunEntity> Runs { get; set; } = new List<TaskRunEntity>();
|
|
public ICollection<SubtaskEntity> Subtasks { get; set; } = new List<SubtaskEntity>();
|
|
|
|
public TaskEntity? Parent { get; set; }
|
|
public ICollection<TaskEntity> Children { get; set; } = new List<TaskEntity>();
|
|
```
|
|
|
|
- [ ] **Step 2: Build**
|
|
|
|
Run: `dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj`
|
|
Expected: build succeeds.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/ClaudeDo.Data/Models/TaskEntity.cs
|
|
git commit -m "feat(data): add planning columns and self-ref navigations to TaskEntity"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: Configure self-ref FK and new columns in `TaskEntityConfiguration`
|
|
|
|
**Files:**
|
|
- Modify: `src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs`
|
|
|
|
- [ ] **Step 1: Map the four new columns + configure self-ref FK**
|
|
|
|
After the existing `SortOrder` property mapping (currently ends with `.HasDefaultValue(0);`), insert:
|
|
|
|
```csharp
|
|
builder.Property(t => t.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0);
|
|
|
|
builder.Property(t => t.ParentTaskId).HasColumnName("parent_task_id");
|
|
builder.Property(t => t.PlanningSessionId).HasColumnName("planning_session_id");
|
|
builder.Property(t => t.PlanningSessionToken).HasColumnName("planning_session_token");
|
|
builder.Property(t => t.PlanningFinalizedAt).HasColumnName("planning_finalized_at");
|
|
|
|
builder.HasOne(t => t.Parent)
|
|
.WithMany(t => t.Children)
|
|
.HasForeignKey(t => t.ParentTaskId)
|
|
.OnDelete(DeleteBehavior.Restrict);
|
|
|
|
builder.HasOne(t => t.List)
|
|
```
|
|
|
|
(The existing `builder.HasOne(t => t.List)` block immediately follows; do not duplicate it — just insert the new block above it.)
|
|
|
|
- [ ] **Step 2: Add index on `ParentTaskId`**
|
|
|
|
At the end of `Configure`, after the existing `HasIndex` calls:
|
|
|
|
```csharp
|
|
builder.HasIndex(t => t.ParentTaskId).HasDatabaseName("idx_tasks_parent_task_id");
|
|
```
|
|
|
|
- [ ] **Step 3: Build**
|
|
|
|
Run: `dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj`
|
|
Expected: build succeeds.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs
|
|
git commit -m "feat(data): configure planning columns and self-ref FK with Restrict"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Generate EF migration
|
|
|
|
**Files:**
|
|
- Create: `src/ClaudeDo.Data/Migrations/<timestamp>_AddPlanningSupport.cs` (auto-generated, then reviewed)
|
|
|
|
- [ ] **Step 1: Scaffold the migration**
|
|
|
|
Run from project root:
|
|
|
|
```bash
|
|
dotnet ef migrations add AddPlanningSupport \
|
|
--project src/ClaudeDo.Data \
|
|
--startup-project src/ClaudeDo.Worker
|
|
```
|
|
|
|
Expected: three new files under `src/ClaudeDo.Data/Migrations/` (the migration, its designer file, and an updated model snapshot).
|
|
|
|
- [ ] **Step 2: Review the generated migration**
|
|
|
|
Open the new `<timestamp>_AddPlanningSupport.cs` file. Verify the `Up` method contains:
|
|
- Four `AddColumn<string>` / `AddColumn<DateTime>` calls for `parent_task_id` (nullable string), `planning_session_id` (nullable string), `planning_session_token` (nullable string), `planning_finalized_at` (nullable DateTime).
|
|
- A `CreateIndex` for `idx_tasks_parent_task_id` on `parent_task_id`.
|
|
- An `AddForeignKey` call with `onDelete: ReferentialAction.Restrict` linking `parent_task_id` → `tasks.id`.
|
|
|
|
No data migration needed (all new columns nullable).
|
|
|
|
- [ ] **Step 3: Apply to a scratch DB to verify**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
dotnet ef database update \
|
|
--project src/ClaudeDo.Data \
|
|
--startup-project src/ClaudeDo.Worker \
|
|
--connection "Data Source=./scratch_migration_test.db"
|
|
```
|
|
|
|
Expected: no errors. Then delete the scratch DB file:
|
|
|
|
```bash
|
|
rm -f ./scratch_migration_test.db ./scratch_migration_test.db-wal ./scratch_migration_test.db-shm
|
|
```
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add src/ClaudeDo.Data/Migrations/
|
|
git commit -m "feat(data): migration AddPlanningSupport"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Repository method `GetChildrenAsync`
|
|
|
|
**Files:**
|
|
- Test: `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs` (create)
|
|
- Modify: `src/ClaudeDo.Data/Repositories/TaskRepository.cs`
|
|
|
|
- [ ] **Step 1: Create the test class skeleton**
|
|
|
|
Create `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs`:
|
|
|
|
```csharp
|
|
using ClaudeDo.Data;
|
|
using ClaudeDo.Data.Models;
|
|
using ClaudeDo.Data.Repositories;
|
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
|
|
|
namespace ClaudeDo.Worker.Tests.Repositories;
|
|
|
|
public sealed class TaskRepositoryPlanningTests : IDisposable
|
|
{
|
|
private readonly DbFixture _db = new();
|
|
private readonly ClaudeDoDbContext _ctx;
|
|
private readonly TaskRepository _tasks;
|
|
private readonly ListRepository _lists;
|
|
private readonly TagRepository _tags;
|
|
|
|
public TaskRepositoryPlanningTests()
|
|
{
|
|
_ctx = _db.CreateContext();
|
|
_tasks = new TaskRepository(_ctx);
|
|
_lists = new ListRepository(_ctx);
|
|
_tags = new TagRepository(_ctx);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_ctx.Dispose();
|
|
_db.Dispose();
|
|
}
|
|
|
|
private async Task<string> CreateListAsync(string? id = null)
|
|
{
|
|
var listId = id ?? Guid.NewGuid().ToString();
|
|
await _lists.AddAsync(new ListEntity
|
|
{
|
|
Id = listId,
|
|
Name = "Test List",
|
|
CreatedAt = DateTime.UtcNow,
|
|
});
|
|
return listId;
|
|
}
|
|
|
|
private TaskEntity MakeTask(string listId, TaskStatus status = TaskStatus.Manual, string? parentId = null) => new()
|
|
{
|
|
Id = Guid.NewGuid().ToString(),
|
|
ListId = listId,
|
|
Title = "t",
|
|
Status = status,
|
|
CreatedAt = DateTime.UtcNow,
|
|
CommitType = "feat",
|
|
ParentTaskId = parentId,
|
|
};
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Write the failing test for `GetChildrenAsync`**
|
|
|
|
Add inside the class:
|
|
|
|
```csharp
|
|
[Fact]
|
|
public async Task GetChildrenAsync_ReturnsOnlyDirectChildren_Sorted()
|
|
{
|
|
var listId = await CreateListAsync();
|
|
var parent = MakeTask(listId, TaskStatus.Planning);
|
|
parent.Title = "parent";
|
|
await _tasks.AddAsync(parent);
|
|
|
|
var childA = MakeTask(listId, TaskStatus.Draft, parentId: parent.Id);
|
|
childA.Title = "a"; childA.SortOrder = 1;
|
|
await _tasks.AddAsync(childA);
|
|
|
|
var childB = MakeTask(listId, TaskStatus.Draft, parentId: parent.Id);
|
|
childB.Title = "b"; childB.SortOrder = 0;
|
|
await _tasks.AddAsync(childB);
|
|
|
|
var unrelated = MakeTask(listId, TaskStatus.Manual);
|
|
await _tasks.AddAsync(unrelated);
|
|
|
|
var children = await _tasks.GetChildrenAsync(parent.Id);
|
|
|
|
Assert.Equal(2, children.Count);
|
|
Assert.Equal("b", children[0].Title);
|
|
Assert.Equal("a", children[1].Title);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Run the test; verify it fails**
|
|
|
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskRepositoryPlanningTests.GetChildrenAsync"`
|
|
Expected: FAIL — `TaskRepository` has no method `GetChildrenAsync`.
|
|
|
|
- [ ] **Step 4: Implement the method**
|
|
|
|
In `src/ClaudeDo.Data/Repositories/TaskRepository.cs`, add a new region at the end of the class (before the final `}`):
|
|
|
|
```csharp
|
|
#region Planning
|
|
|
|
public async Task<List<TaskEntity>> GetChildrenAsync(string parentId, CancellationToken ct = default)
|
|
{
|
|
return await _context.Tasks
|
|
.AsNoTracking()
|
|
.Where(t => t.ParentTaskId == parentId)
|
|
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
|
|
.ToListAsync(ct);
|
|
}
|
|
|
|
#endregion
|
|
```
|
|
|
|
- [ ] **Step 5: Run the test; verify it passes**
|
|
|
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskRepositoryPlanningTests.GetChildrenAsync"`
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs src/ClaudeDo.Data/Repositories/TaskRepository.cs
|
|
git commit -m "feat(data): TaskRepository.GetChildrenAsync"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: Repository method `CreateChildAsync`
|
|
|
|
**Files:**
|
|
- Modify: `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs`
|
|
- Modify: `src/ClaudeDo.Data/Repositories/TaskRepository.cs`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Append to the planning test class:
|
|
|
|
```csharp
|
|
[Fact]
|
|
public async Task CreateChildAsync_CreatesDraftUnderParent()
|
|
{
|
|
var listId = await CreateListAsync();
|
|
var parent = MakeTask(listId, TaskStatus.Planning);
|
|
await _tasks.AddAsync(parent);
|
|
|
|
var child = await _tasks.CreateChildAsync(
|
|
parent.Id,
|
|
title: "child title",
|
|
description: "child desc",
|
|
tagNames: new[] { "agent" },
|
|
commitType: "feat");
|
|
|
|
Assert.Equal(TaskStatus.Draft, child.Status);
|
|
Assert.Equal(parent.Id, child.ParentTaskId);
|
|
Assert.Equal(listId, child.ListId);
|
|
Assert.Equal("child title", child.Title);
|
|
Assert.Equal("child desc", child.Description);
|
|
Assert.Equal("feat", child.CommitType);
|
|
|
|
var loaded = await _tasks.GetByIdAsync(child.Id);
|
|
Assert.NotNull(loaded);
|
|
Assert.Equal(TaskStatus.Draft, loaded!.Status);
|
|
|
|
var tags = await _tasks.GetTagsAsync(child.Id);
|
|
Assert.Contains(tags, t => t.Name == "agent");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateChildAsync_ThrowsIfParentNotFound()
|
|
{
|
|
var listId = await CreateListAsync();
|
|
_ = listId; // just to create the DB
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
|
_tasks.CreateChildAsync("nonexistent-parent-id", "t", null, null, null));
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run; verify fail**
|
|
|
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskRepositoryPlanningTests.CreateChildAsync"`
|
|
Expected: FAIL (method not defined).
|
|
|
|
- [ ] **Step 3: Implement**
|
|
|
|
In the `#region Planning` section of `TaskRepository.cs`, add:
|
|
|
|
```csharp
|
|
public async Task<TaskEntity> CreateChildAsync(
|
|
string parentId,
|
|
string title,
|
|
string? description,
|
|
IReadOnlyList<string>? tagNames,
|
|
string? commitType,
|
|
CancellationToken ct = default)
|
|
{
|
|
var parent = await _context.Tasks.FirstOrDefaultAsync(t => t.Id == parentId, ct);
|
|
if (parent is null)
|
|
throw new InvalidOperationException($"Parent task {parentId} not found.");
|
|
|
|
var maxSort = await _context.Tasks
|
|
.Where(t => t.ListId == parent.ListId)
|
|
.Select(t => (int?)t.SortOrder)
|
|
.MaxAsync(ct);
|
|
|
|
var child = new TaskEntity
|
|
{
|
|
Id = Guid.NewGuid().ToString(),
|
|
ListId = parent.ListId,
|
|
Title = title,
|
|
Description = description,
|
|
Status = TaskStatus.Draft,
|
|
CreatedAt = DateTime.UtcNow,
|
|
CommitType = string.IsNullOrEmpty(commitType) ? parent.CommitType : commitType,
|
|
ParentTaskId = parentId,
|
|
SortOrder = (maxSort ?? -1) + 1,
|
|
};
|
|
_context.Tasks.Add(child);
|
|
|
|
if (tagNames is not null && tagNames.Count > 0)
|
|
{
|
|
foreach (var tagName in tagNames.Distinct(StringComparer.OrdinalIgnoreCase))
|
|
{
|
|
var tag = await _context.Tags.FirstOrDefaultAsync(t => t.Name == tagName, ct);
|
|
if (tag is null)
|
|
{
|
|
tag = new TagEntity { Name = tagName };
|
|
_context.Tags.Add(tag);
|
|
await _context.SaveChangesAsync(ct);
|
|
}
|
|
child.Tags.Add(tag);
|
|
}
|
|
}
|
|
|
|
await _context.SaveChangesAsync(ct);
|
|
return child;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run; verify pass**
|
|
|
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskRepositoryPlanningTests.CreateChildAsync"`
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs src/ClaudeDo.Data/Repositories/TaskRepository.cs
|
|
git commit -m "feat(data): TaskRepository.CreateChildAsync"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: Repository method `SetPlanningStartedAsync`
|
|
|
|
**Files:**
|
|
- Modify: `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs`
|
|
- Modify: `src/ClaudeDo.Data/Repositories/TaskRepository.cs`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
Append:
|
|
|
|
```csharp
|
|
[Fact]
|
|
public async Task SetPlanningStartedAsync_ManualTask_TransitionsToPlanning()
|
|
{
|
|
var listId = await CreateListAsync();
|
|
var task = MakeTask(listId, TaskStatus.Manual);
|
|
await _tasks.AddAsync(task);
|
|
|
|
var result = await _tasks.SetPlanningStartedAsync(task.Id, "tok-abc");
|
|
|
|
Assert.NotNull(result);
|
|
Assert.Equal(TaskStatus.Planning, result!.Status);
|
|
Assert.Equal("tok-abc", result.PlanningSessionToken);
|
|
|
|
var loaded = await _tasks.GetByIdAsync(task.Id);
|
|
Assert.Equal(TaskStatus.Planning, loaded!.Status);
|
|
Assert.Equal("tok-abc", loaded.PlanningSessionToken);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task SetPlanningStartedAsync_NonManualTask_ReturnsNull()
|
|
{
|
|
var listId = await CreateListAsync();
|
|
var task = MakeTask(listId, TaskStatus.Queued);
|
|
await _tasks.AddAsync(task);
|
|
|
|
var result = await _tasks.SetPlanningStartedAsync(task.Id, "tok-xyz");
|
|
|
|
Assert.Null(result);
|
|
var loaded = await _tasks.GetByIdAsync(task.Id);
|
|
Assert.Equal(TaskStatus.Queued, loaded!.Status);
|
|
Assert.Null(loaded.PlanningSessionToken);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run; verify fail**
|
|
|
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskRepositoryPlanningTests.SetPlanningStarted"`
|
|
Expected: FAIL.
|
|
|
|
- [ ] **Step 3: Implement**
|
|
|
|
In `#region Planning`:
|
|
|
|
```csharp
|
|
public async Task<TaskEntity?> SetPlanningStartedAsync(
|
|
string taskId,
|
|
string sessionToken,
|
|
CancellationToken ct = default)
|
|
{
|
|
var affected = await _context.Tasks
|
|
.Where(t => t.Id == taskId && t.Status == TaskStatus.Manual)
|
|
.ExecuteUpdateAsync(s => s
|
|
.SetProperty(t => t.Status, TaskStatus.Planning)
|
|
.SetProperty(t => t.PlanningSessionToken, sessionToken), ct);
|
|
|
|
if (affected == 0) return null;
|
|
return await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId, ct);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run; verify pass**
|
|
|
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskRepositoryPlanningTests.SetPlanningStarted"`
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs src/ClaudeDo.Data/Repositories/TaskRepository.cs
|
|
git commit -m "feat(data): TaskRepository.SetPlanningStartedAsync"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: Repository method `UpdatePlanningSessionIdAsync`
|
|
|
|
**Files:**
|
|
- Modify: `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs`
|
|
- Modify: `src/ClaudeDo.Data/Repositories/TaskRepository.cs`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```csharp
|
|
[Fact]
|
|
public async Task UpdatePlanningSessionIdAsync_StoresClaudeSessionId()
|
|
{
|
|
var listId = await CreateListAsync();
|
|
var task = MakeTask(listId, TaskStatus.Manual);
|
|
await _tasks.AddAsync(task);
|
|
await _tasks.SetPlanningStartedAsync(task.Id, "tok");
|
|
|
|
await _tasks.UpdatePlanningSessionIdAsync(task.Id, "claude-session-42");
|
|
|
|
var loaded = await _tasks.GetByIdAsync(task.Id);
|
|
Assert.Equal("claude-session-42", loaded!.PlanningSessionId);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run; verify fail**
|
|
|
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskRepositoryPlanningTests.UpdatePlanningSessionId"`
|
|
Expected: FAIL.
|
|
|
|
- [ ] **Step 3: Implement**
|
|
|
|
```csharp
|
|
public async Task UpdatePlanningSessionIdAsync(
|
|
string parentId,
|
|
string sessionId,
|
|
CancellationToken ct = default)
|
|
{
|
|
await _context.Tasks
|
|
.Where(t => t.Id == parentId)
|
|
.ExecuteUpdateAsync(s => s
|
|
.SetProperty(t => t.PlanningSessionId, sessionId), ct);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run; verify pass**
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs src/ClaudeDo.Data/Repositories/TaskRepository.cs
|
|
git commit -m "feat(data): TaskRepository.UpdatePlanningSessionIdAsync"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 9: Repository method `FindByPlanningTokenAsync`
|
|
|
|
**Files:**
|
|
- Modify: `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs`
|
|
- Modify: `src/ClaudeDo.Data/Repositories/TaskRepository.cs`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```csharp
|
|
[Fact]
|
|
public async Task FindByPlanningTokenAsync_ReturnsTask_WhenTokenMatches()
|
|
{
|
|
var listId = await CreateListAsync();
|
|
var task = MakeTask(listId, TaskStatus.Manual);
|
|
await _tasks.AddAsync(task);
|
|
await _tasks.SetPlanningStartedAsync(task.Id, "unique-token-123");
|
|
|
|
var found = await _tasks.FindByPlanningTokenAsync("unique-token-123");
|
|
|
|
Assert.NotNull(found);
|
|
Assert.Equal(task.Id, found!.Id);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task FindByPlanningTokenAsync_ReturnsNull_WhenTokenUnknown()
|
|
{
|
|
var found = await _tasks.FindByPlanningTokenAsync("no-such-token");
|
|
Assert.Null(found);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run; verify fail**
|
|
|
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskRepositoryPlanningTests.FindByPlanningToken"`
|
|
Expected: FAIL.
|
|
|
|
- [ ] **Step 3: Implement**
|
|
|
|
```csharp
|
|
public async Task<TaskEntity?> FindByPlanningTokenAsync(
|
|
string token,
|
|
CancellationToken ct = default)
|
|
{
|
|
if (string.IsNullOrEmpty(token)) return null;
|
|
return await _context.Tasks
|
|
.AsNoTracking()
|
|
.FirstOrDefaultAsync(t => t.PlanningSessionToken == token, ct);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run; verify pass**
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs src/ClaudeDo.Data/Repositories/TaskRepository.cs
|
|
git commit -m "feat(data): TaskRepository.FindByPlanningTokenAsync"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 10: Repository method `FinalizePlanningAsync`
|
|
|
|
**Files:**
|
|
- Modify: `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs`
|
|
- Modify: `src/ClaudeDo.Data/Repositories/TaskRepository.cs`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```csharp
|
|
[Fact]
|
|
public async Task FinalizePlanningAsync_TransitionsDraftsAndParent()
|
|
{
|
|
var listId = await CreateListAsync();
|
|
var parent = MakeTask(listId, TaskStatus.Manual);
|
|
await _tasks.AddAsync(parent);
|
|
await _tasks.SetPlanningStartedAsync(parent.Id, "tok");
|
|
|
|
var c1 = await _tasks.CreateChildAsync(parent.Id, "c1", null, tagNames: new[] { "agent" }, commitType: null);
|
|
var c2 = await _tasks.CreateChildAsync(parent.Id, "c2", null, tagNames: null, commitType: null);
|
|
|
|
var count = await _tasks.FinalizePlanningAsync(parent.Id, queueAgentTasks: true);
|
|
|
|
Assert.Equal(2, count);
|
|
|
|
var c1Loaded = await _tasks.GetByIdAsync(c1.Id);
|
|
var c2Loaded = await _tasks.GetByIdAsync(c2.Id);
|
|
var parentLoaded = await _tasks.GetByIdAsync(parent.Id);
|
|
|
|
Assert.Equal(TaskStatus.Queued, c1Loaded!.Status);
|
|
Assert.Equal(TaskStatus.Manual, c2Loaded!.Status);
|
|
Assert.Equal(TaskStatus.Planned, parentLoaded!.Status);
|
|
Assert.NotNull(parentLoaded.PlanningFinalizedAt);
|
|
Assert.Null(parentLoaded.PlanningSessionToken);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task FinalizePlanningAsync_QueueAgentTasksFalse_AllToManual()
|
|
{
|
|
var listId = await CreateListAsync();
|
|
var parent = MakeTask(listId, TaskStatus.Manual);
|
|
await _tasks.AddAsync(parent);
|
|
await _tasks.SetPlanningStartedAsync(parent.Id, "tok");
|
|
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, tagNames: new[] { "agent" }, commitType: null);
|
|
|
|
await _tasks.FinalizePlanningAsync(parent.Id, queueAgentTasks: false);
|
|
|
|
var cLoaded = await _tasks.GetByIdAsync(c.Id);
|
|
Assert.Equal(TaskStatus.Manual, cLoaded!.Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task FinalizePlanningAsync_ParentWithAgentListTag_ChildIsQueued()
|
|
{
|
|
var listId = await CreateListAsync();
|
|
var agentTag = await _tags.GetOrCreateAsync("agent");
|
|
// Attach "agent" tag to the list (list-level tags propagate to its tasks).
|
|
await _lists.AddTagAsync(listId, agentTag.Id);
|
|
|
|
var parent = MakeTask(listId, TaskStatus.Manual);
|
|
await _tasks.AddAsync(parent);
|
|
await _tasks.SetPlanningStartedAsync(parent.Id, "tok");
|
|
var c = await _tasks.CreateChildAsync(parent.Id, "c", null, tagNames: null, commitType: null);
|
|
|
|
await _tasks.FinalizePlanningAsync(parent.Id, queueAgentTasks: true);
|
|
|
|
var cLoaded = await _tasks.GetByIdAsync(c.Id);
|
|
Assert.Equal(TaskStatus.Queued, cLoaded!.Status);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run; verify fail**
|
|
|
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskRepositoryPlanningTests.FinalizePlanning"`
|
|
Expected: FAIL.
|
|
|
|
- [ ] **Step 3: Implement**
|
|
|
|
```csharp
|
|
public async Task<int> FinalizePlanningAsync(
|
|
string parentId,
|
|
bool queueAgentTasks,
|
|
CancellationToken ct = default)
|
|
{
|
|
using var tx = await _context.Database.BeginTransactionAsync(ct);
|
|
|
|
var parent = await _context.Tasks
|
|
.Include(t => t.List).ThenInclude(l => l.Tags)
|
|
.FirstOrDefaultAsync(t => t.Id == parentId, ct);
|
|
if (parent is null || parent.Status != TaskStatus.Planning)
|
|
throw new InvalidOperationException($"Task {parentId} is not in Planning state.");
|
|
|
|
var listHasAgentTag = parent.List.Tags.Any(t => t.Name == "agent");
|
|
|
|
var drafts = await _context.Tasks
|
|
.Include(t => t.Tags)
|
|
.Where(t => t.ParentTaskId == parentId && t.Status == TaskStatus.Draft)
|
|
.ToListAsync(ct);
|
|
|
|
int count = 0;
|
|
foreach (var draft in drafts)
|
|
{
|
|
var childHasAgentTag = draft.Tags.Any(t => t.Name == "agent");
|
|
var shouldQueue = queueAgentTasks && (childHasAgentTag || listHasAgentTag);
|
|
draft.Status = shouldQueue ? TaskStatus.Queued : TaskStatus.Manual;
|
|
count++;
|
|
}
|
|
|
|
parent.Status = TaskStatus.Planned;
|
|
parent.PlanningFinalizedAt = DateTime.UtcNow;
|
|
parent.PlanningSessionToken = null;
|
|
|
|
await _context.SaveChangesAsync(ct);
|
|
await tx.CommitAsync(ct);
|
|
return count;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run; verify pass**
|
|
|
|
Expected: PASS for all three finalize tests.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs src/ClaudeDo.Data/Repositories/TaskRepository.cs
|
|
git commit -m "feat(data): TaskRepository.FinalizePlanningAsync"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 11: Repository method `DiscardPlanningAsync`
|
|
|
|
**Files:**
|
|
- Modify: `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs`
|
|
- Modify: `src/ClaudeDo.Data/Repositories/TaskRepository.cs`
|
|
|
|
- [ ] **Step 1: Write the failing test**
|
|
|
|
```csharp
|
|
[Fact]
|
|
public async Task DiscardPlanningAsync_DeletesDraftsAndResetsParent()
|
|
{
|
|
var listId = await CreateListAsync();
|
|
var parent = MakeTask(listId, TaskStatus.Manual);
|
|
await _tasks.AddAsync(parent);
|
|
await _tasks.SetPlanningStartedAsync(parent.Id, "tok");
|
|
await _tasks.UpdatePlanningSessionIdAsync(parent.Id, "claude-42");
|
|
var c1 = await _tasks.CreateChildAsync(parent.Id, "c1", null, null, null);
|
|
var c2 = await _tasks.CreateChildAsync(parent.Id, "c2", null, null, null);
|
|
|
|
var ok = await _tasks.DiscardPlanningAsync(parent.Id);
|
|
|
|
Assert.True(ok);
|
|
Assert.Null(await _tasks.GetByIdAsync(c1.Id));
|
|
Assert.Null(await _tasks.GetByIdAsync(c2.Id));
|
|
|
|
var parentLoaded = await _tasks.GetByIdAsync(parent.Id);
|
|
Assert.Equal(TaskStatus.Manual, parentLoaded!.Status);
|
|
Assert.Null(parentLoaded.PlanningSessionId);
|
|
Assert.Null(parentLoaded.PlanningSessionToken);
|
|
Assert.Null(parentLoaded.PlanningFinalizedAt);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task DiscardPlanningAsync_OnNonPlanningTask_ReturnsFalse()
|
|
{
|
|
var listId = await CreateListAsync();
|
|
var task = MakeTask(listId, TaskStatus.Manual);
|
|
await _tasks.AddAsync(task);
|
|
|
|
var ok = await _tasks.DiscardPlanningAsync(task.Id);
|
|
|
|
Assert.False(ok);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run; verify fail**
|
|
|
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskRepositoryPlanningTests.DiscardPlanning"`
|
|
Expected: FAIL.
|
|
|
|
- [ ] **Step 3: Implement**
|
|
|
|
```csharp
|
|
public async Task<bool> DiscardPlanningAsync(
|
|
string parentId,
|
|
CancellationToken ct = default)
|
|
{
|
|
using var tx = await _context.Database.BeginTransactionAsync(ct);
|
|
|
|
var parent = await _context.Tasks.FirstOrDefaultAsync(t => t.Id == parentId, ct);
|
|
if (parent is null || parent.Status != TaskStatus.Planning)
|
|
{
|
|
await tx.RollbackAsync(ct);
|
|
return false;
|
|
}
|
|
|
|
await _context.Tasks
|
|
.Where(t => t.ParentTaskId == parentId && t.Status == TaskStatus.Draft)
|
|
.ExecuteDeleteAsync(ct);
|
|
|
|
parent.Status = TaskStatus.Manual;
|
|
parent.PlanningSessionId = null;
|
|
parent.PlanningSessionToken = null;
|
|
parent.PlanningFinalizedAt = null;
|
|
|
|
await _context.SaveChangesAsync(ct);
|
|
await tx.CommitAsync(ct);
|
|
return true;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run; verify pass**
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs src/ClaudeDo.Data/Repositories/TaskRepository.cs
|
|
git commit -m "feat(data): TaskRepository.DiscardPlanningAsync"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 12: Repository method `TryCompleteParentAsync` (auto-status hook)
|
|
|
|
**Files:**
|
|
- Create: `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryParentCompletionTests.cs`
|
|
- Modify: `src/ClaudeDo.Data/Repositories/TaskRepository.cs`
|
|
|
|
- [ ] **Step 1: Create the test class**
|
|
|
|
Create `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryParentCompletionTests.cs`:
|
|
|
|
```csharp
|
|
using ClaudeDo.Data;
|
|
using ClaudeDo.Data.Models;
|
|
using ClaudeDo.Data.Repositories;
|
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
|
|
|
namespace ClaudeDo.Worker.Tests.Repositories;
|
|
|
|
public sealed class TaskRepositoryParentCompletionTests : IDisposable
|
|
{
|
|
private readonly DbFixture _db = new();
|
|
private readonly ClaudeDoDbContext _ctx;
|
|
private readonly TaskRepository _tasks;
|
|
private readonly ListRepository _lists;
|
|
|
|
public TaskRepositoryParentCompletionTests()
|
|
{
|
|
_ctx = _db.CreateContext();
|
|
_tasks = new TaskRepository(_ctx);
|
|
_lists = new ListRepository(_ctx);
|
|
}
|
|
|
|
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
|
|
|
|
private async Task<string> ListAsync()
|
|
{
|
|
var id = Guid.NewGuid().ToString();
|
|
await _lists.AddAsync(new ListEntity { Id = id, Name = "L", CreatedAt = DateTime.UtcNow });
|
|
return id;
|
|
}
|
|
|
|
private async Task<TaskEntity> PlannedParentAsync(string listId)
|
|
{
|
|
var parent = new TaskEntity
|
|
{
|
|
Id = Guid.NewGuid().ToString(),
|
|
ListId = listId,
|
|
Title = "p",
|
|
Status = TaskStatus.Planned,
|
|
CreatedAt = DateTime.UtcNow,
|
|
CommitType = "chore",
|
|
};
|
|
await _tasks.AddAsync(parent);
|
|
return parent;
|
|
}
|
|
|
|
private async Task<TaskEntity> ChildAsync(string listId, string parentId, TaskStatus status)
|
|
{
|
|
var child = new TaskEntity
|
|
{
|
|
Id = Guid.NewGuid().ToString(),
|
|
ListId = listId,
|
|
Title = "c",
|
|
Status = status,
|
|
CreatedAt = DateTime.UtcNow,
|
|
CommitType = "chore",
|
|
ParentTaskId = parentId,
|
|
};
|
|
await _tasks.AddAsync(child);
|
|
return child;
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Write failing tests**
|
|
|
|
Append inside the class:
|
|
|
|
```csharp
|
|
[Fact]
|
|
public async Task TryCompleteParentAsync_AllChildrenDone_ParentBecomesDone()
|
|
{
|
|
var listId = await ListAsync();
|
|
var parent = await PlannedParentAsync(listId);
|
|
await ChildAsync(listId, parent.Id, TaskStatus.Done);
|
|
await ChildAsync(listId, parent.Id, TaskStatus.Done);
|
|
|
|
await _tasks.TryCompleteParentAsync(parent.Id);
|
|
|
|
var loaded = await _tasks.GetByIdAsync(parent.Id);
|
|
Assert.Equal(TaskStatus.Done, loaded!.Status);
|
|
Assert.NotNull(loaded.FinishedAt);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TryCompleteParentAsync_OneFailedRestDone_ParentBecomesFailed()
|
|
{
|
|
var listId = await ListAsync();
|
|
var parent = await PlannedParentAsync(listId);
|
|
await ChildAsync(listId, parent.Id, TaskStatus.Done);
|
|
await ChildAsync(listId, parent.Id, TaskStatus.Failed);
|
|
|
|
await _tasks.TryCompleteParentAsync(parent.Id);
|
|
|
|
var loaded = await _tasks.GetByIdAsync(parent.Id);
|
|
Assert.Equal(TaskStatus.Failed, loaded!.Status);
|
|
Assert.NotNull(loaded.FinishedAt);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TryCompleteParentAsync_OneStillRunning_ParentStaysPlanned()
|
|
{
|
|
var listId = await ListAsync();
|
|
var parent = await PlannedParentAsync(listId);
|
|
await ChildAsync(listId, parent.Id, TaskStatus.Done);
|
|
await ChildAsync(listId, parent.Id, TaskStatus.Running);
|
|
|
|
await _tasks.TryCompleteParentAsync(parent.Id);
|
|
|
|
var loaded = await _tasks.GetByIdAsync(parent.Id);
|
|
Assert.Equal(TaskStatus.Planned, loaded!.Status);
|
|
Assert.Null(loaded.FinishedAt);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TryCompleteParentAsync_ChildStillDraft_ParentStaysPlanned()
|
|
{
|
|
var listId = await ListAsync();
|
|
var parent = await PlannedParentAsync(listId);
|
|
await ChildAsync(listId, parent.Id, TaskStatus.Done);
|
|
await ChildAsync(listId, parent.Id, TaskStatus.Draft);
|
|
|
|
await _tasks.TryCompleteParentAsync(parent.Id);
|
|
|
|
var loaded = await _tasks.GetByIdAsync(parent.Id);
|
|
Assert.Equal(TaskStatus.Planned, loaded!.Status);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task TryCompleteParentAsync_ParentIsNotPlanned_NoChange()
|
|
{
|
|
var listId = await ListAsync();
|
|
var parent = await PlannedParentAsync(listId);
|
|
await _ctx.Database.ExecuteSqlRawAsync("UPDATE tasks SET status = 'planning' WHERE id = {0}", parent.Id);
|
|
await ChildAsync(listId, parent.Id, TaskStatus.Done);
|
|
|
|
await _tasks.TryCompleteParentAsync(parent.Id);
|
|
|
|
var loaded = await _tasks.GetByIdAsync(parent.Id);
|
|
Assert.Equal(TaskStatus.Planning, loaded!.Status);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Run; verify fail**
|
|
|
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskRepositoryParentCompletionTests"`
|
|
Expected: FAIL.
|
|
|
|
- [ ] **Step 4: Implement**
|
|
|
|
In `#region Planning` of `TaskRepository.cs`:
|
|
|
|
```csharp
|
|
public async Task TryCompleteParentAsync(
|
|
string parentId,
|
|
CancellationToken ct = default)
|
|
{
|
|
var parent = await _context.Tasks.FirstOrDefaultAsync(t => t.Id == parentId, ct);
|
|
if (parent is null || parent.Status != TaskStatus.Planned) return;
|
|
|
|
var children = await _context.Tasks
|
|
.Where(t => t.ParentTaskId == parentId)
|
|
.Select(t => t.Status)
|
|
.ToListAsync(ct);
|
|
|
|
if (children.Count == 0) return;
|
|
|
|
bool allTerminal = children.All(s => s == TaskStatus.Done || s == TaskStatus.Failed);
|
|
if (!allTerminal) return;
|
|
|
|
bool anyFailed = children.Any(s => s == TaskStatus.Failed);
|
|
parent.Status = anyFailed ? TaskStatus.Failed : TaskStatus.Done;
|
|
parent.FinishedAt = DateTime.UtcNow;
|
|
await _context.SaveChangesAsync(ct);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Run; verify pass**
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
git add tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryParentCompletionTests.cs src/ClaudeDo.Data/Repositories/TaskRepository.cs
|
|
git commit -m "feat(data): TaskRepository.TryCompleteParentAsync"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 13: Regression test — queue skips Drafts/Planning/Planned
|
|
|
|
**Files:**
|
|
- Modify: `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs`
|
|
|
|
- [ ] **Step 1: Write the test**
|
|
|
|
Append to planning test class:
|
|
|
|
```csharp
|
|
[Fact]
|
|
public async Task GetNextQueuedAgentTask_SkipsDraftPlanningPlanned()
|
|
{
|
|
var listId = await CreateListAsync();
|
|
var agent = await _tags.GetOrCreateAsync("agent");
|
|
|
|
async Task<TaskEntity> T(TaskStatus s, bool withTag, string? parent = null)
|
|
{
|
|
var t = MakeTask(listId, s, parentId: parent);
|
|
await _tasks.AddAsync(t);
|
|
if (withTag) await _tasks.AddTagAsync(t.Id, agent.Id);
|
|
return t;
|
|
}
|
|
|
|
var planning = await T(TaskStatus.Planning, withTag: true);
|
|
var planned = await T(TaskStatus.Planned, withTag: true);
|
|
var draft = await T(TaskStatus.Draft, withTag: true, parent: planning.Id);
|
|
var queued = await T(TaskStatus.Queued, withTag: true);
|
|
|
|
var picked = await _tasks.GetNextQueuedAgentTaskAsync(DateTime.UtcNow);
|
|
|
|
Assert.NotNull(picked);
|
|
Assert.Equal(queued.Id, picked!.Id);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run**
|
|
|
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskRepositoryPlanningTests.GetNextQueuedAgentTask_SkipsDraftPlanningPlanned"`
|
|
Expected: PASS immediately (the existing query filters on `status = 'queued'`, so Planning/Planned/Draft are already excluded).
|
|
|
|
If this test fails, something is wrong with the queue filter — investigate before continuing.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs
|
|
git commit -m "test(data): queue skips Planning/Planned/Draft"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 14: Regression test — Restrict cascade on parent delete
|
|
|
|
**Files:**
|
|
- Modify: `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs`
|
|
|
|
- [ ] **Step 1: Write the test**
|
|
|
|
```csharp
|
|
[Fact]
|
|
public async Task DeleteAsync_ParentWithChildren_ThrowsOrDoesNotDelete()
|
|
{
|
|
var listId = await CreateListAsync();
|
|
var parent = MakeTask(listId, TaskStatus.Planning);
|
|
await _tasks.AddAsync(parent);
|
|
await _tasks.CreateChildAsync(parent.Id, "c", null, null, null);
|
|
|
|
// ExecuteDelete bypasses the FK check in EF but SQLite enforces it at the DB level
|
|
// when foreign_keys = ON (which ClaudeDoDbContext enables).
|
|
await Assert.ThrowsAsync<Microsoft.EntityFrameworkCore.DbUpdateException>(async () =>
|
|
{
|
|
await _tasks.DeleteAsync(parent.Id);
|
|
});
|
|
|
|
var stillThere = await _tasks.GetByIdAsync(parent.Id);
|
|
Assert.NotNull(stillThere);
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run**
|
|
|
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskRepositoryPlanningTests.DeleteAsync_ParentWithChildren"`
|
|
Expected: PASS.
|
|
|
|
**If this test fails** because `ExecuteDeleteAsync` succeeds: SQLite may not be enforcing FKs in the test environment. Diagnose:
|
|
- Confirm `ClaudeDoDbContext` sets `PRAGMA foreign_keys = ON` on connection open.
|
|
- If not enforced, we need to replace `ExecuteDeleteAsync` in `DeleteAsync` with an EF-tracked delete (`_context.Tasks.Remove(...); SaveChangesAsync()`) so EF enforces Restrict before SQL is sent.
|
|
|
|
If FK enforcement is the issue, add this helper to `ClaudeDoDbContext.OnConfiguring` (if not already there) or verify the existing pragma:
|
|
```csharp
|
|
optionsBuilder.UseSqlite(conn, opts => opts.CommandTimeout(30));
|
|
```
|
|
and ensure the connection string or `UseSqlite` callback issues `PRAGMA foreign_keys = ON`.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryPlanningTests.cs
|
|
# include any DbContext fix if needed
|
|
git commit -m "test(data): parent delete with children is restricted"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 15: Wire `TryCompleteParentAsync` into `TaskRunner`
|
|
|
|
**Files:**
|
|
- Modify: `src/ClaudeDo.Worker/Runner/TaskRunner.cs`
|
|
- Create: `tests/ClaudeDo.Worker.Tests/Runner/TaskRunnerParentCompletionTests.cs` (integration)
|
|
|
|
- [ ] **Step 1: Locate the call-sites**
|
|
|
|
Open `src/ClaudeDo.Worker/Runner/TaskRunner.cs`. Three locations already call `MarkDoneAsync` / `MarkFailedAsync` (line numbers per spec reference: ~333, ~348, ~362).
|
|
|
|
At each location, the variable `task` (or `taskId` in the last two) is in scope. For the one at line ~333 `task` is the full `TaskEntity`; for the two failure paths, only `taskId` — but we can load the parent id from the repo.
|
|
|
|
- [ ] **Step 2: Update the Done-path (around line 333)**
|
|
|
|
Right after:
|
|
```csharp
|
|
await taskRepo.MarkDoneAsync(task.Id, finishedAt, result.ResultMarkdown, CancellationToken.None);
|
|
```
|
|
add:
|
|
```csharp
|
|
if (task.ParentTaskId is not null)
|
|
await taskRepo.TryCompleteParentAsync(task.ParentTaskId, CancellationToken.None);
|
|
```
|
|
|
|
- [ ] **Step 3: Update the two Failed-paths (around lines 348, 362)**
|
|
|
|
For each, right after the `MarkFailedAsync` call, add a block that re-reads the task to get `ParentTaskId` (the scope has `taskId` not the entity):
|
|
|
|
```csharp
|
|
await taskRepo.MarkFailedAsync(taskId, finishedAt, result.ErrorMarkdown, CancellationToken.None);
|
|
var justFailed = await taskRepo.GetByIdAsync(taskId, CancellationToken.None);
|
|
if (justFailed?.ParentTaskId is not null)
|
|
await taskRepo.TryCompleteParentAsync(justFailed.ParentTaskId, CancellationToken.None);
|
|
```
|
|
|
|
Do the same for the second failure path at line ~362.
|
|
|
|
- [ ] **Step 4: Build**
|
|
|
|
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj`
|
|
Expected: builds.
|
|
|
|
- [ ] **Step 5: Write integration test**
|
|
|
|
Create `tests/ClaudeDo.Worker.Tests/Runner/TaskRunnerParentCompletionTests.cs`:
|
|
|
|
The intent is to exercise the full path: child task ends → parent updates. Rather than invoking the full `TaskRunner` (which requires Claude CLI), we test the repository integration by calling `MarkDoneAsync` + `TryCompleteParentAsync` in the same sequence the runner now does.
|
|
|
|
```csharp
|
|
using ClaudeDo.Data;
|
|
using ClaudeDo.Data.Models;
|
|
using ClaudeDo.Data.Repositories;
|
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
|
|
|
namespace ClaudeDo.Worker.Tests.Runner;
|
|
|
|
public sealed class TaskRunnerParentCompletionTests : IDisposable
|
|
{
|
|
private readonly DbFixture _db = new();
|
|
private readonly ClaudeDoDbContext _ctx;
|
|
private readonly TaskRepository _tasks;
|
|
private readonly ListRepository _lists;
|
|
|
|
public TaskRunnerParentCompletionTests()
|
|
{
|
|
_ctx = _db.CreateContext();
|
|
_tasks = new TaskRepository(_ctx);
|
|
_lists = new ListRepository(_ctx);
|
|
}
|
|
|
|
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
|
|
|
|
[Fact]
|
|
public async Task ChildMarkedDone_LastOne_ParentFinalized()
|
|
{
|
|
var listId = Guid.NewGuid().ToString();
|
|
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
|
|
|
|
var parent = new TaskEntity
|
|
{
|
|
Id = Guid.NewGuid().ToString(),
|
|
ListId = listId,
|
|
Title = "p",
|
|
Status = TaskStatus.Planned,
|
|
CreatedAt = DateTime.UtcNow,
|
|
CommitType = "chore",
|
|
};
|
|
await _tasks.AddAsync(parent);
|
|
|
|
var c1 = new TaskEntity
|
|
{
|
|
Id = Guid.NewGuid().ToString(),
|
|
ListId = listId,
|
|
Title = "c1",
|
|
Status = TaskStatus.Done,
|
|
CreatedAt = DateTime.UtcNow,
|
|
CommitType = "chore",
|
|
ParentTaskId = parent.Id,
|
|
};
|
|
var c2 = new TaskEntity
|
|
{
|
|
Id = Guid.NewGuid().ToString(),
|
|
ListId = listId,
|
|
Title = "c2",
|
|
Status = TaskStatus.Running,
|
|
CreatedAt = DateTime.UtcNow,
|
|
CommitType = "chore",
|
|
ParentTaskId = parent.Id,
|
|
};
|
|
await _tasks.AddAsync(c1);
|
|
await _tasks.AddAsync(c2);
|
|
|
|
// Simulate the runner finishing the second child:
|
|
await _tasks.MarkDoneAsync(c2.Id, DateTime.UtcNow, "done");
|
|
if (c2.ParentTaskId is not null)
|
|
await _tasks.TryCompleteParentAsync(c2.ParentTaskId);
|
|
|
|
var parentLoaded = await _tasks.GetByIdAsync(parent.Id);
|
|
Assert.Equal(TaskStatus.Done, parentLoaded!.Status);
|
|
Assert.NotNull(parentLoaded.FinishedAt);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 6: Run; verify pass**
|
|
|
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskRunnerParentCompletionTests"`
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
git add src/ClaudeDo.Worker/Runner/TaskRunner.cs tests/ClaudeDo.Worker.Tests/Runner/TaskRunnerParentCompletionTests.cs
|
|
git commit -m "feat(worker): hook TryCompleteParentAsync after MarkDone/MarkFailed"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 16: Full test run and merge preparation
|
|
|
|
**Files:** none (verification step)
|
|
|
|
- [ ] **Step 1: Run the full test suite**
|
|
|
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests`
|
|
Expected: all tests pass, no regressions.
|
|
|
|
- [ ] **Step 2: Verify build of every project**
|
|
|
|
Run:
|
|
```bash
|
|
dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj
|
|
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
|
|
dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj
|
|
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj
|
|
```
|
|
Expected: all succeed.
|
|
|
|
- [ ] **Step 3: Verify the migration file is present for parallel-plan detection**
|
|
|
|
Run:
|
|
```bash
|
|
ls src/ClaudeDo.Data/Migrations/*AddPlanningSupport*.cs
|
|
```
|
|
Expected: exactly one file matches (the migration + designer file). This is the marker that Plans B and C wait for.
|
|
|
|
- [ ] **Step 4: Summarize what's done**
|
|
|
|
Plan A now delivers:
|
|
- Three new `TaskStatus` values (Planning/Planned/Draft) with persistence.
|
|
- Four new columns on `tasks` (`parent_task_id`, `planning_session_id`, `planning_session_token`, `planning_finalized_at`).
|
|
- Self-ref FK with `DeleteBehavior.Restrict`.
|
|
- Seven new repository methods.
|
|
- Auto-parent-completion hook wired into `TaskRunner`.
|
|
- Regression test that the queue still only picks `Queued`.
|
|
- Regression test that parent delete with children fails.
|
|
|
|
Nothing UI-visible changes yet; users notice only that deleting a task that has children now fails with a DB error (Plan C will fix the UX by adding a confirmation dialog).
|
|
|
|
---
|
|
|
|
## Out of scope for Plan A
|
|
|
|
- Any MCP server, SignalR endpoints, or terminal launcher → Plan B.
|
|
- Any UI rendering of hierarchy, context menu entries, or draft styling → Plan C.
|
|
- Session-folder file management (`~/.todo-app/planning-sessions/`) → Plan B.
|
|
- Resolving unknowns around Claude CLI flags (`--thinking-budget`, `--allowedTools` casing, session-ID capture) → Plan B.
|