feat: planning sessions foundation (Plan A) (#4)

Merges Plan A: schema + repos + auto-parent-completion hook.
This commit was merged in pull request #4.
This commit is contained in:
2026-04-23 16:31:37 +00:00
10 changed files with 890 additions and 0 deletions

View File

@@ -45,6 +45,13 @@ public class ClaudeDoDbContext : DbContext
walCmd.ExecuteNonQuery(); walCmd.ExecuteNonQuery();
} }
// Enable FK enforcement — SQLite defaults to OFF per connection.
using (var fkCmd = conn.CreateCommand())
{
fkCmd.CommandText = "PRAGMA foreign_keys=ON;";
fkCmd.ExecuteNonQuery();
}
// If the 'lists' table exists but __EFMigrationsHistory does not, // If the 'lists' table exists but __EFMigrationsHistory does not,
// this is a pre-EF database. Baseline the InitialCreate migration. // this is a pre-EF database. Baseline the InitialCreate migration.
using (var cmd = conn.CreateCommand()) using (var cmd = conn.CreateCommand())

View File

@@ -14,6 +14,9 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
: v == TaskStatus.Running ? "running" : v == TaskStatus.Running ? "running"
: v == TaskStatus.Done ? "done" : v == TaskStatus.Done ? "done"
: v == TaskStatus.Failed ? "failed" : v == TaskStatus.Failed ? "failed"
: v == TaskStatus.Planning ? "planning"
: v == TaskStatus.Planned ? "planned"
: v == TaskStatus.Draft ? "draft"
: throw new ArgumentOutOfRangeException(nameof(v)); : throw new ArgumentOutOfRangeException(nameof(v));
private static TaskStatus StatusFromString(string v) private static TaskStatus StatusFromString(string v)
@@ -22,6 +25,9 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
: v == "running" ? TaskStatus.Running : v == "running" ? TaskStatus.Running
: v == "done" ? TaskStatus.Done : v == "done" ? TaskStatus.Done
: v == "failed" ? TaskStatus.Failed : v == "failed" ? TaskStatus.Failed
: v == "planning" ? TaskStatus.Planning
: v == "planned" ? TaskStatus.Planned
: v == "draft" ? TaskStatus.Draft
: throw new ArgumentOutOfRangeException(nameof(v)); : throw new ArgumentOutOfRangeException(nameof(v));
private static readonly ValueConverter<TaskStatus, string> StatusConverter = private static readonly ValueConverter<TaskStatus, string> StatusConverter =
@@ -53,6 +59,16 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
builder.Property(t => t.Notes).HasColumnName("notes"); builder.Property(t => t.Notes).HasColumnName("notes");
builder.Property(t => t.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0); 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) builder.HasOne(t => t.List)
.WithMany(l => l.Tasks) .WithMany(l => l.Tasks)
.HasForeignKey(t => t.ListId) .HasForeignKey(t => t.ListId)
@@ -76,5 +92,6 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
builder.HasIndex(t => t.ListId).HasDatabaseName("idx_tasks_list_id"); builder.HasIndex(t => t.ListId).HasDatabaseName("idx_tasks_list_id");
builder.HasIndex(t => t.Status).HasDatabaseName("idx_tasks_status"); builder.HasIndex(t => t.Status).HasDatabaseName("idx_tasks_status");
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");
} }
} }

View File

@@ -0,0 +1,80 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddPlanningSupport : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "parent_task_id",
table: "tasks",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "planning_finalized_at",
table: "tasks",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "planning_session_id",
table: "tasks",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "planning_session_token",
table: "tasks",
type: "TEXT",
nullable: true);
migrationBuilder.CreateIndex(
name: "idx_tasks_parent_task_id",
table: "tasks",
column: "parent_task_id");
migrationBuilder.AddForeignKey(
name: "FK_tasks_tasks_parent_task_id",
table: "tasks",
column: "parent_task_id",
principalTable: "tasks",
principalColumn: "id",
onDelete: ReferentialAction.Restrict);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_tasks_tasks_parent_task_id",
table: "tasks");
migrationBuilder.DropIndex(
name: "idx_tasks_parent_task_id",
table: "tasks");
migrationBuilder.DropColumn(
name: "parent_task_id",
table: "tasks");
migrationBuilder.DropColumn(
name: "planning_finalized_at",
table: "tasks");
migrationBuilder.DropColumn(
name: "planning_session_id",
table: "tasks");
migrationBuilder.DropColumn(
name: "planning_session_token",
table: "tasks");
}
}
}

View File

