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
This commit is contained in:
Mika Kuns
2026-09-02 15:08:05 +02:00
parent 84d955dfc7
commit c25732cff8
31 changed files with 1893 additions and 206 deletions
+67 -39
View File
@@ -1,4 +1,4 @@
using System.ComponentModel;
using System.ComponentModel;
using System.Diagnostics;
using System.Globalization;
using System.Text;
@@ -55,11 +55,11 @@ public sealed record TaskDto(
string? FailureReason = null,
int? FailureTurnsUsed = null,
int? FailureMaxTurns = null,
// A user/MCP-declared predecessor (set via add_task/update_task), distinct from the
// planning chain's own internal BlockedByTaskId link.
string? DependsOnTaskId = 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 DependsOnTaskId hasn't reached Done. See BlockedReason.
// planning-chain predecessor or some dependsOnTaskIds entry hasn't reached Done. See BlockedReason.
bool Blocked = false,
string? BlockedReason = null,
bool IsManual = false,
@@ -82,7 +82,7 @@ public sealed record TaskRefDto(
string? FailureReason = null,
int? FailureTurnsUsed = null,
int? FailureMaxTurns = null,
string? DependsOnTaskId = null,
IReadOnlyList<string>? DependsOnTaskIds = null,
bool Blocked = false,
string? BlockedReason = null,
DirtyBaseWarning? BaseDirty = null,
@@ -205,14 +205,14 @@ public sealed class ExternalMcpService
}
// 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
// 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 && t.DependsOnTaskId is not null)
.Select(t => t.DependsOnTaskId!)
.Where(t => t.Status == TaskStatus.Queued && t.BlockedByTaskId is null)
.SelectMany(t => t.DependsOnTaskIds)
.Distinct()
.ToList();
@@ -237,17 +237,10 @@ public sealed class ExternalMcpService
{
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);
var unmet = TaskBlocking.Unmet(t.DependsOnTaskIds, dependencyStatuses);
result[t.Id] = unmet.Count == 0 ? (false, null) : (true, TaskBlocking.Describe(unmet));
}
}
return result;
@@ -298,10 +291,15 @@ public sealed class ExternalMcpService
"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 " +
"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.")]
@@ -316,7 +314,7 @@ public sealed class ExternalMcpService
var list = await _lists.GetByIdAsync(listId, cancellationToken)
?? throw new InvalidOperationException($"List {listId} not found.");
dependsOnTaskId = await TaskIdResolver.ResolveOptionalAsync(_tasks, dependsOnTaskId, cancellationToken);
var dependsOn = await ResolveDependsOnAsync(dependsOnTaskId, dependsOnTaskIds, cancellationToken);
var possibleDuplicates = await FindPossibleDuplicatesAsync(listId, title, cancellationToken);
@@ -337,12 +335,11 @@ public sealed class ExternalMcpService
};
await _tasks.AddAsync(entity, cancellationToken);
if (dependsOnTaskId is not null)
if (dependsOn is { Count: > 0 })
{
var dependsResult = await _state.SetDependsOnAsync(entity.Id, dependsOnTaskId, cancellationToken);
var dependsResult = await _state.SetDependsOnAsync(entity.Id, dependsOn, cancellationToken);
if (!dependsResult.Ok)
throw new InvalidOperationException(dependsResult.Reason ?? "Cannot set dependsOnTaskId.");
entity.DependsOnTaskId = dependsOnTaskId;
throw new InvalidOperationException(dependsResult.Reason ?? "Cannot set dependsOnTaskIds.");
}
if (queueImmediately)
@@ -350,12 +347,39 @@ public sealed class ExternalMcpService
var enqueue = await _state.EnqueueAsync(entity.Id, cancellationToken);
if (!enqueue.Ok)
throw new InvalidOperationException(enqueue.Reason ?? "Cannot enqueue task.");
entity.Status = TaskStatus.Queued;
}
await _broadcaster.TaskUpdated(entity.Id);
var blocked = await ComputeBlockedInfoAsync([entity], cancellationToken);
return new AddTaskResult(ToRefDto(entity, blocked[entity.Id].Blocked, blocked[entity.Id].Reason), possibleDuplicates);
// 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
@@ -436,10 +460,15 @@ public sealed class ExternalMcpService
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, " +
[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.")]
@@ -463,15 +492,12 @@ public sealed class ExternalMcpService
if (expectsNoDiff is not null) task.ExpectsNoDiff = expectsNoDiff.Value;
await _tasks.UpdateAsync(task, cancellationToken);
if (dependsOnTaskId is not null)
var dependsOn = await ResolveDependsOnAsync(dependsOnTaskId, dependsOnTaskIds, cancellationToken);
if (dependsOn is not null)
{
// Empty string is a deliberate sentinel (see the dependsOnTaskId parameter doc) to
// clear the link — TaskIdResolver passes it through untouched, then NullIfBlank
// below still turns it into the clear signal SetDependsOnAsync expects.
dependsOnTaskId = await TaskIdResolver.ResolveOptionalAsync(_tasks, dependsOnTaskId, cancellationToken);
var dependsResult = await _state.SetDependsOnAsync(taskId, dependsOnTaskId.NullIfBlank(), cancellationToken);
var dependsResult = await _state.SetDependsOnAsync(taskId, dependsOn, cancellationToken);
if (!dependsResult.Ok)
throw new InvalidOperationException(dependsResult.Reason ?? "Cannot set dependsOnTaskId.");
throw new InvalidOperationException(dependsResult.Reason ?? "Cannot set dependsOnTaskIds.");
}
var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
@@ -742,6 +768,7 @@ public sealed class ExternalMcpService
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);
@@ -935,7 +962,8 @@ public sealed class ExternalMcpService
await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
var task = await ctx.Tasks.FirstOrDefaultAsync(t => t.Id == taskId, 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)
@@ -995,7 +1023,7 @@ public sealed class ExternalMcpService
FailureReasonOf(t),
t.Status == TaskStatus.Failed ? t.FailureTurnsUsed : null,
t.Status == TaskStatus.Failed ? t.FailureMaxTurns : null,
t.DependsOnTaskId,
t.DependsOnTaskIds,
blocked,
blockedReason,
t.IsManual,
@@ -1014,7 +1042,7 @@ public sealed class ExternalMcpService
FailureReasonOf(t),
t.Status == TaskStatus.Failed ? t.FailureTurnsUsed : null,
t.Status == TaskStatus.Failed ? t.FailureMaxTurns : null,
t.DependsOnTaskId,
t.DependsOnTaskIds,
blocked,
blockedReason,
BaseDirty: null,