Files
ClaudeDo/src/ClaudeDo.Worker/External/ExternalMcpService.cs
T
Mika Kuns c25732cff8 feat(claude-do): depends_on als Menge statt Einzelfeld
Ein Task kann jetzt auf beliebig viele Vorgaenger warten. Die Einzelspalte
tasks.depends_on_task_id wird durch die Beziehungstabelle task_dependencies
ersetzt (Composite-PK, beide FKs ON DELETE CASCADE -- das reproduziert das
alte ON DELETE SET NULL: geloeschter Vorgaenger = Kante weg = Task wieder
pickbar). BlockedByTaskId bleibt unberuehrt.

- Migration AddTaskDependencies kopiert jede bestehende depends_on_task_id
  verlustfrei in die Tabelle, bevor die Spalte fallengelassen wird
- QueuePicker gatet per doppelt negiertem NOT EXISTS: kein Task mit 0 Kanten
  wird blockiert, alle Kanten muessen done sein, eine Kante auf einen
  verschwundenen Task blockiert weiter
- SetDependsOnAsync ersetzt die ganze Menge; Zykluspruefung ist jetzt eine
  BFS ueber den Graphen, faengt also auch A->B->C->A
- blockedReason nennt alle offenen Vorgaenger (External/TaskBlocking, von
  ExternalMcpService und TaskWaitMcpTools gemeinsam genutzt)
- add_task/update_task nehmen dependsOnTaskIds; die alte Skalarform bleibt
  als Ein-Element-Menge inkl. ""-Clear-Sentinel gueltig
- UI: Fan-in-Zeile bleibt flach und listet alle Vorgaenger im After-Label,
  die lineare Kette rendert unveraendert
2026-09-02 15:08:05 +02:00

1081 lines
54 KiB
C#

