Hard-coded 47821 meant .mcp.json pointed at the wrong port for any worker running on a custom signalr_port (e.g. 37821), causing "Unable to connect" auth failures in the planning session. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
366 lines
14 KiB
C#
366 lines
14 KiB
C#
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using ClaudeDo.Data;
|
|
using ClaudeDo.Data.Git;
|
|
using ClaudeDo.Data.Models;
|
|
using ClaudeDo.Data.Repositories;
|
|
using ClaudeDo.Worker.Config;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
|
|
|
namespace ClaudeDo.Worker.Planning;
|
|
|
|
public sealed class PlanningSessionManager
|
|
{
|
|
private string McpServerUrl => $"http://127.0.0.1:{_cfg.SignalRPort}/mcp";
|
|
|
|
private readonly IDbContextFactory<ClaudeDoDbContext>? _factory;
|
|
private readonly TaskRepository? _tasksOverride;
|
|
private readonly ListRepository? _listsOverride;
|
|
private readonly AppSettingsRepository? _settingsOverride;
|
|
private readonly GitService _git;
|
|
private readonly WorkerConfig _cfg;
|
|
private readonly string _rootDirectory;
|
|
|
|
// DI constructor.
|
|
public PlanningSessionManager(
|
|
IDbContextFactory<ClaudeDoDbContext> factory,
|
|
GitService git,
|
|
WorkerConfig cfg,
|
|
string rootDirectory)
|
|
{
|
|
_factory = factory;
|
|
_git = git;
|
|
_cfg = cfg;
|
|
_rootDirectory = rootDirectory;
|
|
}
|
|
|
|
// Test constructor.
|
|
public PlanningSessionManager(
|
|
TaskRepository tasks,
|
|
ListRepository lists,
|
|
AppSettingsRepository settings,
|
|
GitService git,
|
|
WorkerConfig cfg,
|
|
string rootDirectory)
|
|
{
|
|
_tasksOverride = tasks;
|
|
_listsOverride = lists;
|
|
_settingsOverride = settings;
|
|
_git = git;
|
|
_cfg = cfg;
|
|
_rootDirectory = rootDirectory;
|
|
}
|
|
|
|
private (TaskRepository tasks, ListRepository lists, AppSettingsRepository settings, ClaudeDoDbContext? ctx) CreateRepos()
|
|
{
|
|
if (_tasksOverride is not null)
|
|
return (_tasksOverride, _listsOverride!, _settingsOverride!, null);
|
|
var ctx = _factory!.CreateDbContext();
|
|
return (new TaskRepository(ctx), new ListRepository(ctx), new AppSettingsRepository(ctx), ctx);
|
|
}
|
|
|
|
public async Task<PlanningSessionStartContext> StartAsync(string taskId, CancellationToken ct)
|
|
{
|
|
var (tasks, lists, settings, ctx) = CreateRepos();
|
|
await using var _ = ctx;
|
|
|
|
var task = await tasks.GetByIdAsync(taskId, ct)
|
|
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
|
if (task.ParentTaskId is not null)
|
|
throw new InvalidOperationException("Cannot start a planning session on a child task.");
|
|
if (task.Status != TaskStatus.Manual)
|
|
throw new InvalidOperationException($"Task is in status {task.Status}; only Manual can start planning.");
|
|
|
|
var list = await lists.GetByIdAsync(task.ListId, ct)
|
|
?? throw new InvalidOperationException($"List {task.ListId} not found.");
|
|
var listWorkingDir = list.WorkingDir
|
|
?? throw new InvalidOperationException($"List {task.ListId} has no working directory configured.");
|
|
|
|
if (!await _git.IsGitRepoAsync(listWorkingDir, ct))
|
|
throw new InvalidOperationException($"Working directory is not a git repository: {listWorkingDir}");
|
|
|
|
var appSettings = await settings.GetAsync(ct);
|
|
var worktreePath = WorktreePathFor(taskId, appSettings.WorktreeStrategy, appSettings.CentralWorktreeRoot, listWorkingDir);
|
|
var branchName = BranchNameFor(taskId);
|
|
var baseCommit = await _git.RevParseHeadAsync(listWorkingDir, ct);
|
|
|
|
Directory.CreateDirectory(Path.GetDirectoryName(worktreePath)!);
|
|
try
|
|
{
|
|
await _git.WorktreeAddAsync(listWorkingDir, branchName, worktreePath, baseCommit, ct);
|
|
}
|
|
catch (InvalidOperationException ex) when (ex.Message.Contains("already exists", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
// Self-heal: remove phantom worktrees, prune, delete branch, retry once.
|
|
var stalePaths = await _git.ListWorktreePathsForBranchAsync(listWorkingDir, branchName, ct);
|
|
foreach (var stale in stalePaths)
|
|
{
|
|
try { await _git.WorktreeRemoveAsync(listWorkingDir, stale, force: true, ct); } catch { }
|
|
}
|
|
try { await _git.WorktreePruneAsync(listWorkingDir, ct); } catch { }
|
|
try { await _git.BranchDeleteAsync(listWorkingDir, branchName, force: true, ct); } catch { }
|
|
await _git.WorktreeAddAsync(listWorkingDir, branchName, worktreePath, baseCommit, ct);
|
|
}
|
|
|
|
// Write .mcp.json and .claude/settings.local.json into the worktree.
|
|
var mcpPath = Path.Combine(worktreePath, ".mcp.json");
|
|
await File.WriteAllTextAsync(mcpPath, BuildMcpConfigJson(), ct);
|
|
|
|
var claudeDir = Path.Combine(worktreePath, ".claude");
|
|
Directory.CreateDirectory(claudeDir);
|
|
await File.WriteAllTextAsync(Path.Combine(claudeDir, "settings.local.json"), SettingsLocalJson, ct);
|
|
|
|
// Session dir + token + prompt files.
|
|
var token = GenerateToken();
|
|
var started = await tasks.SetPlanningStartedAsync(taskId, token, ct)
|
|
?? throw new InvalidOperationException("Failed to transition task to Planning.");
|
|
|
|
var sessionDir = Path.Combine(_rootDirectory, taskId);
|
|
Directory.CreateDirectory(sessionDir);
|
|
|
|
var files = new PlanningSessionFiles(
|
|
sessionDir,
|
|
Path.Combine(sessionDir, "system-prompt.md"),
|
|
Path.Combine(sessionDir, "initial-prompt.txt"));
|
|
|
|
await WriteTokenFileAsync(TokenFilePathFor(sessionDir), token, ct);
|
|
await File.WriteAllTextAsync(files.SystemPromptPath, BuildSystemPrompt(), ct);
|
|
await File.WriteAllTextAsync(files.InitialPromptPath, BuildInitialPrompt(task), ct);
|
|
|
|
return new PlanningSessionStartContext(
|
|
ParentTaskId: taskId,
|
|
WorkingDir: worktreePath,
|
|
Token: token,
|
|
WorktreePath: worktreePath,
|
|
BranchName: branchName,
|
|
Files: files);
|
|
}
|
|
|
|
public async Task<int> FinalizeAsync(string taskId, bool queueAgentTasks, CancellationToken ct)
|
|
{
|
|
var (tasks, lists, settings, ctx) = CreateRepos();
|
|
await using var __ = ctx;
|
|
|
|
var count = await tasks.FinalizePlanningAsync(taskId, queueAgentTasks, ct);
|
|
|
|
// Best-effort cleanup — don't block finalization on git state.
|
|
await TryCleanupWorktreeAsync(taskId, lists, settings, ct);
|
|
|
|
var sessionDir = Path.Combine(_rootDirectory, taskId);
|
|
if (Directory.Exists(sessionDir))
|
|
{
|
|
try { Directory.Delete(sessionDir, recursive: true); } catch { }
|
|
}
|
|
|
|
return count;
|
|
}
|
|
|
|
public async Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct)
|
|
{
|
|
var (tasks, _, settings, ctx) = CreateRepos();
|
|
await using var __ = ctx;
|
|
var children = await tasks.GetChildrenAsync(taskId, ct);
|
|
return children.Count(c => c.Status == TaskStatus.Draft);
|
|
}
|
|
|
|
public async Task DiscardAsync(string taskId, CancellationToken ct)
|
|
{
|
|
var (tasks, lists, settings, ctx) = CreateRepos();
|
|
await using var __ = ctx;
|
|
|
|
var ok = await tasks.DiscardPlanningAsync(taskId, ct);
|
|
|
|
await TryCleanupWorktreeAsync(taskId, lists, settings, ct);
|
|
|
|
var sessionDir = Path.Combine(_rootDirectory, taskId);
|
|
if (Directory.Exists(sessionDir))
|
|
{
|
|
try { Directory.Delete(sessionDir, recursive: true); } catch { }
|
|
}
|
|
|
|
if (!ok)
|
|
throw new InvalidOperationException($"Task {taskId} was not in Planning state; nothing to discard.");
|
|
}
|
|
|
|
public async Task<PlanningSessionResumeContext> ResumeAsync(string taskId, CancellationToken ct)
|
|
{
|
|
var (tasks, lists, settings, ctx) = CreateRepos();
|
|
await using var _ = ctx;
|
|
|
|
var task = await tasks.GetByIdAsync(taskId, ct)
|
|
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
|
if (task.Status != TaskStatus.Planning)
|
|
throw new InvalidOperationException($"Task is in status {task.Status}; resume requires Planning.");
|
|
if (string.IsNullOrEmpty(task.PlanningSessionId))
|
|
throw new InvalidOperationException("No Claude session ID captured yet; cannot resume.");
|
|
|
|
var sessionDir = Path.Combine(_rootDirectory, taskId);
|
|
if (!Directory.Exists(sessionDir))
|
|
throw new InvalidOperationException($"Session directory missing: {sessionDir}");
|
|
|
|
var list = await lists.GetByIdAsync(task.ListId, ct)
|
|
?? throw new InvalidOperationException($"List {task.ListId} not found.");
|
|
var listWorkingDir = list.WorkingDir
|
|
?? throw new InvalidOperationException($"List {task.ListId} has no working directory configured.");
|
|
|
|
var appSettings = await settings.GetAsync(ct);
|
|
var worktreePath = WorktreePathFor(taskId, appSettings.WorktreeStrategy, appSettings.CentralWorktreeRoot, listWorkingDir);
|
|
if (!Directory.Exists(worktreePath))
|
|
throw new InvalidOperationException($"Planning worktree missing — cannot resume: {worktreePath}");
|
|
|
|
var token = await ReadTokenFileAsync(TokenFilePathFor(sessionDir), ct);
|
|
|
|
return new PlanningSessionResumeContext(
|
|
ParentTaskId: taskId,
|
|
WorkingDir: worktreePath,
|
|
ClaudeSessionId: task.PlanningSessionId,
|
|
Token: token,
|
|
WorktreePath: worktreePath);
|
|
}
|
|
|
|
private async Task TryCleanupWorktreeAsync(
|
|
string taskId,
|
|
ListRepository lists,
|
|
AppSettingsRepository settings,
|
|
CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
var (tasks, _, _, ctx2) = CreateRepos();
|
|
await using var __ = ctx2;
|
|
|
|
var task = await tasks.GetByIdAsync(taskId, ct);
|
|
if (task is null) return;
|
|
|
|
var list = await lists.GetByIdAsync(task.ListId, ct);
|
|
var listWorkingDir = list?.WorkingDir;
|
|
if (string.IsNullOrEmpty(listWorkingDir) || !Directory.Exists(listWorkingDir)) return;
|
|
|
|
var appSettings = await settings.GetAsync(ct);
|
|
var worktreePath = WorktreePathFor(taskId, appSettings.WorktreeStrategy, appSettings.CentralWorktreeRoot, listWorkingDir);
|
|
var branchName = BranchNameFor(taskId);
|
|
|
|
if (Directory.Exists(worktreePath))
|
|
{
|
|
try { await _git.WorktreeRemoveAsync(listWorkingDir, worktreePath, force: true, ct); }
|
|
catch { /* best effort */ }
|
|
}
|
|
try { await _git.BranchDeleteAsync(listWorkingDir, branchName, force: true, ct); } catch { }
|
|
}
|
|
catch { /* best effort — never block finalize/discard */ }
|
|
}
|
|
|
|
private static string GenerateToken()
|
|
{
|
|
var bytes = RandomNumberGenerator.GetBytes(32);
|
|
return Convert.ToBase64String(bytes)
|
|
.Replace('+', '-')
|
|
.Replace('/', '_')
|
|
.TrimEnd('=');
|
|
}
|
|
|
|
private string BuildMcpConfigJson()
|
|
{
|
|
var payload = new
|
|
{
|
|
mcpServers = new
|
|
{
|
|
claudedo = new
|
|
{
|
|
type = "http",
|
|
url = McpServerUrl,
|
|
headers = new Dictionary<string, string>
|
|
{
|
|
["Authorization"] = "Bearer ${CLAUDEDO_PLANNING_TOKEN}"
|
|
}
|
|
}
|
|
}
|
|
};
|
|
return JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true });
|
|
}
|
|
|
|
private const string SettingsLocalJson = """
|
|
{
|
|
"enableAllProjectMcpServers": true
|
|
}
|
|
""";
|
|
|
|
private static string BuildSystemPrompt() =>
|
|
"""
|
|
You are a planning assistant for ClaudeDo.
|
|
Your role is to help break down a task into smaller, actionable subtasks.
|
|
|
|
Use the available MCP tools (mcp__claudedo__*) to create child tasks.
|
|
When you are done planning, finalize the session.
|
|
|
|
Be concise and focused. Each subtask should be independently executable.
|
|
""";
|
|
|
|
private static string BuildInitialPrompt(TaskEntity task)
|
|
{
|
|
var sb = new StringBuilder();
|
|
sb.AppendLine($"# Task: {task.Title}");
|
|
if (!string.IsNullOrWhiteSpace(task.Description))
|
|
{
|
|
sb.AppendLine();
|
|
sb.AppendLine(task.Description);
|
|
}
|
|
sb.AppendLine();
|
|
sb.AppendLine("---");
|
|
sb.AppendLine();
|
|
sb.AppendLine("Please analyze this task and break it down into concrete subtasks.");
|
|
return sb.ToString();
|
|
}
|
|
|
|
private static string BranchNameFor(string taskId) =>
|
|
$"claudedo/planning/{taskId.Replace("-", "")}";
|
|
|
|
private string WorktreePathFor(string taskId, string strategy, string? centralRootOverride, string listWorkingDir)
|
|
{
|
|
var centralRoot = !string.IsNullOrWhiteSpace(centralRootOverride)
|
|
? centralRootOverride!
|
|
: _cfg.CentralWorktreeRoot;
|
|
|
|
var raw = strategy.Equals("central", StringComparison.OrdinalIgnoreCase)
|
|
? Path.Combine(centralRoot, "planning", taskId)
|
|
: Path.Combine(Path.GetDirectoryName(listWorkingDir)!, ".claudedo-worktrees", "planning", taskId);
|
|
|
|
return Path.GetFullPath(raw);
|
|
}
|
|
|
|
private static string TokenFilePathFor(string sessionDir) =>
|
|
Path.Combine(sessionDir, "token");
|
|
|
|
private static async Task WriteTokenFileAsync(string path, string token, CancellationToken ct)
|
|
{
|
|
await File.WriteAllTextAsync(path, token, ct);
|
|
// Best-effort current-user-only ACL on Windows. On non-Windows the inherited
|
|
// perms from the parent dir apply; acceptable because sessionDir is already
|
|
// under the user's home (~/.todo-app/sessions/).
|
|
if (OperatingSystem.IsWindows())
|
|
{
|
|
try
|
|
{
|
|
var fi = new FileInfo(path);
|
|
var ac = fi.GetAccessControl();
|
|
ac.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
|
|
var me = System.Security.Principal.WindowsIdentity.GetCurrent().User!;
|
|
ac.AddAccessRule(new System.Security.AccessControl.FileSystemAccessRule(
|
|
me,
|
|
System.Security.AccessControl.FileSystemRights.FullControl,
|
|
System.Security.AccessControl.AccessControlType.Allow));
|
|
fi.SetAccessControl(ac);
|
|
}
|
|
catch { /* ACL hardening is best-effort */ }
|
|
}
|
|
}
|
|
|
|
private static async Task<string> ReadTokenFileAsync(string path, CancellationToken ct)
|
|
{
|
|
if (!File.Exists(path))
|
|
throw new InvalidOperationException($"Token file missing: {path}");
|
|
return (await File.ReadAllTextAsync(path, ct)).Trim();
|
|
}
|
|
} |