From fce91bcf86a3c05652a7ab58b6aefae636560adb Mon Sep 17 00:00:00 2001 From: mika kuns Date: Fri, 24 Apr 2026 11:37:42 +0200 Subject: [PATCH] feat(worker): create ephemeral worktree and write .mcp.json in StartAsync Rewrites StartAsync to provision a git worktree before transitioning the task to Planning state, writes .mcp.json and .claude/settings.local.json into the worktree, and fixes ResumeAsync to supply the updated PlanningSessionResumeContext fields (Token, WorktreePath). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Planning/PlanningSessionManager.cs | 65 ++++++++++++++++--- 1 file changed, 57 insertions(+), 8 deletions(-) diff --git a/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs b/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs index 3dbf3c6..0286a0f 100644 --- a/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs +++ b/src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs @@ -73,6 +73,46 @@ public sealed class PlanningSessionManager 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."); @@ -82,17 +122,20 @@ public sealed class PlanningSessionManager var files = new PlanningSessionFiles( sessionDir, - Path.Combine(sessionDir, "mcp.json"), Path.Combine(sessionDir, "system-prompt.md"), Path.Combine(sessionDir, "initial-prompt.txt")); - await File.WriteAllTextAsync(files.McpConfigPath, BuildMcpConfigJson(), ct); + await WriteTokenFileAsync(TokenFilePathFor(sessionDir), token, ct); await File.WriteAllTextAsync(files.SystemPromptPath, BuildSystemPrompt(), ct); await File.WriteAllTextAsync(files.InitialPromptPath, BuildInitialPrompt(task), ct); - var list = await lists.GetByIdAsync(task.ListId, ct) - ?? throw new InvalidOperationException($"List {task.ListId} not found."); - return new PlanningSessionStartContext(taskId, list.WorkingDir ?? throw new InvalidOperationException($"List {task.ListId} has no working directory configured."), files); + return new PlanningSessionStartContext( + ParentTaskId: taskId, + WorkingDir: worktreePath, + Token: token, + WorktreePath: worktreePath, + BranchName: branchName, + Files: files); } public async Task FinalizeAsync(string taskId, bool queueAgentTasks, CancellationToken ct) @@ -139,13 +182,19 @@ public sealed class PlanningSessionManager throw new InvalidOperationException("No Claude session ID captured yet; cannot resume."); var sessionDir = Path.Combine(_rootDirectory, taskId); - var mcpConfigPath = Path.Combine(sessionDir, "mcp.json"); - if (!File.Exists(mcpConfigPath)) + 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."); - return new PlanningSessionResumeContext(taskId, list.WorkingDir ?? throw new InvalidOperationException($"List {task.ListId} has no working directory configured."), task.PlanningSessionId, mcpConfigPath); + 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); + var token = task.PlanningSessionToken ?? string.Empty; + + return new PlanningSessionResumeContext(taskId, listWorkingDir, task.PlanningSessionId, token, worktreePath); } private static string GenerateToken()