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.
170 lines
8.0 KiB
C#
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;
|
|
}
|
|
}
|