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:
mika kuns
2026-08-10 14:18:55 +02:00
parent 6a2a19cc9e
commit 6c8ec245b3
20 changed files with 1750 additions and 51 deletions
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}