feat(worker): dependsOn via MCP + honest staleness signal in merge preview
Adds a user/MCP-declared task dependency (DependsOnTaskId) distinct from the planning chain's internal BlockedByTaskId: add_task/update_task can set it, the queue picker skips a Queued task until the dependency reaches Done, a Failed/Cancelled dependency leaves the dependent blocked instead of starving silently, and setting a link rejects self-reference/unknown-id/cycles. get_task/list_tasks/batch_get_tasks now report blocked/blockedReason, and wait_for_task_change reports "Blocked" immediately instead of running out its timeout on a task the picker will never claim. preview_merge/preview_merge_set gain staleFiles: files a branch touches that the target branch also changed since the branch's fork point, a more honest staleness signal than `behind` alone.
This commit is contained in:
+1
-1
@@ -102,7 +102,7 @@ public sealed class BatchMcpTools
|
||||
{
|
||||
var created = await _svc.AddTask(
|
||||
listId, item.Title, item.Description, createdBy,
|
||||
queueImmediately, item.Model, cancellationToken);
|
||||
queueImmediately, item.Model, cancellationToken: cancellationToken);
|
||||
results.Add(new BatchAddTaskResult(i, item.Title, true, created.Task, created.PossibleDuplicates, null));
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
|
||||
+142
-25
@@ -45,7 +45,14 @@ public sealed record TaskDto(
|
||||
// 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);
|
||||
int RoadblockCount = 0,
|
||||
// A user/MCP-declared predecessor (set via add_task/update_task), distinct from the
|
||||
// planning chain's own internal BlockedByTaskId link.
|
||||
string? DependsOnTaskId = null,
|
||||
// True only while Status is Queued and the picker will not claim this task yet -- either a
|
||||
// planning-chain predecessor or DependsOnTaskId hasn't reached Done. See BlockedReason.
|
||||
bool Blocked = false,
|
||||
string? BlockedReason = null);
|
||||
|
||||
// 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.
|
||||
@@ -56,7 +63,10 @@ public sealed record TaskRefDto(
|
||||
string Status,
|
||||
int SortOrder,
|
||||
bool IsMyDay,
|
||||
int RoadblockCount = 0);
|
||||
int RoadblockCount = 0,
|
||||
string? DependsOnTaskId = null,
|
||||
bool Blocked = false,
|
||||
string? BlockedReason = null);
|
||||
|
||||
// tasks is populated when includeDescription=false (the default): lean references, no
|
||||
// Description/Result. tasksFull is populated when includeDescription=true: full tasks incl.
|
||||
@@ -93,10 +103,17 @@ public sealed record MergeContinuationResultDto(
|
||||
// a merge that is merely small, so an empty branch can't be misread as "changedFileCount: 0
|
||||
// means tiny" when it actually means "nothing to review".
|
||||
public sealed record MergePreviewToolDto(
|
||||
string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount, int Behind, bool IsEmpty = false);
|
||||
string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount, int Behind, bool IsEmpty = false,
|
||||
// Files this branch touches that the target branch ALSO touched since the branch's fork
|
||||
// point -- an honest staleness signal `behind` alone can't give (a branch can be far behind
|
||||
// yet touch nothing the target changed, or close behind yet collide on the one file that
|
||||
// matters). Empty for a worktree-less handler task, which commits straight onto the list's
|
||||
// working dir and has no fork point to compare against.
|
||||
IReadOnlyList<string>? StaleFiles = null);
|
||||
|
||||
public sealed record MergePreviewSetEntryDto(
|
||||
string TaskId, string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount, int Behind, string? Error, bool IsEmpty = false);
|
||||
string TaskId, string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount, int Behind, string? Error, bool IsEmpty = false,
|
||||
IReadOnlyList<string>? StaleFiles = null);
|
||||
|
||||
public sealed record FileOverlapDto(string File, IReadOnlyList<string> TaskIds);
|
||||
|
||||
@@ -200,9 +217,59 @@ public sealed class ExternalMcpService
|
||||
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(ToDto).ToList())
|
||||
: new ListTasksResult(false, filtered.Select(ToRefDto).ToList(), null);
|
||||
? 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 DependsOnTaskId) 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 && t.DependsOnTaskId is not null)
|
||||
.Select(t => t.DependsOnTaskId!)
|
||||
.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 if (t.DependsOnTaskId is not null)
|
||||
{
|
||||
var known = dependencyStatuses.TryGetValue(t.DependsOnTaskId, out var depStatus);
|
||||
result[t.Id] = known && depStatus == TaskStatus.Done
|
||||
? (false, null)
|
||||
: (true, $"Blocked: depends on task {t.DependsOnTaskId} (status: " +
|
||||
(known ? depStatus.ToString() : "not found") + ").");
|
||||
}
|
||||
else
|
||||
{
|
||||
result[t.Id] = (false, null);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
[McpServerTool, Description(
|
||||
@@ -216,7 +283,8 @@ public sealed class ExternalMcpService
|
||||
{
|
||||
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
return ToDto(task);
|
||||
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)
|
||||
@@ -225,7 +293,8 @@ public sealed class ExternalMcpService
|
||||
{
|
||||
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
return ToRefDto(task);
|
||||
var blocked = await ComputeBlockedInfoAsync([task], cancellationToken);
|
||||
return ToRefDto(task, blocked[task.Id].Blocked, blocked[task.Id].Reason);
|
||||
}
|
||||
|
||||
[McpServerTool, Description(
|
||||
@@ -243,6 +312,11 @@ public sealed class ExternalMcpService
|
||||
"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. 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,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(listId))
|
||||
@@ -271,6 +345,14 @@ public sealed class ExternalMcpService
|
||||
};
|
||||
await _tasks.AddAsync(entity, cancellationToken);
|
||||
|
||||
if (dependsOnTaskId is not null)
|
||||
{
|
||||
var dependsResult = await _state.SetDependsOnAsync(entity.Id, dependsOnTaskId, cancellationToken);
|
||||
if (!dependsResult.Ok)
|
||||
throw new InvalidOperationException(dependsResult.Reason ?? "Cannot set dependsOnTaskId.");
|
||||
entity.DependsOnTaskId = dependsOnTaskId;
|
||||
}
|
||||
|
||||
if (queueImmediately)
|
||||
{
|
||||
var enqueue = await _state.EnqueueAsync(entity.Id, cancellationToken);
|
||||
@@ -280,7 +362,8 @@ public sealed class ExternalMcpService
|
||||
}
|
||||
|
||||
await _broadcaster.TaskUpdated(entity.Id);
|
||||
return new AddTaskResult(ToRefDto(entity), possibleDuplicates);
|
||||
var blocked = await ComputeBlockedInfoAsync([entity], cancellationToken);
|
||||
return new AddTaskResult(ToRefDto(entity, blocked[entity.Id].Blocked, blocked[entity.Id].Reason), possibleDuplicates);
|
||||
}
|
||||
|
||||
// Non-terminal: a task still open enough that a new, similarly-titled task might be a duplicate
|
||||
@@ -354,13 +437,17 @@ public sealed class ExternalMcpService
|
||||
}
|
||||
|
||||
[McpServerTool, Description(
|
||||
"Update an existing task's title, description, and/or commit type. Pass null to leave a field unchanged." +
|
||||
McpToolDocs.NotWhileRunning + McpToolDocs.LeanTaskRef)]
|
||||
"Update an existing task's title, description, commit type, and/or dependsOn link. 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. Pass an empty " +
|
||||
"string to clear an existing link; null leaves it unchanged. Rejected if it doesn't exist, " +
|
||||
"is this task's own id, or would create a dependency cycle.")]
|
||||
string? dependsOnTaskId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||
@@ -373,9 +460,17 @@ public sealed class ExternalMcpService
|
||||
if (commitType is not null) task.CommitType = commitType;
|
||||
await _tasks.UpdateAsync(task, cancellationToken);
|
||||
|
||||
if (dependsOnTaskId is not null)
|
||||
{
|
||||
var dependsResult = await _state.SetDependsOnAsync(taskId, dependsOnTaskId.NullIfBlank(), cancellationToken);
|
||||
if (!dependsResult.Ok)
|
||||
throw new InvalidOperationException(dependsResult.Reason ?? "Cannot set dependsOnTaskId.");
|
||||
}
|
||||
|
||||
var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
return ToRefDto(reload);
|
||||
var blocked = await ComputeBlockedInfoAsync([reload], cancellationToken);
|
||||
return ToRefDto(reload, blocked[reload.Id].Blocked, blocked[reload.Id].Reason);
|
||||
}
|
||||
|
||||
[McpServerTool, Description(
|
||||
@@ -934,16 +1029,20 @@ public sealed class ExternalMcpService
|
||||
"when the preview is clean. IMPORTANT: a clean preview says nothing about whether the result compiles or " +
|
||||
"passes tests — git can merge two changes cleanly (one file deleting a symbol another still references) and " +
|
||||
"still break the build. isEmpty=true means the task's review range contributed nothing; check that flag " +
|
||||
"rather than reading a small changedFileCount as empty. Throws if the task has neither an active worktree " +
|
||||
"nor a handler commit range, or the list's working directory is missing from disk.")]
|
||||
"rather than reading a small changedFileCount as empty. staleFiles lists files this branch touches that the " +
|
||||
"target branch ALSO changed since this branch's fork point — a more honest staleness signal than `behind` " +
|
||||
"alone, since a branch can be far behind yet touch nothing the target changed, or barely behind yet collide " +
|
||||
"on the one file that matters (always empty for a worktree-less handler task, which has no fork point). " +
|
||||
"Throws if the task has neither an active worktree nor a handler commit range, or the list's working " +
|
||||
"directory is missing from disk.")]
|
||||
public async Task<MergePreviewToolDto> PreviewMerge(
|
||||
string taskId,
|
||||
[Description("Branch to preview against; defaults to the repo's current branch.")]
|
||||
string? targetBranch = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var (preview, behind, _, isEmpty) = await PreviewMergeCoreAsync(taskId, targetBranch, cancellationToken);
|
||||
return new MergePreviewToolDto(preview.Status, preview.ConflictFiles, preview.ChangedFileCount, behind, isEmpty);
|
||||
var (preview, behind, _, isEmpty, staleFiles) = await PreviewMergeCoreAsync(taskId, targetBranch, cancellationToken);
|
||||
return new MergePreviewToolDto(preview.Status, preview.ConflictFiles, preview.ChangedFileCount, behind, isEmpty, staleFiles);
|
||||
}
|
||||
|
||||
[McpServerTool, Description(
|
||||
@@ -970,9 +1069,9 @@ public sealed class ExternalMcpService
|
||||
{
|
||||
try
|
||||
{
|
||||
var (preview, behind, changedFiles, isEmpty) = await PreviewMergeCoreAsync(taskId, targetBranch, cancellationToken);
|
||||
var (preview, behind, changedFiles, isEmpty, staleFiles) = await PreviewMergeCoreAsync(taskId, targetBranch, cancellationToken);
|
||||
entries.Add(new MergePreviewSetEntryDto(
|
||||
taskId, preview.Status, preview.ConflictFiles, preview.ChangedFileCount, behind, null, isEmpty));
|
||||
taskId, preview.Status, preview.ConflictFiles, preview.ChangedFileCount, behind, null, isEmpty, staleFiles));
|
||||
filesByTask[taskId] = changedFiles;
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
@@ -1000,7 +1099,7 @@ public sealed class ExternalMcpService
|
||||
// (its commits already sit on the list's working dir) — falls back to the fixed
|
||||
// HandlerBaseCommit..HandlerHeadCommit range, reporting a synthetic "clean" preview of that
|
||||
// range's own diff-stat instead of throwing "has no worktree".
|
||||
private async Task<(MergePreviewResult Preview, int Behind, IReadOnlyList<string> ChangedFiles, bool IsEmpty)> PreviewMergeCoreAsync(
|
||||
private async Task<(MergePreviewResult Preview, int Behind, IReadOnlyList<string> ChangedFiles, bool IsEmpty, IReadOnlyList<string> StaleFiles)> PreviewMergeCoreAsync(
|
||||
string taskId, string? targetBranch, CancellationToken ct)
|
||||
{
|
||||
using var ctx = _dbFactory.CreateDbContext();
|
||||
@@ -1032,7 +1131,16 @@ public sealed class ExternalMcpService
|
||||
? ParseDiffStatFileNames(await _git.DiffStatAsync(wt.Path, wt.BaseCommit, "HEAD", ct))
|
||||
: Array.Empty<string>();
|
||||
|
||||
return (preview, behind, changedFiles, changedFiles.Count == 0);
|
||||
// What the target itself picked up since this branch's fork point, so `behind`
|
||||
// (a commit count) doesn't have to stand in for "does this collide" -- a branch can
|
||||
// be far behind but touch nothing the target changed, or close behind and collide on
|
||||
// the one file that matters.
|
||||
var targetChangedSinceFork = await _git.GetChangedFileNamesAsync(list.WorkingDir, wt.BaseCommit, target, ct);
|
||||
var staleFiles = changedFiles
|
||||
.Intersect(targetChangedSinceFork, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
return (preview, behind, changedFiles, changedFiles.Count == 0, staleFiles);
|
||||
}
|
||||
|
||||
if (task.HandlerBaseCommit is { Length: > 0 } handlerBase && task.HandlerHeadCommit is { Length: > 0 } handlerHead)
|
||||
@@ -1046,7 +1154,10 @@ public sealed class ExternalMcpService
|
||||
: ParseDiffStatFileNames(await _git.DiffStatAsync(list.WorkingDir, handlerBase, handlerHead, ct));
|
||||
|
||||
var preview = new MergePreviewResult(TaskMergeService.PreviewClean, Array.Empty<string>(), changedFiles.Count);
|
||||
return (preview, 0, changedFiles, isEmpty);
|
||||
// No fork point to diff against: a handler task commits straight onto the list's
|
||||
// working dir instead of a branch, so there is nothing else that could have "changed
|
||||
// in the target since the fork".
|
||||
return (preview, 0, changedFiles, isEmpty, Array.Empty<string>());
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Task {taskId} has no worktree.");
|
||||
@@ -1313,7 +1424,7 @@ public sealed class ExternalMcpService
|
||||
return files;
|
||||
}
|
||||
|
||||
private static TaskDto ToDto(TaskEntity t) => new(
|
||||
private static TaskDto ToDto(TaskEntity t, bool blocked = false, string? blockedReason = null) => new(
|
||||
t.Id,
|
||||
t.ListId,
|
||||
t.Title,
|
||||
@@ -1326,16 +1437,22 @@ public sealed class ExternalMcpService
|
||||
t.FinishedAt,
|
||||
t.IsMyDay,
|
||||
t.SortOrder,
|
||||
t.RoadblockCount);
|
||||
t.RoadblockCount,
|
||||
t.DependsOnTaskId,
|
||||
blocked,
|
||||
blockedReason);
|
||||
|
||||
private static TaskRefDto ToRefDto(TaskEntity t) => new(
|
||||
private static TaskRefDto ToRefDto(TaskEntity t, bool blocked = false, string? blockedReason = null) => new(
|
||||
t.Id,
|
||||
t.ListId,
|
||||
t.Title,
|
||||
t.Status.ToString(),
|
||||
t.SortOrder,
|
||||
t.IsMyDay,
|
||||
t.RoadblockCount);
|
||||
t.RoadblockCount,
|
||||
t.DependsOnTaskId,
|
||||
blocked,
|
||||
blockedReason);
|
||||
}
|
||||
|
||||
internal static class DailyPrepFilter
|
||||
|
||||
+55
-12
@@ -6,7 +6,9 @@ using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Worker.External;
|
||||
|
||||
public sealed record TaskStatusChangeDto(string TaskId, string Status);
|
||||
// BlockedReason is set only when Status is "Blocked" -- a Queued task the picker will not
|
||||
// claim yet, either because of a planning-chain predecessor or an unmet depends-on link.
|
||||
public sealed record TaskStatusChangeDto(string TaskId, string Status, string? BlockedReason = null);
|
||||
public sealed record WaitForTaskChangeResult(IReadOnlyList<TaskStatusChangeDto> Changed, bool TimedOut);
|
||||
|
||||
[McpServerToolType]
|
||||
@@ -31,11 +33,14 @@ public sealed class TaskWaitMcpTools
|
||||
[McpServerTool, Description(
|
||||
"Blocks until at least one of the given tasks leaves Queued/Running -- use this instead of " +
|
||||
"polling get_task in a loop. Returns immediately if a task is already outside Queued/Running " +
|
||||
"(an unknown id reports status \"NotFound\" and counts as changed). Pitfall: a planning parent " +
|
||||
"goes Running -> WaitingForChildren while its children are still working, so by default " +
|
||||
"waiting on a parent returns early; see treatWaitingForChildrenAsBusy. Requires the calling " +
|
||||
"claude process to run with MCP_TOOL_TIMEOUT >= 930000 (ms) for a long wait to actually be " +
|
||||
"held open -- ClaudeDo's own launchers already set this.")]
|
||||
"(an unknown id reports status \"NotFound\" and counts as changed). A Queued task the picker " +
|
||||
"will not claim yet (a planning-chain predecessor, or a depends_on link whose target isn't " +
|
||||
"Done) also reports immediately as status \"Blocked\" with blockedReason set, instead of " +
|
||||
"silently waiting out the full timeout. Pitfall: a planning parent goes Running -> " +
|
||||
"WaitingForChildren while its children are still working, so by default waiting on a parent " +
|
||||
"returns early; see treatWaitingForChildrenAsBusy. Requires the calling claude process to run " +
|
||||
"with MCP_TOOL_TIMEOUT >= 930000 (ms) for a long wait to actually be held open -- ClaudeDo's " +
|
||||
"own launchers already set this.")]
|
||||
public async Task<WaitForTaskChangeResult> WaitForTaskChange(
|
||||
string[] taskIds,
|
||||
[Description(
|
||||
@@ -80,22 +85,60 @@ public sealed class TaskWaitMcpTools
|
||||
var rows = await ctx.Tasks
|
||||
.AsNoTracking()
|
||||
.Where(t => taskIds.Contains(t.Id))
|
||||
.Select(t => new { t.Id, t.Status })
|
||||
.Select(t => new { t.Id, t.Status, t.BlockedByTaskId, t.DependsOnTaskId })
|
||||
.ToListAsync(ct);
|
||||
|
||||
var byId = rows.ToDictionary(r => r.Id, r => r.Status);
|
||||
var byId = rows.ToDictionary(r => r.Id, r => r);
|
||||
|
||||
// A Queued task with an unmet depends_on link never becomes "changed" by itself -- the
|
||||
// picker will not touch it. Batch-resolve those dependencies' statuses once instead of a
|
||||
// query per candidate.
|
||||
var dependencyIds = rows
|
||||
.Where(r => r.Status == TaskStatus.Queued && r.BlockedByTaskId is null && r.DependsOnTaskId is not null)
|
||||
.Select(r => r.DependsOnTaskId!)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
var dependencyStatuses = dependencyIds.Count == 0
|
||||
? new Dictionary<string, TaskStatus>()
|
||||
: 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 List<TaskStatusChangeDto>();
|
||||
foreach (var id in taskIds)
|
||||
{
|
||||
if (!byId.TryGetValue(id, out var status))
|
||||
if (!byId.TryGetValue(id, out var row))
|
||||
{
|
||||
result.Add(new TaskStatusChangeDto(id, "NotFound"));
|
||||
continue;
|
||||
}
|
||||
var busy = status == TaskStatus.Queued || status == TaskStatus.Running
|
||||
|| (treatWaitingForChildrenAsBusy && status == TaskStatus.WaitingForChildren);
|
||||
|
||||
if (row.Status == TaskStatus.Queued)
|
||||
{
|
||||
if (row.BlockedByTaskId is not null)
|
||||
{
|
||||
result.Add(new TaskStatusChangeDto(id, "Blocked",
|
||||
$"Blocked by planning-chain predecessor {row.BlockedByTaskId}."));
|
||||
continue;
|
||||
}
|
||||
if (row.DependsOnTaskId is not null)
|
||||
{
|
||||
var known = dependencyStatuses.TryGetValue(row.DependsOnTaskId, out var depStatus);
|
||||
if (!known || depStatus != TaskStatus.Done)
|
||||
{
|
||||
result.Add(new TaskStatusChangeDto(id, "Blocked",
|
||||
$"Blocked: depends on task {row.DependsOnTaskId} (status: " +
|
||||
(known ? depStatus.ToString() : "not found") + ")."));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var busy = row.Status == TaskStatus.Queued || row.Status == TaskStatus.Running
|
||||
|| (treatWaitingForChildrenAsBusy && row.Status == TaskStatus.WaitingForChildren);
|
||||
if (!busy)
|
||||
result.Add(new TaskStatusChangeDto(id, status.ToString()));
|
||||
result.Add(new TaskStatusChangeDto(id, row.Status.ToString()));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user