631 lines
33 KiB
C#
631 lines
33 KiB
C#
using System.Diagnostics;
|
|
using ClaudeDo.Data;
|
|
using ClaudeDo.Data.Git;
|
|
using ClaudeDo.Data.Models;
|
|
using ClaudeDo.Data.Repositories;
|
|
using ClaudeDo.Worker.Config;
|
|
using ClaudeDo.Worker.Planning;
|
|
using ClaudeDo.Worker.Skills;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
|
|
|
namespace ClaudeDo.Worker.Runner;
|
|
|
|
// Builds the launch spec an embedded ConPTY terminal needs to open an interactive Claude
|
|
// session for a task -- the SAME worktree prep as an autonomous run when the task's list
|
|
// points at a git repo: session-skills seeded onto disk (reuses ISessionSkillSeeder +
|
|
// TaskRunner.UnionSkillNames, exactly like TaskRunner.RunAsync/ContinueAsync) and the same
|
|
// run environment variables (matches ClaudeProcess's MCP_TOOL_TIMEOUT). When the list's
|
|
// WorkingDir is not a git repo, no worktree is created at all -- the session opens directly
|
|
// in that directory (session-skills seeded there too, but isWorktree: false so no
|
|
// .git/info/exclude write is attempted). Exe/Args reuse WindowsTerminalLauncher's --resume
|
|
// argument construction. Guards: no running/queued task, and (once a worktree exists) it
|
|
// must be live on disk -- but a never-run task (no persisted SessionId) is not an error
|
|
// here, it's a fresh-start spec.
|
|
//
|
|
// The run-scoped "claudedo_run" MCP server (AskUser/SuggestImprovement) that TaskRunner
|
|
// wires per headless run is intentionally NOT reused: it exists so an unattended run can
|
|
// ask the user a question, which is moot when the user is already driving the session by
|
|
// hand. The always-on `mcp__claudedo__*` tools remain available via the globally-registered
|
|
// MCP server (installer's RegisterMcpStep), exactly as they already are for a plain
|
|
// `--resume` pickup in a Windows Terminal window.
|
|
public sealed class InteractiveLaunchSpecService
|
|
{
|
|
// Claude Code caps HTTP MCP tool calls at 60s unless raised; every ConPTY spec built by this
|
|
// service lifts it well past wait_for_task_change's 900s server-side cap. Keep in sync with
|
|
// ClaudeProcess's own MCP_TOOL_TIMEOUT (same value, set independently for headless runs).
|
|
public const string McpToolTimeoutMs = "930000";
|
|
|
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
|
private readonly ISessionSkillSeeder _skillSeeder;
|
|
private readonly ISessionSkillRegistry _skillRegistry;
|
|
private readonly WorktreeManager _wtManager;
|
|
private readonly GitService _git;
|
|
private readonly string _claudePath;
|
|
|
|
public InteractiveLaunchSpecService(
|
|
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
|
ISessionSkillSeeder skillSeeder,
|
|
ISessionSkillRegistry skillRegistry,
|
|
WorktreeManager wtManager,
|
|
GitService git,
|
|
WorkerConfig cfg)
|
|
{
|
|
_dbFactory = dbFactory;
|
|
_skillSeeder = skillSeeder;
|
|
_skillRegistry = skillRegistry;
|
|
_wtManager = wtManager;
|
|
_git = git;
|
|
_claudePath = cfg.ClaudeBin;
|
|
}
|
|
|
|
/// <summary>Builds a LaunchSpec for opening an interactive session in a task's worktree.
|
|
/// Throws KeyNotFoundException if the task doesn't exist, InvalidOperationException
|
|
/// if it's Running/Queued. If the task has no usable worktree yet, one is created on
|
|
/// demand (same mechanism as an autonomous run) provided the task's list has a working
|
|
/// directory pointing at a git repo -- otherwise throws InvalidOperationException. Resumes
|
|
/// (--resume) this task's own last interactive session (TaskEntity.InteractiveSessionId) if
|
|
/// it has one, else the latest autonomous run's session; a task that has never run either
|
|
/// way, or whose worktree was just created fresh, gets a fresh-start spec instead -- pre-
|
|
/// assigned a new session id via --session-id and persisted to InteractiveSessionId before
|
|
/// launch, so a closed/aborted session can be resumed next time.</summary>
|
|
public async Task<LaunchSpec> BuildForTaskAsync(string taskId, CancellationToken ct)
|
|
{
|
|
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
|
|
|
var task = await new TaskRepository(ctx).GetByIdAsync(taskId, ct)
|
|
?? throw new KeyNotFoundException();
|
|
if (task.Status is TaskStatus.Running or TaskStatus.Queued)
|
|
throw new InvalidOperationException("Can't open an interactive session for a running or queued task -- interrupt it first.");
|
|
|
|
var worktree = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, ct);
|
|
var isFreshWorktree = false;
|
|
string sessionDir;
|
|
bool sessionIsWorktree;
|
|
|
|
if (worktree is null || worktree.State is not (WorktreeState.Active or WorktreeState.Kept))
|
|
{
|
|
// No usable worktree yet -- if the task's list points at a git repo, create one
|
|
// on demand via the SAME mechanism an autonomous run uses (WorktreeManager.CreateAsync:
|
|
// branch naming, base commit resolution, worktree-root strategy, DB registration).
|
|
// If the list's directory isn't a git repo at all, there is nothing to branch from --
|
|
// open the session directly in that directory instead, worktree-less. This mirrors an
|
|
// existing shape in the codebase (a list-handler task also has no worktree and commits
|
|
// straight to the list's working dir), so no WorktreeEntity is created here either.
|
|
var list = await new ListRepository(ctx).GetByIdAsync(task.ListId, ct);
|
|
if (list?.WorkingDir is null)
|
|
throw new InvalidOperationException("This task has no working directory configured -- can't create a worktree.");
|
|
|
|
if (await _git.IsGitRepoAsync(list.WorkingDir, ct))
|
|
{
|
|
// A stale (Merged/Discarded) row is already tracked in `ctx`'s identity map from
|
|
// the read above -- CreateAsync replaces it via its OWN context, so re-querying on
|
|
// `ctx` without detaching first would hand back the old cached instance instead of
|
|
// the freshly created row.
|
|
if (worktree is not null)
|
|
ctx.Entry(worktree).State = EntityState.Detached;
|
|
|
|
await _wtManager.CreateAsync(task, list, ct);
|
|
worktree = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, ct)
|
|
?? throw new InvalidOperationException("Worktree creation did not persist a worktree row.");
|
|
isFreshWorktree = true;
|
|
sessionDir = worktree.Path;
|
|
sessionIsWorktree = true;
|
|
}
|
|
else
|
|
{
|
|
sessionDir = list.WorkingDir;
|
|
sessionIsWorktree = false;
|
|
}
|
|
}
|
|
else if (!Directory.Exists(worktree.Path))
|
|
{
|
|
throw new InvalidOperationException("The task's worktree directory no longer exists.");
|
|
}
|
|
else
|
|
{
|
|
sessionDir = worktree.Path;
|
|
sessionIsWorktree = true;
|
|
}
|
|
|
|
var listConfig = await new ListRepository(ctx).GetConfigAsync(task.ListId, ct);
|
|
var globalSettings = await new AppSettingsRepository(ctx).GetAsync(ct);
|
|
// A brand-new worktree has no prior session to resume, regardless of any session
|
|
// history the task accumulated before its previous worktree went away.
|
|
var run = isFreshWorktree ? null : await new TaskRunRepository(ctx).GetLatestByTaskIdAsync(taskId, ct);
|
|
|
|
var requestedSkills = TaskRunner.UnionSkillNames(task.SessionSkills, listConfig?.SessionSkills, globalSettings.SessionSkills);
|
|
var skillNames = await FilterToInstalledSkillsAsync(requestedSkills, ct);
|
|
await _skillSeeder.SeedAsync(sessionDir, skillNames, isWorktree: sessionIsWorktree, ct);
|
|
|
|
var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath)
|
|
?? throw new InvalidOperationException($"claude executable not found: {_claudePath}");
|
|
|
|
// Start the hand-driven session at the effort configured for the model this task would run
|
|
// under, instead of inheriting whatever the user's global Claude Code config happens to be.
|
|
// The model itself is deliberately NOT forced here — the user can still switch it in the TUI.
|
|
var effort = EffortFor(globalSettings, task.Model ?? listConfig?.Model);
|
|
|
|
// Resume this task's own last interactive conversation when it has one -- it takes
|
|
// precedence over the latest autonomous run's session, since an interactive session is a
|
|
// distinct conversation from an autonomous run even against the same worktree. Fall back
|
|
// to the autonomous run's session so opening a task interactively for the first time still
|
|
// picks up prior context. Neither survives a freshly (re)created worktree (isFreshWorktree
|
|
// already forced `run` to null above).
|
|
var resumeSessionId = isFreshWorktree ? null : task.InteractiveSessionId ?? run?.SessionId;
|
|
|
|
// For a fresh session, seed the interactive TUI with the task's brief (title +
|
|
// description) via a file, never as a positional CLI argument -- see
|
|
// BuildFreshTaskArgsAsync for why. The session id claude will run under is generated and
|
|
// persisted HERE, before launch, so a closed/aborted session -- even one that never got
|
|
// past startup -- still leaves an id the next open can resume.
|
|
IReadOnlyList<string> args;
|
|
if (resumeSessionId is { Length: > 0 })
|
|
{
|
|
args = WithEffort(WindowsTerminalLauncher.BuildResumeArgs(resumeSessionId), effort);
|
|
}
|
|
else
|
|
{
|
|
var sessionId = Guid.NewGuid().ToString();
|
|
await new TaskRepository(ctx).SetInteractiveSessionIdAsync(taskId, sessionId, ct);
|
|
args = await BuildFreshTaskArgsAsync(task, effort, sessionId, ct);
|
|
}
|
|
|
|
// Same run environment variable ClaudeProcess sets for every headless run: the
|
|
// AskUser MCP tool call and wait_for_task_change cap at 60s unless raised, and lifting
|
|
// it is harmless for every other tool. Keep in sync with ClaudeProcess's MCP_TOOL_TIMEOUT.
|
|
var env = new Dictionary<string, string>
|
|
{
|
|
["MCP_TOOL_TIMEOUT"] = McpToolTimeoutMs,
|
|
};
|
|
|
|
return new LaunchSpec(sessionDir, resolvedClaude, args, env);
|
|
}
|
|
|
|
/// <summary>Maps an already-prepared planning START context (worktree + prompt files + token,
|
|
/// produced by PlanningSessionManager.StartAsync) into a LaunchSpec for an embedded ConPTY
|
|
/// planning session — same planning CLI args as the wt launcher, planning env carried in Env.</summary>
|
|
public LaunchSpec BuildPlanningStart(PlanningSessionStartContext ctx)
|
|
{
|
|
var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath)
|
|
?? throw new InvalidOperationException($"claude executable not found: {_claudePath}");
|
|
|
|
// Mirrors the env the wt planning launcher set (MAX_THINKING_TOKENS + the per-session
|
|
// planning token); MCP_TOOL_TIMEOUT matches the other embedded-ConPTY specs. Applied to
|
|
// the UI process env at spawn time (see PtyTerminalSession) — process-global by design.
|
|
var env = new Dictionary<string, string>
|
|
{
|
|
["MAX_THINKING_TOKENS"] = "20000",
|
|
["CLAUDEDO_PLANNING_TOKEN"] = ctx.Token,
|
|
["MCP_TOOL_TIMEOUT"] = McpToolTimeoutMs,
|
|
};
|
|
|
|
return new LaunchSpec(
|
|
ctx.WorkingDir, resolvedClaude,
|
|
WithEffort(WindowsTerminalLauncher.BuildPlanningStartArgs(ctx),
|
|
EffortFor(ReadSettings(), ModelRegistry.PlanningAlias)),
|
|
env);
|
|
}
|
|
|
|
/// <summary>Maps a planning RESUME context (from PlanningSessionManager.ResumeAsync) into a
|
|
/// LaunchSpec for an embedded ConPTY planning session (--permission-mode plan --resume).</summary>
|
|
public LaunchSpec BuildPlanningResume(PlanningSessionResumeContext ctx)
|
|
{
|
|
var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath)
|
|
?? throw new InvalidOperationException($"claude executable not found: {_claudePath}");
|
|
|
|
var env = new Dictionary<string, string>
|
|
{
|
|
["CLAUDEDO_PLANNING_TOKEN"] = ctx.Token,
|
|
["MCP_TOOL_TIMEOUT"] = McpToolTimeoutMs,
|
|
};
|
|
|
|
return new LaunchSpec(
|
|
ctx.WorkingDir, resolvedClaude,
|
|
WithEffort(WindowsTerminalLauncher.BuildPlanningResumeArgs(ctx.ClaudeSessionId),
|
|
EffortFor(ReadSettings(), ModelRegistry.PlanningAlias)),
|
|
env);
|
|
}
|
|
|
|
/// <summary>Builds a LaunchSpec for an ad-hoc interactive session in an arbitrary directory --
|
|
/// no task, no worktree, no session-skills seeding. Throws InvalidOperationException if the
|
|
/// directory doesn't exist.</summary>
|
|
public async Task<LaunchSpec> BuildForDirectoryAsync(string directory, CancellationToken ct)
|
|
{
|
|
if (!Directory.Exists(directory))
|
|
throw new InvalidOperationException($"Directory does not exist: {directory}");
|
|
|
|
var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath)
|
|
?? throw new InvalidOperationException($"claude executable not found: {_claudePath}");
|
|
|
|
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
|
var settings = await new AppSettingsRepository(ctx).GetAsync(ct);
|
|
|
|
var env = new Dictionary<string, string>
|
|
{
|
|
["MCP_TOOL_TIMEOUT"] = McpToolTimeoutMs,
|
|
};
|
|
|
|
// No task and no list here — the global default model's preset decides the effort.
|
|
return new LaunchSpec(
|
|
directory, resolvedClaude,
|
|
WithEffort(Array.Empty<string>(), EffortFor(settings, settings.DefaultModel)), env);
|
|
}
|
|
|
|
// Tools the merge helper may use without prompting: the claudedo MCP surface (run, poll,
|
|
// diff, review/merge, continue/abort merge), read/search, Edit + Bash for hand-resolving
|
|
// conflict markers the MCP tools left behind, web/skill lookups, and Task so the Merge phase
|
|
// can delegate diff reviews to sonnet subagents instead of reading every diff itself.
|
|
private const string MergeHelperAllowedTools =
|
|
"mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill,Task";
|
|
|
|
/// <summary>Builds a LaunchSpec for an embedded ConPTY "merge helper" session that drives the
|
|
/// given tasks to a merged/Done state via the mcp__claudedo__* tools. Writes a per-session
|
|
/// system prompt + task brief under ~/.claudeDo/merge-helper-sessions/<guid> and exposes
|
|
/// that dir plus the list's repo dir via --add-dir. cwd is the list's working directory.
|
|
/// handlerTaskId (the id returned by CreateMergeHelperTaskAsync) is rendered into the brief so
|
|
/// the session can call handoff_list_handler/submit_task_for_review on its own handler task.
|
|
/// Throws KeyNotFoundException if the list doesn't exist; InvalidOperationException if
|
|
/// taskIds is empty or the list has no existing working directory.</summary>
|
|
public async Task<LaunchSpec> BuildForMergeHelperAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, CancellationToken ct)
|
|
{
|
|
if (taskIds.Count == 0)
|
|
throw new InvalidOperationException("No tasks selected for the list handler.");
|
|
|
|
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
|
var taskRepo = new TaskRepository(ctx);
|
|
var listRepo = new ListRepository(ctx);
|
|
|
|
var list = await listRepo.GetByIdAsync(listId, ct)
|
|
?? throw new KeyNotFoundException($"List not found: {listId}");
|
|
|
|
var repoDir = list.WorkingDir;
|
|
if (string.IsNullOrEmpty(repoDir) || !Directory.Exists(repoDir))
|
|
throw new InvalidOperationException($"list '{list.Name}' has no existing working directory");
|
|
repoDir = TrimTrailingSeparator(repoDir);
|
|
|
|
var briefLines = new List<string>();
|
|
foreach (var id in taskIds)
|
|
{
|
|
var task = await taskRepo.GetByIdAsync(id, ct)
|
|
?? throw new KeyNotFoundException($"Task not found: {id}");
|
|
briefLines.Add(RenderBriefEntry(task));
|
|
}
|
|
|
|
var sessionDir = Path.Combine(Paths.AppDataRoot(), "merge-helper-sessions", Guid.NewGuid().ToString());
|
|
Directory.CreateDirectory(sessionDir);
|
|
|
|
var systemPromptPath = Path.Combine(sessionDir, "system-prompt.md");
|
|
await File.WriteAllTextAsync(systemPromptPath, PromptFiles.ReadOrDefault(PromptKind.MergeHelperTriage), ct);
|
|
|
|
var briefPath = Path.Combine(sessionDir, "brief.md");
|
|
await File.WriteAllTextAsync(briefPath, PromptFiles.Render(PromptKind.MergeHelperInitial,
|
|
new Dictionary<string, string>
|
|
{
|
|
["scope"] = $"List: {list.Name}",
|
|
["repo"] = repoDir,
|
|
["handlerTaskId"] = handlerTaskId,
|
|
["tasks"] = string.Join("\n", briefLines),
|
|
}), ct);
|
|
|
|
var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath)
|
|
?? throw new InvalidOperationException($"claude executable not found: {_claudePath}");
|
|
|
|
// Mirrors WindowsTerminalLauncher.BuildPlanningStartArgs ordering: variadic flags
|
|
// (--allowedTools, --add-dir) first, then a single-value flag, then the single-line
|
|
// positional kickoff LAST — a multi-line positional prompt truncates at the first
|
|
// newline, so the full multi-line brief travels via the file exposed through --add-dir.
|
|
// Model/effort/permission-mode are fixed to the Triage role -- the list config's model no
|
|
// longer influences a handler session at all, it would otherwise apply the LIST's effort
|
|
// preset to a completely different model.
|
|
var settings = await new AppSettingsRepository(ctx).GetAsync(ct);
|
|
|
|
var args = new List<string>
|
|
{
|
|
"--model", ModelRegistry.HandlerTriageAlias,
|
|
"--effort", EffortFor(settings, ModelRegistry.HandlerTriageAlias),
|
|
"--permission-mode", PermissionModeResolver.Resolve(ModelRegistry.HandlerTriageAlias, "auto"),
|
|
"--allowedTools", MergeHelperAllowedTools,
|
|
"--add-dir", sessionDir, repoDir,
|
|
"--append-system-prompt-file", systemPromptPath,
|
|
$"Read the file {briefPath} first. It lists the tasks you must handle and their status. " +
|
|
"After reading it, begin the session as your instructions describe.",
|
|
};
|
|
|
|
var env = new Dictionary<string, string>
|
|
{
|
|
["MCP_TOOL_TIMEOUT"] = McpToolTimeoutMs,
|
|
};
|
|
|
|
return new LaunchSpec(repoDir, resolvedClaude, args, env);
|
|
}
|
|
|
|
// Builds the LaunchSpec for the fresh session a merge-helper run hands off to for the given
|
|
// phase -- SAME handler task id as the run that called handoff_list_handler, so
|
|
// HandlerBaseCommit/HandlerHeadCommit and the review range stay untouched; this never creates
|
|
// a task. nextPhase picks the system prompt (MergeHelperWait for wait/wait_final,
|
|
// MergeHelperMerge for merge/merge_final) so the handoff session only ever carries the
|
|
// instructions for the role it is actually about to run, never the Triage dedupe/enhance ones.
|
|
// Writes a fresh handoff kickoff file in a NEW session dir -- the old ConPTY tile keeps running
|
|
// against its own session-dir files untouched.
|
|
/// <summary>Builds a LaunchSpec for the fresh ConPTY session a merge-helper run hands off to for
|
|
/// the given phase -- reuses the SAME handler task id (no new task, HandlerBaseCommit untouched),
|
|
/// writing only a fresh handoff kickoff naming the surviving tasks. nextPhase selects both the
|
|
/// system prompt and the model: "wait"/"wait_final" -> MergeHelperWait + HandlerWaitAlias,
|
|
/// "merge"/"merge_final" -> MergeHelperMerge + HandlerMergeAlias; the "_final" variants render an
|
|
/// extra line in the handoff kickoff marking the final round and forbidding further reruns. Throws
|
|
/// ArgumentException for an unrecognized nextPhase; KeyNotFoundException if the task/list doesn't
|
|
/// exist; InvalidOperationException if survivingTaskIds is empty or the list has no working
|
|
/// directory.</summary>
|
|
public async Task<LaunchSpec> BuildForMergeHelperHandoffAsync(
|
|
string taskId, IReadOnlyList<string> survivingTaskIds, string nextPhase, CancellationToken ct)
|
|
{
|
|
if (survivingTaskIds.Count == 0)
|
|
throw new InvalidOperationException("No surviving tasks to hand off.");
|
|
|
|
var phase = ResolveHandoffPhase(nextPhase);
|
|
|
|
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
|
var taskRepo = new TaskRepository(ctx);
|
|
var listRepo = new ListRepository(ctx);
|
|
|
|
var handlerTask = await taskRepo.GetByIdAsync(taskId, ct)
|
|
?? throw new KeyNotFoundException($"Task not found: {taskId}");
|
|
var list = await listRepo.GetByIdAsync(handlerTask.ListId, ct)
|
|
?? throw new KeyNotFoundException($"List not found: {handlerTask.ListId}");
|
|
|
|
var repoDir = list.WorkingDir;
|
|
if (string.IsNullOrEmpty(repoDir) || !Directory.Exists(repoDir))
|
|
throw new InvalidOperationException($"list '{list.Name}' has no existing working directory");
|
|
repoDir = TrimTrailingSeparator(repoDir);
|
|
|
|
var briefLines = new List<string>();
|
|
foreach (var id in survivingTaskIds)
|
|
{
|
|
var task = await taskRepo.GetByIdAsync(id, ct)
|
|
?? throw new KeyNotFoundException($"Task not found: {id}");
|
|
briefLines.Add($"- [{task.Status}] {task.Title} (id: {task.Id})");
|
|
}
|
|
|
|
var sessionDir = Path.Combine(Paths.AppDataRoot(), "merge-helper-sessions", Guid.NewGuid().ToString());
|
|
Directory.CreateDirectory(sessionDir);
|
|
|
|
var systemPromptPath = Path.Combine(sessionDir, "system-prompt.md");
|
|
await File.WriteAllTextAsync(systemPromptPath, PromptFiles.ReadOrDefault(phase.PromptKind), ct);
|
|
|
|
// A "_final" round differs from its non-final counterpart in exactly one rendered line --
|
|
// the same system prompt and model drive both -- marking the final round and forbidding a
|
|
// further rerun, so the chain is bounded structurally rather than by prompt appeal alone.
|
|
var finalNote = phase.IsFinal
|
|
? "\nThis is the FINAL round of this list-handler run -- do not start any further reruns; " +
|
|
"merge what you can, report the rest, and print the summary."
|
|
: "";
|
|
|
|
var briefPath = Path.Combine(sessionDir, "handoff.md");
|
|
await File.WriteAllTextAsync(briefPath, PromptFiles.Render(PromptKind.MergeHelperHandoff,
|
|
new Dictionary<string, string>
|
|
{
|
|
["scope"] = $"List: {list.Name}",
|
|
["repo"] = repoDir,
|
|
["handlerTaskId"] = taskId,
|
|
["tasks"] = string.Join("\n", briefLines),
|
|
["finalNote"] = finalNote,
|
|
}), ct);
|
|
|
|
var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath)
|
|
?? throw new InvalidOperationException($"claude executable not found: {_claudePath}");
|
|
|
|
var settings = await new AppSettingsRepository(ctx).GetAsync(ct);
|
|
|
|
var args = new List<string>
|
|
{
|
|
"--model", phase.Model,
|
|
"--effort", EffortFor(settings, phase.Model),
|
|
"--permission-mode", PermissionModeResolver.Resolve(phase.Model, "auto"),
|
|
"--allowedTools", MergeHelperAllowedTools,
|
|
"--add-dir", sessionDir, repoDir,
|
|
"--append-system-prompt-file", systemPromptPath,
|
|
$"Read the file {briefPath} first. It lists the surviving tasks and their status. " +
|
|
$"After reading it, begin at {phase.PhaseLabel} as your instructions describe.",
|
|
};
|
|
|
|
var env = new Dictionary<string, string>
|
|
{
|
|
["MCP_TOOL_TIMEOUT"] = McpToolTimeoutMs,
|
|
};
|
|
|
|
return new LaunchSpec(repoDir, resolvedClaude, args, env);
|
|
}
|
|
|
|
// The ConPTY host flattens Args into ONE Windows command line and quotes each token, so a token
|
|
// ending in '\' escapes its own closing quote and every following argument is absorbed into the
|
|
// preceding variadic flag -- for a list handler that means --add-dir swallows
|
|
// --append-system-prompt-file AND the positional kickoff, and the session opens with no prompt
|
|
// at all. ListRepository normalizes on write now, but rows written before that still carry one.
|
|
private static string TrimTrailingSeparator(string dir) => Paths.TrimTrailingSeparator(dir)!;
|
|
|
|
// Renders one task as a brief list item. A description can itself be arbitrary Markdown
|
|
// (headings, lists, fenced code) — those must not merge into the brief's own task list, so
|
|
// the description is placed in a fenced code block indented to the list item's continuation
|
|
// column (2 spaces, matching "- "). That keeps CommonMark parsing the fence as part of THIS
|
|
// bullet rather than breaking the list, while the code fence itself stops any inner heading
|
|
// or list syntax from being interpreted. The fence length is extended past the longest run of
|
|
// backticks already present in the description so an embedded ``` block can't prematurely
|
|
// close it.
|
|
private static string RenderBriefEntry(TaskEntity task)
|
|
{
|
|
var header = $"- [{task.Status}] {task.Title} (id: {task.Id})";
|
|
var description = task.Description?.Trim();
|
|
if (string.IsNullOrEmpty(description)) return header;
|
|
|
|
var fence = new string('`', Math.Max(3, LongestBacktickRun(description) + 1));
|
|
var lines = new List<string>(4) { header, $" {fence}" };
|
|
lines.AddRange(description.Replace("\r\n", "\n").Split('\n').Select(line => $" {line}"));
|
|
lines.Add($" {fence}");
|
|
return string.Join("\n", lines);
|
|
}
|
|
|
|
private static int LongestBacktickRun(string text)
|
|
{
|
|
var max = 0;
|
|
var current = 0;
|
|
foreach (var ch in text)
|
|
{
|
|
current = ch == '`' ? current + 1 : 0;
|
|
if (current > max) max = current;
|
|
}
|
|
return max;
|
|
}
|
|
|
|
// Creates the ClaudeDo task that hosts a list-handler run (Mission Control's "Let Claude
|
|
// handle it") and stamps the list repo's current HEAD as the review range's base commit.
|
|
// The handler never gets its own worktree -- it commits straight to the list's working
|
|
// dir -- so this HandlerBaseCommit/HandlerHeadCommit pair (see TaskEntity) is what lets the
|
|
// normal diff/get_task_diff paths show what the run changed once it submits for review.
|
|
// IsManual=true so the queue picker, daily prep, and the "send to queue"/"refine" UI
|
|
// affordances all skip it, matching the "reminder only a human/ConPTY session can act on"
|
|
// semantics IsManual already carries elsewhere; the ConPTY session itself is still allowed.
|
|
/// <summary>Creates the ClaudeDo task that hosts a list-handler run (Mission Control's
|
|
/// "Let Claude handle it") and stamps the list repo's current HEAD as the review range's
|
|
/// base commit (see TaskEntity.HandlerBaseCommit). Returns the new task's id. Throws
|
|
/// KeyNotFoundException if the list doesn't exist; InvalidOperationException if taskIds
|
|
/// is empty or the list has no existing working directory.</summary>
|
|
public async Task<string> CreateMergeHelperTaskAsync(
|
|
IReadOnlyList<string> taskIds, string listId, string title, string descriptionHeader, CancellationToken ct)
|
|
{
|
|
if (taskIds.Count == 0)
|
|
throw new InvalidOperationException("No tasks selected for the list handler.");
|
|
|
|
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
|
var listRepo = new ListRepository(ctx);
|
|
var taskRepo = new TaskRepository(ctx);
|
|
|
|
var list = await listRepo.GetByIdAsync(listId, ct)
|
|
?? throw new KeyNotFoundException($"List not found: {listId}");
|
|
|
|
var repoDir = list.WorkingDir;
|
|
if (string.IsNullOrEmpty(repoDir) || !Directory.Exists(repoDir))
|
|
throw new InvalidOperationException($"list '{list.Name}' has no existing working directory");
|
|
|
|
var descriptionLines = new List<string>();
|
|
foreach (var id in taskIds)
|
|
{
|
|
var task = await taskRepo.GetByIdAsync(id, ct);
|
|
if (task is not null) descriptionLines.Add($"- [{task.Status}] {task.Title} (id: {task.Id})");
|
|
}
|
|
|
|
var baseCommit = await _git.RevParseHeadAsync(repoDir, ct);
|
|
|
|
var handlerTask = new TaskEntity
|
|
{
|
|
Id = Guid.NewGuid().ToString(),
|
|
ListId = listId,
|
|
Title = title,
|
|
Description = descriptionLines.Count > 0
|
|
? $"{descriptionHeader}\n{string.Join("\n", descriptionLines)}"
|
|
: descriptionHeader,
|
|
IsManual = true,
|
|
HandlerBaseCommit = baseCommit,
|
|
CreatedAt = DateTime.UtcNow,
|
|
};
|
|
await taskRepo.AddAsync(handlerTask, ct);
|
|
|
|
return handlerTask.Id;
|
|
}
|
|
|
|
// Maps a handoff nextPhase to the system prompt/model the next session runs under, plus the
|
|
// "phase N" label used in its short positional kickoff. wait/wait_final share MergeHelperWait
|
|
// + HandlerWaitAlias; merge/merge_final share MergeHelperMerge + HandlerMergeAlias -- only
|
|
// IsFinal differs between a phase and its "_final" counterpart.
|
|
private static (PromptKind PromptKind, string Model, string PhaseLabel, bool IsFinal) ResolveHandoffPhase(string nextPhase)
|
|
{
|
|
MergeHelperPhase.Validate(nextPhase);
|
|
return nextPhase switch
|
|
{
|
|
MergeHelperPhase.Wait => (PromptKind.MergeHelperWait, ModelRegistry.HandlerWaitAlias, "phase 3", false),
|
|
MergeHelperPhase.WaitFinal => (PromptKind.MergeHelperWait, ModelRegistry.HandlerWaitAlias, "phase 3", true),
|
|
MergeHelperPhase.Merge => (PromptKind.MergeHelperMerge, ModelRegistry.HandlerMergeAlias, "phase 4", false),
|
|
MergeHelperPhase.MergeFinal => (PromptKind.MergeHelperMerge, ModelRegistry.HandlerMergeAlias, "phase 4", true),
|
|
_ => throw new UnreachableException(),
|
|
};
|
|
}
|
|
|
|
// The reasoning effort configured for a model in Settings → General. Falls back to the shipped
|
|
// preset for that model, so a missing/malformed settings row can never block a launch.
|
|
private static string EffortFor(AppSettingsEntity settings, string? model)
|
|
=> ModelPresets.For(settings.ModelPresets, model ?? settings.DefaultModel).Effort;
|
|
|
|
// Prepends `--effort <level>`. It has to lead: a positional kickoff prompt must stay last, and
|
|
// it may only follow a single-value flag — a variadic flag would swallow it.
|
|
private static IReadOnlyList<string> WithEffort(IReadOnlyList<string> args, string effort)
|
|
{
|
|
var result = new List<string>(args.Count + 2) { "--effort", effort };
|
|
result.AddRange(args);
|
|
return result;
|
|
}
|
|
|
|
private AppSettingsEntity ReadSettings()
|
|
{
|
|
using var ctx = _dbFactory.CreateDbContext();
|
|
return new AppSettingsRepository(ctx).GetAsync().GetAwaiter().GetResult();
|
|
}
|
|
|
|
// The task's brief (title + description, unmodified/multi-line) must never travel as a CLI
|
|
// argument: the ConPTY host flattens Args into one command line to spawn the process, and
|
|
// claude re-splits that line on whitespace, so any token in the description that starts with
|
|
// '-' (e.g. "->", "--abort") gets misread as an unknown option, and a newline would truncate
|
|
// the brief at its first line even if it didn't. So it's written to a file instead --
|
|
// same pattern as BuildForMergeHelperAsync / WindowsTerminalLauncher.BuildPlanningStartArgs --
|
|
// and claude is pointed at it with a single-line kickoff. --add-dir exposes the session dir to
|
|
// Read; --effort (single-value) must sit directly before the positional kickoff so the
|
|
// preceding variadic --add-dir doesn't swallow the kickoff as another directory.
|
|
// No brief (task has neither a title nor a description) -> no positional arg at all.
|
|
// `--session-id` pre-assigns the claude session id the caller already persisted (see
|
|
// BuildForTaskAsync) so this fresh conversation is resumable from its very first turn --
|
|
// it's a single-value flag, so it may sit directly before the positional kickoff.
|
|
private static async Task<IReadOnlyList<string>> BuildFreshTaskArgsAsync(
|
|
TaskEntity task, string effort, string sessionId, CancellationToken ct)
|
|
{
|
|
var brief = BuildTaskBrief(task);
|
|
if (string.IsNullOrEmpty(brief))
|
|
return new[] { "--effort", effort, "--session-id", sessionId };
|
|
|
|
var sessionDir = Path.Combine(Paths.AppDataRoot(), "task-sessions", task.Id);
|
|
Directory.CreateDirectory(sessionDir);
|
|
var briefPath = Path.Combine(sessionDir, "brief.md");
|
|
await File.WriteAllTextAsync(briefPath, brief, ct);
|
|
|
|
return new[]
|
|
{
|
|
"--add-dir", sessionDir,
|
|
"--effort", effort,
|
|
"--session-id", sessionId,
|
|
$"Read the file {briefPath} first. It contains the task you must work on. " +
|
|
"After reading it, begin the session as your instructions describe.",
|
|
};
|
|
}
|
|
|
|
private static string BuildTaskBrief(TaskEntity task)
|
|
{
|
|
var title = task.Title?.Trim();
|
|
var description = task.Description?.Trim();
|
|
return (string.IsNullOrEmpty(title), string.IsNullOrEmpty(description)) switch
|
|
{
|
|
(false, false) => $"{title}\n\n{description}",
|
|
(false, true) => title!,
|
|
(true, false) => description!,
|
|
_ => string.Empty,
|
|
};
|
|
}
|
|
|
|
private async Task<IReadOnlyList<string>> FilterToInstalledSkillsAsync(IReadOnlyList<string> requested, CancellationToken ct)
|
|
{
|
|
if (requested.Count == 0) return requested;
|
|
|
|
var installed = (await _skillRegistry.ListAsync(ct))
|
|
.Select(s => s.Name)
|
|
.ToHashSet(StringComparer.Ordinal);
|
|
return requested.Where(installed.Contains).ToList();
|
|
}
|
|
}
|