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) <noreply@anthropic.com>
This commit is contained in:
mika kuns
2026-04-24 11:37:42 +02:00
parent 975e1ce50c
commit fce91bcf86

View File

@@ -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<int> 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()