Files
ClaudeDo/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs
T
mika kuns 5d362b6973 fix(worker): stop sibling worktrees from landing inside the repo tree
Path.GetDirectoryName returned workingDir itself instead of its parent
whenever list.WorkingDir ended in a directory separator, placing
.claudedo-worktrees inside the target repo's own working tree. Add
WorktreeRootResolver to normalize the trailing separator before deriving
the sibling root and to guard that the resulting worktree path never
lands inside workingDir, used by both WorktreeManager and
PlanningSessionManager. Add a startup sweep (LegacyWorktreeFolderRecovery)
that warns when a list's working dir already contains a leftover
.claudedo-worktrees folder from before the fix.
2026-08-10 11:58:34 +02:00

446 lines
18 KiB
C#

using System.Security.Cryptography;
using System.Text.Json;
using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.Git;
using ClaudeDo.Worker.State;
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;
private readonly string _projectsRoot;
private readonly ITaskStateService? _state;
private readonly PlanningChainCoordinator? _chain;
// DI constructor.
public PlanningSessionManager(
IDbContextFactory<ClaudeDoDbContext> factory,
GitService git,
WorkerConfig cfg,
ITaskStateService state,
PlanningChainCoordinator chain,
string rootDirectory,
string? projectsRoot = null)
{
_factory = factory;
_git = git;
_cfg = cfg;
_state = state;
_chain = chain;
_rootDirectory = rootDirectory;
_projectsRoot = projectsRoot ?? Paths.Expand("~/.claude/projects");
}
// Test constructor.
public PlanningSessionManager(
TaskRepository tasks,
ListRepository lists,
AppSettingsRepository settings,
GitService git,
WorkerConfig cfg,
string rootDirectory,
ITaskStateService? state = null,
PlanningChainCoordinator? chain = null,
string? projectsRoot = null)
{
_tasksOverride = tasks;
_listsOverride = lists;
_settingsOverride = settings;
_git = git;
_cfg = cfg;
_state = state;
_chain = chain;
_rootDirectory = rootDirectory;
_projectsRoot = projectsRoot ?? Paths.Expand("~/.claude/projects");
}
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.Idle || task.PlanningPhase != PlanningPhase.None)
throw new InvalidOperationException(
$"Task is in status {task.Status}/{task.PlanningPhase}; only Idle+None 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();
if (_state is not null)
{
var startResult = await _state.StartPlanningAsync(taskId, ct);
if (!startResult.Ok)
throw new InvalidOperationException(startResult.Reason ?? "Failed to transition task to Planning.");
await tasks.SetPlanningSessionTokenAsync(taskId, token, ct);
}
else
{
// Test fallback when no state-service is provided.
if (await tasks.SetPlanningStartedAsync(taskId, token, ct) is null)
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;
if (_state is null || _chain is null)
throw new InvalidOperationException(
"PlanningSessionManager.FinalizeAsync requires ITaskStateService and PlanningChainCoordinator.");
var finalizeResult = await _state.FinalizePlanningAsync(taskId, ct);
if (!finalizeResult.Ok)
throw new InvalidOperationException(
finalizeResult.Reason ?? $"Could not finalize planning for task {taskId}.");
// Establish the blocked-by chain but leave children Idle; queueing is a
// deliberate user action ("Queue plan"), never an automatic finalize step.
// queueAgentTasks is accepted for compatibility but no longer auto-queues.
var children = await tasks.GetChildrenAsync(taskId, ct);
int count = children.Count;
if (children.Count > 0)
count = await _chain.SetupChainAsync(taskId, enqueue: false, 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.Idle);
}
public async Task<DiscardPlanningOutcome> DiscardAsync(
string taskId,
bool dequeueQueuedChildren,
CancellationToken ct)
{
var (tasks, lists, settings, ctx) = CreateRepos();
await using var __ = ctx;
var outcome = await tasks.DiscardPlanningAsync(taskId, dequeueQueuedChildren, ct);
if (outcome.Result != DiscardPlanningResult.Discarded)
return outcome;
await TryCleanupWorktreeAsync(taskId, lists, settings, ct);
var sessionDir = Path.Combine(_rootDirectory, taskId);
if (Directory.Exists(sessionDir))
{
try { Directory.Delete(sessionDir, recursive: true); } catch { }
}
return outcome;
}
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.PlanningPhase != PlanningPhase.Active)
throw new InvalidOperationException(
$"Task planning phase is {task.PlanningPhase}; resume requires Active planning.");
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}");
// The planning session runs in an interactive TUI, which never reports its session id
// back to us — so nothing ever wrote planning_session_id. Recover it from the transcript
// Claude Code leaves on disk for the planning worktree, and persist it so the next
// resume is a plain DB read.
var sessionId = task.PlanningSessionId;
if (string.IsNullOrEmpty(sessionId))
{
sessionId = PlanningTranscriptLocator.FindSessionId(worktreePath, _projectsRoot)
?? throw new InvalidOperationException(
"No Claude session transcript found for this planning worktree; cannot resume. " +
"Finalize or discard the planning session instead.");
await tasks.UpdatePlanningSessionIdAsync(taskId, sessionId, ct);
}
var token = await ReadTokenFileAsync(TokenFilePathFor(sessionDir), ct);
return new PlanningSessionResumeContext(
ParentTaskId: taskId,
WorkingDir: worktreePath,
ClaudeSessionId: sessionId,
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() => PromptFiles.ReadOrDefault(PromptKind.Planning);
private static string BuildInitialPrompt(TaskEntity task) =>
PromptFiles.Render(PromptKind.PlanningInitial, new Dictionary<string, string>
{
["title"] = task.Title,
["description"] = task.Description ?? "",
});
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(WorktreeRootResolver.ResolveSiblingRoot(listWorkingDir), ".claudedo-worktrees", "planning", taskId);
var worktreePath = Path.GetFullPath(raw);
WorktreeRootResolver.EnsureOutsideWorkingDir(worktreePath, listWorkingDir);
return worktreePath;
}
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();
}
}
/// <summary>
/// Recovers the Claude session id of an interactive session from the transcript Claude Code
/// writes to <c>~/.claude/projects/&lt;encoded cwd&gt;/&lt;sessionId&gt;.jsonl</c>. A planning
/// worktree is used by exactly one session, so the newest transcript in that folder is it.
/// </summary>
/// <remarks>
/// The folder-name encoding is undocumented CLI behaviour (every non-alphanumeric character in
/// the absolute path becomes '-'). If it ever changes, <see cref="FindSessionId"/> returns null
/// and the caller reports "cannot resume" instead of resuming the wrong session.
/// </remarks>
public static class PlanningTranscriptLocator
{
public static string? FindSessionId(string worktreePath, string projectsRoot)
{
var dir = Path.Combine(projectsRoot, EncodeCwd(worktreePath));
if (!Directory.Exists(dir)) return null;
var newest = new DirectoryInfo(dir)
.EnumerateFiles("*.jsonl")
.OrderByDescending(f => f.LastWriteTimeUtc)
.FirstOrDefault();
return newest is null ? null : Path.GetFileNameWithoutExtension(newest.Name);
}
private static string EncodeCwd(string path)
{
var full = Path.GetFullPath(path);
return string.Create(full.Length, full, static (span, src) =>
{
for (var i = 0; i < src.Length; i++)
span[i] = char.IsAsciiLetterOrDigit(src[i]) ? src[i] : '-';
});
}
}