86 KiB
Reusable Child Tasks + Agent Improvement Loop — 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: Let an executing task agent file out-of-scope work as child tasks (via a narrow SuggestImprovement MCP tool) that auto-run, then surface the parent for review once all children finish — generalizing the planning parent/child machinery into a reusable subsystem.
Architecture: Add a WaitingForChildren status (enum-only, no DB column). A standalone task whose run succeeds with ≥1 child goes WaitingForChildren, auto-queues its children (each worktree based off the parent's HEAD), and advances to WaitingForReview when all children are terminal. A per-run MCP token (mirroring planning's per-session token) lets the server stamp the caller's id onto suggested children. The planning merge orchestrator is generalized to fold the parent's own branch ahead of the children.
Tech Stack: .NET 8, EF Core (SQLite), ASP.NET Core, ModelContextProtocol.AspNetCore, Avalonia (UI phase), xUnit.
Build/test reminder: build individual csproj with -c Release (.slnx needs .NET 9; a running Worker locks Debug). Stage files by explicit path — never git add -A. Use the sonnet model for subagents.
Coordination note: The UI phase (Phase G) is BLOCKED until the claudedo-ux session lands its changes on main (review actions move to the detail panel, a ⚠ roadblock badge is added to TaskRowView, and a RoadblockCount int column + ONE migration are added to TaskEntity). Do NOT add a competing migration — WaitingForChildren is enum-only. Phases A–F + the prompt task are independent of that work and can proceed immediately. Rebase onto updated main before starting Phase G.
File Structure
Created:
src/ClaudeDo.Worker/Runner/TaskRunTokenRegistry.cs— in-memory token→taskId map (singleton), the per-run MCP identity store.src/ClaudeDo.Worker/Runner/TaskRunMcpContext.cs— request-scoped caller context +TaskRunMcpContextAccessor(small single-consumer types live together).src/ClaudeDo.Worker/Runner/TaskRunMcpService.cs— theSuggestImprovementMCP tool.tests/ClaudeDo.Data.Tests/CreateChildTests.cs,tests/ClaudeDo.Worker.Tests/WaitingForChildrenLifecycleTests.cs,tests/ClaudeDo.Worker.Tests/SuggestImprovementTests.cs,tests/ClaudeDo.Worker.Tests/ChildWorktreeBaseTests.cs,tests/ClaudeDo.Worker.Tests/TreeMergeTests.cs— new test files.
Modified:
src/ClaudeDo.Data/Models/TaskEntity.cs— addWaitingForChildrenenum value.src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs— converter arms.src/ClaudeDo.Data/Repositories/TaskRepository.cs— generalizeCreateChildAsync; removeTryCompleteParentAsync(logic moves toTaskStateService).src/ClaudeDo.Worker/State/ITaskStateService.cs+TaskStateService.cs—SubmitForChildrenAsync; generalized terminal-child parent advancement.src/ClaudeDo.Worker/Runner/TaskRunner.cs— route toWaitingForChildren+ enqueue children; mint per-run token + write--mcp-config.src/ClaudeDo.Worker/Runner/WorktreeManager.cs— base improvement-child worktree on parent HEAD.src/ClaudeDo.Worker/Runner/ClaudeArgsBuilder.cs—--mcp-config+--allowedToolsflags.src/ClaudeDo.Worker/Planning/PlanningTokenAuth.cs— resolve task-run tokens too.src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs+PlanningAggregator.cs— fold the parent's own branch; relax child validation to skip non-mergeable children.src/ClaudeDo.Worker/Program.cs— DI for the registry/accessor/service +.WithTools<TaskRunMcpService>().src/ClaudeDo.Data/PromptFiles.cs—## Out-of-scope improvementssection inSystemDefault.- UI (Phase G):
StatusColorConverter, task tree grouping, parent review card.
Phase A — Data layer (no migration)
Task 1: Add WaitingForChildren status value
Files:
-
Modify:
src/ClaudeDo.Data/Models/TaskEntity.cs:3-12 -
Modify:
src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs:11-38 -
Test:
tests/ClaudeDo.Data.Tests/CreateChildTests.cs(round-trip test added here; file reused by Task 2) -
Step 1: Write the failing test
Read an existing sibling test in tests/ClaudeDo.Data.Tests/ first to copy the SQLite fixture pattern (temp file DB + MigrateAndConfigure). Then create tests/ClaudeDo.Data.Tests/CreateChildTests.cs:
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
using Xunit;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Data.Tests;
public class CreateChildTests
{
private static ClaudeDoDbContext NewDb()
{
var opts = new DbContextOptionsBuilder<ClaudeDoDbContext>()
.UseSqlite($"Data Source=file:{Guid.NewGuid():N}?mode=memory&cache=shared")
.Options;
var ctx = new ClaudeDoDbContext(opts);
ctx.Database.OpenConnection();
ctx.Database.EnsureCreated();
return ctx;
}
[Fact]
public async Task WaitingForChildren_roundtrips_through_value_converter()
{
using var ctx = NewDb();
var list = new ListEntity { Id = "l1", Name = "L", CreatedAt = DateTime.UtcNow };
ctx.Lists.Add(list);
ctx.Tasks.Add(new TaskEntity { Id = "t1", ListId = "l1", Title = "T",
Status = TaskStatus.WaitingForChildren, CreatedAt = DateTime.UtcNow });
await ctx.SaveChangesAsync();
ctx.ChangeTracker.Clear();
var reloaded = await ctx.Tasks.SingleAsync(t => t.Id == "t1");
Assert.Equal(TaskStatus.WaitingForChildren, reloaded.Status);
}
}
If
EnsureCreated()doesn't match the project's fixture convention, copy the exact setup from a sibling test instead (e.g. temp-file DB +ClaudeDoDbContext.MigrateAndConfigure). The assertion is what matters.
- Step 2: Run test to verify it fails
Run: dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release --filter WaitingForChildren_roundtrips_through_value_converter
Expected: FAIL — WaitingForChildren is not a member of TaskStatus (compile error).
- Step 3: Add the enum value
In src/ClaudeDo.Data/Models/TaskEntity.cs, add WaitingForChildren to the enum after WaitingForReview:
public enum TaskStatus
{
Idle,
Queued,
Running,
WaitingForReview,
WaitingForChildren,
Done,
Failed,
Cancelled,
}
- Step 4: Add converter arms
In src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs, add the arm to BOTH switch expressions:
// in StatusToString:
TaskStatus.WaitingForChildren => "waiting_for_children",
// in StatusFromString:
"waiting_for_children" => TaskStatus.WaitingForChildren,
- Step 5: Run test to verify it passes
Run: dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release --filter WaitingForChildren_roundtrips_through_value_converter
Expected: PASS.
- Step 6: Commit
git add src/ClaudeDo.Data/Models/TaskEntity.cs src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs tests/ClaudeDo.Data.Tests/CreateChildTests.cs
git commit -m "feat(status): add WaitingForChildren task status value"
Task 2: Generalize TaskRepository.CreateChildAsync
Drop the planning-phase guard and let the caller stamp CreatedBy. Planning callers pass createdBy: null (unchanged behavior); the improvement tool passes the caller's task id.
Files:
-
Modify:
src/ClaudeDo.Data/Repositories/TaskRepository.cs:187-224 -
Modify:
src/ClaudeDo.Worker/Planning/PlanningMcpService.cs:48(caller passescreatedBy: null) -
Test:
tests/ClaudeDo.Data.Tests/CreateChildTests.cs(add cases) -
Step 1: Write the failing tests
Add to tests/ClaudeDo.Data.Tests/CreateChildTests.cs:
[Fact]
public async Task CreateChild_attaches_to_non_planning_parent_and_stamps_createdBy()
{
using var ctx = NewDb();
ctx.Lists.Add(new ListEntity { Id = "l1", Name = "L", CreatedAt = DateTime.UtcNow });
ctx.Tasks.Add(new TaskEntity { Id = "p1", ListId = "l1", Title = "Parent",
Status = TaskStatus.Running, PlanningPhase = PlanningPhase.None, CreatedAt = DateTime.UtcNow });
await ctx.SaveChangesAsync();
var repo = new ClaudeDo.Data.Repositories.TaskRepository(ctx);
var child = await repo.CreateChildAsync("p1", "Refactor X", "desc", commitType: null, createdBy: "p1");
Assert.Equal("p1", child.ParentTaskId);
Assert.Equal("p1", child.CreatedBy);
Assert.Equal(TaskStatus.Idle, child.Status);
Assert.Equal("l1", child.ListId);
}
[Fact]
public async Task CreateChild_throws_when_parent_missing()
{
using var ctx = NewDb();
var repo = new ClaudeDo.Data.Repositories.TaskRepository(ctx);
await Assert.ThrowsAsync<InvalidOperationException>(
() => repo.CreateChildAsync("nope", "T", null, null, null));
}
- Step 2: Run to verify failure
Run: dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release --filter CreateChildTests
Expected: FAIL — CreateChildAsync has no createdBy parameter (compile error).
- Step 3: Generalize the method
Replace CreateChildAsync in src/ClaudeDo.Data/Repositories/TaskRepository.cs (lines 187-224) with:
public async Task<TaskEntity> CreateChildAsync(
string parentId,
string title,
string? description,
string? commitType,
string? createdBy = null,
CancellationToken ct = default)
{
var parent = await _context.Tasks.AsNoTracking()
.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.Idle,
CreatedAt = DateTime.UtcNow,
CommitType = string.IsNullOrEmpty(commitType) ? parent.CommitType : commitType,
ParentTaskId = parentId,
CreatedBy = createdBy,
SortOrder = (maxSort ?? -1) + 1,
};
_context.Tasks.Add(child);
await _context.SaveChangesAsync(ct);
return child;
}
(The parent.PlanningPhase == PlanningPhase.None guard is removed.)
- Step 4: Fix the planning caller
In src/ClaudeDo.Worker/Planning/PlanningMcpService.cs:48, update the call to pass createdBy: null:
var child = await _tasks.CreateChildAsync(ctx.ParentTaskId, title, description, commitType, createdBy: null, cancellationToken);
- Step 5: Run to verify pass + build Worker
Run: dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release --filter CreateChildTests
Expected: PASS.
Run: dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
Expected: Build succeeded (the caller change compiles).
- Step 6: Commit
git add src/ClaudeDo.Data/Repositories/TaskRepository.cs src/ClaudeDo.Worker/Planning/PlanningMcpService.cs tests/ClaudeDo.Data.Tests/CreateChildTests.cs
git commit -m "feat(children): generalize CreateChildAsync for any parent + CreatedBy stamp"
Phase B — State transitions & generalized completion
Scoping decision (deliberate): planning-parent completion stays in TaskRepository.TryCompleteParentAsync (already covered by TaskRepositoryParentCompletionTests, only acts on PlanningPhase == Finalized). The NEW improvement-parent transition (WaitingForChildren → WaitingForReview) is written by TaskStateService (the sole writer of that transition). OnChildTerminalAsync dispatches by context: planning → TryCompleteParentAsync; improvement → TryAdvanceImprovementParentAsync. This is the "per-context completion policy" with minimal regression risk.
Task 3: SubmitForChildrenAsync transition (Running → WaitingForChildren)
Files:
-
Modify:
src/ClaudeDo.Worker/State/Interfaces/ITaskStateService.cs -
Modify:
src/ClaudeDo.Worker/State/TaskStateService.cs(add method afterSubmitForReviewAsync, ~line 108) -
Test:
tests/ClaudeDo.Worker.Tests/WaitingForChildrenLifecycleTests.cs(new) -
Step 1: Write the failing test
Create tests/ClaudeDo.Worker.Tests/WaitingForChildrenLifecycleTests.cs:
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Tests.Infrastructure;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
using Xunit;
namespace ClaudeDo.Worker.Tests;
public sealed class WaitingForChildrenLifecycleTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly TestDbContextFactory _factory;
private readonly TaskStateServiceBuilder.Built _built;
public WaitingForChildrenLifecycleTests()
{
_factory = _db.CreateFactory();
_built = TaskStateServiceBuilder.Build(_factory);
}
public void Dispose() => _db.Dispose();
private async Task<string> SeedRunningStandaloneAsync()
{
using var ctx = _db.CreateContext();
ctx.Lists.Add(new ListEntity { Id = "l1", Name = "L", CreatedAt = DateTime.UtcNow });
ctx.Tasks.Add(new TaskEntity { Id = "p1", ListId = "l1", Title = "Parent",
Status = TaskStatus.Running, CreatedAt = DateTime.UtcNow });
await ctx.SaveChangesAsync();
return "p1";
}
[Fact]
public async Task SubmitForChildren_moves_running_task_to_WaitingForChildren()
{
var id = await SeedRunningStandaloneAsync();
var result = await _built.State.SubmitForChildrenAsync(id, DateTime.UtcNow, "ran ok", default);
Assert.True(result.Ok);
using var ctx = _db.CreateContext();
var t = await new TaskRepository(ctx).GetByIdAsync(id);
Assert.Equal(TaskStatus.WaitingForChildren, t!.Status);
Assert.Equal("ran ok", t.Result);
Assert.NotNull(t.FinishedAt);
}
[Fact]
public async Task SubmitForChildren_rejects_when_not_running()
{
var id = await SeedRunningStandaloneAsync();
await _built.State.SubmitForChildrenAsync(id, DateTime.UtcNow, null, default);
var second = await _built.State.SubmitForChildrenAsync(id, DateTime.UtcNow, null, default);
Assert.False(second.Ok);
}
}
- Step 2: Run to verify failure
Run: dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter WaitingForChildrenLifecycleTests
Expected: FAIL — SubmitForChildrenAsync does not exist (compile error).
- Step 3: Add interface member
In src/ClaudeDo.Worker/State/Interfaces/ITaskStateService.cs, add next to SubmitForReviewAsync:
Task<TransitionResult> SubmitForChildrenAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct);
- Step 4: Implement in TaskStateService
In src/ClaudeDo.Worker/State/TaskStateService.cs, add after SubmitForReviewAsync (after line 108):
public async Task<TransitionResult> SubmitForChildrenAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct)
{
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var affected = await ctx.Tasks
.Where(t => t.Id == taskId && t.Status == TaskStatus.Running)
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.Status, TaskStatus.WaitingForChildren)
.SetProperty(t => t.FinishedAt, finishedAt)
.SetProperty(t => t.Result, result), ct);
if (affected == 0)
return new TransitionResult(false, "Task not running; cannot submit for children.");
await _broadcaster.TaskUpdated(taskId);
return new TransitionResult(true, null);
}
- Step 5: Run to verify pass
Run: dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter WaitingForChildrenLifecycleTests
Expected: PASS (both cases).
Note: adding a member to
ITaskStateServicemay break hand-rolled fakes that implement it. Search: ITaskStateServiceacross both test projects; add the new method to any fake (returnnew TransitionResult(true, null)). Build both test projects to confirm.
- Step 6: Commit
git add src/ClaudeDo.Worker/State/Interfaces/ITaskStateService.cs src/ClaudeDo.Worker/State/TaskStateService.cs tests/ClaudeDo.Worker.Tests/WaitingForChildrenLifecycleTests.cs
git commit -m "feat(state): add SubmitForChildrenAsync (Running -> WaitingForChildren)"
Task 4: Improvement-parent advancement on child-terminal
When a child reaches a terminal state, if the parent is WaitingForChildren and ALL children are terminal (Done/Failed/Cancelled), advance the parent to WaitingForReview, flagging any failed/cancelled children in Result.
Files:
-
Modify:
src/ClaudeDo.Worker/State/TaskStateService.cs(OnChildTerminalAsync~line 345; add privateTryAdvanceImprovementParentAsync) -
Test:
tests/ClaudeDo.Worker.Tests/WaitingForChildrenLifecycleTests.cs(add cases) -
Step 1: Write the failing tests
Add to WaitingForChildrenLifecycleTests:
private async Task SeedParentWithChildrenAsync(string parentStatus, params TaskStatus[] childStatuses)
{
using var ctx = _db.CreateContext();
if (!ctx.Lists.Any())
ctx.Lists.Add(new ListEntity { Id = "l1", Name = "L", CreatedAt = DateTime.UtcNow });
ctx.Tasks.Add(new TaskEntity { Id = "par", ListId = "l1", Title = "Parent",
Status = Enum.Parse<TaskStatus>(parentStatus), Result = "parent ran",
CreatedAt = DateTime.UtcNow });
int i = 0;
foreach (var cs in childStatuses)
ctx.Tasks.Add(new TaskEntity { Id = $"c{i++}", ListId = "l1", Title = "Child",
Status = cs, ParentTaskId = "par", CreatedAt = DateTime.UtcNow });
await ctx.SaveChangesAsync();
}
[Fact]
public async Task LastChildDone_advances_WaitingForChildren_parent_to_WaitingForReview()
{
await SeedParentWithChildrenAsync("WaitingForChildren", TaskStatus.Done, TaskStatus.Running);
await _built.State.CompleteAsync("c1", DateTime.UtcNow, "child ok", default);
using var ctx = _db.CreateContext();
var parent = await new TaskRepository(ctx).GetByIdAsync("par");
Assert.Equal(TaskStatus.WaitingForReview, parent!.Status);
}
[Fact]
public async Task NonLastChild_leaves_parent_in_WaitingForChildren()
{
await SeedParentWithChildrenAsync("WaitingForChildren", TaskStatus.Running, TaskStatus.Idle);
await _built.State.CompleteAsync("c0", DateTime.UtcNow, "ok", default);
using var ctx = _db.CreateContext();
var parent = await new TaskRepository(ctx).GetByIdAsync("par");
Assert.Equal(TaskStatus.WaitingForChildren, parent!.Status);
}
[Fact]
public async Task FailedChild_still_advances_parent_and_flags_failure()
{
await SeedParentWithChildrenAsync("WaitingForChildren", TaskStatus.Done, TaskStatus.Running);
await _built.State.FailAsync("c1", DateTime.UtcNow, "boom", default);
using var ctx = _db.CreateContext();
var parent = await new TaskRepository(ctx).GetByIdAsync("par");
Assert.Equal(TaskStatus.WaitingForReview, parent!.Status);
Assert.Contains("1 failed", parent.Result!);
}
- Step 2: Run to verify failure
Run: dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter WaitingForChildrenLifecycleTests
Expected: FAIL — parent stays WaitingForChildren.
- Step 3: Wire dispatch in
OnChildTerminalAsync
In src/ClaudeDo.Worker/State/TaskStateService.cs, inside OnChildTerminalAsync, AFTER the existing TryCompleteParentAsync try/catch (after line 377), add:
try
{
await TryAdvanceImprovementParentAsync(parentId);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Improvement-parent advance failed for {ParentId}", parentId);
}
- Step 4: Implement
TryAdvanceImprovementParentAsync
Add this private method at the end of the class (before the final closing brace):
// Improvement parents sit in WaitingForChildren while their suggested children run.
// Once every child is terminal (Done/Failed/Cancelled) the parent surfaces for review;
// a failed or cancelled child does not wedge the parent — it is flagged on the result.
private async Task TryAdvanceImprovementParentAsync(string parentId)
{
string? parentResult;
List<TaskStatus> childStatuses;
await using (var ctx = await _dbFactory.CreateDbContextAsync(CancellationToken.None))
{
var parent = await ctx.Tasks.AsNoTracking()
.FirstOrDefaultAsync(t => t.Id == parentId, CancellationToken.None);
if (parent is null || parent.Status != TaskStatus.WaitingForChildren) return;
parentResult = parent.Result;
childStatuses = await ctx.Tasks
.Where(t => t.ParentTaskId == parentId)
.Select(t => t.Status)
.ToListAsync(CancellationToken.None);
}
if (childStatuses.Count == 0) return;
bool allTerminal = childStatuses.All(s =>
s == TaskStatus.Done || s == TaskStatus.Failed || s == TaskStatus.Cancelled);
if (!allTerminal) return;
int failed = childStatuses.Count(s => s == TaskStatus.Failed);
int cancelled = childStatuses.Count(s => s == TaskStatus.Cancelled);
var newResult = parentResult;
if (failed + cancelled > 0)
{
var note = $"⚠ Children: {failed} failed, {cancelled} cancelled.";
newResult = string.IsNullOrWhiteSpace(parentResult) ? note : $"{parentResult}\n\n{note}";
}
await using var writeCtx = await _dbFactory.CreateDbContextAsync(CancellationToken.None);
await writeCtx.Tasks
.Where(t => t.Id == parentId && t.Status == TaskStatus.WaitingForChildren)
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.Status, TaskStatus.WaitingForReview)
.SetProperty(t => t.Result, newResult), CancellationToken.None);
await _broadcaster.TaskUpdated(parentId);
}
- Step 5: Run to verify pass + planning regression
Run: dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "WaitingForChildrenLifecycleTests|TaskRepositoryParentCompletionTests|PlanningChainCoordinatorTests|PlanningEndToEndTests"
Expected: PASS — improvement cases pass AND planning completion unaffected.
- Step 6: Commit
git add src/ClaudeDo.Worker/State/TaskStateService.cs tests/ClaudeDo.Worker.Tests/WaitingForChildrenLifecycleTests.cs
git commit -m "feat(state): advance WaitingForChildren parent to review when children terminal"
Phase C — Runner routing & child auto-queue
Task 5: Fix the draft-child guard for improvement children
IsDraftChildAsync currently treats ANY child whose parent isn't Finalized as a non-runnable "draft". Improvement children have a parent with PlanningPhase == None, so they'd be wrongly rejected by EnqueueAsync/StartRunningAsync. A child is only a draft while its parent's planning session is still open (Active).
Files:
-
Modify:
src/ClaudeDo.Worker/State/TaskStateService.cs:332-343(IsDraftChildAsync) -
Test:
tests/ClaudeDo.Worker.Tests/WaitingForChildrenLifecycleTests.cs(add cases) -
Step 1: Write the failing tests
Add to WaitingForChildrenLifecycleTests:
private async Task SeedParentChildPairAsync(PlanningPhase parentPhase)
{
using var ctx = _db.CreateContext();
if (!ctx.Lists.Any())
ctx.Lists.Add(new ListEntity { Id = "l1", Name = "L", CreatedAt = DateTime.UtcNow });
ctx.Tasks.Add(new TaskEntity { Id = "par", ListId = "l1", Title = "Parent",
Status = TaskStatus.WaitingForChildren, PlanningPhase = parentPhase, CreatedAt = DateTime.UtcNow });
ctx.Tasks.Add(new TaskEntity { Id = "kid", ListId = "l1", Title = "Child",
Status = TaskStatus.Idle, ParentTaskId = "par", CreatedAt = DateTime.UtcNow });
await ctx.SaveChangesAsync();
}
[Fact]
public async Task ImprovementChild_can_be_enqueued()
{
await SeedParentChildPairAsync(PlanningPhase.None);
var result = await _built.State.EnqueueAsync("kid", default);
Assert.True(result.Ok);
using var ctx = _db.CreateContext();
var kid = await new TaskRepository(ctx).GetByIdAsync("kid");
Assert.Equal(TaskStatus.Queued, kid!.Status);
}
[Fact]
public async Task PlanningDraftChild_cannot_be_enqueued()
{
await SeedParentChildPairAsync(PlanningPhase.Active);
var result = await _built.State.EnqueueAsync("kid", default);
Assert.False(result.Ok);
}
- Step 2: Run to verify failure
Run: dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "ImprovementChild_can_be_enqueued|PlanningDraftChild_cannot_be_enqueued"
Expected: FAIL — ImprovementChild_can_be_enqueued fails (guard rejects it as draft).
- Step 3: Fix the guard
In src/ClaudeDo.Worker/State/TaskStateService.cs, replace the body of IsDraftChildAsync (lines 332-343) with:
// A subtask is "draft" only while its parent's planning session is still OPEN
// (PlanningPhase.Active) — those children must not run until the plan is finalized.
// Finalized-planning children and improvement children (parent PlanningPhase.None)
// are runnable. Standalone tasks (no parent) are never draft.
private static async Task<bool> IsDraftChildAsync(ClaudeDoDbContext ctx, string taskId, CancellationToken ct)
{
var parentId = await ctx.Tasks.AsNoTracking()
.Where(t => t.Id == taskId)
.Select(t => t.ParentTaskId)
.FirstOrDefaultAsync(ct);
if (parentId is null) return false;
return await ctx.Tasks.AsNoTracking()
.AnyAsync(p => p.Id == parentId && p.PlanningPhase == PlanningPhase.Active, ct);
}
- Step 4: Run to verify pass + planning regression
Run: dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "WaitingForChildrenLifecycleTests|PlanningEndToEndTests|PlanningMcpServiceTests"
Expected: PASS — improvement children enqueue; planning drafts still blocked.
- Step 5: Commit
git add src/ClaudeDo.Worker/State/TaskStateService.cs tests/ClaudeDo.Worker.Tests/WaitingForChildrenLifecycleTests.cs
git commit -m "fix(state): only planning-active children are drafts; allow improvement children to queue"
Task 6: Route a standalone success with children to WaitingForChildren and enqueue them
Files:
-
Modify:
src/ClaudeDo.Worker/Runner/TaskRunner.cs(HandleSuccess, lines 319-353) -
Test:
tests/ClaudeDo.Worker.Tests/Runner/StandaloneChildrenRoutingTests.cs(new) -
Step 1: Write the failing test
Create tests/ClaudeDo.Worker.Tests/Runner/StandaloneChildrenRoutingTests.cs. Mirror the CreateService harness from Services/QueueServiceTests.cs (FakeClaudeProcess + real WorktreeManager + TaskStateServiceBuilder). Use a list with WorkingDir = null so the run uses a sandbox dir (no git needed):
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.Extensions.Logging.Abstractions;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
using Xunit;
namespace ClaudeDo.Worker.Tests.Runner;
public sealed class StandaloneChildrenRoutingTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly WorkerConfig _cfg;
private readonly string _tempDir;
public StandaloneChildrenRoutingTests()
{
_tempDir = Path.Combine(Path.GetTempPath(), $"cd_routing_{Guid.NewGuid():N}");
Directory.CreateDirectory(_tempDir);
_cfg = new WorkerConfig { SandboxRoot = _tempDir, LogRoot = _tempDir };
}
public void Dispose() { _db.Dispose(); try { Directory.Delete(_tempDir, true); } catch { } }
[Fact]
public async Task StandaloneSuccess_withChild_goesWaitingForChildren_andEnqueuesChild()
{
var dbFactory = _db.CreateFactory();
using (var ctx = _db.CreateContext())
{
ctx.Lists.Add(new ListEntity { Id = "l1", Name = "L", WorkingDir = null, CreatedAt = DateTime.UtcNow });
ctx.Tasks.Add(new TaskEntity { Id = "p1", ListId = "l1", Title = "Parent",
Status = TaskStatus.Running, CreatedAt = DateTime.UtcNow });
ctx.Tasks.Add(new TaskEntity { Id = "kid", ListId = "l1", Title = "Improve",
Status = TaskStatus.Idle, ParentTaskId = "p1", CreatedBy = "p1", CreatedAt = DateTime.UtcNow });
await ctx.SaveChangesAsync();
}
var fake = new FakeClaudeProcess((_, _, _, _, _) =>
Task.FromResult(new RunResult { ExitCode = 0, ResultMarkdown = "done" }));
var broadcaster = new HubBroadcaster(new FakeHubContext());
var state = TaskStateServiceBuilder.Build(dbFactory).State;
var wt = new WorktreeManager(new GitService(), dbFactory, _cfg, NullLogger<WorktreeManager>.Instance);
var runner = new TaskRunner(fake, dbFactory, broadcaster, wt, new ClaudeArgsBuilder(), _cfg,
NullLogger<TaskRunner>.Instance, state);
using (var ctx = _db.CreateContext())
{
var task = await new TaskRepository(ctx).GetByIdAsync("p1");
await runner.RunAsync(task!, "slot-1", default);
}
using var verify = _db.CreateContext();
var repo = new TaskRepository(verify);
Assert.Equal(TaskStatus.WaitingForChildren, (await repo.GetByIdAsync("p1"))!.Status);
Assert.Equal(TaskStatus.Queued, (await repo.GetByIdAsync("kid"))!.Status);
}
[Fact]
public async Task StandaloneSuccess_noChildren_goesWaitingForReview()
{
var dbFactory = _db.CreateFactory();
using (var ctx = _db.CreateContext())
{
ctx.Lists.Add(new ListEntity { Id = "l1", Name = "L", WorkingDir = null, CreatedAt = DateTime.UtcNow });
ctx.Tasks.Add(new TaskEntity { Id = "solo", ListId = "l1", Title = "Solo",
Status = TaskStatus.Running, CreatedAt = DateTime.UtcNow });
await ctx.SaveChangesAsync();
}
var fake = new FakeClaudeProcess((_, _, _, _, _) =>
Task.FromResult(new RunResult { ExitCode = 0, ResultMarkdown = "done" }));
var state = TaskStateServiceBuilder.Build(dbFactory).State;
var wt = new WorktreeManager(new GitService(), dbFactory, _cfg, NullLogger<WorktreeManager>.Instance);
var runner = new TaskRunner(fake, dbFactory, new HubBroadcaster(new FakeHubContext()), wt,
new ClaudeArgsBuilder(), _cfg, NullLogger<TaskRunner>.Instance, state);
using (var ctx = _db.CreateContext())
await runner.RunAsync((await new TaskRepository(ctx).GetByIdAsync("solo"))!, "slot-1", default);
using var verify = _db.CreateContext();
Assert.Equal(TaskStatus.WaitingForReview, (await new TaskRepository(verify).GetByIdAsync("solo"))!.Status);
}
}
FakeClaudeProcessis declaredinternalinServices/QueueServiceTests.cs. If it isn't accessible, move it totests/ClaudeDo.Worker.Tests/Infrastructure/FakeClaudeProcess.cs(sameinternalmodifier, namespaceClaudeDo.Worker.Tests.Infrastructure) and add theusing— do that as the first step and re-pointQueueServiceTeststo the shared type.
- Step 2: Run to verify failure
Run: dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter StandaloneChildrenRoutingTests
Expected: FAIL — parent goes WaitingForReview (routing not implemented), child stays Idle.
- Step 3: Implement the routing
In src/ClaudeDo.Worker/Runner/TaskRunner.cs (post-rebase), HandleSuccess already declares var finishedAt and calls SetRoadblockCountAsync ABOVE the routing block — KEEP both. Replace ONLY the var reviewResult = …; line plus the if (task.ParentTaskId is null && task.PlanningPhase == PlanningPhase.None) … else block (the routing decision) with:
var reviewResult = ComposeReviewResult(result.ResultMarkdown, result.Blocks);
bool isStandalone = task.ParentTaskId is null && task.PlanningPhase == PlanningPhase.None;
List<TaskEntity> pendingChildren = new();
if (isStandalone)
{
using var ctx = _dbFactory.CreateDbContext();
var children = await new TaskRepository(ctx).GetChildrenAsync(task.Id, CancellationToken.None);
pendingChildren = children
.Where(c => c.Status is TaskStatus.Idle or TaskStatus.Queued)
.ToList();
}
if (isStandalone && pendingChildren.Count > 0)
{
// Suggested improvements exist: hold for children, queue them (they branch off this
// task's worktree HEAD and run under the normal queue).
await _state.SubmitForChildrenAsync(task.Id, finishedAt, reviewResult, CancellationToken.None);
foreach (var child in pendingChildren)
await _state.EnqueueAsync(child.Id, CancellationToken.None);
await _broadcaster.WorkerLog(
$"Finished \"{task.Title}\" (waiting on {pendingChildren.Count} improvement(s))",
WorkerLogLevel.Success, DateTime.UtcNow);
await _broadcaster.TaskFinished(slot, task.Id, "waiting_for_children", finishedAt);
}
else if (isStandalone)
{
await _state.SubmitForReviewAsync(task.Id, finishedAt, reviewResult, CancellationToken.None);
await _broadcaster.WorkerLog($"Finished \"{task.Title}\" (waiting for review)", WorkerLogLevel.Success, DateTime.UtcNow);
await _broadcaster.TaskFinished(slot, task.Id, "waiting_for_review", finishedAt);
}
else
{
await _state.CompleteAsync(task.Id, finishedAt, reviewResult, CancellationToken.None);
await _broadcaster.WorkerLog($"Finished \"{task.Title}\" (done)", WorkerLogLevel.Success, DateTime.UtcNow);
await _broadcaster.TaskFinished(slot, task.Id, "done", finishedAt);
}
- Step 4: Run to verify pass
Run: dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter StandaloneChildrenRoutingTests
Expected: PASS (both cases).
- Step 5: Commit
git add src/ClaudeDo.Worker/Runner/TaskRunner.cs tests/ClaudeDo.Worker.Tests/Runner/StandaloneChildrenRoutingTests.cs
git commit -m "feat(runner): route standalone success with children to WaitingForChildren + enqueue them"
Phase D — Base improvement-child worktrees on the parent's HEAD
An improvement child must branch from the parent's worktree HEAD so it sees the code the parent just wrote (the parent's work lives only on claudedo/{parentId} until merged). Planning children keep basing off the list HEAD.
Files:
-
Modify:
src/ClaudeDo.Worker/Runner/WorktreeManager.cs:27-57(CreateAsyncbase-commit selection) -
Test:
tests/ClaudeDo.Worker.Tests/Runner/ChildWorktreeBaseTests.cs(new) -
Step 1: Write the failing test
Create tests/ClaudeDo.Worker.Tests/Runner/ChildWorktreeBaseTests.cs:
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.Extensions.Logging.Abstractions;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
using Xunit;
namespace ClaudeDo.Worker.Tests.Runner;
public sealed class ChildWorktreeBaseTests : IDisposable
{
private readonly List<GitRepoFixture> _repos = new();
private readonly List<DbFixture> _dbs = new();
private readonly List<(string repoDir, string wtPath)> _cleanups = new();
private static bool GitAvailable => GitRepoFixture.IsGitAvailable();
[Fact]
public async Task ImprovementChild_basesOff_parentWorktreeHead()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = new GitRepoFixture(); _repos.Add(repo);
var db = new DbFixture(); _dbs.Add(db);
var listId = Guid.NewGuid().ToString();
var parentId = Guid.NewGuid().ToString();
var childId = Guid.NewGuid().ToString();
var list = new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow };
var parent = new TaskEntity { Id = parentId, ListId = listId, Title = "Parent",
CommitType = "chore", PlanningPhase = PlanningPhase.None, CreatedAt = DateTime.UtcNow };
var child = new TaskEntity { Id = childId, ListId = listId, Title = "Improve",
CommitType = "chore", ParentTaskId = parentId, CreatedBy = parentId, CreatedAt = DateTime.UtcNow };
using (var seed = db.CreateContext())
{
await new ListRepository(seed).AddAsync(list);
await new TaskRepository(seed).AddAsync(parent);
await new TaskRepository(seed).AddAsync(child);
}
var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" };
var mgr = new WorktreeManager(new GitService(), db.CreateFactory(), cfg, NullLogger<WorktreeManager>.Instance);
// Parent worktree gets a commit so its HeadCommit advances past the list HEAD.
var parentCtx = await mgr.CreateAsync(parent, list, CancellationToken.None);
_cleanups.Add((repo.RepoDir, parentCtx.WorktreePath));
File.WriteAllText(Path.Combine(parentCtx.WorktreePath, "parent.txt"), "parent work");
await mgr.CommitIfChangedAsync(parentCtx, parent, list, CancellationToken.None);
string parentHead;
using (var read = db.CreateContext())
parentHead = (await new WorktreeRepository(read).GetByTaskIdAsync(parentId))!.HeadCommit!;
// Child worktree must base off the parent's HEAD, not the list HEAD.
var childCtx = await mgr.CreateAsync(child, list, CancellationToken.None);
_cleanups.Add((repo.RepoDir, childCtx.WorktreePath));
Assert.Equal(parentHead, childCtx.BaseCommit);
Assert.NotEqual(repo.BaseCommit, childCtx.BaseCommit);
Assert.True(File.Exists(Path.Combine(childCtx.WorktreePath, "parent.txt")),
"child worktree should contain the parent's committed file");
}
public void Dispose()
{
foreach (var (repoDir, wtPath) in _cleanups)
try { GitRepoFixture.RunGit(repoDir, "worktree", "remove", "--force", wtPath); } catch { }
foreach (var r in _repos) r.Dispose();
foreach (var d in _dbs) d.Dispose();
}
}
- Step 2: Run to verify failure
Run: dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter ChildWorktreeBaseTests
Expected: FAIL — child bases off the list HEAD (repo.BaseCommit), so parent.txt is absent.
- Step 3: Implement base-commit resolution
In src/ClaudeDo.Worker/Runner/WorktreeManager.cs, replace line 35:
var baseCommit = await _git.RevParseHeadAsync(workingDir, ct);
with:
var baseCommit = await ResolveBaseCommitAsync(task, workingDir, ct);
Then add this private method to the class (e.g. after CreateAsync):
// Improvement children (parent is a non-planning task with its own worktree) branch
// from the parent's recorded HEAD so they build on the parent's not-yet-merged work.
// Planning children and standalone tasks base off the list's current HEAD.
private async Task<string> ResolveBaseCommitAsync(TaskEntity task, string workingDir, CancellationToken ct)
{
if (task.ParentTaskId is not null)
{
using var ctx = _dbFactory.CreateDbContext();
var parent = await ctx.Tasks.AsNoTracking()
.FirstOrDefaultAsync(t => t.Id == task.ParentTaskId, ct);
if (parent is not null && parent.PlanningPhase == PlanningPhase.None)
{
var parentWt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.ParentTaskId, ct);
var parentHead = parentWt?.HeadCommit ?? parentWt?.BaseCommit;
if (parentHead is not null)
return parentHead;
}
}
return await _git.RevParseHeadAsync(workingDir, ct);
}
(Microsoft.EntityFrameworkCore and ClaudeDo.Data.Models are already imported in this file.)
- Step 4: Run to verify pass + no regression
Run: dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "ChildWorktreeBaseTests|WorktreeManagerTests"
Expected: PASS — child bases off parent HEAD; existing worktree tests unaffected (they have no ParentTaskId).
- Step 5: Commit
git add src/ClaudeDo.Worker/Runner/WorktreeManager.cs tests/ClaudeDo.Worker.Tests/Runner/ChildWorktreeBaseTests.cs
git commit -m "feat(worktree): base improvement-child worktree on parent HEAD"
Phase E — The SuggestImprovement MCP tool + per-run identity
Mirrors planning's per-session token. The Worker mints a per-run token, registers token → taskId in an in-memory singleton (no DB column), and writes a run-scoped --mcp-config file OUTSIDE the worktree (so it is never committed). The shared /mcp endpoint's auth middleware resolves both planning tokens and task-run tokens; the SuggestImprovement tool reads the caller's id from the request context and stamps the child server-side.
Task 7: TaskRunTokenRegistry
Files:
-
Create:
src/ClaudeDo.Worker/Runner/TaskRunTokenRegistry.cs -
Test:
tests/ClaudeDo.Worker.Tests/Runner/TaskRunTokenRegistryTests.cs(new) -
Step 1: Write the failing test
Create tests/ClaudeDo.Worker.Tests/Runner/TaskRunTokenRegistryTests.cs:
using ClaudeDo.Worker.Runner;
using Xunit;
namespace ClaudeDo.Worker.Tests.Runner;
public sealed class TaskRunTokenRegistryTests
{
[Fact]
public void Register_then_resolve_returns_taskId()
{
var reg = new TaskRunTokenRegistry();
reg.Register("tok", "task-1");
Assert.True(reg.TryResolve("tok", out var id));
Assert.Equal("task-1", id);
}
[Fact]
public void Unregister_removes_token()
{
var reg = new TaskRunTokenRegistry();
reg.Register("tok", "task-1");
reg.Unregister("tok");
Assert.False(reg.TryResolve("tok", out _));
}
[Fact]
public void GenerateToken_is_urlsafe_and_unique()
{
var a = TaskRunTokenRegistry.GenerateToken();
var b = TaskRunTokenRegistry.GenerateToken();
Assert.NotEqual(a, b);
Assert.DoesNotContain('+', a);
Assert.DoesNotContain('/', a);
Assert.DoesNotContain('=', a);
}
}
- Step 2: Run to verify failure
Run: dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter TaskRunTokenRegistryTests
Expected: FAIL — TaskRunTokenRegistry does not exist.
- Step 3: Implement the registry
Create src/ClaudeDo.Worker/Runner/TaskRunTokenRegistry.cs:
using System.Collections.Concurrent;
using System.Security.Cryptography;
namespace ClaudeDo.Worker.Runner;
// In-memory per-run MCP identity store. A task run mints a token, registers it here,
// and tears it down when the run ends. Kept out of the DB on purpose: a run that
// outlives a Worker restart is already dead (StaleTaskRecovery flips it to Failed).
public sealed class TaskRunTokenRegistry
{
private readonly ConcurrentDictionary<string, string> _tokenToTaskId = new();
public void Register(string token, string taskId) => _tokenToTaskId[token] = taskId;
public bool TryResolve(string token, out string taskId)
{
if (_tokenToTaskId.TryGetValue(token, out var id)) { taskId = id; return true; }
taskId = string.Empty;
return false;
}
public void Unregister(string token) => _tokenToTaskId.TryRemove(token, out _);
public static string GenerateToken()
{
var bytes = RandomNumberGenerator.GetBytes(32);
return Convert.ToBase64String(bytes).Replace('+', '-').Replace('/', '_').TrimEnd('=');
}
}
- Step 4: Run to verify pass
Run: dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter TaskRunTokenRegistryTests
Expected: PASS.
- Step 5: Commit
git add src/ClaudeDo.Worker/Runner/TaskRunTokenRegistry.cs tests/ClaudeDo.Worker.Tests/Runner/TaskRunTokenRegistryTests.cs
git commit -m "feat(mcp): add per-run TaskRunTokenRegistry"
Task 8: TaskRunMcpContext + accessor
Files:
- Create:
src/ClaudeDo.Worker/Runner/TaskRunMcpContext.cs
(No standalone test — exercised by Task 9.)
- Step 1: Create the context + accessor
Create src/ClaudeDo.Worker/Runner/TaskRunMcpContext.cs:
using Microsoft.AspNetCore.Http;
namespace ClaudeDo.Worker.Runner;
// Per-request caller identity for the task-run MCP surface, populated by the auth
// middleware from the run's token (mirrors PlanningMcpContext).
public sealed class TaskRunMcpContext
{
public required string CallerTaskId { get; init; }
}
public sealed class TaskRunMcpContextAccessor
{
private readonly IHttpContextAccessor _http;
public TaskRunMcpContextAccessor(IHttpContextAccessor http) => _http = http;
public TaskRunMcpContext Current =>
(_http.HttpContext?.Items["TaskRunContext"] as TaskRunMcpContext)
?? throw new InvalidOperationException("No task-run context on request.");
}
- Step 2: Build to verify it compiles
Run: dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
Expected: Build succeeded.
- Step 3: Commit
git add src/ClaudeDo.Worker/Runner/TaskRunMcpContext.cs
git commit -m "feat(mcp): add TaskRunMcpContext + accessor"
Task 9: TaskRunMcpService.SuggestImprovement
Files:
-
Create:
src/ClaudeDo.Worker/Runner/TaskRunMcpService.cs -
Test:
tests/ClaudeDo.Worker.Tests/SuggestImprovementTests.cs(new) -
Step 1: Write the failing tests
Create tests/ClaudeDo.Worker.Tests/SuggestImprovementTests.cs:
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.AspNetCore.Http;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
using Xunit;
namespace ClaudeDo.Worker.Tests;
public sealed class SuggestImprovementTests : IDisposable
{
private readonly DbFixture _db = new();
public void Dispose() => _db.Dispose();
private static TaskRunMcpContextAccessor AccessorFor(string callerTaskId)
{
var http = new HttpContextAccessor { HttpContext = new DefaultHttpContext() };
http.HttpContext!.Items["TaskRunContext"] = new TaskRunMcpContext { CallerTaskId = callerTaskId };
return new TaskRunMcpContextAccessor(http);
}
private async Task SeedCallerAsync(string id, string? parentId)
{
using var ctx = _db.CreateContext();
if (!ctx.Lists.Any())
ctx.Lists.Add(new ListEntity { Id = "l1", Name = "L", CreatedAt = DateTime.UtcNow });
ctx.Tasks.Add(new TaskEntity { Id = id, ListId = "l1", Title = "Caller",
Status = TaskStatus.Running, ParentTaskId = parentId, CommitType = "feat",
CreatedAt = DateTime.UtcNow });
await ctx.SaveChangesAsync();
}
[Fact]
public async Task SuggestImprovement_stamps_parent_createdBy_status_and_list()
{
await SeedCallerAsync("caller", parentId: null);
using var ctx = _db.CreateContext();
var svc = new TaskRunMcpService(new TaskRepository(ctx), AccessorFor("caller"),
new HubBroadcaster(new FakeHubContext()));
var dto = await svc.SuggestImprovement("Refactor X", "details", default);
var child = await new TaskRepository(ctx).GetByIdAsync(dto.ChildTaskId);
Assert.Equal("caller", child!.ParentTaskId);
Assert.Equal("caller", child.CreatedBy);
Assert.Equal(TaskStatus.Idle, child.Status);
Assert.Equal("l1", child.ListId);
}
[Fact]
public async Task SuggestImprovement_rejects_when_caller_is_a_child()
{
await SeedCallerAsync("parent", parentId: null);
await SeedCallerAsync("child", parentId: "parent");
using var ctx = _db.CreateContext();
var svc = new TaskRunMcpService(new TaskRepository(ctx), AccessorFor("child"),
new HubBroadcaster(new FakeHubContext()));
await Assert.ThrowsAsync<InvalidOperationException>(
() => svc.SuggestImprovement("nested", "x", default));
}
}
- Step 2: Run to verify failure
Run: dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter SuggestImprovementTests
Expected: FAIL — TaskRunMcpService does not exist.
- Step 3: Implement the tool
Create src/ClaudeDo.Worker/Runner/TaskRunMcpService.cs:
using System.ComponentModel;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Hub;
using ModelContextProtocol.Server;
namespace ClaudeDo.Worker.Runner;
public sealed record SuggestedImprovementDto(string ChildTaskId);
[McpServerToolType]
public sealed class TaskRunMcpService
{
private readonly TaskRepository _tasks;
private readonly TaskRunMcpContextAccessor _ctx;
private readonly HubBroadcaster _broadcaster;
public TaskRunMcpService(TaskRepository tasks, TaskRunMcpContextAccessor ctx, HubBroadcaster broadcaster)
{
_tasks = tasks;
_ctx = ctx;
_broadcaster = broadcaster;
}
[McpServerTool, Description(
"File an out-of-scope improvement as a child task of the current task. The child runs " +
"automatically after this task finishes and is surfaced for review alongside it. Use ONLY " +
"for work that is genuinely outside this task's scope (a refactor, follow-up, or tech debt) " +
"— never for work that belongs to the current task.")]
public async Task<SuggestedImprovementDto> SuggestImprovement(
string title,
string description,
CancellationToken cancellationToken)
{
var callerId = _ctx.Current.CallerTaskId;
var caller = await _tasks.GetByIdAsync(callerId, cancellationToken)
?? throw new InvalidOperationException("Calling task not found.");
if (caller.ParentTaskId is not null)
throw new InvalidOperationException(
"A child task cannot suggest further improvements (improvements are one layer deep).");
var child = await _tasks.CreateChildAsync(
callerId, title, description, commitType: null, createdBy: callerId, cancellationToken);
await _broadcaster.TaskUpdated(child.Id);
await _broadcaster.TaskUpdated(callerId);
return new SuggestedImprovementDto(child.Id);
}
}
- Step 4: Run to verify pass
Run: dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter SuggestImprovementTests
Expected: PASS (both cases).
- Step 5: Commit
git add src/ClaudeDo.Worker/Runner/TaskRunMcpService.cs tests/ClaudeDo.Worker.Tests/SuggestImprovementTests.cs
git commit -m "feat(mcp): add SuggestImprovement tool (server-stamped, one layer deep)"
Task 10: Resolve task-run tokens in the MCP auth middleware + wire DI
Files:
-
Modify:
src/ClaudeDo.Worker/Planning/PlanningTokenAuth.cs -
Modify:
src/ClaudeDo.Worker/Program.cs(DI +.WithTools<TaskRunMcpService>()) -
Test:
tests/ClaudeDo.Worker.Tests/Hub/TaskRunTokenAuthTests.cs(new) -
Step 1: Write the failing test
Create tests/ClaudeDo.Worker.Tests/Hub/TaskRunTokenAuthTests.cs:
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Planning;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.AspNetCore.Http;
using Xunit;
namespace ClaudeDo.Worker.Tests.Hub;
public sealed class TaskRunTokenAuthTests : IDisposable
{
private readonly DbFixture _db = new();
public void Dispose() => _db.Dispose();
[Fact]
public async Task Valid_taskRun_token_populates_TaskRunContext_and_calls_next()
{
var reg = new TaskRunTokenRegistry();
reg.Register("run-token", "task-1");
bool nextCalled = false;
var mw = new PlanningTokenAuthMiddleware(_ => { nextCalled = true; return Task.CompletedTask; });
var ctx = new DefaultHttpContext();
ctx.Request.Path = "/mcp";
ctx.Request.Headers["Authorization"] = "Bearer run-token";
using var db = _db.CreateContext();
await mw.InvokeAsync(ctx, new TaskRepository(db), reg);
Assert.True(nextCalled);
var resolved = ctx.Items["TaskRunContext"] as TaskRunMcpContext;
Assert.NotNull(resolved);
Assert.Equal("task-1", resolved!.CallerTaskId);
}
[Fact]
public async Task Unknown_token_returns_401()
{
var mw = new PlanningTokenAuthMiddleware(_ => Task.CompletedTask);
var ctx = new DefaultHttpContext();
ctx.Request.Path = "/mcp";
ctx.Request.Headers["Authorization"] = "Bearer nope";
using var db = _db.CreateContext();
await mw.InvokeAsync(ctx, new TaskRepository(db), new TaskRunTokenRegistry());
Assert.Equal(401, ctx.Response.StatusCode);
}
}
- Step 2: Run to verify failure
Run: dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter TaskRunTokenAuthTests
Expected: FAIL — InvokeAsync has no TaskRunTokenRegistry parameter (compile error).
- Step 3: Extend the middleware
In src/ClaudeDo.Worker/Planning/PlanningTokenAuth.cs, add using ClaudeDo.Worker.Runner; at the top, then replace InvokeAsync with:
public async Task InvokeAsync(HttpContext ctx, TaskRepository tasks, TaskRunTokenRegistry runTokens)
{
if (!ctx.Request.Path.StartsWithSegments("/mcp"))
{
await _next(ctx);
return;
}
var auth = ctx.Request.Headers["Authorization"].ToString();
if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{
ctx.Response.StatusCode = 401;
await ctx.Response.WriteAsync("Missing bearer token");
return;
}
var token = auth.Substring("Bearer ".Length).Trim();
// Planning session token (long-lived, DB-backed).
var parent = await tasks.FindByPlanningTokenAsync(token, ctx.RequestAborted);
if (parent is not null && parent.PlanningPhase == ClaudeDo.Data.Models.PlanningPhase.Active)
{
ctx.Items["PlanningContext"] = new PlanningMcpContext { ParentTaskId = parent.Id };
await _next(ctx);
return;
}
// Per-run task token (in-memory, scoped to a live run).
if (runTokens.TryResolve(token, out var callerTaskId))
{
ctx.Items["TaskRunContext"] = new TaskRunMcpContext { CallerTaskId = callerTaskId };
await _next(ctx);
return;
}
ctx.Response.StatusCode = 401;
await ctx.Response.WriteAsync("Invalid or expired token");
}
- Step 4: Wire DI in
Program.cs
In src/ClaudeDo.Worker/Program.cs, add the registry singleton near the runner stack (after line 59, AddSingleton<TaskRunner>()):
builder.Services.AddSingleton<TaskRunTokenRegistry>();
In the planning-session DI block (after line 133, AddScoped<PlanningMcpContextAccessor>()), add:
builder.Services.AddScoped<TaskRunMcpContextAccessor>();
builder.Services.AddScoped<TaskRunMcpService>();
And extend the MCP tool registration (lines 139-141):
builder.Services.AddMcpServer()
.WithHttpTransport()
.WithTools<PlanningMcpService>()
.WithTools<TaskRunMcpService>();
(Add using ClaudeDo.Worker.Runner; to Program.cs if not already present — it is, via the runner stack.)
- Step 5: Run to verify pass + build Worker
Run: dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "TaskRunTokenAuthTests|PlanningHubTests|PlanningMcpServiceTests"
Expected: PASS — task-run tokens resolve; planning auth still works.
Run: dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
Expected: Build succeeded.
- Step 6: Commit
git add src/ClaudeDo.Worker/Planning/PlanningTokenAuth.cs src/ClaudeDo.Worker/Program.cs tests/ClaudeDo.Worker.Tests/Hub/TaskRunTokenAuthTests.cs
git commit -m "feat(mcp): resolve per-run tokens in MCP auth + register TaskRunMcpService"
Task 11: TaskRunner mints the per-run token + emits --mcp-config
Provision the MCP only for improvement-eligible runs (ParentTaskId == null && PlanningPhase == None). Write the config OUTSIDE the worktree (under LogRoot) so the run never commits it. Tear the token + file down in a finally.
Files:
-
Modify:
src/ClaudeDo.Worker/Runner/ClaudeArgsBuilder.cs(addMcpConfigPath,AllowedTools) -
Modify:
src/ClaudeDo.Worker/Runner/TaskRunner.cs(ctor +RunAsync) -
Modify: all test sites constructing
new TaskRunner(...)—tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs,Services/QueueServiceSlotGuardTests.cs,External/ExternalMcpServiceTests.cs, andRunner/StandaloneChildrenRoutingTests.cs(Task 6). -
Test:
tests/ClaudeDo.Worker.Tests/Runner/ClaudeArgsBuilderTests.cs(add case) -
Step 1: Write the failing args test
Add to tests/ClaudeDo.Worker.Tests/Runner/ClaudeArgsBuilderTests.cs (match the file's existing construction style):
[Fact]
public void Build_emits_mcpConfig_and_allowedTools_when_set()
{
var args = new ClaudeArgsBuilder().Build(new ClaudeRunConfig(
Model: null, SystemPrompt: null, AgentPath: null, ResumeSessionId: null,
McpConfigPath: "C:\\tmp\\t_mcp.json",
AllowedTools: "mcp__claudedo_run__SuggestImprovement"));
Assert.Contains("--mcp-config", args);
Assert.Contains("t_mcp.json", args);
Assert.Contains("--allowedTools mcp__claudedo_run__SuggestImprovement", args);
}
- Step 2: Run to verify failure
Run: dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter Build_emits_mcpConfig_and_allowedTools_when_set
Expected: FAIL — ClaudeRunConfig has no McpConfigPath/AllowedTools (compile error).
- Step 3: Extend
ClaudeRunConfig+Build
In src/ClaudeDo.Worker/Runner/ClaudeArgsBuilder.cs, extend the record:
public sealed record ClaudeRunConfig(
string? Model,
string? SystemPrompt,
string? AgentPath,
string? ResumeSessionId,
int? MaxTurns = null,
string? PermissionMode = null,
string? McpConfigPath = null,
string? AllowedTools = null
);
In Build, before the --resume block (after line 58, the --json-schema add):
if (config.McpConfigPath is not null)
args.Add($"--mcp-config {Escape(config.McpConfigPath)}");
if (config.AllowedTools is not null)
args.Add($"--allowedTools {config.AllowedTools}");
- Step 4: Run the args test to verify pass
Run: dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter Build_emits_mcpConfig_and_allowedTools_when_set
Expected: PASS.
- Step 5: Add the registry to
TaskRunner+ provision inRunAsync
In src/ClaudeDo.Worker/Runner/TaskRunner.cs:
(a) Add using System.Text.Json; to the usings.
(b) Add a field + ctor parameter:
private readonly TaskRunTokenRegistry _tokens;
In the constructor signature add TaskRunTokenRegistry tokens, (after ITaskStateService state) and assign _tokens = tokens;.
(c) In RunAsync, change the body so the provisioning is visible to a finally. Declare the token/path BEFORE the existing try (replace the try opening at line 45):
string? mcpToken = null;
string? mcpConfigPath = null;
try
{
Then, right after var resolvedConfig = await ResolveConfigAsync(task, listConfig, null, ct); (line 76), insert:
// Improvement-eligible runs get a per-run MCP identity so the agent can file
// out-of-scope follow-ups via SuggestImprovement. Children and planning runs do not.
if (task.ParentTaskId is null && task.PlanningPhase == PlanningPhase.None)
{
mcpToken = TaskRunTokenRegistry.GenerateToken();
_tokens.Register(mcpToken, task.Id);
mcpConfigPath = Path.Combine(_cfg.LogRoot, $"{task.Id}_mcp.json");
await File.WriteAllTextAsync(mcpConfigPath, BuildRunMcpConfigJson(mcpToken), ct);
resolvedConfig = resolvedConfig with
{
McpConfigPath = mcpConfigPath,
AllowedTools = "mcp__claudedo_run__SuggestImprovement",
};
}
(d) Add a finally to the existing try/catch (after the catch (Exception ex) block that ends at line 137):
finally
{
if (mcpToken is not null)
{
_tokens.Unregister(mcpToken);
if (mcpConfigPath is not null)
try { File.Delete(mcpConfigPath); } catch { /* best effort */ }
}
}
(e) Add the config-builder helper method to the class:
private string BuildRunMcpConfigJson(string token)
{
var payload = new
{
mcpServers = new
{
claudedo_run = new
{
type = "http",
url = $"http://127.0.0.1:{_cfg.SignalRPort}/mcp",
headers = new Dictionary<string, string>
{
["Authorization"] = $"Bearer {token}",
},
},
},
};
return JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true });
}
- Step 6: Update all
new TaskRunner(...)call sites
Search the test projects for new TaskRunner( and add new TaskRunTokenRegistry() as the final argument (after the state argument). Sites: QueueServiceTests.cs:58, QueueServiceSlotGuardTests.cs, ExternalMcpServiceTests.cs, and StandaloneChildrenRoutingTests.cs. Example for QueueServiceTests:
var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, argsBuilder, _cfg,
NullLogger<TaskRunner>.Instance, state, new TaskRunTokenRegistry());
(Program.cs resolves TaskRunner from DI — the singleton TaskRunTokenRegistry registered in Task 10 is injected automatically; no change needed there.)
- Step 7: Run to verify pass + build
Run: dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
Expected: Build succeeded.
Run: dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "ClaudeArgsBuilderTests|QueueServiceTests|StandaloneChildrenRoutingTests"
Expected: PASS — all TaskRunner constructions compile and run.
- Step 8: Commit
git add src/ClaudeDo.Worker/Runner/ClaudeArgsBuilder.cs src/ClaudeDo.Worker/Runner/TaskRunner.cs tests/ClaudeDo.Worker.Tests/Runner/ClaudeArgsBuilderTests.cs tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs tests/ClaudeDo.Worker.Tests/Services/QueueServiceSlotGuardTests.cs tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs tests/ClaudeDo.Worker.Tests/Runner/StandaloneChildrenRoutingTests.cs
git commit -m "feat(runner): mint per-run MCP token + emit run-scoped --mcp-config"
Phase F — Generalize the merge into a tree-merge that folds the parent's branch
TaskMergeService.MergeAsync(taskId, …) already merges any task's claudedo/{id} branch onto a target. So generalizing PlanningMergeOrchestrator is mostly: when the parent is a non-planning (improvement) parent that has its own Active worktree, prepend the parent's id to the merge queue (so its commits land first and the children — which descend from the parent's HEAD — merge cleanly on top). Planning parents are unchanged (no worktree → not prepended; strict child validation preserved). The conflict pause/continue/abort machinery is reused as-is. The existing hub methods (MergeAllPlanning / ContinuePlanningMerge / AbortPlanningMerge) and PlanningMerge* broadcast events serve both parent kinds — they are NOT renamed (the UI listens on those event names).
Task 12: Fold the parent branch into the merge queue + lenient improvement-child validation
Files:
-
Modify:
src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs -
Test:
tests/ClaudeDo.Worker.Tests/Planning/TreeMergeTests.cs(new) -
Step 1: Write the failing test
Create tests/ClaudeDo.Worker.Tests/Planning/TreeMergeTests.cs:
using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Lifecycle;
using ClaudeDo.Worker.Planning;
using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging.Abstractions;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
using Xunit;
namespace ClaudeDo.Worker.Tests.Planning;
file sealed class TreeMergeHubClients : IHubClients
{
public TreeMergeClientProxy Proxy { get; } = new();
public IClientProxy All => Proxy;
public IClientProxy AllExcept(IReadOnlyList<string> e) => Proxy;
public IClientProxy Client(string c) => Proxy;
public IClientProxy Clients(IReadOnlyList<string> c) => Proxy;
public IClientProxy Group(string g) => Proxy;
public IClientProxy GroupExcept(string g, IReadOnlyList<string> e) => Proxy;
public IClientProxy Groups(IReadOnlyList<string> g) => Proxy;
public IClientProxy User(string u) => Proxy;
public IClientProxy Users(IReadOnlyList<string> u) => Proxy;
}
file sealed class TreeMergeClientProxy : IClientProxy
{
public List<(string Method, object?[] Args)> Calls { get; } = new();
public Task SendCoreAsync(string m, object?[] a, CancellationToken ct = default) { Calls.Add((m, a)); return Task.CompletedTask; }
}
file sealed class TreeMergeHubContext : IHubContext<WorkerHub>
{
public TreeMergeHubClients RecordingClients { get; } = new();
public IHubClients Clients => RecordingClients;
public IGroupManager Groups => throw new NotImplementedException();
}
public sealed class TreeMergeTests : IDisposable
{
private readonly List<DbFixture> _dbs = new();
private readonly List<GitRepoFixture> _repos = new();
private readonly List<(string repo, string wt)> _cleanups = new();
private DbFixture NewDb() { var d = new DbFixture(); _dbs.Add(d); return d; }
private GitRepoFixture NewRepo() { var r = new GitRepoFixture(); _repos.Add(r); return r; }
public void Dispose()
{
foreach (var (repo, wt) in _cleanups) try { GitRepoFixture.RunGit(repo, "worktree", "remove", "--force", wt); } catch { }
foreach (var d in _dbs) try { d.Dispose(); } catch { }
foreach (var r in _repos) try { r.Dispose(); } catch { }
}
[Fact]
public async Task ImprovementParent_foldsOwnBranch_thenChild_andMarksDone()
{
if (!GitRepoFixture.IsGitAvailable()) { Assert.True(true, "git not available"); return; }
var db = NewDb();
var repo = NewRepo();
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
var listId = Guid.NewGuid().ToString();
var parentId = Guid.NewGuid().ToString();
var childId = Guid.NewGuid().ToString();
using (var ctx = db.CreateContext())
{
ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
ctx.Tasks.Add(new TaskEntity { Id = parentId, ListId = listId, Title = "Parent",
Status = TaskStatus.WaitingForReview, PlanningPhase = PlanningPhase.None, SortOrder = 0, CreatedAt = DateTime.UtcNow });
ctx.Tasks.Add(new TaskEntity { Id = childId, ListId = listId, Title = "Child",
Status = TaskStatus.Done, ParentTaskId = parentId, SortOrder = 1, CreatedAt = DateTime.UtcNow });
await ctx.SaveChangesAsync();
// Parent worktree: commit parent.txt off main.
var parentWt = SeedWorktree(repo, parentId, repo.BaseCommit, "parent.txt", "parent work");
// Child worktree: branch off the PARENT's head, add child.txt.
var childWt = SeedWorktree(repo, childId, parentWt.head, "child.txt", "child work");
ctx.Worktrees.Add(MakeRow(parentId, parentWt));
ctx.Worktrees.Add(MakeRow(childId, childWt));
await ctx.SaveChangesAsync();
}
var (orch, calls) = BuildOrchestrator(db);
await orch.StartAsync(parentId, "main", CancellationToken.None);
using var verify = db.CreateContext();
Assert.Equal(TaskStatus.Done, verify.Tasks.Single(t => t.Id == parentId).Status);
Assert.Equal(WorktreeState.Merged, verify.Worktrees.Single(w => w.TaskId == parentId).State);
Assert.Equal(WorktreeState.Merged, verify.Worktrees.Single(w => w.TaskId == childId).State);
Assert.Contains(calls, c => c.Method == "PlanningSubtaskMerged" && (string)c.Args[1]! == parentId);
Assert.Contains(calls, c => c.Method == "PlanningSubtaskMerged" && (string)c.Args[1]! == childId);
Assert.Contains(calls, c => c.Method == "PlanningCompleted");
// main now contains both files.
Assert.True(File.Exists(Path.Combine(repo.RepoDir, "parent.txt")));
Assert.True(File.Exists(Path.Combine(repo.RepoDir, "child.txt")));
}
private (string path, string branch, string head) SeedWorktree(
GitRepoFixture repo, string taskId, string baseCommit, string file, string content)
{
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
_cleanups.Add((repo.RepoDir, wtPath));
var branch = $"claudedo/{taskId.Replace("-", "")}";
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", branch, wtPath, baseCommit);
File.WriteAllText(Path.Combine(wtPath, file), content);
GitRepoFixture.RunGit(wtPath, "add", file);
GitRepoFixture.RunGit(wtPath, "commit", "-m", $"add {file}");
var head = GitRepoFixture.RunGit(wtPath, "rev-parse", "HEAD").Trim();
return (wtPath, branch, head);
}
private static WorktreeEntity MakeRow(string taskId, (string path, string branch, string head) wt)
=> new() { TaskId = taskId, Path = wt.path, BranchName = wt.branch, BaseCommit = "x",
HeadCommit = wt.head, State = WorktreeState.Active, CreatedAt = DateTime.UtcNow };
private (PlanningMergeOrchestrator orch, List<(string Method, object?[] Args)> calls) BuildOrchestrator(DbFixture db)
{
var fakeHub = new TreeMergeHubContext();
var broadcaster = new HubBroadcaster(fakeHub);
var git = new GitService();
var factory = db.CreateFactory();
var merge = new TaskMergeService(factory, git, broadcaster, NullLogger<TaskMergeService>.Instance);
var aggregator = new PlanningAggregator(factory, git, NullLogger<PlanningAggregator>.Instance);
var orch = new PlanningMergeOrchestrator(factory, merge, aggregator, broadcaster, git,
NullLogger<PlanningMergeOrchestrator>.Instance);
return (orch, fakeHub.RecordingClients.Proxy.Calls);
}
}
The
BaseCommit = "x"placeholder is fine —TaskMergeServicemerges byBranchName, notBaseCommit.
- Step 2: Run to verify failure
Run: dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter TreeMergeTests
Expected: FAIL — the parent's own branch is never merged (parent.txt is merged only transitively if at all; parent worktree stays Active, and no PlanningSubtaskMerged is emitted for parentId).
- Step 3: Generalize the orchestrator
In src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs:
(a) Add IsPlanning to the State record (after CurrentSubtaskId):
private sealed class State
{
public required string TargetBranch { get; init; }
public required Queue<string> RemainingSubtaskIds { get; init; }
public required bool IsPlanning { get; init; }
public string? CurrentSubtaskId { get; set; }
}
(b) Replace the whole StartAsync method (lines 46-90) with:
public async Task StartAsync(string parentTaskId, string targetBranch, CancellationToken ct)
{
string workingDir;
bool isPlanning;
bool parentHasWorktree;
List<TaskEntity> children;
using (var ctx = _dbFactory.CreateDbContext())
{
var parent = await ctx.Tasks
.Include(t => t.List)
.Include(t => t.Worktree)
.Include(t => t.Children).ThenInclude(c => c.Worktree)
.SingleOrDefaultAsync(t => t.Id == parentTaskId, ct)
?? throw new KeyNotFoundException($"Parent task '{parentTaskId}' not found.");
workingDir = parent.List.WorkingDir
?? throw new InvalidOperationException("List has no working directory.");
isPlanning = parent.PlanningPhase != PlanningPhase.None;
parentHasWorktree = parent.Worktree is { State: WorktreeState.Active };
children = parent.Children.OrderBy(c => c.SortOrder).ToList();
}
// Planning chains require every child Done with a usable worktree (unchanged).
// Improvement parents are lenient: failed/cancelled children are simply skipped.
if (isPlanning)
{
foreach (var c in children)
{
if (c.Status != TaskStatus.Done)
throw new InvalidOperationException($"subtask {c.Id} is not Done (status {c.Status})");
if (c.Worktree is null)
throw new InvalidOperationException($"subtask {c.Id} has no worktree");
if (c.Worktree.State != WorktreeState.Active && c.Worktree.State != WorktreeState.Merged)
throw new InvalidOperationException($"subtask {c.Id} worktree state is {c.Worktree.State}");
}
}
if (await _git.IsMidMergeAsync(workingDir, ct))
throw new InvalidOperationException("repo is mid-merge");
if (await _git.HasChangesAsync(workingDir, ct))
throw new InvalidOperationException("working tree has uncommitted changes");
var idsToMerge = new List<string>();
// Improvement parents carry their own code branch — fold it in first so the
// children (which descend from the parent HEAD) merge cleanly on top.
if (!isPlanning && parentHasWorktree)
idsToMerge.Add(parentTaskId);
idsToMerge.AddRange(children
.Where(c => c.Status == TaskStatus.Done && c.Worktree is { State: WorktreeState.Active })
.Select(c => c.Id));
var queue = new Queue<string>(idsToMerge);
var state = new State
{
TargetBranch = targetBranch,
RemainingSubtaskIds = queue,
IsPlanning = isPlanning,
};
if (!_states.TryAdd(parentTaskId, state))
throw new InvalidOperationException($"Merge already in progress for {parentTaskId}.");
await _broadcaster.PlanningMergeStarted(parentTaskId, targetBranch);
await DrainAsync(parentTaskId, ct);
}
(c) In DrainAsync, replace the success-path call await FinalizePlanningDoneAsync(planningTaskId, ct); (line 170) with:
await FinalizeParentDoneAsync(planningTaskId, state.IsPlanning, ct);
(d) Replace FinalizePlanningDoneAsync (lines 179-190) with:
private async Task FinalizeParentDoneAsync(string parentTaskId, bool isPlanning, CancellationToken ct)
{
using var ctx = _dbFactory.CreateDbContext();
var parent = await ctx.Tasks.SingleOrDefaultAsync(t => t.Id == parentTaskId, ct);
if (parent is null) return;
parent.Status = TaskStatus.Done;
parent.FinishedAt = DateTime.UtcNow;
await ctx.SaveChangesAsync(ct);
// Only planning builds an integration branch via the aggregator; skip cleanup otherwise.
if (isPlanning)
{
try { await _aggregator.CleanupIntegrationBranchAsync(parentTaskId, ct); }
catch (Exception ex) { _logger.LogWarning(ex, "integration branch cleanup failed"); }
}
}
- Step 4: Run to verify pass + planning regression
Run: dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "TreeMergeTests|PlanningMergeOrchestratorTests"
Expected: PASS — improvement parent folds its own branch + child and marks Done; ALL existing planning merge tests still pass (planning parents have no worktree, so behavior is byte-identical).
- Step 5: Commit
git add src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs tests/ClaudeDo.Worker.Tests/Planning/TreeMergeTests.cs
git commit -m "feat(merge): fold parent branch into tree-merge for improvement parents"
Phase F2 — Prompt: teach the agent to offload
Task 13: Add the "Out-of-scope improvements" section to SystemDefault
Files:
-
Modify:
src/ClaudeDo.Data/PromptFiles.cs(SystemDefaultconst, after the## Scopeblock) -
Test:
tests/ClaudeDo.Data.Tests/SystemPromptTests.cs(new) -
Step 1: Write the failing test
Create tests/ClaudeDo.Data.Tests/SystemPromptTests.cs:
using ClaudeDo.Data;
using Xunit;
namespace ClaudeDo.Data.Tests;
public class SystemPromptTests
{
[Fact]
public void SystemDefault_mentions_SuggestImprovement_offload()
{
var prompt = PromptFiles.DefaultFor(PromptKind.System);
Assert.Contains("Out-of-scope improvements", prompt);
Assert.Contains("SuggestImprovement", prompt);
}
}
- Step 2: Run to verify failure
Run: dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release --filter SystemPromptTests
Expected: FAIL — the marker is absent.
- Step 3: Add the section
In src/ClaudeDo.Data/PromptFiles.cs, inside the SystemDefault raw string literal, insert this block immediately AFTER the ## Scope bullet list (after the line hypothetical future needs. and before ## Working in the repo):
## Out-of-scope improvements
If you notice worthwhile work that is genuinely outside this task's scope
(a refactor, a follow-up, tech debt), do NOT do it here. File it with
SuggestImprovement(title, description) and stay focused on the task at hand.
(Keep the surrounding blank lines so the markdown sections stay separated. Preserve the raw-string indentation used by the """ literal.)
- Step 4: Run to verify pass
Run: dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release --filter SystemPromptTests
Expected: PASS.
- Step 5: Commit
git add src/ClaudeDo.Data/PromptFiles.cs tests/ClaudeDo.Data.Tests/SystemPromptTests.cs
git commit -m "feat(prompt): instruct agents to offload out-of-scope work via SuggestImprovement"
Phase G — UI (BLOCKED on claudedo-ux; rebase first)
Do not start Phase G until
claudedo-uxconfirms its commits are on main. Then: commit/stash Phases A–F2,git rebase main(orgit merge main) this worktree, resolve, re-run the full build + test suite, and only then implement below — building on the detail-panel review structure UX added (review actions live inDetailsIslandViewModel/DetailsIslandView, the⚠badge lives inTaskRowView, andRoadblockCount+ its migration already exist). Coordinate via mailbox before editing the shared files. Re-read each file at the start of each task — line numbers below are pre-rebase estimates and will have shifted.
Task 14: WaitingForChildren status chip + color
Files:
-
Modify:
src/ClaudeDo.Ui/Converters/StatusColorConverter.cs(add aWaitingForChildrenarm — amber) -
Modify: the status-label/localization source for status display text — add
WaitingForChildrentolocales/en.jsonANDlocales/de.jsonin parity (Localization.Tests enforces parity). -
Modify: wherever status chips are rendered (the row/detail status badge) to map
WaitingForChildren. -
Test: rely on
Localization.Testsfor key parity; build the UI project. -
Step 1: Locate the converter + status display
Run: dotnet build src/ClaudeDo.Ui is not standalone — read src/ClaudeDo.Ui/Converters/StatusColorConverter.cs and grep for where WaitingForReview is handled in converters/labels:
grep -rn "WaitingForReview" src/ClaudeDo.Ui
Mirror every site for WaitingForChildren.
- Step 2: Add the color arm
In StatusColorConverter, add a WaitingForChildren arm returning an amber brush (distinct from WaitingForReview). Snap to an existing color token if the project tokenizes brushes — do not hardcode a new hex if a token scale exists (see memory: "UI work — single source of truth").
- Step 3: Add localization keys (parity)
Add the WaitingForChildren status label key to BOTH locales/en.json (e.g. "Waiting on improvements") and locales/de.json (e.g. "Wartet auf Verbesserungen"). Keys must match exactly (Localization.Tests fails on drift).
- Step 4: Build + localization test
Run: dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
Run: dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release
Expected: Build + localization parity pass.
- Step 5: Commit
git add src/ClaudeDo.Ui/Converters/StatusColorConverter.cs locales/en.json locales/de.json <any chip/label files>
git commit -m "feat(ui): WaitingForChildren status chip + color"
Task 15: Child tree grouping with agent-suggested marker
Files:
-
Modify: the task-list view/viewmodel that groups children under parents by
ParentTaskId. -
Modify:
TaskRowView.axaml(rebased) to mark improvement children (CreatedBy == ParentTaskId) distinctly from planning children. -
Step 1: Find the existing parent/child grouping
Planning already groups children under a parent. Read the list/tree viewmodel that builds that grouping (grep -rn "ParentTaskId" src/ClaudeDo.Ui). Improvement children attach to a non-planning parent — confirm the grouping keys on ParentTaskId (not on PlanningPhase) so improvement children also nest. If it gates on planning, generalize it to any parent.
- Step 2: Add the agent-suggested marker
Expose IsAgentSuggested = (CreatedBy != null && CreatedBy == ParentTaskId) on the row viewmodel and show a small "agent" glyph/badge on those rows. If using PathIcon, author the glyph as FILLED geometry (memory: PathIcon fills geometry — stroke-only renders invisible).
- Step 3: Build + visually verify
Run: dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
Then run the app, create a task, let it suggest an improvement (or seed a child with CreatedBy == ParentTaskId), and confirm the child nests under the parent with the agent marker.
Visual-verification gap: flag to the user that the grouping/marker needs a human visual pass (cannot be asserted in tests).
- Step 4: Commit
git add <tree viewmodel + TaskRowView.axaml>
git commit -m "feat(ui): nest improvement children under parent with agent-suggested marker"
Task 16: Parent review card — child outcomes, rolled-up roadblocks, tree-merge
Files:
-
Modify:
DetailsIslandViewModel.cs(rebased — review actions already hosted here) +DetailsIslandView. -
Reuse hub methods:
MergeAllPlanning(parentId, targetBranch),ContinuePlanningMerge,AbortPlanningMerge, and thePlanningMerge*client events (already wired inWorkerClient/IslandsShellViewModel/ConflictResolutionViewModel). -
Step 1: Show child outcomes + rolled-up roadblocks
When the selected task is a parent in WaitingForReview with children, the detail review card lists each child's outcome (Done/Failed/Cancelled) and surfaces each child's CLAUDEDO_BLOCKED problems. Child roadblocks live in the child's Result (the runner appends them via ComposeReviewResult) and/or the rebased RoadblockCount column UX added — read the children and render their roadblock reasons under the parent. Load children via the existing data path (the SignalR client already fetches tasks; filter by ParentTaskId == parent.Id).
- Step 2: Drive the tree-merge from "approve"
For a parent that has children (improvement parent), the review "approve" action should trigger the guided tree-merge instead of a plain ApproveReview: call MergeAllPlanning(parent.Id, targetBranch) and reuse the existing planning conflict UI (ConflictResolutionViewModel + ContinuePlanningMerge/AbortPlanningMerge). For a parent with NO children, keep the existing plain approve→Done path. Pick the target branch the same way the planning merge UI does (read GetMergeTargets/the planning target selection).
If
DetailsIslandViewModel's constructor changes, update the hand-rolled fakes in BOTH test projects (memory + CLAUDE.md: changingIWorkerClient/WorkerHub/DetailsIslandViewModelctors breaks fakes).
- Step 3: Build + visually verify
Run: dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
Run the app end-to-end: task → suggests improvement → both run → parent shows WaitingForChildren then WaitingForReview with child outcomes → approve → tree-merge folds parent + child to the target (exercise a conflict to confirm pause/continue/abort).
Visual-verification gap: flag the full review-card + merge flow for a human visual pass.
- Step 4: Commit
git add <DetailsIslandViewModel.cs, DetailsIslandView, any fakes>
git commit -m "feat(ui): parent review card with child outcomes, rolled-up roadblocks, tree-merge"
Final verification
- Build everything:
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Releaseanddotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release. - Run the full suites:
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release,tests/ClaudeDo.Data.Tests,tests/ClaudeDo.Localization.Tests. - Manual smoke (no real-
claudetests — memory): start the Worker + UI, create a task whose run callsSuggestImprovement, confirm parent →WaitingForChildren→ children auto-run off the parent's HEAD → parent →WaitingForReview, approve → tree-merge folds parent + children. - Coordinate the worktree merge-to-main with
claudedo-uxbefore integrating (mailboxclaudedo-ux).