@@ -273,6 +273,22 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT") .HasColumnType("TEXT")
.HasColumnName("notes"); .HasColumnName("notes");
b.Property<string>("ParentTaskId")
.HasColumnType("TEXT")
.HasColumnName("parent_task_id");
b.Property<DateTime?>("PlanningFinalizedAt")
.HasColumnType("TEXT")
.HasColumnName("planning_finalized_at");
b.Property<string>("PlanningSessionId")
.HasColumnType("TEXT")
.HasColumnName("planning_session_id");
b.Property<string>("PlanningSessionToken")
.HasColumnType("TEXT")
.HasColumnName("planning_session_token");
b.Property<string>("Result") b.Property<string>("Result")
.HasColumnType("TEXT") .HasColumnType("TEXT")
.HasColumnName("result"); .HasColumnName("result");
@@ -310,6 +326,9 @@ namespace ClaudeDo.Data.Migrations
b.HasIndex("ListId") b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id"); .HasDatabaseName("idx_tasks_list_id");
b.HasIndex("ParentTaskId")
.HasDatabaseName("idx_tasks_parent_task_id");
b.HasIndex("Status") b.HasIndex("Status")
.HasDatabaseName("idx_tasks_status"); .HasDatabaseName("idx_tasks_status");
@@ -502,7 +521,14 @@ namespace ClaudeDo.Data.Migrations
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
.WithMany("Children")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("List"); b.Navigation("List");
b.Navigation("Parent");
}); });
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b => modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
@@ -566,6 +592,8 @@ namespace ClaudeDo.Data.Migrations
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b => modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{ {
b.Navigation("Children");
b.Navigation("Runs"); b.Navigation("Runs");
b.Navigation("Subtasks"); b.Navigation("Subtasks");

View File

@@ -7,6 +7,9 @@ public enum TaskStatus
Running, Running,
Done, Done,
Failed, Failed,
Planning,
Planned,
Draft,
} }
public sealed class TaskEntity public sealed class TaskEntity
@@ -31,10 +34,18 @@ public sealed class TaskEntity
public string? Notes { get; set; } public string? Notes { get; set; }
public int SortOrder { get; set; } 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 // Navigation properties
public ListEntity List { get; set; } = null!; public ListEntity List { get; set; } = null!;
public WorktreeEntity? Worktree { get; set; } public WorktreeEntity? Worktree { get; set; }
public ICollection<TagEntity> Tags { get; set; } = new List<TagEntity>(); public ICollection<TagEntity> Tags { get; set; } = new List<TagEntity>();
public ICollection<TaskRunEntity> Runs { get; set; } = new List<TaskRunEntity>(); public ICollection<TaskRunEntity> Runs { get; set; } = new List<TaskRunEntity>();
public ICollection<SubtaskEntity> Subtasks { get; set; } = new List<SubtaskEntity>(); public ICollection<SubtaskEntity> Subtasks { get; set; } = new List<SubtaskEntity>();
public TaskEntity? Parent { get; set; }
public ICollection<TaskEntity> Children { get; set; } = new List<TaskEntity>();
} }

View File

@@ -206,6 +206,206 @@ public sealed class TaskRepository
#endregion #endregion
#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);
}
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;
}
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);
}
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);
}
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);
}
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
.AsNoTracking()
.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++;
}
var finalizedAt = DateTime.UtcNow;
await _context.Tasks
.Where(t => t.Id == parentId)
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.Status, TaskStatus.Planned)
.SetProperty(t => t.PlanningFinalizedAt, finalizedAt)
.SetProperty(t => t.PlanningSessionToken, (string?)null), ct);
await _context.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
return count;
}
public async Task<bool> DiscardPlanningAsync(
string parentId,
CancellationToken ct = default)
{
using var tx = await _context.Database.BeginTransactionAsync(ct);
var parent = await _context.Tasks
.AsNoTracking()
.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);
await _context.Tasks
.Where(t => t.Id == parentId)
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.Status, TaskStatus.Manual)
.SetProperty(t => t.PlanningSessionId, (string?)null)
.SetProperty(t => t.PlanningSessionToken, (string?)null)
.SetProperty(t => t.PlanningFinalizedAt, (DateTime?)null), ct);
await tx.CommitAsync(ct);
return true;
}
public async Task TryCompleteParentAsync(
string parentId,
CancellationToken ct = default)
{
var parent = await _context.Tasks.AsNoTracking().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);
var finalStatus = anyFailed ? TaskStatus.Failed : TaskStatus.Done;
var finishedAt = DateTime.UtcNow;
await _context.Tasks
.Where(t => t.Id == parentId)
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.Status, finalStatus)
.SetProperty(t => t.FinishedAt, finishedAt), ct);
}
#endregion
#region Queue selection #region Queue selection
public async Task<TaskEntity?> GetNextQueuedAgentTaskAsync(DateTime now, CancellationToken ct = default) public async Task<TaskEntity?> GetNextQueuedAgentTaskAsync(DateTime now, CancellationToken ct = default)

View File

