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:
+67
-39
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user