using System.ComponentModel;
using System.Diagnostics;
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Git;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Lifecycle;
using ClaudeDo.Worker.Planning;
using ClaudeDo.Worker.Queue;
using ClaudeDo.Worker.State;
using ClaudeDo.Worker.Worktrees;
using Microsoft.EntityFrameworkCore;
using ModelContextProtocol;
using ModelContextProtocol.Server;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.External;
public sealed record TaskListDto(string Id, string Name, string? WorkingDir);
public sealed record DeleteTaskResult(bool Deleted, string Id, int Number);
public sealed record CancelTaskResult(bool Cancelled, string Id, int? Number = null);
// EmptyChildren is non-null only for a parent's approve (unit merge): the Done children whose
// review range (worktree ahead, or HandlerBaseCommit..HandlerHeadCommit for a worktree-less
// child) contributed nothing, so a reviewer sees them before approving instead of after.
public sealed record ReviewTaskResult(TaskRefDto Task, string? MergeStatus, IReadOnlyList<string> MergeConflicts, string? MergeMessage, string? RepoPath = null, IReadOnlyList<TaskRefDto>? EmptyChildren = null);
public sealed record RunTaskNowResult(bool Started, string TaskId, DirtyBaseWarning? BaseDirty = null, int? Number = null);
public sealed record TaskDto(
string Id,
int Number,
string ListId,
string Title,
string? Description,
string Status,
string? Result,
string? CreatedBy,
DateTime CreatedAt,
DateTime? StartedAt,
DateTime? FinishedAt,
bool IsMyDay,
int SortOrder,
// Count of CLAUDEDO_BLOCKED roadblocks the run reported, stamped by TaskRunner on finish.
// A planning/improvement child reporting > 0 still goes straight to Done (see
// ClaudeDo.Worker/CLAUDE.md → Unified parent model) -- this is the only MCP-visible signal
// that it may have delivered nothing despite that Done status.
int RoadblockCount = 0,
// The three below are non-null only when Status=Failed; stamped by TaskRunner.MarkFailed via
// TaskStateService.FailAsync. failureReason is "unknown" for a Failed task that predates this
// field. Lets a caller triage without pulling get_task_log's raw NDJSON.
string? FailureReason = null,
int? FailureTurnsUsed = null,
int? FailureMaxTurns = null,
// User/MCP-declared predecessors (set via add_task/update_task), distinct from the planning
// chain's own internal BlockedByTaskId link. Empty unless the task declares dependencies.
IReadOnlyList<string>? DependsOnTaskIds = null,
// True only while Status is Queued and the picker will not claim this task yet -- either a
// planning-chain predecessor or some dependsOnTaskIds entry hasn't reached Done. See BlockedReason.
bool Blocked = false,
string? BlockedReason = null,
bool IsManual = false,
bool ExpectsNoDiff = false);
// Lean counterpart to TaskDto for writing/status-changing tools: echoes back what changed
// without re-sending Description/Result, which the caller just sent or already has.
// BaseDirty is populated only where the task just transitioned to Queued (or ran immediately
// via run_task_now) and its list's working directory has uncommitted changes -- see
// ClaudeDo.Worker.Git.BaseDirtyChecker. Every other caller leaves it null.
public sealed record TaskRefDto(
string Id,
int Number,
string ListId,
string Title,
string Status,
int SortOrder,
bool IsMyDay,
int RoadblockCount = 0,
string? FailureReason = null,
int? FailureTurnsUsed = null,
int? FailureMaxTurns = null,
IReadOnlyList<string>? DependsOnTaskIds = null,
bool Blocked = false,
string? BlockedReason = null,
DirtyBaseWarning? BaseDirty = null,
bool IsManual = false,
bool ExpectsNoDiff = false);
// tasks is populated when includeDescription=false (the default): lean references, no
// Description/Result. tasksFull is populated when includeDescription=true: full tasks incl.
// Description/Result. Exactly one of the two is non-null per the includeDescription flag —
// same "flag alongside nullable payload" idiom as BatchGetTaskResult.
public sealed record ListTasksResult(
bool IncludeDescription,
IReadOnlyList<TaskRefDto>? Tasks,
IReadOnlyList<TaskDto>? TasksFull);
// Deliberately small: no descriptions, capped candidate count (see AddTask's duplicate check).
public sealed record PossibleDuplicateDto(string TaskId, int Number, string Title, string Status);
public sealed record AddTaskResult(TaskRefDto Task, IReadOnlyList<PossibleDuplicateDto> PossibleDuplicates);
public sealed record DailyPrepCandidateDto(
string Id, int Number, string ListId, string ListName, string Title, string? Description,
bool IsStarred, DateTime? ScheduledFor, DateTime CreatedAt);
public sealed record DailyPrepDataDto(
int MaxTasks,
IReadOnlyList<DailyPrepCandidateDto> Candidates,
IReadOnlyList<DailyPrepCandidateDto> CurrentMyDay);
[McpServerToolType]
public sealed class ExternalMcpService
{
// Mirrors TaskMergeService.ProgressReportInterval / TaskWaitMcpTools.ProgressReportInterval:
// a separate field (not shared) so shrinking one for a test can't race another's tests. Used
// by the single-element long-runners here (GetTaskDiff, CleanupTaskWorktree) that wrap a bare
// GitService/WorktreeMaintenanceService call with no natural i/n to report instead.
internal static TimeSpan ProgressReportInterval = TimeSpan.FromSeconds(30);
private readonly TaskRepository _tasks;
private readonly ListRepository _lists;
private readonly QueueService _queue;
private readonly HubBroadcaster _broadcaster;
private readonly ITaskStateService _state;
private readonly GitService _git;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly TaskMergeService _merge;
private readonly PlanningMergeOrchestrator _planningMerge;
private readonly BaseDirtyChecker _baseDirtyChecker;
public ExternalMcpService(
TaskRepository tasks,
ListRepository lists,
QueueService queue,
HubBroadcaster broadcaster,
ITaskStateService state,
GitService git,
IDbContextFactory<ClaudeDoDbContext> dbFactory,
TaskMergeService merge,
PlanningMergeOrchestrator planningMerge,
BaseDirtyChecker baseDirtyChecker)
{
_tasks = tasks;
_lists = lists;
_queue = queue;
_broadcaster = broadcaster;
_state = state;
_git = git;
_dbFactory = dbFactory;
_merge = merge;
_planningMerge = planningMerge;
_baseDirtyChecker = baseDirtyChecker;
}
[McpServerTool, Description(
"List all task lists available in ClaudeDo. Start here — every task tool needs a listId from this call.")]
public async Task<IReadOnlyList<TaskListDto>> ListTaskLists(CancellationToken cancellationToken)
{
var lists = await _lists.GetAllAsync(cancellationToken);
return lists.Select(l => new TaskListDto(l.Id, l.Name, l.WorkingDir)).ToList();
}
[McpServerTool, Description(
"List the tasks in one list — the usual way to find a taskId. Optionally filter by creator and/or status." +
McpToolDocs.TaskNumberHint)]
public async Task<ListTasksResult> ListTasks(
string listId,
[Description("Only return tasks with this CreatedBy value.")]
string? createdBy = null,
[Description("Only return tasks in this status: Idle, Queued, Running, WaitingForReview, " +
"WaitingForChildren, Done, Failed or Cancelled.")]
string? status = null,
[Description("false (default): lean references in `tasks`, no Description/Result — keep this unless you " +
"need the description text, since verbosely-described tasks can blow past the response size " +
"limit. true: full tasks in `tasksFull` instead (`tasks` is then null).")]
bool includeDescription = false,
CancellationToken cancellationToken = default)
{
TaskStatus? statusFilter = null;
if (!string.IsNullOrWhiteSpace(status))
{
if (!Enum.TryParse<TaskStatus>(status, ignoreCase: true, out var parsed))
throw new InvalidOperationException(
$"Unknown status '{status}'. Valid values: Idle, Queued, Running, WaitingForReview, WaitingForChildren, Done, Failed, Cancelled.");
statusFilter = parsed;
}
var tasks = await _tasks.GetByListIdAsync(listId, cancellationToken);
IEnumerable<TaskEntity> query = tasks;
if (createdBy is not null)
query = query.Where(t => t.CreatedBy == createdBy);
if (statusFilter is not null)
query = query.Where(t => t.Status == statusFilter);
var filtered = query.ToList();
var blocked = await ComputeBlockedInfoAsync(filtered, cancellationToken);
return includeDescription
? new ListTasksResult(true, null, filtered.Select(t => ToDto(t, blocked[t.Id].Blocked, blocked[t.Id].Reason)).ToList())
: new ListTasksResult(false, filtered.Select(t => ToRefDto(t, blocked[t.Id].Blocked, blocked[t.Id].Reason)).ToList(), null);
}
// Batch-resolves, for each task, whether the picker is currently skipping it (Queued with
// either a planning-chain BlockedByTaskId or an unmet dependency) and why. Only Queued
// tasks can be blocked -- once a task has run, or hasn't been queued yet, blocking is moot.
private async Task<Dictionary<string, (bool Blocked, string? Reason)>> ComputeBlockedInfoAsync(
IReadOnlyList<TaskEntity> tasks, CancellationToken ct)
{
var dependencyIds = tasks
.Where(t => t.Status == TaskStatus.Queued && t.BlockedByTaskId is null)
.SelectMany(t => t.DependsOnTaskIds)
.Distinct()
.ToList();
var dependencyStatuses = new Dictionary<string, TaskStatus>();
if (dependencyIds.Count > 0)
{
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
dependencyStatuses = await ctx.Tasks.AsNoTracking()
.Where(t => dependencyIds.Contains(t.Id))
.Select(t => new { t.Id, t.Status })
.ToDictionaryAsync(t => t.Id, t => t.Status, ct);
}
var result = new Dictionary<string, (bool Blocked, string? Reason)>();
foreach (var t in tasks)
{
if (t.Status != TaskStatus.Queued)
{
result[t.Id] = (false, null);
}
else if (t.BlockedByTaskId is not null)
{
result[t.Id] = (true, $"Blocked by planning-chain predecessor {t.BlockedByTaskId}.");
}
else
{
var unmet = TaskBlocking.Unmet(t.DependsOnTaskIds, dependencyStatuses);
result[t.Id] = unmet.Count == 0 ? (false, null) : (true, TaskBlocking.Describe(unmet));
}
}
return result;
}
[McpServerTool, Description(
"Get a single task by id, including its current status and result — the canonical reference for what a " +
"status means. Lifecycle: Idle → Queued → Running → WaitingForReview → Done | Failed | Cancelled. " +
"A successful run lands in WaitingForReview; use review_task to approve, reject or cancel it. " +
"Done/Failed/Cancelled tasks can be reset to Idle for re-execution. A Queued task with a blocker waits " +
"for its predecessor before the picker will claim it, and WaitingForChildren is a parent whose own work " +
"is done but whose children are still running. For Status=Failed, failureReason (max_turns|timeout|" +
"usage_limit|error|cancelled|unknown) plus failureTurnsUsed/failureMaxTurns say why without pulling get_task_log." +
McpToolDocs.TaskNumberHint)]
public async Task<TaskDto> GetTask(string taskId, CancellationToken cancellationToken)
{
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
var blocked = await ComputeBlockedInfoAsync([task], cancellationToken);
return ToDto(task, blocked[task.Id].Blocked, blocked[task.Id].Reason);
}
// Lean counterpart to GetTask, used internally by BatchGetTasks' default (includeDescription=false)
// path. Not an MCP tool itself — GetTask's own behavior stays untouched.
internal async Task<TaskRefDto> GetTaskRefAsync(string taskId, CancellationToken cancellationToken)
{
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
var blocked = await ComputeBlockedInfoAsync([task], cancellationToken);
return ToRefDto(task, blocked[task.Id].Blocked, blocked[task.Id].Reason);
}
[McpServerTool, Description(
"Create a new task in the given list. The task is always created — possibleDuplicates is a non-blocking " +
"heads-up (up to 3 open tasks in the same list with a strongly overlapping title); check it and mention " +
"any hit to the caller, but do not treat it as an error." + McpToolDocs.LeanTaskRef + McpToolDocs.TaskNumberHint)]
public async Task<AddTaskResult> AddTask(
string listId,
string title,
string? description = null,
string? createdBy = null,
[Description("true: enqueue the task for agent execution right away.")]
bool queueImmediately = false,
[Description("Cheapest model that can do the task well: 'haiku' for trivial/mechanical work, 'sonnet' " +
"for normal coding, 'opus' only for complex or cross-cutting work. null inherits the " +
"list/global default (normally sonnet).")]
string? model = null,
[Description("Id of a task that must reach Done (i.e. be merged) before the picker will claim this one, " +
"even once queued. Shorthand for a one-element dependsOnTaskIds; ignored when that is given. " +
"Rejected if it doesn't exist, is this task's own id, or would create a " +
"dependency cycle. If that predecessor instead ends up Failed or Cancelled, this task simply " +
"stays blocked rather than starving silently -- check get_task/list_tasks' blocked field.")]
string? dependsOnTaskId = null,
[Description("Ids of ALL tasks that must reach Done before the picker will claim this one. Use this when a " +
"task wires together, or verifies, the results of several independent tasks -- one waiting on " +
"A and B is the normal shape of a wave-style plan. Same rejection rules as dependsOnTaskId.")]
string[]? dependsOnTaskIds = null,
[Description("true: this task's correct outcome may be an empty diff (verification/audit/research work " +
"that doesn't change code) — the merge helper won't treat a 0-changed-files result as a red " +
"flag. false (default): an empty diff is treated as suspicious, not a clean success.")]
bool expectsNoDiff = false,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(listId))
throw new InvalidOperationException("listId is required.");
if (string.IsNullOrWhiteSpace(title))
throw new InvalidOperationException("title is required.");
var list = await _lists.GetByIdAsync(listId, cancellationToken)
?? throw new InvalidOperationException($"List {listId} not found.");
var dependsOn = await ResolveDependsOnAsync(dependsOnTaskId, dependsOnTaskIds, cancellationToken);
var possibleDuplicates = await FindPossibleDuplicatesAsync(listId, title, cancellationToken);
var entity = new TaskEntity
{
Id = Guid.NewGuid().ToString(),
ListId = listId,
Title = title,
Description = description,
Status = TaskStatus.Idle,
CreatedAt = DateTime.UtcNow,
CommitType = list.DefaultCommitType,
CreatedBy = createdBy.NullIfBlank() ?? "mcp",
Model = ModelRegistry.NormalizeAlias(model),
// A manual list holds reminders, so anything filed there starts out manual.
IsManual = list.IsManual,
ExpectsNoDiff = expectsNoDiff,
};
await _tasks.AddAsync(entity, cancellationToken);
if (dependsOn is { Count: > 0 })
{
var dependsResult = await _state.SetDependsOnAsync(entity.Id, dependsOn, cancellationToken);
if (!dependsResult.Ok)
throw new InvalidOperationException(dependsResult.Reason ?? "Cannot set dependsOnTaskIds.");
}
if (queueImmediately)
{
var enqueue = await _state.EnqueueAsync(entity.Id, cancellationToken);
if (!enqueue.Ok)
throw new InvalidOperationException(enqueue.Reason ?? "Cannot enqueue task.");
}
await _broadcaster.TaskUpdated(entity.Id);
// Reload rather than patching `entity`: SetDependsOnAsync and EnqueueAsync both write
// through their own context, and adding the edges to this still-tracked entity's nav
// would queue a duplicate INSERT on the next SaveChanges.
var reload = (await _tasks.GetByIdAsync(entity.Id, cancellationToken))!;
var blocked = await ComputeBlockedInfoAsync([reload], cancellationToken);
return new AddTaskResult(ToRefDto(reload, blocked[reload.Id].Blocked, blocked[reload.Id].Reason), possibleDuplicates);
}
// Folds add_task/update_task's two dependency parameters into one resolved set.
// `dependsOnTaskIds` wins when given; otherwise the legacy scalar form is treated as a
// one-element set, with the empty string still its documented "clear it" sentinel.
// Returns null only when neither parameter was passed = leave the task's set untouched.
private async Task<IReadOnlyList<string>?> ResolveDependsOnAsync(
string? dependsOnTaskId, string[]? dependsOnTaskIds, CancellationToken ct)
{
if (dependsOnTaskIds is not null)
{
var resolved = new List<string>();
foreach (var raw in dependsOnTaskIds)
{
var id = await TaskIdResolver.ResolveOptionalAsync(_tasks, raw, ct);
if (id.NullIfBlank() is { } v) resolved.Add(v);
}
return resolved;
}
if (dependsOnTaskId is null) return null;
// TaskIdResolver passes "" through untouched, so NullIfBlank still yields the clear signal.
var single = (await TaskIdResolver.ResolveOptionalAsync(_tasks, dependsOnTaskId, ct)).NullIfBlank();
return single is null ? [] : [single];
}
// Non-terminal: a task still open enough that a new, similarly-titled task might be a duplicate
// of it. Done/Failed/Cancelled tasks are retired and never flagged.
private static readonly TaskStatus[] NonTerminalStatuses =
[
TaskStatus.Idle, TaskStatus.Queued, TaskStatus.Running,
TaskStatus.WaitingForChildren, TaskStatus.WaitingForReview,
];
private const int MaxPossibleDuplicates = 3;
// Cheap, dependency-free "near-duplicate title" heuristic — no embeddings, no LLM call, just a
// normalized-word overlap coefficient (shared significant words / smaller word-set size, not
// Jaccard). Overlap-coefficient beats Jaccard here because titles in this project vary wildly in
// length (a terse one-liner vs. a fully-spelled-out German sentence) — dividing by the union
// would wash out a real match against a longer title. Threshold tuned against the pair that
// motivated this check: "MaxTurnsCeiling ohne Bedienoberflaeche im Settings-Modal" vs.
// "Settings: MaxTurnsCeiling editierbar machen" (>=2 shared significant words, >=50% overlap of
// the shorter title's words) without over-firing on titles that merely share one generic word.
private async Task<IReadOnlyList<PossibleDuplicateDto>> FindPossibleDuplicatesAsync(
string listId, string title, CancellationToken ct)
{
var newWords = NormalizeTitleWords(title);
if (newWords.Count == 0) return [];
var candidates = await _tasks.GetByListIdAsync(listId, ct);
return candidates
.Where(t => NonTerminalStatuses.Contains(t.Status))
.Select(t => (Task: t, Words: NormalizeTitleWords(t.Title)))
.Select(x => (x.Task, Shared: newWords.Count(x.Words.Contains), x.Words))
.Where(x => x.Shared >= 2 && x.Words.Count > 0
&& (double)x.Shared / Math.Min(newWords.Count, x.Words.Count) >= 0.5)
.OrderByDescending(x => (double)x.Shared / Math.Min(newWords.Count, x.Words.Count))
.ThenByDescending(x => x.Shared)
.Take(MaxPossibleDuplicates)
.Select(x => new PossibleDuplicateDto(x.Task.Id, x.Task.Number, x.Task.Title, x.Task.Status.ToString()))
.ToList();
}
private static readonly Regex NonWordChars = new(@"[^a-z0-9]+", RegexOptions.Compiled);
// Common structural filler that would otherwise dominate the overlap score across most titles
// in this project's lists without indicating a real duplicate (German articles/prepositions
// plus the recurring "mcp"/"task(s)"/"cleanup" nouns called out in the task write-up).
private static readonly HashSet<string> TitleStopWords = new(StringComparer.Ordinal)
{
"der", "die", "das", "und", "von", "auf", "mit", "fuer", "ein", "eine",
"ist", "sind", "oder", "nicht", "mcp", "task", "tasks", "cleanup",
};
// Lowercases, transliterates German umlauts/ß to their digraph spelling (ä→ae, ß→ss, ...) so
// "Prüfung" and "Pruefung" normalize to the same token, strips remaining diacritics/punctuation,
// then drops short (<3 char) and stopword tokens.
private static HashSet<string> NormalizeTitleWords(string title)
{
var s = title.ToLowerInvariant()
.Replace("ä", "ae").Replace("ö", "oe").Replace("ü", "ue").Replace("ß", "ss");
s = s.Normalize(NormalizationForm.FormD);
var sb = new StringBuilder(s.Length);
foreach (var ch in s)
if (CharUnicodeInfo.GetUnicodeCategory(ch) != UnicodeCategory.NonSpacingMark)
sb.Append(ch);
var ascii = NonWordChars.Replace(sb.ToString(), " ");
return ascii.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Where(w => w.Length >= 3 && !TitleStopWords.Contains(w))
.ToHashSet(StringComparer.Ordinal);
}
[McpServerTool, Description(
"Update an existing task's title, description, commit type, dependsOn link, and/or manual/expects-no-diff " +
"flags. Pass null to leave a field unchanged." + McpToolDocs.NotWhileRunning + McpToolDocs.LeanTaskRef)]
public async Task<TaskRefDto> UpdateTask(
string taskId,
string? title = null,
string? description = null,
string? commitType = null,
[Description("Id of a task that must reach Done before the picker will claim this one. Shorthand for a " +
"one-element dependsOnTaskIds; ignored when that is given. Pass an empty " +
"string to clear all existing links; null leaves them unchanged. Rejected if it doesn't exist, " +
"is this task's own id, or would create a dependency cycle.")]
string? dependsOnTaskId = null,
[Description("Ids of ALL tasks that must reach Done before the picker will claim this one, replacing the " +
"task's current set. Pass an empty array to clear it; null leaves it unchanged. Use this when " +
"a task wires together, or verifies, the results of several independent tasks.")]
string[]? dependsOnTaskIds = null,
[Description("true: mark this task as a manual reminder only the user can complete — the queue picker, " +
"daily prep, and the list handler all skip it (a ConPTY session may still touch it). false: " +
"clear the flag. null (default): leave unchanged.")]
bool? isManual = null,
[Description("true: this task's correct outcome may be an empty diff (verification/audit/research work " +
"that doesn't change code) — the merge helper won't treat a 0-changed-files result as a red " +
"flag. false: clear the flag. null (default): leave unchanged.")]
bool? expectsNoDiff = null,
CancellationToken cancellationToken = default)
{
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
if (task.Status == TaskStatus.Running)
throw new InvalidOperationException("Cannot update a running task. Cancel it first.");
if (title is not null) task.Title = title;
if (description is not null) task.Description = description;
if (commitType is not null) task.CommitType = commitType;
if (isManual is not null) task.IsManual = isManual.Value;
if (expectsNoDiff is not null) task.ExpectsNoDiff = expectsNoDiff.Value;
await _tasks.UpdateAsync(task, cancellationToken);
var dependsOn = await ResolveDependsOnAsync(dependsOnTaskId, dependsOnTaskIds, cancellationToken);
if (dependsOn is not null)
{
var dependsResult = await _state.SetDependsOnAsync(taskId, dependsOn, cancellationToken);
if (!dependsResult.Ok)
throw new InvalidOperationException(dependsResult.Reason ?? "Cannot set dependsOnTaskIds.");
}
var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
await _broadcaster.TaskUpdated(taskId);
var blocked = await ComputeBlockedInfoAsync([reload], cancellationToken);
return ToRefDto(reload, blocked[reload.Id].Blocked, blocked[reload.Id].Reason);
}
[McpServerTool, Description(
"Append a subtask (step) to a task. Subtasks are surfaced to the agent at run time and shown in the " +
"task's Steps list." + McpToolDocs.NotWhileRunning + McpToolDocs.LeanTaskRef)]
public async Task<TaskRefDto> AddSubtask(
string taskId,
string title,
[Description("Position among the existing steps; defaults to the end.")]
int? orderNum = null,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(title))
throw new InvalidOperationException("title is required.");
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
var tasks = new TaskRepository(ctx);
var subtasks = new SubtaskRepository(ctx);
var task = await tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
if (task.Status == TaskStatus.Running)
throw new InvalidOperationException("Cannot add a subtask to a running task. Cancel it first.");
var existing = await subtasks.GetByTaskIdAsync(taskId, cancellationToken);
var order = orderNum ?? (existing.Count == 0 ? 0 : existing.Max(s => s.OrderNum) + 1);
await subtasks.AddAsync(new SubtaskEntity
{
Id = Guid.NewGuid().ToString(),
TaskId = taskId,
Title = title.Trim(),
Completed = false,
OrderNum = order,
CreatedAt = DateTime.UtcNow,
}, cancellationToken);
await _broadcaster.TaskUpdated(taskId);
return ToRefDto(task);
}
[McpServerTool, Description(
"Move a task between the statuses a caller may set directly. Use run_task_now for execution control and " +
"review_task to act on a WaitingForReview task — neither is reachable from here." +
McpToolDocs.LeanTaskRef + McpToolDocs.TaskNumberHint)]
public async Task<TaskRefDto> UpdateTaskStatus(
string taskId,
[Description("'Idle' (reset to editable), 'Queued' (enqueue for execution), 'Cancelled' (retire without " +
"deleting; can be reset to Idle later) or 'Done' (mark complete; refused if the task has an " +
"active worktree — use review_task to approve and merge that worktree instead). 'Idle' and " +
"'Done' are both refused while the task is Running — cancel it first. No other value is " +
"settable externally.")]
string status,
CancellationToken cancellationToken)
{
if (!Enum.TryParse<TaskStatus>(status, ignoreCase: true, out var target))
throw new InvalidOperationException(
$"Unknown status '{status}'. Valid values: Idle, Queued, Running, Done, Failed, Cancelled.");
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
DirtyBaseWarning? baseDirty = null;
switch (target)
{
case TaskStatus.Idle:
var idleResult = await _state.ResetToIdleAsync(taskId, cancellationToken);
if (!idleResult.Ok)
throw new InvalidOperationException(idleResult.Reason ?? "Cannot reset task to Idle.");
break;
case TaskStatus.Queued:
var enqueueResult = await _state.EnqueueAsync(taskId, cancellationToken);
if (!enqueueResult.Ok)
throw new InvalidOperationException(enqueueResult.Reason ?? "Cannot enqueue task.");
baseDirty = enqueueResult.BaseDirty;
break;
case TaskStatus.Cancelled:
var cancelResult = await _state.CancelAsync(taskId, DateTime.UtcNow, cancellationToken, allowFromIdle: true);
if (!cancelResult.Ok)
throw new InvalidOperationException(cancelResult.Reason ?? "Cannot cancel task.");
break;
case TaskStatus.Done:
if (task.Status == TaskStatus.Running)
throw new InvalidOperationException("Cannot mark a running task Done — cancel it first.");
await using (var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken))
{
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, cancellationToken);
if (wt is not null && wt.State == WorktreeState.Active)
throw new InvalidOperationException(
"Task has an active worktree — use review_task to approve and merge instead.");
}
var doneResult = await _state.ForceSetStatusAsync(taskId, TaskStatus.Done, cancellationToken);
if (!doneResult.Ok)
throw new InvalidOperationException(doneResult.Reason ?? "Cannot set task to Done.");
break;
default:
throw new InvalidOperationException(
$"Status '{target}' is not settable externally. Use run_task_now or review_task.");
}
var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
return ToRefDto(reload) with { BaseDirty = baseDirty };
}
[McpServerTool, Description(
"Act on a task that is WaitingForReview — the only way to approve, reject or retire a reviewed run. " +
"'approve' is review+merge, exactly like the UI's Approve: a childless task merges its worktree into " +
"targetBranch then goes Done; a task with children drives the unit merge (parent worktree if active + each " +
"Done child in order); a task without an active worktree approves straight to Done. Fails if the task is " +
"not WaitingForReview (except 'cancel', which also works while Running/Queued). mergeStatus 'conflict' " +
"means the merge stopped on conflicts, with the files listed. emptyChildren (parent approve only) lists " +
"the Done children about to be unit-merged whose own review range contributed nothing (e.g. a child that " +
"reported CLAUDEDO_BLOCKED and committed no code) — check it before trusting that every child actually " +
"delivered something." + McpToolDocs.LeanTaskRef + McpToolDocs.TaskNumberHint + McpToolDocs.ProgressHint)]
public async Task<ReviewTaskResult> ReviewTask(
string taskId,
[Description("'approve', 'reject_rerun', 'reject_park' or 'cancel'.")]
string decision,
[Description("Rejection comment. Required for 'reject_rerun', where the task goes Queued and re-runs with " +
"this text as the next turn of the agent's resumed session; ignored for 'reject_park', which " +
"just returns the task to Idle for manual editing.")]
string? feedback = null,
[Description("Branch an approve merges into; defaults to the repo's current branch.")]
string? targetBranch = null,
[Description("What an approve does when the merge hits conflicts. false (default): abort cleanly, leaving " +
"no half-merged state, and you resolve in the ClaudeDo UI. true: leave the conflict markers " +
"in the working tree (repoPath in the result) so you can resolve them and call continue_merge, " +
"or abort_merge to cancel.")]
bool leaveConflictsInTree = false,
[Description("Batch mode for an approve: merge normally but defer the list's verify command instead of " +
"running it after this one merge. The task then stays WaitingForReview with mergeStatus " +
"merged_verify_pending; after the LAST merge of the batch call verify_merges once with every " +
"pending task id — it runs the verify command a single time and promotes them all to Done. " +
"No-op when the list has no verify command.")]
bool skipVerify = false,
CancellationToken cancellationToken = default,
IProgress<ProgressNotificationValue>? progress = null)
{
// First report fires before any git/verify work starts -- an approve that has to wait on
// the per-repo merge gate (another merge/verify already running) must not sit silent long
// enough to trip Claude Code's ~300s MCP idle-silence abort before RunVerifyGateAsync's own
// periodic reports even begin.
progress?.Report(new ProgressNotificationValue { Progress = 0, Message = "review_task started" });
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
string? mergeStatus = null;
IReadOnlyList<string> mergeConflicts = Array.Empty<string>();
string? mergeMessage = null;
string? repoPath = null;
IReadOnlyList<TaskRefDto>? emptyChildren = null;
if (decision.Trim().ToLowerInvariant() == "approve")
{
// Approve is the single review+merge action — mirror the hub's ApproveReview
// routing instead of only flipping the status (which left branches unmerged).
bool hasChildren;
await using (var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken))
hasChildren = await ctx.Tasks.AnyAsync(t => t.ParentTaskId == taskId, cancellationToken);
if (hasChildren)
{
// Compute before the merge starts -- children are still Done with their own
// pre-merge worktree/commit range at this point, so "did it contribute anything"
// reflects the review range the reviewer is about to approve.
emptyChildren = await GetEmptyDoneChildrenAsync(taskId, cancellationToken);
// externallyDriven: true — this call came from an MCP session, not the UI's
// Approve button. A unit-merge conflict must not auto-open the in-app resolver;
// the driving session resolves it via continue_merge/abort_merge instead.
var startResult = await _planningMerge.StartAsync(taskId, targetBranch ?? "", cancellationToken, externallyDriven: true, progress, skipVerify);
if (startResult.Status == TaskMergeService.StatusBlocked)
throw new InvalidOperationException(startResult.Reason ?? "approve failed");
mergeStatus = startResult.Status;
if (startResult.Status == TaskMergeService.StatusConflict)
{
var list = await _lists.GetByIdAsync(task.ListId, cancellationToken);
repoPath = list?.WorkingDir;
mergeMessage = "unit merge paused on a conflict — markers left in the working tree; " +
"resolve them then call continue_merge with the parent task id, or abort_merge to cancel";
}
else if (startResult.Status == TaskMergeService.StatusMergedVerifyPending)
{
mergeMessage = "unit merged; verify deferred — the parent stays WaitingForReview until " +
"verify_merges (with this parent's id) passes";
}
else if (startResult.Status != TaskMergeService.StatusMerged)
{
mergeMessage = startResult.Reason;
}
}
else
{
var r = await _merge.ApproveAndMergeAsync(taskId, targetBranch ?? "", leaveConflictsInTree, cancellationToken, progress, skipVerify);
if (r.Status == TaskMergeService.StatusBlocked)
throw new InvalidOperationException(r.ErrorMessage ?? "approve failed");
mergeStatus = r.Status;
mergeConflicts = r.ConflictFiles;
if (r.Status == TaskMergeService.StatusMergedVerifyPending)
{
mergeMessage = "merged; verify deferred — the task stays WaitingForReview until " +
"verify_merges (with its id) passes";
}
else if (r.Status == TaskMergeService.StatusConflict)
{
if (leaveConflictsInTree)
{
var list = await _lists.GetByIdAsync(task.ListId, cancellationToken);
repoPath = list?.WorkingDir;
mergeMessage = "merge conflict — markers left in the working tree; " +
"resolve them then call continue_merge, or abort_merge to cancel";
}
else
{
mergeMessage = "merge conflict — the task stays WaitingForReview; resolve it in the ClaudeDo UI";
}
}
else
{
mergeMessage = r.ErrorMessage;
}
}
}
else
{
TransitionResult result = decision.Trim().ToLowerInvariant() switch
{
"reject_rerun" => await _state.RejectToQueueAsync(taskId, feedback ?? "", cancellationToken),
"reject_park" => await _state.RejectToIdleAsync(taskId, cancellationToken),
"cancel" => await _state.CancelAsync(taskId, DateTime.UtcNow, cancellationToken),
_ => throw new InvalidOperationException(
$"Unknown decision '{decision}'. Use approve, reject_rerun, reject_park, or cancel."),
};
if (!result.Ok)
throw new InvalidOperationException(result.Reason ?? "Review action failed.");
}
return new ReviewTaskResult(
ToRefDto((await _tasks.GetByIdAsync(taskId, cancellationToken))!),
mergeStatus, mergeConflicts, mergeMessage, repoPath, emptyChildren);
}
// Done children about to be unit-merged whose own review range contributed nothing: an
// active worktree with zero changed files against its base commit, or a worktree-less
// handler child whose HandlerBaseCommit == HandlerHeadCommit. A child with neither (never
// committed anything at all) also counts as empty. Best-effort per child -- a diff failure
// on one child must not block the caller from seeing the others or from approving.
private async Task<IReadOnlyList<TaskRefDto>> GetEmptyDoneChildrenAsync(string parentTaskId, CancellationToken ct)
{
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var children = await ctx.Tasks
.AsNoTracking()
.Include(t => t.Worktree)
.Include(t => t.Dependencies)
.Where(t => t.ParentTaskId == parentTaskId && t.Status == TaskStatus.Done)
.ToListAsync(ct);
var empty = new List<TaskRefDto>();
foreach (var child in children)
{
if (await IsChildEmptyAsync(child, ct))
empty.Add(ToRefDto(child));
}
return empty;
}
private async Task<bool> IsChildEmptyAsync(TaskEntity child, CancellationToken ct)
{
try
{
if (child.Worktree is not null)
{
if (!Directory.Exists(child.Worktree.Path)) return false;
var files = ParseDiffStatFileNames(
await _git.DiffStatAsync(child.Worktree.Path, child.Worktree.BaseCommit, "HEAD", ct: ct));
return files.Count == 0;
}
if (child.HandlerBaseCommit is { Length: > 0 } handlerBase && child.HandlerHeadCommit is { Length: > 0 } handlerHead)
return string.Equals(handlerBase, handlerHead, StringComparison.Ordinal);
return true;
}
catch
{
return false;
}
}
[McpServerTool, Description(
"Run a task immediately in the override execution slot, bypassing the agent queue. That slot is single-" +
"occupancy and shared with continue_task — throws \"Override slot busy\" if something else holds it; " +
"enqueue via update_task_status instead of retrying in a loop.")]
public async Task<RunTaskNowResult> RunTaskNow(string taskId, CancellationToken cancellationToken)
{
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
try
{
await _queue.RunNow(taskId);
}
catch (InvalidOperationException)
{
throw new InvalidOperationException("Override slot busy. Try again later.");
}
catch (KeyNotFoundException)
{
throw new InvalidOperationException($"Task {taskId} not found.");
}
await _broadcaster.TaskUpdated(taskId);
// Heads-up only, same as update_task_status's Queued path: a worktree forks from the
// commit tip, not the working tree, so uncommitted changes in the list's repo right now
// won't be included.
var task = await _tasks.GetByIdAsync(taskId, cancellationToken);
var list = task is not null ? await _lists.GetByIdAsync(task.ListId, cancellationToken) : null;
var baseDirty = await _baseDirtyChecker.CheckAsync(list?.WorkingDir, cancellationToken);
return new RunTaskNowResult(true, taskId, baseDirty, task?.Number);
}
[McpServerTool, Description(
"Cancel a running task, killing its agent process. cancelled=false means the task was not running.")]
public async Task<CancelTaskResult> CancelTask(string taskId, CancellationToken cancellationToken)
{
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
var cancelled = _queue.CancelTask(taskId);
if (cancelled) await _broadcaster.TaskUpdated(taskId);
var task = await _tasks.GetByIdAsync(taskId, cancellationToken);
return new CancelTaskResult(cancelled, taskId, task?.Number);
}
[McpServerTool, Description(
"Delete a task permanently. Prefer update_task_status 'Cancelled' to retire a task you may want back." +
McpToolDocs.NotWhileRunning)]
public async Task<DeleteTaskResult> DeleteTask(string taskId, CancellationToken cancellationToken)
{
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
if (task.Status == TaskStatus.Running)
throw new InvalidOperationException("Cannot delete a running task. Cancel it first.");
try
{
await _tasks.DeleteAsync(taskId, cancellationToken);
}
// TaskRepository.DeleteAsync uses ExecuteDeleteAsync, which bypasses SaveChanges and
// surfaces provider errors directly as SqliteException rather than DbUpdateException.
catch (Exception ex) when (
(ex is Microsoft.Data.Sqlite.SqliteException || ex.InnerException is Microsoft.Data.Sqlite.SqliteException)
&& (ex.Message.Contains("FOREIGN KEY", StringComparison.OrdinalIgnoreCase)
|| ex.InnerException?.Message.Contains("FOREIGN KEY", StringComparison.OrdinalIgnoreCase) == true))
{
throw new InvalidOperationException(
"This task has child tasks. Discard the planning session or delete child tasks first.");
}
if (task.ParentTaskId is not null)
await _state.TryAdvanceParentAsync(task.ParentTaskId);
await _broadcaster.TaskUpdated(taskId);
return new DeleteTaskResult(true, taskId, task.Number);
}
[McpServerTool, Description(
"Send a follow-up prompt to a task's existing Claude session instead of starting a fresh run — the agent " +
"resumes via --resume with the session id from the task's last run, so its prior context is kept. Uses the " +
"same single-occupancy override slot as run_task_now and throws \"Override slot busy\" when that is taken.")]
public async Task<string> ContinueTask(
string taskId,
string followUpPrompt,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(followUpPrompt))
throw new InvalidOperationException("followUpPrompt is required.");
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
string result;
try
{
result = await _queue.ContinueTask(taskId, followUpPrompt);
}
catch (InvalidOperationException)
{
throw new InvalidOperationException("Override slot busy. Try again later.");
}
catch (KeyNotFoundException)
{
throw new InvalidOperationException($"Task {taskId} not found.");
}
await _broadcaster.TaskUpdated(taskId);
return result;
}
// ── Daily prep ───────────────────────────────────────────────────────────
[McpServerTool, Description(
"Daily prep: the open tasks eligible for today's MyDay selection. candidates are Idle, unblocked, " +
"non-manual and in a git repo not excluded from the weekly report; currentMyDay are Idle tasks already " +
"flagged and count toward maxTasks, the hard cap on open MyDay tasks. Add your picks with set_my_day and " +
"never exceed maxTasks.")]
public async Task<DailyPrepDataDto> GetDailyPrepCandidates(CancellationToken cancellationToken)
{
await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
var settings = await new AppSettingsRepository(ctx).GetAsync(cancellationToken);
var excludes = DailyPrepFilter.ParseExcludes(settings.ReportExcludedPaths);
var maxTasks = settings.DailyPrepMaxTasks < 1 ? 1 : settings.DailyPrepMaxTasks;
var idle = await ctx.Tasks
.AsNoTracking()
.Include(t => t.List)
.Where(t => t.Status == TaskStatus.Idle)
.ToListAsync(cancellationToken);
var currentMyDay = idle
.Where(t => t.IsMyDay)
.OrderBy(t => t.SortOrder)
.Select(ToCandidate)
.ToList();
var candidates = idle
.Where(t => !t.IsMyDay
&& t.BlockedByTaskId == null
// A manual task is a reminder only the user can do — never a Claude candidate.
&& !t.IsManual
&& DailyPrepFilter.IsIncludedRepo(t.List?.WorkingDir, excludes))
.OrderBy(t => t.CreatedAt)
.Select(ToCandidate)
.ToList();
return new DailyPrepDataDto(maxTasks, candidates, currentMyDay);
}
[McpServerTool, Description(
"Daily prep: set or clear a task's MyDay flag. Setting it is rejected once the MyDay cap " +
"(DailyPrepMaxTasks open MyDay tasks) would be exceeded; clearing is always allowed." +
McpToolDocs.LeanTaskRef)]
public async Task<TaskRefDto> SetMyDay(
string taskId,
bool isMyDay,
[Description("Position in the MyDay list; use consecutive values to keep related tasks together.")]
int? sortOrder = null,
CancellationToken cancellationToken = default)
{
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
var task = await ctx.Tasks.Include(t => t.Dependencies)
.FirstOrDefaultAsync(t => t.Id == taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
if (isMyDay && !task.IsMyDay)
{
var settings = await new AppSettingsRepository(ctx).GetAsync(cancellationToken);
var max = settings.DailyPrepMaxTasks < 1 ? 1 : settings.DailyPrepMaxTasks;
var openMyDay = await ctx.Tasks.CountAsync(
t => t.IsMyDay && t.Status == TaskStatus.Idle, cancellationToken);
if (openMyDay >= max)
throw new InvalidOperationException(
$"MyDay limit {max} reached. Clear a task before adding another.");
}
task.IsMyDay = isMyDay;
if (sortOrder is not null) task.SortOrder = sortOrder.Value;
await ctx.SaveChangesAsync(cancellationToken);
await _broadcaster.TaskUpdated(taskId);
return ToRefDto(task);
}
private static DailyPrepCandidateDto ToCandidate(TaskEntity t) => new(
t.Id, t.Number, t.ListId, t.List?.Name ?? "", t.Title, t.Description,
t.IsStarred, t.ScheduledFor, t.CreatedAt);
// ── Private helpers ───────────────────────────────────────────────────────
// internal: also used by MergeMcpTools/WorktreeMcpTools' diff-stat parsing, which need it via
// an ExternalMcpService.ParseDiffStatFileNames(...) call since a private member of one class
// isn't visible from another, even in the same namespace.
internal static IReadOnlyList<string> ParseDiffStatFileNames(string stat)
{
var files = new List<string>();
foreach (var line in stat.Split('\n'))
{
var idx = line.IndexOf('|');
if (idx > 0) files.Add(line[..idx].Trim());
}
return files;
}
private static TaskDto ToDto(TaskEntity t, bool blocked = false, string? blockedReason = null) => new(
t.Id,
t.Number,
t.ListId,
t.Title,
t.Description,
t.Status.ToString(),
t.Result,
t.CreatedBy,
t.CreatedAt,
t.StartedAt,
t.FinishedAt,
t.IsMyDay,
t.SortOrder,
t.RoadblockCount,
FailureReasonOf(t),
t.Status == TaskStatus.Failed ? t.FailureTurnsUsed : null,
t.Status == TaskStatus.Failed ? t.FailureMaxTurns : null,
t.DependsOnTaskIds,
blocked,
blockedReason,
t.IsManual,
t.ExpectsNoDiff);
// internal: AbortMerge in MergeMcpTools also needs this to build its TaskRefDto result.
internal static TaskRefDto ToRefDto(TaskEntity t, bool blocked = false, string? blockedReason = null) => new(
t.Id,
t.Number,
t.ListId,
t.Title,
t.Status.ToString(),
t.SortOrder,
t.IsMyDay,
t.RoadblockCount,
FailureReasonOf(t),
t.Status == TaskStatus.Failed ? t.FailureTurnsUsed : null,
t.Status == TaskStatus.Failed ? t.FailureMaxTurns : null,
t.DependsOnTaskIds,
blocked,
blockedReason,
BaseDirty: null,
IsManual: t.IsManual,
ExpectsNoDiff: t.ExpectsNoDiff);
// "unknown" covers a Failed task that predates this field (never got a classified reason
// stamped) — a defined value rather than null so callers don't have to special-case it.
private static string? FailureReasonOf(TaskEntity t) =>
t.Status == TaskStatus.Failed ? (t.FailureReason ?? "unknown") : null;
}
internal static class DailyPrepFilter
{
public static string[] ParseExcludes(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return [];
try
{
var list = System.Text.Json.JsonSerializer.Deserialize<List<string>>(json);
return list is null ? [] : list.Select(Normalize).Where(p => p.Length > 0).ToArray();
}
catch (System.Text.Json.JsonException) { return []; }
}
public static bool IsIncludedRepo(string? workingDir, string[] excludes)
{
if (string.IsNullOrWhiteSpace(workingDir)) return false;
var norm = Normalize(workingDir);
return !excludes.Any(p => norm.StartsWith(p, StringComparison.OrdinalIgnoreCase));
}
private static string Normalize(string path) =>
path.Trim().Replace('/', '\\').TrimEnd('\\');
}