@@ -331,6 +331,8 @@ public sealed class TaskRunner
{ {
var taskRepo = new TaskRepository(context); var taskRepo = new TaskRepository(context);
await taskRepo.MarkDoneAsync(task.Id, finishedAt, result.ResultMarkdown, CancellationToken.None); await taskRepo.MarkDoneAsync(task.Id, finishedAt, result.ResultMarkdown, CancellationToken.None);
if (task.ParentTaskId is not null)
await taskRepo.TryCompleteParentAsync(task.ParentTaskId, CancellationToken.None);
} }
await _broadcaster.WorkerLog($"Finished \"{task.Title}\" (done)", WorkerLogLevel.Success, DateTime.UtcNow); await _broadcaster.WorkerLog($"Finished \"{task.Title}\" (done)", WorkerLogLevel.Success, DateTime.UtcNow);
await _broadcaster.TaskFinished(slot, task.Id, "done", finishedAt); await _broadcaster.TaskFinished(slot, task.Id, "done", finishedAt);
@@ -346,6 +348,9 @@ public sealed class TaskRunner
using var context = _dbFactory.CreateDbContext(); using var context = _dbFactory.CreateDbContext();
var taskRepo = new TaskRepository(context); var taskRepo = new TaskRepository(context);
await taskRepo.MarkFailedAsync(taskId, finishedAt, result.ErrorMarkdown, CancellationToken.None); 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);
await _broadcaster.WorkerLog($"Finished \"{taskTitle}\" (failed)", WorkerLogLevel.Error, DateTime.UtcNow); await _broadcaster.WorkerLog($"Finished \"{taskTitle}\" (failed)", WorkerLogLevel.Error, DateTime.UtcNow);
await _broadcaster.TaskFinished(slot, taskId, "failed", finishedAt); await _broadcaster.TaskFinished(slot, taskId, "failed", finishedAt);
_logger.LogWarning("Task {TaskId} failed (turns={Turns}): {Error}", taskId, result.TurnCount, result.ErrorMarkdown); _logger.LogWarning("Task {TaskId} failed (turns={Turns}): {Error}", taskId, result.TurnCount, result.ErrorMarkdown);
@@ -360,6 +365,9 @@ public sealed class TaskRunner
using var context = _dbFactory.CreateDbContext(); using var context = _dbFactory.CreateDbContext();
var taskRepo = new TaskRepository(context); var taskRepo = new TaskRepository(context);
await taskRepo.MarkFailedAsync(taskId, now, error, CancellationToken.None); await taskRepo.MarkFailedAsync(taskId, now, error, CancellationToken.None);
var justFailed = await taskRepo.GetByIdAsync(taskId, CancellationToken.None);
if (justFailed?.ParentTaskId is not null)
await taskRepo.TryCompleteParentAsync(justFailed.ParentTaskId, CancellationToken.None);
await _broadcaster.WorkerLog($"Finished \"{taskTitle}\" (failed)", WorkerLogLevel.Error, DateTime.UtcNow); await _broadcaster.WorkerLog($"Finished \"{taskTitle}\" (failed)", WorkerLogLevel.Error, DateTime.UtcNow);
await _broadcaster.TaskFinished(slot, taskId, "failed", now); await _broadcaster.TaskFinished(slot, taskId, "failed", now);
await _broadcaster.TaskUpdated(taskId); await _broadcaster.TaskUpdated(taskId);

View File

@@ -0,0 +1,136 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.EntityFrameworkCore;
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;
}
[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);
}
}

View File

@@ -0,0 +1,329 @@
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,
};
[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";
await _tasks.AddAsync(childA);
childA.SortOrder = 1;
await _tasks.UpdateAsync(childA);
var childB = MakeTask(listId, TaskStatus.Draft, parentId: parent.Id);
childB.Title = "b";
await _tasks.AddAsync(childB);
childB.SortOrder = 0;
await _tasks.UpdateAsync(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);
}
[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));
}
[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);
}
[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);
}
[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);
}
[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 agentTagId = await _tags.GetOrCreateAsync("agent");
await _lists.AddTagAsync(listId, agentTagId);
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);
}
[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);
}
[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 EF change tracking, so SQLite's FK enforcement
// (foreign_keys = ON, set by ClaudeDoDbContext) throws SqliteException directly.
await Assert.ThrowsAsync<Microsoft.Data.Sqlite.SqliteException>(async () =>
{
await _tasks.DeleteAsync(parent.Id);
});
var stillThere = await _tasks.GetByIdAsync(parent.Id);
Assert.NotNull(stillThere);
}
[Fact]
public async Task GetNextQueuedAgentTask_SkipsDraftPlanningPlanned()
{
var listId = await CreateListAsync();
var agentTagId = 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, agentTagId);
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);
}
}

View File

@@ -0,0 +1,74 @@
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);
}
}