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:
@@ -113,7 +113,14 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
[NotifyPropertyChangedFor(nameof(IsWaiting))]
|
||||
[NotifyPropertyChangedFor(nameof(StatusChipClass))]
|
||||
private string? _blockedByTaskId;
|
||||
[ObservableProperty] private string? _dependsOnTaskId;
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(DependsOnTaskId))]
|
||||
private IReadOnlyList<string> _dependsOnTaskIds = [];
|
||||
|
||||
// The single predecessor, or null when the task has none or several. The chain rail can only
|
||||
// draw a line, so a task waiting on n predecessors gets a ChainAfterLabel instead — see
|
||||
// TasksIslandViewModel.ApplyChainGrouping.
|
||||
public string? DependsOnTaskId => DependsOnTaskIds.Count == 1 ? DependsOnTaskIds[0] : null;
|
||||
[ObservableProperty] private bool _isExpanded = true;
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsPlanningParent))]
|
||||
@@ -488,7 +495,10 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
ParentTaskId = t.ParentTaskId;
|
||||
CreatedBy = t.CreatedBy;
|
||||
BlockedByTaskId = t.BlockedByTaskId;
|
||||
DependsOnTaskId = t.DependsOnTaskId;
|
||||
// Guarded: TaskEntity.DependsOnTaskIds projects a fresh List every read, so a plain
|
||||
// assignment would fire PropertyChanged on every reconcile tick even when nothing moved.
|
||||
var deps = t.DependsOnTaskIds;
|
||||
if (!DependsOnTaskIds.SequenceEqual(deps)) DependsOnTaskIds = deps;
|
||||
RoadblockCount = t.RoadblockCount;
|
||||
FailureReason = t.FailureReason;
|
||||
FailureTurnsUsed = t.FailureTurnsUsed;
|
||||
|
||||
@@ -313,6 +313,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
var entity = await db.Tasks
|
||||
.Include(t => t.List)
|
||||
.Include(t => t.Worktree)
|
||||
.Include(t => t.Dependencies)
|
||||
.FirstOrDefaultAsync(t => t.Id == taskId);
|
||||
|
||||
// A newer refresh for this task started while we were reading — its result is fresher.
|
||||
@@ -453,7 +454,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
await using var db = await _dbFactory.CreateDbContextAsync(ct);
|
||||
var filter = _filters.Resolve(list.Id);
|
||||
var baseQuery = db.Tasks.Include(t => t.List).Include(t => t.Worktree);
|
||||
var baseQuery = db.Tasks.Include(t => t.List).Include(t => t.Worktree).Include(t => t.Dependencies);
|
||||
|
||||
var filteredList = filter is null
|
||||
? new List<TaskEntity>()
|
||||
@@ -722,12 +723,17 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
}
|
||||
|
||||
// Content only — "after " + localization is Slice 2's concern (the view), which is why this
|
||||
// holds just the identifying fragment rather than an assembled sentence.
|
||||
// holds just the identifying fragment rather than an assembled sentence. Names every
|
||||
// predecessor it can resolve: a task can wait on n of them, and hiding all but the first
|
||||
// would misreport what the row is actually waiting for.
|
||||
private static string? BuildAfterLabel(TaskRowViewModel row, Dictionary<string, TaskRowViewModel> rowsById)
|
||||
{
|
||||
if (string.IsNullOrEmpty(row.DependsOnTaskId)) return null;
|
||||
if (!rowsById.TryGetValue(row.DependsOnTaskId, out var predecessor)) return null;
|
||||
return predecessor.Number > 0 ? $"#{predecessor.Number}" : predecessor.Title;
|
||||
var parts = row.DependsOnTaskIds
|
||||
.Select(id => rowsById.TryGetValue(id, out var p) ? p : null)
|
||||
.Where(p => p is not null)
|
||||
.Select(p => p!.Number > 0 ? $"#{p.Number}" : p.Title)
|
||||
.ToList();
|
||||
return parts.Count == 0 ? null : string.Join(", ", parts);
|
||||
}
|
||||
|
||||
// Assigns ShowAsChainMember/ChainStep/ChainAfterLabel for every row in `section`, then
|
||||
@@ -743,7 +749,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
|
||||
foreach (var r in section)
|
||||
{
|
||||
if (string.IsNullOrEmpty(r.DependsOnTaskId))
|
||||
if (r.DependsOnTaskIds.Count == 0)
|
||||
{
|
||||
r.ShowAsChainMember = false;
|
||||
r.ChainStep = null;
|
||||
@@ -751,6 +757,16 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
continue;
|
||||
}
|
||||
|
||||
// Several predecessors aren't a chain — the rail draws one line, so a fan-in row
|
||||
// only ever gets the label listing everything it waits on.
|
||||
if (r.DependsOnTaskId is null)
|
||||
{
|
||||
r.ShowAsChainMember = false;
|
||||
r.ChainStep = null;
|
||||
r.ChainAfterLabel = BuildAfterLabel(r, rowsById);
|
||||
continue;
|
||||
}
|
||||
|
||||
// A planning child keeps its parent indent — chain membership only ever shows as a
|
||||
// label for it, never as a second, nested rail (design: "parent wins").
|
||||
if (r.IsChild)
|
||||
@@ -1684,6 +1700,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
entities = await db.Tasks
|
||||
.Include(t => t.List)
|
||||
.Include(t => t.Worktree)
|
||||
.Include(t => t.Dependencies)
|
||||
.Where(t => idSet.Contains(t.Id))
|
||||
.ToListAsync(ct);
|
||||
dbOk = true;
|
||||
@@ -1728,6 +1745,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
// The slice of a row that Regroup reads: which section it lands in, where it sits inside it,
|
||||
// and what its chain rail/after-chip says. Only fields UpdateFromEntity actually writes —
|
||||
// IsExpanded and HasPlanningChildren are owned elsewhere and would produce false positives.
|
||||
private static (bool, DateTime?, string?, string?, PlanningPhase, int, string) GroupingKey(TaskRowViewModel r) =>
|
||||
(r.Done, r.ScheduledFor, r.ParentTaskId, r.DependsOnTaskId, r.PlanningPhase, r.Number, r.Title);
|
||||
private static (bool, DateTime?, string?, string, PlanningPhase, int, string) GroupingKey(TaskRowViewModel r) =>
|
||||
(r.Done, r.ScheduledFor, r.ParentTaskId, string.Join(",", r.DependsOnTaskIds),
|
||||
r.PlanningPhase, r.Number, r.Title);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user