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:
@@ -37,9 +37,10 @@ public interface ITaskStateService
|
||||
Task<TransitionResult> BlockOnAsync(string taskId, string predecessorTaskId, CancellationToken ct);
|
||||
Task<TransitionResult> UnblockAsync(string taskId, CancellationToken ct);
|
||||
|
||||
// dependsOnTaskId null clears the dependency. Rejects self-reference, an unknown dependency
|
||||
// id, and a link that would create a cycle -- see TaskStateService for the walk.
|
||||
Task<TransitionResult> SetDependsOnAsync(string taskId, string? dependsOnTaskId, CancellationToken ct);
|
||||
// Replaces the task's whole dependency set; an empty list clears it. Rejects a self-reference,
|
||||
// an unknown dependency id, and any link that would create a cycle in the dependency graph
|
||||
// -- see TaskStateService for the walk.
|
||||
Task<TransitionResult> SetDependsOnAsync(string taskId, IReadOnlyList<string> dependsOnTaskIds, CancellationToken ct);
|
||||
|
||||
// Surfaces a WaitingForChildren parent for review once all its children are terminal.
|
||||
// Best-effort (swallows and logs failures) — safe to call after any child mutation,
|
||||
|
||||
@@ -501,50 +501,89 @@ public sealed class TaskStateService : ITaskStateService
|
||||
return new TransitionResult(true, null);
|
||||
}
|
||||
|
||||
public async Task<TransitionResult> SetDependsOnAsync(string taskId, string? dependsOnTaskId, CancellationToken ct)
|
||||
public async Task<TransitionResult> SetDependsOnAsync(
|
||||
string taskId, IReadOnlyList<string> dependsOnTaskIds, CancellationToken ct)
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||
|
||||
if (dependsOnTaskId is not null)
|
||||
{
|
||||
if (dependsOnTaskId == taskId)
|
||||
return new TransitionResult(false, "A task cannot depend on itself.");
|
||||
|
||||
if (!await ctx.Tasks.AsNoTracking().AnyAsync(t => t.Id == dependsOnTaskId, ct))
|
||||
return new TransitionResult(false, $"Dependency task {dependsOnTaskId} not found.");
|
||||
|
||||
// Walk the proposed predecessor's own chain of dependencies; if it leads back to
|
||||
// taskId, linking here would create a cycle that starves both tasks forever (the
|
||||
// picker never claims either). `visited` also stops us looping forever on
|
||||
// pre-existing bad data unrelated to this write.
|
||||
var current = dependsOnTaskId;
|
||||
var visited = new HashSet<string>();
|
||||
while (current is not null)
|
||||
{
|
||||
if (current == taskId)
|
||||
return new TransitionResult(false, "Setting this dependency would create a cycle.");
|
||||
if (!visited.Add(current))
|
||||
break;
|
||||
current = await ctx.Tasks.AsNoTracking()
|
||||
.Where(t => t.Id == current)
|
||||
.Select(t => t.DependsOnTaskId)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
var affected = await ctx.Tasks
|
||||
.Where(t => t.Id == taskId)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(t => t.DependsOnTaskId, dependsOnTaskId), ct);
|
||||
|
||||
if (affected == 0)
|
||||
if (!await ctx.Tasks.AsNoTracking().AnyAsync(t => t.Id == taskId, ct))
|
||||
return new TransitionResult(false, "Task not found.");
|
||||
|
||||
// Clearing a dependency may free up a Queued task the picker was skipping.
|
||||
if (dependsOnTaskId is null) _waker.Wake();
|
||||
var wanted = dependsOnTaskIds.Distinct(StringComparer.Ordinal).ToList();
|
||||
|
||||
if (wanted.Count > 0)
|
||||
{
|
||||
if (wanted.Contains(taskId, StringComparer.Ordinal))
|
||||
return new TransitionResult(false, "A task cannot depend on itself.");
|
||||
|
||||
var known = await ctx.Tasks.AsNoTracking()
|
||||
.Where(t => wanted.Contains(t.Id))
|
||||
.Select(t => t.Id)
|
||||
.ToListAsync(ct);
|
||||
var missing = wanted.Except(known, StringComparer.Ordinal).FirstOrDefault();
|
||||
if (missing is not null)
|
||||
return new TransitionResult(false, $"Dependency task {missing} not found.");
|
||||
|
||||
if (await WouldCycleAsync(ctx, taskId, wanted, ct))
|
||||
return new TransitionResult(false, "Setting this dependency would create a cycle.");
|
||||
}
|
||||
|
||||
var existing = await ctx.TaskDependencies.AsNoTracking()
|
||||
.Where(d => d.TaskId == taskId)
|
||||
.Select(d => d.DependsOnTaskId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var removed = existing.Except(wanted, StringComparer.Ordinal).ToList();
|
||||
var added = wanted.Except(existing, StringComparer.Ordinal).ToList();
|
||||
if (removed.Count == 0 && added.Count == 0)
|
||||
return new TransitionResult(true, null);
|
||||
|
||||
if (removed.Count > 0)
|
||||
await ctx.TaskDependencies
|
||||
.Where(d => d.TaskId == taskId && removed.Contains(d.DependsOnTaskId))
|
||||
.ExecuteDeleteAsync(ct);
|
||||
|
||||
if (added.Count > 0)
|
||||
{
|
||||
ctx.TaskDependencies.AddRange(added.Select(id =>
|
||||
new TaskDependencyEntity { TaskId = taskId, DependsOnTaskId = id }));
|
||||
await ctx.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
// Dropping an edge may free up a Queued task the picker was skipping.
|
||||
if (removed.Count > 0) _waker.Wake();
|
||||
await NotifyAsync(taskId);
|
||||
return new TransitionResult(true, null);
|
||||
}
|
||||
|
||||
// True when any proposed predecessor can already reach taskId through the dependency graph --
|
||||
// linking there would starve both ends forever, since the picker never claims either.
|
||||
// ponytail: loads the whole edge set (only *declared* dependencies land there, so it stays
|
||||
// small). Swap in a recursive CTE if task_dependencies ever grows past a few thousand rows.
|
||||
private static async Task<bool> WouldCycleAsync(
|
||||
ClaudeDoDbContext ctx, string taskId, IReadOnlyList<string> proposed, CancellationToken ct)
|
||||
{
|
||||
var edges = await ctx.TaskDependencies.AsNoTracking()
|
||||
.Select(d => new { d.TaskId, d.DependsOnTaskId })
|
||||
.ToListAsync(ct);
|
||||
var successors = edges
|
||||
.GroupBy(e => e.TaskId, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => g.Select(e => e.DependsOnTaskId).ToList(), StringComparer.Ordinal);
|
||||
|
||||
// `visited` also stops the walk looping forever on pre-existing bad data.
|
||||
var visited = new HashSet<string>(StringComparer.Ordinal);
|
||||
var frontier = new Queue<string>(proposed);
|
||||
while (frontier.Count > 0)
|
||||
{
|
||||
var current = frontier.Dequeue();
|
||||
if (current == taskId) return true;
|
||||
if (!visited.Add(current)) continue;
|
||||
if (successors.TryGetValue(current, out var next))
|
||||
foreach (var n in next) frontier.Enqueue(n);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public async Task<int> RecoverStaleRunningAsync(string reason, CancellationToken ct)
|
||||
{
|
||||
var resultText = "[stale] " + reason;
|
||||
|
||||
Reference in New Issue
Block a user