Files
ClaudeDo/src/ClaudeDo.Worker/Planning/PlanningMcpService.cs
T
mika kuns 65db1cdefa feat(planning): let CreateChildTask set maxTurns on child tasks
Planning sessions could already steer a subtask's model but not its turn
budget, so a visibly large subtask would still die at the global default
turn limit. maxTurns is optional (default null = inherit list/global
default, matching model); 0/negative values are rejected as invalid input,
consistent with the existing model-alias validation.
2026-08-05 11:33:16 +02:00

170 lines
8.0 KiB
C#

using System.ComponentModel;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.State;
using ModelContextProtocol.Server;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Planning;
public sealed record ChildTaskDto(string TaskId, string Title, string? Description, string Status);
public sealed record CreatedChildDto(string TaskId, string Status);
[McpServerToolType]
public sealed class PlanningMcpService
{
private readonly TaskRepository _tasks;
private readonly PlanningMcpContextAccessor _contextAccessor;
private readonly HubBroadcaster _broadcaster;
private readonly ITaskStateService _state;
private readonly PlanningChainCoordinator _chain;
public PlanningMcpService(
TaskRepository tasks,
PlanningMcpContextAccessor contextAccessor,
HubBroadcaster broadcaster,
ITaskStateService state,
PlanningChainCoordinator chain)
{
_tasks = tasks;
_contextAccessor = contextAccessor;
_broadcaster = broadcaster;
_state = state;
_chain = chain;
}
private Task BroadcastTaskUpdatedAsync(string taskId, CancellationToken ct)
=> _broadcaster.TaskUpdated(taskId);
[McpServerTool, Description(
"Create a new draft child task under the current planning session's parent task. " +
"Set model to the cheapest model that can do this subtask well — 'haiku' for trivial/mechanical " +
"work, 'sonnet' for normal coding (the default), 'opus' only for complex or cross-cutting work. " +
"Leave model null to inherit the list/global default. Set maxTurns only when this subtask is " +
"visibly larger than the default turn budget; leave it null to inherit the list/global default.")]
public async Task<CreatedChildDto> CreateChildTask(
string title,
string? description,
string? commitType,
string? model,
int? maxTurns = null,
CancellationToken cancellationToken = default)
{
var ctx = _contextAccessor.Current;
var child = await _tasks.CreateChildAsync(ctx.ParentTaskId, title, description, commitType, createdBy: null, model: model, maxTurns: maxTurns, ct: cancellationToken);
await BroadcastTaskUpdatedAsync(child.Id, cancellationToken);
await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken);
return new CreatedChildDto(child.Id, child.Status.ToString());
}
[McpServerTool, Description("List all child tasks under the current planning session's parent task.")]
public async Task<IReadOnlyList<ChildTaskDto>> ListChildTasks(
CancellationToken cancellationToken)
{
var ctx = _contextAccessor.Current;
var children = await _tasks.GetChildrenAsync(ctx.ParentTaskId, cancellationToken);
return children
.Select(c => new ChildTaskDto(c.Id, c.Title, c.Description, c.Status.ToString()))
.ToList();
}
private static readonly TaskStatus[] EditableStatuses =
{ TaskStatus.Idle, TaskStatus.Queued };
[McpServerTool, Description("Update a child task in the active planning session. Can change title, description, commit type, and status. Status must be one of: Idle, Queued.")]
public async Task<ChildTaskDto> UpdateChildTask(
string taskId,
string? title,
string? description,
string? commitType,
string? status,
CancellationToken cancellationToken)
{
var ctx = _contextAccessor.Current;
var parent = await _tasks.GetByIdAsync(ctx.ParentTaskId, cancellationToken)
?? throw new InvalidOperationException("Planning parent task not found.");
if (parent.PlanningPhase != PlanningPhase.Active)
throw new InvalidOperationException("Cannot modify tasks outside an active planning session.");
var child = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
if (child.ParentTaskId != ctx.ParentTaskId)
throw new InvalidOperationException("Task is not a child of this planning session.");
TaskStatus? newStatus = null;
if (!string.IsNullOrEmpty(status))
{
if (!Enum.TryParse<TaskStatus>(status, ignoreCase: true, out var parsed))
throw new InvalidOperationException($"Unknown status '{status}'.");
if (!EditableStatuses.Contains(parsed))
throw new InvalidOperationException($"Status '{parsed}' cannot be set via MCP. Allowed: Idle, Queued.");
newStatus = parsed;
}
await _tasks.UpdateChildAsync(taskId, title, description, commitType, newStatus, cancellationToken);
var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
await BroadcastTaskUpdatedAsync(reload.Id, cancellationToken);
await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken);
return new ChildTaskDto(reload.Id, reload.Title, reload.Description, reload.Status.ToString());
}
[McpServerTool, Description("Delete a child task in the active planning session.")]
public async Task DeleteChildTask(
string taskId,
CancellationToken cancellationToken)
{
var ctx = _contextAccessor.Current;
var parent = await _tasks.GetByIdAsync(ctx.ParentTaskId, cancellationToken)
?? throw new InvalidOperationException("Planning parent task not found.");
if (parent.PlanningPhase != PlanningPhase.Active)
throw new InvalidOperationException("Cannot delete tasks outside an active planning session.");
var child = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
if (child.ParentTaskId != ctx.ParentTaskId)
throw new InvalidOperationException("Task is not a child of this planning session.");
await _tasks.DeleteAsync(taskId, cancellationToken);
await BroadcastTaskUpdatedAsync(taskId, cancellationToken);
await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken);
}
[McpServerTool, Description("Update the title and/or description of the parent planning task itself.")]
public async Task UpdatePlanningTask(
string? title,
string? description,
CancellationToken cancellationToken)
{
var ctx = _contextAccessor.Current;
await _tasks.UpdatePlanningTaskAsync(ctx.ParentTaskId, title, description, cancellationToken);
await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken);
}
[McpServerTool, Description("Finalize the planning session. Child tasks are left idle and chain-linked (each blocked by its predecessor); they are NOT queued automatically — the user queues the plan from the app when ready. The queueAgentTasks argument is accepted for compatibility but ignored.")]
public async Task<int> Finalize(
bool queueAgentTasks,
CancellationToken cancellationToken)
{
var ctx = _contextAccessor.Current;
var finalizeResult = await _state.FinalizePlanningAsync(ctx.ParentTaskId, cancellationToken);
if (!finalizeResult.Ok)
throw new InvalidOperationException(
finalizeResult.Reason ?? $"Could not finalize planning for task {ctx.ParentTaskId}.");
var children = await _tasks.GetChildrenAsync(ctx.ParentTaskId, cancellationToken);
int count = children.Count;
// Establish the blocked-by chain but leave children Idle; queueing is a
// deliberate user action ("Queue plan"), never an automatic finalize step.
if (children.Count > 0)
count = await _chain.SetupChainAsync(ctx.ParentTaskId, enqueue: false, cancellationToken);
foreach (var c in children)
await BroadcastTaskUpdatedAsync(c.Id, cancellationToken);
await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken);
return count;
}
}