Files
ClaudeDo/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs
T
Mika Kuns 30e87e698e feat(worker): in-app interactive session service, replacing the wt terminal launch
InteractiveSessionService resolves a task's list working dir + seeded prompt,
spawns a StreamingClaudeSession (claude stream-json in the list dir, model+auto
as before), registers it in LiveSessionRegistry, streams output over TaskMessage,
and broadcasts InteractiveSessionStarted/Ended (an exit watcher fires Ended). The
hub's OpenInteractiveTerminalAsync now starts this in-app session; SendInteractiveMessage
and StopInteractiveSession route to it. The external Windows-Terminal interactive
launch (LaunchInteractiveAsync / InteractiveLaunchContext / OpenInteractiveAsync) is
removed; planning sessions keep their terminal launch.
2026-06-26 16:11:52 +02:00

390 lines
16 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.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 ITaskStateService? _state;
private readonly PlanningChainCoordinator? _chain;
// DI constructor.
public PlanningSessionManager(
IDbContextFactory<ClaudeDoDbContext> factory,
GitService git,
WorkerConfig cfg,
ITaskStateService state,
PlanningChainCoordinator chain,
string rootDirectory)
{
_factory = factory;
_git = git;
_cfg = cfg;
_state = state;
_chain = chain;
_rootDirectory = rootDirectory;
}
// Test constructor.
public PlanningSessionManager(
TaskRepository tasks,
ListRepository lists,
AppSettingsRepository settings,
GitService git,
WorkerConfig cfg,
string rootDirectory,
ITaskStateService? state = null,
PlanningChainCoordinator? chain = null)
{
_tasksOverride = tasks;
_listsOverride = lists;
_settingsOverride = settings;
_git = git;
_cfg = cfg;
_state = state;
_chain = chain;
_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.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.");
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() => 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(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();
}
}