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.
390 lines
16 KiB
C#
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();
|
|
}
|
|
} |