Files
ClaudeDo/src/ClaudeDo.Worker/Runner/TaskRunner.cs
T

731 lines
31 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Text.Json;
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Skills;
using ClaudeDo.Worker.State;
using ClaudeDo.Worker.Usage;
using ClaudeDo.Worker.Usage.Interfaces;
using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Runner;
public sealed class TaskRunner
{
private readonly IClaudeProcess _claude;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly HubBroadcaster _broadcaster;
private readonly WorktreeManager _wtManager;
private readonly ClaudeArgsBuilder _argsBuilder;
private readonly WorkerConfig _cfg;
private readonly ILogger<TaskRunner> _logger;
private readonly ITaskStateService _state;
private readonly TaskRunTokenRegistry _tokens;
private readonly AttachmentStore _attachments;
private readonly ISessionSkillSeeder _skillSeeder;
private readonly ITranscriptUsageReader _usageReader;
public TaskRunner(
IClaudeProcess claude,
IDbContextFactory<ClaudeDoDbContext> dbFactory,
HubBroadcaster broadcaster,
WorktreeManager wtManager,
ClaudeArgsBuilder argsBuilder,
WorkerConfig cfg,
ILogger<TaskRunner> logger,
ITaskStateService state,
TaskRunTokenRegistry tokens,
AttachmentStore attachments,
ISessionSkillSeeder skillSeeder,
ITranscriptUsageReader usageReader)
{
_claude = claude;
_dbFactory = dbFactory;
_broadcaster = broadcaster;
_wtManager = wtManager;
_argsBuilder = argsBuilder;
_cfg = cfg;
_logger = logger;
_state = state;
_tokens = tokens;
_attachments = attachments;
_skillSeeder = skillSeeder;
_usageReader = usageReader;
}
public async Task RunAsync(TaskEntity task, string slot, CancellationToken ct, bool alreadyClaimed = false)
{
string? mcpToken = null;
string? mcpConfigPath = null;
try
{
ListEntity? list;
ListConfigEntity? listConfig;
List<SubtaskEntity> subtasks;
List<string>? attachmentPaths = null;
using (var context = _dbFactory.CreateDbContext())
{
var listRepo = new ListRepository(context);
list = await listRepo.GetByIdAsync(task.ListId, ct);
if (list is null)
{
await MarkFailed(task.Id, task.Title, slot, "List not found.");
return;
}
listConfig = await listRepo.GetConfigAsync(task.ListId, ct);
var subtaskRepo = new SubtaskRepository(context);
subtasks = await subtaskRepo.GetByTaskIdAsync(task.Id, ct);
var attachmentRepo = new TaskAttachmentRepository(context);
var attachments = await attachmentRepo.ListByTaskIdAsync(task.Id, ct);
if (attachments.Count > 0)
attachmentPaths = attachments.Select(a => Path.Combine(_attachments.TaskDir(task.Id), a.FileName)).ToList();
}
var now = DateTime.UtcNow;
// Claim Running before creating any resources (worktree, MCP token file, ...):
// the queue picker claims Queued→Running atomically (incl. StartedAt) before
// dispatching, so only unclaimed dispatches (override slot) need to claim here.
// Claiming first means a losing double-dispatch (RunNow racing the picker for the
// same row) bails out immediately instead of creating a worktree the winner then
// has to self-heal past.
if (!alreadyClaimed)
{
var startResult = await _state.StartRunningAsync(task.Id, now, ct);
if (!startResult.Ok)
{
_logger.LogWarning("Task {TaskId} skipped: StartRunningAsync rejected ({Reason})", task.Id, startResult.Reason);
return;
}
}
else
{
// Queue-claimed dispatches skip StartRunningAsync (the atomic SQL claim in
// QueuePicker already flipped the row to Running), so it never broadcasts
// TaskUpdated for this transition. Send it here so the task-list badge flips
// live instead of staying on "Queued" until the run finishes.
await _broadcaster.TaskUpdated(task.Id);
}
await _broadcaster.TaskStarted(slot, task.Id, now);
// Determine working directory: worktree or sandbox.
var prep = await PrepareRunDirectoryAsync(task, list, ct);
if (prep.FailureReason is not null)
{
await MarkFailed(task.Id, task.Title, slot, prep.FailureReason);
return;
}
var wtCtx = prep.WtCtx;
var runDir = prep.RunDir!;
var resolvedConfig = await ResolveConfigAsync(task, list, listConfig, null, ct);
// Every run gets a per-run MCP identity so the agent can ask the user a
// mid-run question via AskUser. Improvement-eligible (standalone top-level)
// runs additionally get SuggestImprovement for filing out-of-scope follow-ups.
mcpToken = TaskRunTokenRegistry.GenerateToken();
_tokens.Register(mcpToken, task.Id);
Directory.CreateDirectory(_cfg.LogRoot);
mcpConfigPath = Path.Combine(_cfg.LogRoot, $"{task.Id}_mcp.json");
await File.WriteAllTextAsync(mcpConfigPath, BuildRunMcpConfigJson(mcpToken), ct);
var improvementEligible = task.ParentTaskId is null && task.PlanningPhase == PlanningPhase.None;
resolvedConfig = resolvedConfig with
{
McpConfigPath = mcpConfigPath,
AllowedTools = improvementEligible
? "mcp__claudedo_run__AskUser,mcp__claudedo_run__SuggestImprovement"
: "mcp__claudedo_run__AskUser",
};
await _skillSeeder.SeedAsync(runDir, resolvedConfig.SkillNames, wtCtx is not null, ct);
// Build prompt: title + description + only the OPEN sub-tasks (resolved ones are dropped).
var prompt = TaskPromptComposer.Compose(
task.Title, task.Description,
subtasks.Select(s => (s.Title, s.Completed)),
attachmentPaths);
// Run 1.
var result = await RunOnceAsync(task.Id, task.Title, slot, runDir, resolvedConfig, 1, false, prompt, ct);
if (result.IsSuccess)
{
await HandleSuccess(task, list, slot, wtCtx, result, ct);
}
else
{
// Auto-retry: one attempt if we have a session ID.
if (result.SessionId is not null)
{
_logger.LogInformation("Auto-retrying task {TaskId} with session {SessionId}", task.Id, result.SessionId);
var retryConfig = resolvedConfig with { ResumeSessionId = result.SessionId };
var retryPrompt = BuildRetryPrompt(result.ErrorMarkdown);
var retryResult = await RunOnceAsync(task.Id, task.Title, slot, runDir, retryConfig, 2, true, retryPrompt, ct);
if (retryResult.IsSuccess)
{
await HandleSuccess(task, list, slot, wtCtx, retryResult, ct);
}
else
{
await MarkFailed(task.Id, task.Title, slot, retryResult.ErrorMarkdown, retryResult.TurnCount);
}
}
else
{
await MarkFailed(task.Id, task.Title, slot, result.ErrorMarkdown, result.TurnCount);
}
}
await _broadcaster.TaskUpdated(task.Id);
}
catch (OperationCanceledException)
{
_logger.LogInformation("Task {TaskId} was cancelled", task.Id);
await MarkFailed(task.Id, task.Title, slot, "Task cancelled.");
}
catch (Exception ex)
{
_logger.LogError(ex, "Unhandled exception running task {TaskId}", task.Id);
await MarkFailed(task.Id, task.Title, slot, $"Unhandled error: {ex.Message}");
}
finally
{
if (mcpToken is not null)
{
_tokens.Unregister(mcpToken);
if (mcpConfigPath is not null)
try { File.Delete(mcpConfigPath); } catch { /* best effort */ }
}
}
}
public async Task ContinueAsync(string taskId, string followUpPrompt, string slot, CancellationToken ct, bool alreadyClaimed = false)
{
TaskEntity task;
TaskRunEntity lastRun;
ListEntity list;
ListConfigEntity? listConfig;
WorktreeEntity? worktree;
using (var context = _dbFactory.CreateDbContext())
{
var taskRepo = new TaskRepository(context);
task = await taskRepo.GetByIdAsync(taskId, ct)
?? throw new KeyNotFoundException($"Task '{taskId}' not found.");
var runRepo = new TaskRunRepository(context);
lastRun = await runRepo.GetLatestByTaskIdAsync(taskId, ct)
?? throw new InvalidOperationException("No previous run to continue.");
if (lastRun.SessionId is null)
throw new InvalidOperationException("Previous run has no session ID — cannot resume.");
var listRepo = new ListRepository(context);
list = await listRepo.GetByIdAsync(task.ListId, ct)
?? throw new InvalidOperationException("List not found.");
listConfig = await listRepo.GetConfigAsync(task.ListId, ct);
var wtRepo = new WorktreeRepository(context);
worktree = await wtRepo.GetByTaskIdAsync(taskId, ct);
}
var resolvedConfig = await ResolveConfigAsync(task, list, listConfig, lastRun.SessionId, ct);
// Determine run directory from existing worktree or sandbox.
string runDir;
WorktreeContext? wtCtx = null;
if (worktree is not null)
{
runDir = worktree.Path;
wtCtx = new WorktreeContext(worktree.Path, worktree.BranchName, worktree.BaseCommit);
}
else
{
runDir = Path.Combine(_cfg.SandboxRoot, taskId);
}
var now = DateTime.UtcNow;
// See RunAsync: queue dispatches arrive pre-claimed by the picker.
if (!alreadyClaimed)
{
var startResult = await _state.StartRunningAsync(taskId, now, ct);
if (!startResult.Ok)
{
_logger.LogWarning("Task {TaskId} skipped: StartRunningAsync rejected ({Reason})", taskId, startResult.Reason);
return;
}
}
else
{
// Queue-claimed dispatches skip StartRunningAsync, so broadcast TaskUpdated here
// (see RunAsync for the full rationale).
await _broadcaster.TaskUpdated(taskId);
}
await _broadcaster.TaskStarted(slot, taskId, now);
await _skillSeeder.SeedAsync(runDir, resolvedConfig.SkillNames, wtCtx is not null, ct);
try
{
var nextRunNumber = lastRun.RunNumber + 1;
var result = await RunOnceAsync(taskId, task.Title, slot, runDir, resolvedConfig, nextRunNumber, false, followUpPrompt, ct);
if (result.IsSuccess)
{
await HandleSuccess(task, list, slot, wtCtx, result, ct);
}
else
{
await MarkFailed(taskId, task.Title, slot, result.ErrorMarkdown, result.TurnCount);
}
await _broadcaster.TaskUpdated(taskId);
}
catch (OperationCanceledException)
{
_logger.LogInformation("Task {TaskId} was cancelled during continue", taskId);
await MarkFailed(taskId, task.Title, slot, "Task cancelled.");
}
catch (Exception ex)
{
_logger.LogError(ex, "Unhandled exception continuing task {TaskId}", taskId);
await MarkFailed(taskId, task.Title, slot, $"Unhandled error: {ex.Message}");
}
}
private readonly record struct RunDirResult(string? RunDir, WorktreeContext? WtCtx, string? FailureReason);
private async Task<RunDirResult> PrepareRunDirectoryAsync(TaskEntity task, ListEntity list, CancellationToken ct)
{
if (list.WorkingDir is not null)
{
try
{
var wtCtx = await _wtManager.CreateAsync(task, list, ct);
await _broadcaster.WorkerLog($"Created worktree for \"{task.Title}\"", WorkerLogLevel.Info, DateTime.UtcNow);
// The worktrees row was just inserted; without this the UI keeps showing the task
// as having no worktree until some unrelated event happens to refresh it.
await _broadcaster.WorktreeUpdated(task.Id);
return new RunDirResult(wtCtx.WorktreePath, wtCtx, null);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create worktree for task {TaskId}", task.Id);
return new RunDirResult(null, null, $"Worktree creation failed: {ex.Message}");
}
}
var sandboxDir = Path.Combine(_cfg.SandboxRoot, task.Id);
Directory.CreateDirectory(sandboxDir);
return new RunDirResult(sandboxDir, null, null);
}
private async Task<RunResult> RunOnceAsync(
string taskId, string taskTitle, string slot, string runDir, ClaudeRunConfig config,
int runNumber, bool isRetry, string prompt, CancellationToken ct)
{
var runId = Guid.NewGuid().ToString();
var logPath = Path.Combine(_cfg.LogRoot, $"{taskId}_run{runNumber}.ndjson");
var run = new TaskRunEntity
{
Id = runId,
TaskId = taskId,
RunNumber = runNumber,
IsRetry = isRetry,
Prompt = prompt,
LogPath = logPath,
StartedAt = DateTime.UtcNow,
Model = config.Model,
};
using (var context = _dbFactory.CreateDbContext())
{
var runRepo = new TaskRunRepository(context);
await runRepo.AddAsync(run, ct);
// Point the task at this run's log immediately so the UI can replay
// live output when the user navigates away and back mid-run.
var taskRepo = new TaskRepository(context);
await taskRepo.SetLogPathAsync(taskId, logPath, ct);
}
var arguments = _argsBuilder.Build(config);
await using var logWriter = new LogWriter(logPath);
try
{
await _broadcaster.WorkerLog($"Started Claude for \"{taskTitle}\"", WorkerLogLevel.Info, DateTime.UtcNow);
var result = await _claude.RunAsync(
arguments,
prompt,
runDir,
async line =>
{
await logWriter.WriteLineAsync(line, ct);
await _broadcaster.TaskMessage(taskId, "[stdout] " + line);
},
ct);
if (!result.IsSuccess)
result = result with { ErrorMarkdown = BuildFailureMarkdown(result, config.MaxTurns) };
// Update the run record with results. Use CancellationToken.None:
// this is a terminal write that must always complete, even if the
// caller's token is already cancelled.
run.SessionId = result.SessionId;
run.ResultMarkdown = result.ResultMarkdown;
run.StructuredOutputJson = result.StructuredOutputJson;
run.ErrorMarkdown = result.ErrorMarkdown;
run.ExitCode = result.ExitCode;
run.TurnCount = result.TurnCount;
if (result.SessionId is not null)
await ApplyUsageAsync(run, taskId, result.SessionId);
run.FinishedAt = DateTime.UtcNow;
using (var context = _dbFactory.CreateDbContext())
{
var runRepo = new TaskRunRepository(context);
await runRepo.UpdateAsync(run, CancellationToken.None);
}
return result;
}
catch (OperationCanceledException)
{
// Ensure the run row is completed so ContinueAsync / inspection
// isn't left staring at a null session_id / finished_at.
run.ErrorMarkdown = "Cancelled.";
run.ExitCode = -1;
run.FinishedAt = DateTime.UtcNow;
try
{
using var context = _dbFactory.CreateDbContext();
var runRepo = new TaskRunRepository(context);
await runRepo.UpdateAsync(run, CancellationToken.None);
}
catch (Exception updateEx)
{
_logger.LogError(updateEx, "Failed to finalize cancelled run {RunId} for task {TaskId}", runId, taskId);
}
throw;
}
}
/// Populates the run's raw token fields from the session transcript (input, output,
/// cache-read, cache-write — the API's "input_tokens" alone is only the uncached
/// remainder and undercounts the real prompt size by orders of magnitude). A resumed
/// session's transcript is cumulative, so the delta against prior runs sharing the same
/// SessionId is stored, not the running total. Any failure here (missing/unreadable
/// transcript) leaves the fields null and must never fail the run itself.
private async Task ApplyUsageAsync(TaskRunEntity run, string taskId, string sessionId)
{
try
{
var totals = await _usageReader.ReadSessionTotalsAsync(sessionId, CancellationToken.None);
if (totals is null) return;
List<TaskRunEntity> priorRuns;
using (var context = _dbFactory.CreateDbContext())
priorRuns = await new TaskRunRepository(context).GetByTaskIdAsync(taskId, CancellationToken.None);
var sameSession = priorRuns.Where(r => r.Id != run.Id && r.SessionId == sessionId).ToList();
var priorInput = sameSession.Sum(r => (long)(r.TokensIn ?? 0));
var priorOutput = sameSession.Sum(r => (long)(r.TokensOut ?? 0));
var priorCacheRead = sameSession.Sum(r => (long)(r.CacheReadTokens ?? 0));
var priorCacheWrite = sameSession.Sum(r => (long)(r.CacheWriteTokens ?? 0));
run.TokensIn = (int)Math.Max(0, totals.InputTokens - priorInput);
run.TokensOut = (int)Math.Max(0, totals.OutputTokens - priorOutput);
run.CacheReadTokens = (int)Math.Max(0, totals.CacheReadTokens - priorCacheRead);
run.CacheWriteTokens = (int)Math.Max(0, totals.CacheCreationTokens - priorCacheWrite);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to read session usage totals for task {TaskId}, session {SessionId}", taskId, sessionId);
}
}
private async Task HandleSuccess(TaskEntity task, ListEntity list, string slot, WorktreeContext? wtCtx, RunResult result, CancellationToken ct)
{
if (wtCtx is not null)
{
var committed = await _wtManager.CommitIfChangedAsync(wtCtx, task, list, ct);
if (committed)
{
await _broadcaster.WorkerLog($"Committed changes in \"{task.Title}\"", WorkerLogLevel.Info, DateTime.UtcNow);
await _broadcaster.WorktreeUpdated(task.Id);
}
}
// Terminal DB write uses CancellationToken.None so the task status
// is never left as 'running' because of a cancel that arrived
// after the Claude run already succeeded.
// Standalone tasks gate on review; planning children go straight to Done
// so the sequential chain (which advances on terminal states) is unaffected.
// Planning parents (PlanningPhase != None) are containers, not reviewable work.
var finishedAt = DateTime.UtcNow;
using (var ctx = _dbFactory.CreateDbContext())
{
await new TaskRepository(ctx).SetRoadblockCountAsync(task.Id, result.Blocks.Count, CancellationToken.None);
}
var reviewResult = ComposeReviewResult(result.ResultMarkdown, result.Blocks);
bool isStandalone = task.ParentTaskId is null && task.PlanningPhase == PlanningPhase.None;
List<TaskEntity> pendingChildren = new();
if (isStandalone)
{
using var ctx = _dbFactory.CreateDbContext();
var children = await new TaskRepository(ctx).GetChildrenAsync(task.Id, CancellationToken.None);
pendingChildren = children
.Where(c => c.Status is TaskStatus.Idle or TaskStatus.Queued)
.ToList();
}
if (isStandalone && pendingChildren.Count > 0)
{
await _state.SubmitForChildrenAsync(task.Id, finishedAt, reviewResult, CancellationToken.None);
foreach (var child in pendingChildren)
await _state.EnqueueAsync(child.Id, CancellationToken.None);
await _broadcaster.WorkerLog(
$"Finished \"{task.Title}\" (waiting on {pendingChildren.Count} improvement(s))",
WorkerLogLevel.Success, DateTime.UtcNow);
await _broadcaster.TaskFinished(slot, task.Id, "waiting_for_children", finishedAt);
}
else if (isStandalone)
{
await _state.SubmitForReviewAsync(task.Id, finishedAt, reviewResult, CancellationToken.None);
await _broadcaster.WorkerLog($"Finished \"{task.Title}\" (waiting for review)", WorkerLogLevel.Success, DateTime.UtcNow);
await _broadcaster.TaskFinished(slot, task.Id, "waiting_for_review", finishedAt);
}
else
{
await _state.CompleteAsync(task.Id, finishedAt, reviewResult, CancellationToken.None);
await _broadcaster.WorkerLog($"Finished \"{task.Title}\" (done)", WorkerLogLevel.Success, DateTime.UtcNow);
await _broadcaster.TaskFinished(slot, task.Id, "done", finishedAt);
}
_logger.LogInformation("Task {TaskId} completed (turns={Turns}, tokens_in={In}, tokens_out={Out})",
task.Id, result.TurnCount, result.TokensIn, result.TokensOut);
}
private async Task MarkFailed(string taskId, string taskTitle, string slot, string? error, int turnCount = 0)
{
// Terminal write for a failed task: never cancel (the status must always
// be persisted) and never throw (a logging failure must not mask the error).
try
{
var finishedAt = DateTime.UtcNow;
await _state.FailAsync(taskId, finishedAt, error, CancellationToken.None);
await _broadcaster.WorkerLog($"Finished \"{taskTitle}\" (failed)", WorkerLogLevel.Error, DateTime.UtcNow);
await _broadcaster.TaskFinished(slot, taskId, "failed", finishedAt);
_logger.LogWarning("Task {TaskId} failed (turns={Turns}): {Error}", taskId, turnCount, error);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to mark task {TaskId} as failed", taskId);
}
}
private string BuildRunMcpConfigJson(string token)
{
var payload = new
{
mcpServers = new
{
claudedo_run = new
{
type = "http",
url = $"http://127.0.0.1:{_cfg.SignalRPort}/mcp",
headers = new Dictionary<string, string>
{
["Authorization"] = $"Bearer {token}",
},
},
},
};
return JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true });
}
private async Task<ClaudeRunConfig> ResolveConfigAsync(
TaskEntity task, ListEntity list, ListConfigEntity? listConfig, string? resumeSessionId, CancellationToken ct)
{
AppSettingsEntity global;
using (var ctx = _dbFactory.CreateDbContext())
{
var settingsRepo = new AppSettingsRepository(ctx);
global = await settingsRepo.GetAsync(ct);
}
var systemFile = PromptFiles.ReadOrDefault(PromptKind.System);
// Improvement children (filed via SuggestImprovement; CreatedBy == ParentTaskId) get a
// narrow follow-up prompt so they stay tightly scoped instead of doing "too much".
var isImprovementChild = task.ParentTaskId is not null && task.CreatedBy == task.ParentTaskId;
var improvementPrompt = isImprovementChild ? PromptFiles.ReadOrDefault(PromptKind.ImprovementChild) : null;
var findingsPointer = BuildFindingsPointer(list.WorkingDir);
var instructions = MergeInstructions(
systemFile, improvementPrompt, global.DefaultClaudeInstructions, findingsPointer,
listConfig?.SystemPrompt, task.SystemPrompt);
var requestedSkills = UnionSkillNames(task.SessionSkills, listConfig?.SessionSkills, global.SessionSkills);
var skillNames = await FilterToInstalledSkillsAsync(task.Id, requestedSkills, ct);
// Model/max-turns/effort/agent-path resolution is shared with get_effective_run_config
// so the two can never report different numbers for the same task.
var effective = EffectiveRunConfigResolver.Resolve(task, listConfig, global, systemFile, improvementPrompt);
if (effective.MaxTurnsClamped)
{
_logger.LogWarning(
"Task {TaskId}: max turns clamped to ceiling (requested={Requested}, effective={Effective}, ceiling={Ceiling})",
task.Id, effective.RequestedMaxTurns, effective.MaxTurns, global.MaxTurnsCeiling);
}
return new ClaudeRunConfig(
Model: effective.Model,
SystemPrompt: string.IsNullOrWhiteSpace(instructions) ? null : instructions,
AgentPath: effective.AgentPath,
ResumeSessionId: resumeSessionId,
MaxTurns: effective.MaxTurns,
PermissionMode: effective.PermissionMode,
SkillNames: skillNames,
Effort: effective.Effort);
}
private async Task<IReadOnlyList<string>> FilterToInstalledSkillsAsync(
string taskId, IReadOnlyList<string> requestedSkills, CancellationToken ct)
{
if (requestedSkills.Count == 0) return requestedSkills;
List<SessionSkillEntity> installed;
using (var ctx = _dbFactory.CreateDbContext())
{
var skillRepo = new SessionSkillRepository(ctx);
installed = (await skillRepo.ListAsync(ct)).ToList();
}
var installedNames = installed.Select(s => s.Name).ToHashSet(StringComparer.Ordinal);
var resolved = FilterToInstalled(requestedSkills, installedNames);
var dropped = requestedSkills.Where(n => !installedNames.Contains(n)).ToList();
if (dropped.Count > 0)
{
_logger.LogWarning(
"Task {TaskId}: dropped unknown session skill(s) not found in registry: {SkillNames}",
taskId, string.Join(", ", dropped));
}
return resolved;
}
/// <summary>Shared with get_effective_run_config so reported skill names match what a run
/// would actually filter down to.</summary>
internal static IReadOnlyList<string> FilterToInstalled(
IReadOnlyList<string> requestedSkills, IReadOnlySet<string> installedNames)
=> requestedSkills.Where(installedNames.Contains).ToList();
internal static IReadOnlyList<string> UnionSkillNames(params string?[] jsonArrays)
{
var names = new List<string>();
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var json in jsonArrays)
{
if (string.IsNullOrWhiteSpace(json)) continue;
string[]? parsed;
try
{
parsed = JsonSerializer.Deserialize<string[]>(json);
}
catch (JsonException)
{
continue;
}
if (parsed is null) continue;
foreach (var name in parsed)
{
if (!string.IsNullOrWhiteSpace(name) && seen.Add(name))
names.Add(name);
}
}
return names;
}
internal static int? ResolveMaxTurns(int? taskTurns, int? listTurns, int globalDefault, int ceiling)
=> Math.Min(taskTurns ?? listTurns ?? globalDefault, ceiling);
/// <summary>
/// Points the run at the project's findings index. Deliberately terse and emitted only when the
/// index exists: it enters the prefix of every message in the run, so its cost is multiplied by
/// the turn count. The closing nudge to record findings is what keeps the store fed — do not drop
/// it when trimming.
/// </summary>
internal static string? BuildFindingsPointer(string? workingDir)
{
if (string.IsNullOrWhiteSpace(workingDir)) return null;
var index = Path.Combine(workingDir, ".claudedo", "INDEX.md");
if (!File.Exists(index)) return null;
return $"Known traps for this project are indexed at {index}. Read that index before exploring "
+ "the code, and open only the findings that touch your task. When you hit a lasting, "
+ "non-obvious trap yourself, record it with the save_finding tool.";
}
public static string MergeInstructions(params string?[] parts)
{
var trimmed = parts
.Where(p => !string.IsNullOrWhiteSpace(p))
.Select(p => p!.Trim());
return string.Join("\n\n", trimmed);
}
public static string? BuildFailureMarkdown(RunResult result, int? configuredMaxTurns)
{
switch (result.TerminalReason)
{
case "max_turns":
var ofConfigured = configuredMaxTurns is int m ? $" von {m}" : "";
return $"Turn-Budget erschöpft ({result.TurnCount}{ofConfigured} Turns). "
+ "Erhöhe `maxTurns` am Task (`set_task_config`) und stelle den Task erneut in die Queue "
+ " der Worktree bleibt erhalten.";
case "api_error":
if (!string.IsNullOrWhiteSpace(result.ResultMarkdown)) return result.ResultMarkdown;
if (result.Errors.Count > 0) return string.Join(" ", result.Errors);
return result.ErrorMarkdown;
default:
return string.IsNullOrWhiteSpace(result.TerminalReason)
? result.ErrorMarkdown
: $"{result.ErrorMarkdown} (terminal_reason: {result.TerminalReason})";
}
}
public static string BuildRetryPrompt(string? capturedError)
{
var basePrompt = PromptFiles.ReadOrDefault(PromptKind.Retry);
var isReal = !string.IsNullOrWhiteSpace(capturedError)
&& !capturedError!.StartsWith(ClaudeProcess.NoResultPrefix, StringComparison.Ordinal);
return isReal
? $"{basePrompt}\n\nCaptured error from the failed run:\n\n{capturedError!.Trim()}"
: basePrompt;
}
public static string? ComposeReviewResult(string? result, IReadOnlyList<string> blocks)
{
if (blocks.Count == 0) return result;
var section = "⚠ Roadblocks reported during the run:\n"
+ string.Join('\n', blocks.Select(b => $"- {b}"));
return string.IsNullOrWhiteSpace(result) ? section : $"{result}\n\n{section}";
}
}