2017 lines
86 KiB
Markdown
2017 lines
86 KiB
Markdown
# 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` — the `SuggestImprovement` MCP 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` — add `WaitingForChildren` enum value.
|
||
- `src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs` — converter arms.
|
||
- `src/ClaudeDo.Data/Repositories/TaskRepository.cs` — generalize `CreateChildAsync`; remove `TryCompleteParentAsync` (logic moves to `TaskStateService`).
|
||
- `src/ClaudeDo.Worker/State/ITaskStateService.cs` + `TaskStateService.cs` — `SubmitForChildrenAsync`; generalized terminal-child parent advancement.
|
||
- `src/ClaudeDo.Worker/Runner/TaskRunner.cs` — route to `WaitingForChildren` + 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` + `--allowedTools` flags.
|
||
- `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 improvements` section in `SystemDefault`.
|
||
- 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`:
|
||
|
||
```csharp
|
||
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`:
|
||
|
||
```csharp
|
||
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:
|
||
|
||
```csharp
|
||
// 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**
|
||
|
||
```bash
|
||
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 passes `createdBy: null`)
|
||
- Test: `tests/ClaudeDo.Data.Tests/CreateChildTests.cs` (add cases)
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Add to `tests/ClaudeDo.Data.Tests/CreateChildTests.cs`:
|
||
|
||
```csharp
|
||
[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:
|
||
|
||
```csharp
|
||
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`:
|
||
|
||
```csharp
|
||
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**
|
||
|
||
```bash
|
||
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 after `SubmitForReviewAsync`, ~line 108)
|
||
- Test: `tests/ClaudeDo.Worker.Tests/WaitingForChildrenLifecycleTests.cs` (new)
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `tests/ClaudeDo.Worker.Tests/WaitingForChildrenLifecycleTests.cs`:
|
||
|
||
```csharp
|
||
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`:
|
||
|
||
```csharp
|
||
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):
|
||
|
||
```csharp
|
||
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 `ITaskStateService` may break hand-rolled fakes that implement it. Search `: ITaskStateService` across both test projects; add the new method to any fake (return `new TransitionResult(true, null)`). Build both test projects to confirm.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
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 private `TryAdvanceImprovementParentAsync`)
|
||
- Test: `tests/ClaudeDo.Worker.Tests/WaitingForChildrenLifecycleTests.cs` (add cases)
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Add to `WaitingForChildrenLifecycleTests`:
|
||
|
||
```csharp
|
||
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:
|
||
|
||
```csharp
|
||
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):
|
||
|
||
```csharp
|
||
// 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**
|
||
|
||
```bash
|
||
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`:
|
||
|
||
```csharp
|
||
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:
|
||
|
||
```csharp
|
||
// 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**
|
||
|
||
```bash
|
||
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):
|
||
|
||
```csharp
|
||
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);
|
||
}
|
||
}
|
||
```
|
||
|
||
> `FakeClaudeProcess` is declared `internal` in `Services/QueueServiceTests.cs`. If it isn't accessible, move it to `tests/ClaudeDo.Worker.Tests/Infrastructure/FakeClaudeProcess.cs` (same `internal` modifier, namespace `ClaudeDo.Worker.Tests.Infrastructure`) and add the `using` — do that as the first step and re-point `QueueServiceTests` to 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:
|
||
|
||
```csharp
|
||
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**
|
||
|
||
```bash
|
||
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` (`CreateAsync` base-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`:
|
||
|
||
```csharp
|
||
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:
|
||
|
||
```csharp
|
||
var baseCommit = await _git.RevParseHeadAsync(workingDir, ct);
|
||
```
|
||
|
||
with:
|
||
|
||
```csharp
|
||
var baseCommit = await ResolveBaseCommitAsync(task, workingDir, ct);
|
||
```
|
||
|
||
Then add this private method to the class (e.g. after `CreateAsync`):
|
||
|
||
```csharp
|
||
// 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**
|
||
|
||
```bash
|
||
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`:
|
||
|
||
```csharp
|
||
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`:
|
||
|
||
```csharp
|
||
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**
|
||
|
||
```bash
|
||
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`:
|
||
|
||
```csharp
|
||
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**
|
||
|
||
```bash
|
||
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`:
|
||
|
||
```csharp
|
||
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`:
|
||
|
||
```csharp
|
||
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**
|
||
|
||
```bash
|
||
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`:
|
||
|
||
```csharp
|
||
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:
|
||
|
||
```csharp
|
||
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>()`):
|
||
|
||
```csharp
|
||
builder.Services.AddSingleton<TaskRunTokenRegistry>();
|
||
```
|
||
|
||
In the planning-session DI block (after line 133, `AddScoped<PlanningMcpContextAccessor>()`), add:
|
||
|
||
```csharp
|
||
builder.Services.AddScoped<TaskRunMcpContextAccessor>();
|
||
builder.Services.AddScoped<TaskRunMcpService>();
|
||
```
|
||
|
||
And extend the MCP tool registration (lines 139-141):
|
||
|
||
```csharp
|
||
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**
|
||
|
||
```bash
|
||
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` (add `McpConfigPath`, `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`, and `Runner/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):
|
||
|
||
```csharp
|
||
[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:
|
||
|
||
```csharp
|
||
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):
|
||
|
||
```csharp
|
||
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 in `RunAsync`**
|
||
|
||
In `src/ClaudeDo.Worker/Runner/TaskRunner.cs`:
|
||
|
||
(a) Add `using System.Text.Json;` to the usings.
|
||
|
||
(b) Add a field + ctor parameter:
|
||
|
||
```csharp
|
||
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):
|
||
|
||
```csharp
|
||
string? mcpToken = null;
|
||
string? mcpConfigPath = null;
|
||
try
|
||
{
|
||
```
|
||
|
||
Then, right after `var resolvedConfig = await ResolveConfigAsync(task, listConfig, null, ct);` (line 76), insert:
|
||
|
||
```csharp
|
||
// 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):
|
||
|
||
```csharp
|
||
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:
|
||
|
||
```csharp
|
||
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`:
|
||
|
||
```csharp
|
||
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**
|
||
|
||
```bash
|
||
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`:
|
||
|
||
```csharp
|
||
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 — `TaskMergeService` merges by `BranchName`, not `BaseCommit`.
|
||
|
||
- [ ] **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`):
|
||
|
||
```csharp
|
||
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:
|
||
|
||
```csharp
|
||
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:
|
||
|
||
```csharp
|
||
await FinalizeParentDoneAsync(planningTaskId, state.IsPlanning, ct);
|
||
```
|
||
|
||
(d) Replace `FinalizePlanningDoneAsync` (lines 179-190) with:
|
||
|
||
```csharp
|
||
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**
|
||
|
||
```bash
|
||
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` (`SystemDefault` const, after the `## Scope` block)
|
||
- Test: `tests/ClaudeDo.Data.Tests/SystemPromptTests.cs` (new)
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `tests/ClaudeDo.Data.Tests/SystemPromptTests.cs`:
|
||
|
||
```csharp
|
||
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**
|
||
|
||
```bash
|
||
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-ux` confirms its commits are on main.** Then: commit/stash Phases A–F2, `git rebase main` (or `git 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 in `DetailsIslandViewModel`/`DetailsIslandView`, the `⚠` badge lives in `TaskRowView`, and `RoadblockCount` + 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 a `WaitingForChildren` arm — amber)
|
||
- Modify: the status-label/localization source for status display text — add `WaitingForChildren` to `locales/en.json` AND `locales/de.json` in parity (Localization.Tests enforces parity).
|
||
- Modify: wherever status chips are rendered (the row/detail status badge) to map `WaitingForChildren`.
|
||
- Test: rely on `Localization.Tests` for 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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
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 the `PlanningMerge*` client events (already wired in `WorkerClient`/`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: changing `IWorkerClient`/`WorkerHub`/`DetailsIslandViewModel` ctors 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**
|
||
|
||
```bash
|
||
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 Release` and `dotnet 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-`claude` tests — memory): start the Worker + UI, create a task whose run calls `SuggestImprovement`, 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-ux` before integrating (mailbox `claudedo-ux`).
|
||
|