TaskEntity.Number is a global, monotonically increasing, never-reused integer (displayed as #123), allocated from AppSettingsEntity.NextTaskNumber via a single UPDATE...RETURNING statement rather than MAX(number)+1, which would reissue a deleted task's number. Both insert paths (TaskRepository. AddAsync and CreateChildAsync) route through the new TaskNumberAllocator, with a bounded retry on a unique-index collision. One migration adds the columns, backfills existing rows in creation order, and creates the unique index afterwards. Data-layer only; MCP/UI wiring is later slices.
593 lines
23 KiB
C#
593 lines
23 KiB
C#
using ClaudeDo.Data.Models;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
|
|
|
namespace ClaudeDo.Data.Repositories;
|
|
|
|
public sealed class TaskRepository
|
|
{
|
|
private readonly ClaudeDoDbContext _context;
|
|
private readonly AttachmentStore _attachments;
|
|
|
|
public TaskRepository(ClaudeDoDbContext context, AttachmentStore? attachments = null)
|
|
{
|
|
_context = context;
|
|
_attachments = attachments ?? new AttachmentStore();
|
|
}
|
|
|
|
#region CRUD
|
|
|
|
public async Task AddAsync(TaskEntity entity, CancellationToken ct = default)
|
|
{
|
|
// Append at bottom of the list by default: SortOrder = max(listId) + 1.
|
|
var maxSort = await _context.Tasks
|
|
.Where(t => t.ListId == entity.ListId)
|
|
.Select(t => (int?)t.SortOrder)
|
|
.MaxAsync(ct);
|
|
entity.SortOrder = (maxSort ?? -1) + 1;
|
|
|
|
await TaskNumberAllocator.AddWithNumberAsync(_context, entity, ct);
|
|
}
|
|
|
|
public async Task UpdateAsync(TaskEntity entity, CancellationToken ct = default)
|
|
{
|
|
var tracked = _context.Tasks.Local.FirstOrDefault(t => t.Id == entity.Id);
|
|
if (tracked is not null && !ReferenceEquals(tracked, entity))
|
|
_context.Entry(tracked).State = Microsoft.EntityFrameworkCore.EntityState.Detached;
|
|
_context.Tasks.Update(entity);
|
|
await _context.SaveChangesAsync(ct);
|
|
}
|
|
|
|
public async Task DeleteAsync(string taskId, CancellationToken ct = default)
|
|
{
|
|
await _context.Tasks.Where(t => t.Id == taskId).ExecuteDeleteAsync(ct);
|
|
_attachments.DeleteTaskDir(taskId);
|
|
}
|
|
|
|
public async Task<TaskEntity?> GetByIdAsync(string taskId, CancellationToken ct = default)
|
|
{
|
|
return await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId, ct);
|
|
}
|
|
|
|
public async Task<TaskEntity?> GetByNumberAsync(int number, CancellationToken ct = default)
|
|
{
|
|
return await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Number == number, ct);
|
|
}
|
|
|
|
public async Task<List<TaskEntity>> GetByListIdAsync(string listId, CancellationToken ct = default)
|
|
{
|
|
return await _context.Tasks
|
|
.Where(t => t.ListId == listId)
|
|
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
|
|
.ToListAsync(ct);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Renumbers tasks in a list to 0..N-1 according to <paramref name="orderedTaskIds"/>.
|
|
/// Ids not belonging to the list are ignored; ids missing from the list are untouched.
|
|
/// </summary>
|
|
public async Task ReorderAsync(string listId, IReadOnlyList<string> orderedTaskIds, CancellationToken ct = default)
|
|
{
|
|
if (orderedTaskIds.Count == 0) return;
|
|
|
|
var idSet = orderedTaskIds.ToHashSet();
|
|
var tasks = await _context.Tasks
|
|
.Where(t => t.ListId == listId && idSet.Contains(t.Id))
|
|
.ToListAsync(ct);
|
|
|
|
for (int i = 0; i < orderedTaskIds.Count; i++)
|
|
{
|
|
var task = tasks.FirstOrDefault(t => t.Id == orderedTaskIds[i]);
|
|
if (task is not null) task.SortOrder = i;
|
|
}
|
|
|
|
await _context.SaveChangesAsync(ct);
|
|
}
|
|
|
|
// Kept for backwards-compatibility with callers using the old name.
|
|
public Task<List<TaskEntity>> GetByListAsync(string listId, CancellationToken ct = default)
|
|
=> GetByListIdAsync(listId, ct);
|
|
|
|
/// <summary>
|
|
/// Returns the ids of every descendant of <paramref name="taskId"/> (children, grandchildren, ...),
|
|
/// walking the ParentTaskId chain breadth-first. Does not include the task itself.
|
|
/// </summary>
|
|
public async Task<List<string>> GetDescendantIdsAsync(string taskId, CancellationToken ct = default)
|
|
{
|
|
var result = new List<string>();
|
|
// Guards against a corrupt parent chain (a self-parent or a cycle would loop forever).
|
|
var seen = new HashSet<string>(StringComparer.Ordinal) { taskId };
|
|
var frontier = new List<string> { taskId };
|
|
while (frontier.Count > 0)
|
|
{
|
|
var children = await _context.Tasks.AsNoTracking()
|
|
.Where(t => t.ParentTaskId != null && frontier.Contains(t.ParentTaskId))
|
|
.Select(t => t.Id)
|
|
.ToListAsync(ct);
|
|
var fresh = children.Where(seen.Add).ToList();
|
|
if (fresh.Count == 0) break;
|
|
result.AddRange(fresh);
|
|
frontier = fresh;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Moves a task (and every descendant, so a child never ends up in a different list than its
|
|
/// parent) to <paramref name="targetListId"/>, appending it at the end of the target list's
|
|
/// order. ListId is init-only, so the move goes through ExecuteUpdate rather than a tracked
|
|
/// entity mutation.
|
|
/// </summary>
|
|
public async Task MoveToListAsync(string taskId, string targetListId, CancellationToken ct = default)
|
|
{
|
|
var exists = await _context.Tasks.AsNoTracking().AnyAsync(t => t.Id == taskId, ct);
|
|
if (!exists)
|
|
throw new InvalidOperationException($"Task {taskId} not found.");
|
|
|
|
var descendantIds = await GetDescendantIdsAsync(taskId, ct);
|
|
var movedIds = new List<string> { taskId };
|
|
movedIds.AddRange(descendantIds);
|
|
|
|
var maxSort = await _context.Tasks
|
|
.Where(t => t.ListId == targetListId)
|
|
.Select(t => (int?)t.SortOrder)
|
|
.MaxAsync(ct);
|
|
|
|
await _context.Tasks
|
|
.Where(t => movedIds.Contains(t.Id))
|
|
.ExecuteUpdateAsync(s => s.SetProperty(t => t.ListId, targetListId), ct);
|
|
|
|
await _context.Tasks
|
|
.Where(t => t.Id == taskId)
|
|
.ExecuteUpdateAsync(s => s.SetProperty(t => t.SortOrder, (maxSort ?? -1) + 1), ct);
|
|
}
|
|
|
|
public async Task<List<TaskEntity>> GetByCreatorAsync(string createdBy, CancellationToken ct = default)
|
|
{
|
|
return await _context.Tasks
|
|
.AsNoTracking()
|
|
.Where(t => t.CreatedBy == createdBy)
|
|
.OrderByDescending(t => t.CreatedAt)
|
|
.ToListAsync(ct);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns all tasks that qualify as "real" Idle backlog items for online mirroring:
|
|
/// Status==Idle, no parent, PlanningPhase==None, not blocked.
|
|
/// </summary>
|
|
public async Task<List<TaskEntity>> GetAllIdleBacklogAsync(CancellationToken ct = default)
|
|
{
|
|
return await _context.Tasks
|
|
.AsNoTracking()
|
|
.Where(t => t.Status == TaskStatus.Idle
|
|
&& t.ParentTaskId == null
|
|
&& t.PlanningPhase == PlanningPhase.None
|
|
&& t.BlockedByTaskId == null)
|
|
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
|
|
.ToListAsync(ct);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Status transitions
|
|
|
|
internal async Task MarkRunningAsync(string taskId, DateTime startedAt, CancellationToken ct = default)
|
|
{
|
|
await _context.Tasks
|
|
.Where(t => t.Id == taskId)
|
|
.ExecuteUpdateAsync(s => s
|
|
.SetProperty(t => t.Status, TaskStatus.Running)
|
|
.SetProperty(t => t.StartedAt, startedAt), ct);
|
|
}
|
|
|
|
internal async Task MarkDoneAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct = default)
|
|
{
|
|
await _context.Tasks
|
|
.Where(t => t.Id == taskId)
|
|
.ExecuteUpdateAsync(s => s
|
|
.SetProperty(t => t.Status, TaskStatus.Done)
|
|
.SetProperty(t => t.FinishedAt, finishedAt)
|
|
.SetProperty(t => t.Result, result), ct);
|
|
}
|
|
|
|
internal async Task MarkFailedAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct = default)
|
|
{
|
|
await _context.Tasks
|
|
.Where(t => t.Id == taskId)
|
|
.ExecuteUpdateAsync(s => s
|
|
.SetProperty(t => t.Status, TaskStatus.Failed)
|
|
.SetProperty(t => t.FinishedAt, finishedAt)
|
|
.SetProperty(t => t.Result, result), ct);
|
|
}
|
|
|
|
public async Task SetLogPathAsync(string taskId, string logPath, CancellationToken ct = default)
|
|
{
|
|
await _context.Tasks
|
|
.Where(t => t.Id == taskId)
|
|
.ExecuteUpdateAsync(s => s.SetProperty(t => t.LogPath, logPath), ct);
|
|
}
|
|
|
|
public async Task SetRoadblockCountAsync(string taskId, int count, CancellationToken ct = default)
|
|
{
|
|
await _context.Tasks
|
|
.Where(t => t.Id == taskId)
|
|
.ExecuteUpdateAsync(s => s.SetProperty(t => t.RoadblockCount, count), ct);
|
|
}
|
|
|
|
internal async Task<int> FlipAllRunningToFailedAsync(string reason, CancellationToken ct = default)
|
|
{
|
|
var resultText = "[stale] " + reason;
|
|
var now = DateTime.UtcNow;
|
|
return await _context.Tasks
|
|
.Where(t => t.Status == TaskStatus.Running)
|
|
.ExecuteUpdateAsync(s => s
|
|
.SetProperty(t => t.Status, TaskStatus.Failed)
|
|
.SetProperty(t => t.FinishedAt, now)
|
|
.SetProperty(t => t.Result, resultText), ct);
|
|
}
|
|
|
|
public async Task ResetToManualAsync(string taskId, CancellationToken ct = default)
|
|
{
|
|
await _context.Tasks
|
|
.Where(t => t.Id == taskId)
|
|
.ExecuteUpdateAsync(s => s
|
|
.SetProperty(t => t.Status, TaskStatus.Idle)
|
|
.SetProperty(t => t.StartedAt, (DateTime?)null)
|
|
.SetProperty(t => t.FinishedAt, (DateTime?)null)
|
|
.SetProperty(t => t.Result, (string?)null), ct);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Agent settings
|
|
|
|
public async Task UpdateAgentSettingsAsync(
|
|
string taskId,
|
|
string? model,
|
|
string? systemPrompt,
|
|
string? agentPath,
|
|
int? maxTurns = null,
|
|
string? sessionSkills = null,
|
|
CancellationToken ct = default)
|
|
{
|
|
await _context.Tasks
|
|
.Where(t => t.Id == taskId)
|
|
.ExecuteUpdateAsync(s => s
|
|
.SetProperty(t => t.Model, model)
|
|
.SetProperty(t => t.SystemPrompt, systemPrompt)
|
|
.SetProperty(t => t.AgentPath, agentPath)
|
|
.SetProperty(t => t.MaxTurns, maxTurns)
|
|
.SetProperty(t => t.SessionSkills, sessionSkills), ct);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Planning
|
|
|
|
public async Task<List<TaskEntity>> GetChildrenAsync(string parentId, CancellationToken ct = default)
|
|
{
|
|
return await _context.Tasks
|
|
.AsNoTracking()
|
|
.Where(t => t.ParentTaskId == parentId)
|
|
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
|
|
.ToListAsync(ct);
|
|
}
|
|
|
|
public async Task<TaskEntity> CreateChildAsync(
|
|
string parentId,
|
|
string title,
|
|
string? description,
|
|
string? commitType,
|
|
string? createdBy = null,
|
|
string? model = null,
|
|
int? maxTurns = null,
|
|
CancellationToken ct = default)
|
|
{
|
|
if (maxTurns.HasValue && maxTurns.Value <= 0)
|
|
throw new ArgumentException($"maxTurns must be positive, got {maxTurns.Value}.");
|
|
|
|
// AsNoTracking: SetPlanningStartedAsync mutates via ExecuteUpdate which
|
|
// bypasses the change tracker; a tracked Find would return stale data.
|
|
var parent = await _context.Tasks.AsNoTracking()
|
|
.FirstOrDefaultAsync(t => t.Id == parentId, ct);
|
|
if (parent is null)
|
|
throw new InvalidOperationException($"Parent task {parentId} not found.");
|
|
|
|
var maxSort = await _context.Tasks
|
|
.Where(t => t.ListId == parent.ListId)
|
|
.Select(t => (int?)t.SortOrder)
|
|
.MaxAsync(ct);
|
|
|
|
var child = new TaskEntity
|
|
{
|
|
Id = Guid.NewGuid().ToString(),
|
|
ListId = parent.ListId,
|
|
Title = title,
|
|
Description = description,
|
|
Status = TaskStatus.Idle,
|
|
CreatedAt = DateTime.UtcNow,
|
|
CommitType = string.IsNullOrEmpty(commitType) ? parent.CommitType : commitType,
|
|
ParentTaskId = parentId,
|
|
SortOrder = (maxSort ?? -1) + 1,
|
|
CreatedBy = createdBy,
|
|
Model = ModelRegistry.NormalizeAlias(model),
|
|
MaxTurns = maxTurns,
|
|
};
|
|
await TaskNumberAllocator.AddWithNumberAsync(_context, child, ct);
|
|
return child;
|
|
}
|
|
|
|
public async Task UpdateChildAsync(
|
|
string taskId,
|
|
string? title,
|
|
string? description,
|
|
string? commitType,
|
|
TaskStatus? status,
|
|
CancellationToken ct = default)
|
|
{
|
|
var task = await _context.Tasks.FirstOrDefaultAsync(t => t.Id == taskId, ct)
|
|
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
|
|
|
if (title is not null) task.Title = title;
|
|
if (description is not null) task.Description = description;
|
|
if (commitType is not null) task.CommitType = commitType;
|
|
if (status.HasValue) task.Status = status.Value;
|
|
|
|
await _context.SaveChangesAsync(ct);
|
|
}
|
|
|
|
public async Task UpdatePlanningTaskAsync(
|
|
string taskId,
|
|
string? title,
|
|
string? description,
|
|
CancellationToken ct = default)
|
|
{
|
|
var entity = await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId, ct)
|
|
?? throw new InvalidOperationException("Planning task not found.");
|
|
if (title is not null) entity.Title = title;
|
|
if (description is not null) entity.Description = description;
|
|
await _context.Tasks
|
|
.Where(t => t.Id == taskId)
|
|
.ExecuteUpdateAsync(s => s
|
|
.SetProperty(t => t.Title, entity.Title)
|
|
.SetProperty(t => t.Description, entity.Description), ct);
|
|
}
|
|
|
|
public async Task<TaskEntity?> SetPlanningStartedAsync(
|
|
string taskId,
|
|
string sessionToken,
|
|
CancellationToken ct = default)
|
|
{
|
|
var affected = await _context.Tasks
|
|
.Where(t => t.Id == taskId
|
|
&& t.Status == TaskStatus.Idle
|
|
&& t.PlanningPhase == PlanningPhase.None)
|
|
.ExecuteUpdateAsync(s => s
|
|
.SetProperty(t => t.PlanningPhase, PlanningPhase.Active)
|
|
.SetProperty(t => t.PlanningSessionToken, sessionToken), ct);
|
|
|
|
if (affected == 0) return null;
|
|
return await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId, ct);
|
|
}
|
|
|
|
public async Task SetPlanningSessionTokenAsync(
|
|
string taskId,
|
|
string sessionToken,
|
|
CancellationToken ct = default)
|
|
{
|
|
await _context.Tasks
|
|
.Where(t => t.Id == taskId)
|
|
.ExecuteUpdateAsync(s => s
|
|
.SetProperty(t => t.PlanningSessionToken, sessionToken), ct);
|
|
}
|
|
|
|
public async Task UpdatePlanningSessionIdAsync(
|
|
string parentId,
|
|
string sessionId,
|
|
CancellationToken ct = default)
|
|
{
|
|
await _context.Tasks
|
|
.Where(t => t.Id == parentId)
|
|
.ExecuteUpdateAsync(s => s
|
|
.SetProperty(t => t.PlanningSessionId, sessionId), ct);
|
|
}
|
|
|
|
// Stamps the review range's head commit for a worktree-less "list handler" host task
|
|
// (Mission Control's "Let Claude handle it") when its ConPTY session is submitted for review.
|
|
public async Task SetHandlerHeadCommitAsync(
|
|
string taskId,
|
|
string headCommit,
|
|
CancellationToken ct = default)
|
|
{
|
|
await _context.Tasks
|
|
.Where(t => t.Id == taskId)
|
|
.ExecuteUpdateAsync(s => s
|
|
.SetProperty(t => t.HandlerHeadCommit, headCommit), ct);
|
|
}
|
|
|
|
// Persists the claude session id a fresh embedded ConPTY interactive task session will run
|
|
// under, written BEFORE launch so a closed/aborted session still leaves a resumable id.
|
|
public async Task SetInteractiveSessionIdAsync(
|
|
string taskId,
|
|
string? sessionId,
|
|
CancellationToken ct = default)
|
|
{
|
|
await _context.Tasks
|
|
.Where(t => t.Id == taskId)
|
|
.ExecuteUpdateAsync(s => s
|
|
.SetProperty(t => t.InteractiveSessionId, sessionId), ct);
|
|
}
|
|
|
|
public async Task<TaskEntity?> FindByPlanningTokenAsync(
|
|
string token,
|
|
CancellationToken ct = default)
|
|
{
|
|
if (string.IsNullOrEmpty(token)) return null;
|
|
return await _context.Tasks
|
|
.AsNoTracking()
|
|
.FirstOrDefaultAsync(t => t.PlanningSessionToken == token, ct);
|
|
}
|
|
|
|
public async Task<DiscardPlanningOutcome> DiscardPlanningAsync(
|
|
string parentId,
|
|
bool dequeueQueuedChildren,
|
|
CancellationToken ct = default)
|
|
{
|
|
using var tx = await _context.Database.BeginTransactionAsync(ct);
|
|
|
|
var parent = await _context.Tasks
|
|
.AsNoTracking()
|
|
.FirstOrDefaultAsync(t => t.Id == parentId, ct);
|
|
if (parent is null || parent.PlanningPhase != PlanningPhase.Active)
|
|
{
|
|
await tx.RollbackAsync(ct);
|
|
return new DiscardPlanningOutcome(DiscardPlanningResult.NotInPlanning, 0, 0);
|
|
}
|
|
|
|
var children = await _context.Tasks
|
|
.Where(t => t.ParentTaskId == parentId)
|
|
.Select(t => new { t.Id, t.Status })
|
|
.ToListAsync(ct);
|
|
|
|
var runningCount = children.Count(c => c.Status == TaskStatus.Running);
|
|
if (runningCount > 0)
|
|
{
|
|
await tx.RollbackAsync(ct);
|
|
return new DiscardPlanningOutcome(DiscardPlanningResult.BlockedByRunningChildren, 0, runningCount);
|
|
}
|
|
|
|
var queuedIds = children.Where(c => c.Status == TaskStatus.Queued).Select(c => c.Id).ToList();
|
|
if (queuedIds.Count > 0)
|
|
{
|
|
if (!dequeueQueuedChildren)
|
|
{
|
|
await tx.RollbackAsync(ct);
|
|
return new DiscardPlanningOutcome(DiscardPlanningResult.BlockedByQueuedChildren, queuedIds.Count, 0);
|
|
}
|
|
|
|
await _context.Tasks
|
|
.Where(t => queuedIds.Contains(t.Id))
|
|
.ExecuteUpdateAsync(s => s
|
|
.SetProperty(t => t.Status, TaskStatus.Idle)
|
|
.SetProperty(t => t.BlockedByTaskId, (string?)null), ct);
|
|
}
|
|
|
|
// Terminal children (Done/Failed/Cancelled) stay attached to the parent even
|
|
// though its PlanningPhase will be reset to None. The lineage is preserved as
|
|
// historical context; the UI nests them under their parent regardless of phase.
|
|
|
|
// Idle children created during this planning session are dropped.
|
|
await _context.Tasks
|
|
.Where(t => t.ParentTaskId == parentId
|
|
&& t.Status == TaskStatus.Idle
|
|
&& t.PlanningPhase == PlanningPhase.None)
|
|
.ExecuteDeleteAsync(ct);
|
|
|
|
await _context.Tasks
|
|
.Where(t => t.Id == parentId)
|
|
.ExecuteUpdateAsync(s => s
|
|
.SetProperty(t => t.Status, TaskStatus.Idle)
|
|
.SetProperty(t => t.PlanningPhase, PlanningPhase.None)
|
|
.SetProperty(t => t.PlanningSessionId, (string?)null)
|
|
.SetProperty(t => t.PlanningSessionToken, (string?)null)
|
|
.SetProperty(t => t.PlanningFinalizedAt, (DateTime?)null), ct);
|
|
|
|
await tx.CommitAsync(ct);
|
|
return new DiscardPlanningOutcome(DiscardPlanningResult.Discarded, queuedIds.Count, 0);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Dequeues child tasks whose parent is missing or no longer in a planning phase:
|
|
/// sets <c>Status</c> from <c>Queued</c> to <c>Idle</c> and clears
|
|
/// <c>BlockedByTaskId</c>. <c>ParentTaskId</c> stays intact — the child remains
|
|
/// part of its (former) planning chain for historical context. Returns the
|
|
/// number of rows dequeued. Idempotent.
|
|
/// </summary>
|
|
internal async Task<int> DequeueOrphanedChildrenAsync(CancellationToken ct = default)
|
|
{
|
|
var orphanIds = await _context.Tasks
|
|
.Where(t => t.ParentTaskId != null && t.Status == TaskStatus.Queued)
|
|
// Agent-suggested improvement children (CreatedBy == ParentTaskId) legitimately
|
|
// queue under a non-planning parent — they are not orphaned planning-chain members.
|
|
.Where(t => t.CreatedBy == null || t.CreatedBy != t.ParentTaskId)
|
|
.Where(t => !_context.Tasks.Any(p =>
|
|
p.Id == t.ParentTaskId && p.PlanningPhase != PlanningPhase.None))
|
|
.Select(t => t.Id)
|
|
.ToListAsync(ct);
|
|
|
|
if (orphanIds.Count == 0) return 0;
|
|
|
|
return await _context.Tasks
|
|
.Where(t => orphanIds.Contains(t.Id))
|
|
.ExecuteUpdateAsync(s => s
|
|
.SetProperty(t => t.Status, TaskStatus.Idle)
|
|
.SetProperty(t => t.BlockedByTaskId, (string?)null), ct);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Restores a planning-session lineage that lost its <c>parent_task_id</c> links.
|
|
/// Given a candidate parent task and a single unambiguous orphan chain in the
|
|
/// same list (linked via <c>BlockedByTaskId</c>), re-attaches the chain members
|
|
/// to the parent, marks the parent as <c>Finalized</c>, and dequeues queued
|
|
/// chain members. No-op if conditions are not met. Returns the number of
|
|
/// re-attached children (0 if skipped).
|
|
/// </summary>
|
|
internal async Task<int> RestorePlanningLineageAsync(string parentId, CancellationToken ct = default)
|
|
{
|
|
var parent = await _context.Tasks.AsNoTracking()
|
|
.FirstOrDefaultAsync(t => t.Id == parentId, ct);
|
|
if (parent is null) return 0;
|
|
if (parent.PlanningPhase != PlanningPhase.None) return 0;
|
|
if (parent.Status is TaskStatus.Done or TaskStatus.Failed or TaskStatus.Cancelled) return 0;
|
|
|
|
// Candidates: unattached tasks in the same list, excluding the parent itself.
|
|
var candidates = await _context.Tasks.AsNoTracking()
|
|
.Where(t => t.ListId == parent.ListId && t.ParentTaskId == null && t.Id != parent.Id)
|
|
.ToListAsync(ct);
|
|
|
|
// A chain is a maximal linear sequence linked via BlockedByTaskId. Find heads
|
|
// (BlockedByTaskId == null) that have at least one successor.
|
|
var bySource = candidates
|
|
.Where(c => c.BlockedByTaskId != null)
|
|
.ToLookup(c => c.BlockedByTaskId!);
|
|
|
|
var heads = candidates
|
|
.Where(c => c.BlockedByTaskId == null && bySource[c.Id].Any())
|
|
.ToList();
|
|
|
|
// Bail unless exactly one chain anchors a successor — anything else is
|
|
// ambiguous and we refuse to guess.
|
|
if (heads.Count != 1) return 0;
|
|
|
|
var chain = new List<TaskEntity> { heads[0] };
|
|
var current = heads[0];
|
|
while (true)
|
|
{
|
|
var next = bySource[current.Id].FirstOrDefault();
|
|
if (next is null) break;
|
|
chain.Add(next);
|
|
current = next;
|
|
}
|
|
|
|
var chainIds = chain.Select(c => c.Id).ToList();
|
|
|
|
await _context.Tasks
|
|
.Where(t => t.Id == parentId)
|
|
.ExecuteUpdateAsync(s => s.SetProperty(t => t.PlanningPhase, PlanningPhase.Finalized), ct);
|
|
|
|
await _context.Tasks
|
|
.Where(t => chainIds.Contains(t.Id))
|
|
.ExecuteUpdateAsync(s => s.SetProperty(t => t.ParentTaskId, (string?)parentId), ct);
|
|
|
|
// Dequeue queued chain members; blocked_by stays intact so chain order is
|
|
// preserved for manual re-queueing.
|
|
await _context.Tasks
|
|
.Where(t => chainIds.Contains(t.Id) && t.Status == TaskStatus.Queued)
|
|
.ExecuteUpdateAsync(s => s.SetProperty(t => t.Status, TaskStatus.Idle), ct);
|
|
|
|
return chainIds.Count;
|
|
}
|
|
|
|
#endregion
|
|
}
|