using Nexus.Api.Data; using Nexus.Api.Models; using Nexus.Api.Repositories; namespace Nexus.Api.Services; public sealed class StaleTaskRecoveryService( ITaskRepository taskRepository, IActivityRepository activityRepository, ILiveUpdateService liveUpdateService) : IStaleTaskRecoveryService { public async Task ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default) { var threshold = DateTimeOffset.UtcNow - staleThreshold; var staleTasks = await GetStaleTasksAsync(threshold, ct); if (staleTasks.Count == 0) return 0; var latestActivityByTaskId = await GetLatestActivityByTaskIdAsync(staleTasks.Select(task => task.Id), ct); var now = DateTimeOffset.UtcNow; var resetCount = 0; foreach (var task in staleTasks) { var currentTask = await taskRepository.GetByIdAsync(task.Id, ct); if (currentTask is null || !IsStaleInProgress(currentTask, threshold)) continue; latestActivityByTaskId.TryGetValue(currentTask.Id, out var lastActivityAt); var message = BuildActivityMessage(currentTask, staleThreshold, now, lastActivityAt); var updated = await taskRepository.TryResetStaleInProgressToBacklogAsync( currentTask.Id, threshold, now, ct); if (!updated) continue; await activityRepository.AddAsync(new ActivityEvent { Type = "task", Message = message, TaskId = task.Id }, ct); resetCount++; } if (resetCount > 0) liveUpdateService.Publish("tasks.board.snapshot", await BuildBoardSnapshotAsync(ct), "board"); return resetCount; } private async Task> GetStaleTasksAsync(DateTimeOffset threshold, CancellationToken ct) { var allTasks = await taskRepository.GetAllAsync(ct); return allTasks .Where(task => IsStaleInProgress(task, threshold)) .ToList(); } private static bool IsStaleInProgress(WorkTask task, DateTimeOffset threshold) => string.Equals(task.State, TaskStateHelper.ToStateString(TaskState.InProgress), StringComparison.OrdinalIgnoreCase) && task.UpdatedAt < threshold; private async Task> GetLatestActivityByTaskIdAsync( IEnumerable taskIds, CancellationToken ct) { var activities = await activityRepository.GetRecentForTasksAsync(taskIds, ct); return activities .Where(activity => activity.TaskId.HasValue) .GroupBy(activity => activity.TaskId!.Value) .ToDictionary(group => group.Key, group => group.Max(activity => activity.CreatedAt)); } private async Task BuildBoardSnapshotAsync(CancellationToken ct) { var allTasks = await taskRepository.GetAllAsync(ct); var taskIds = allTasks.Select(task => task.Id).ToList(); var activity = await activityRepository.GetRecentForTasksAsync(taskIds, ct); var backlog = new List(); var inProgress = new List(); var review = new List(); var blocked = new List(); var done = new List(); foreach (var task in allTasks) { var dto = MapToDtoWithChildren(task, allTasks, activity); switch (task.State.ToLowerInvariant()) { case "backlog": backlog.Add(dto); break; case "in progress": inProgress.Add(dto); break; case "review": review.Add(dto); break; case "blocked": blocked.Add(dto); break; case "done": done.Add(dto); break; default: backlog.Add(dto); break; } } backlog.Sort(SortByPriorityThenCreatedAt); inProgress.Sort(SortByPriorityThenCreatedAt); review.Sort(SortByPriorityThenCreatedAt); blocked.Sort(SortByPriorityThenCreatedAt); done.Sort(SortByPriorityThenCreatedAt); return new BoardResponse(backlog, inProgress, review, blocked, done); } private static DashboardTaskDto MapToDtoWithChildren( WorkTask task, IReadOnlyList allTasks, IEnumerable activity) { var childTasks = allTasks .Where(candidate => candidate.ParentTaskId == task.Id) .OrderByDescending(candidate => candidate.UpdatedAt) .ToList(); var childDtos = childTasks.Select(child => MapToDtoWithActivity(child, activity)).ToList(); var openChildTaskCount = childTasks.Count(child => !string.Equals(child.State, "Done", StringComparison.OrdinalIgnoreCase)); var dto = MapToDtoWithActivity(task, activity); return dto with { ChildTasks = childDtos, ChildTaskCount = childDtos.Count, OpenChildTaskCount = openChildTaskCount, HasVisibleDelegation = dto.ParentTaskId.HasValue || childDtos.Count > 0 || dto.IsAgentTask }; } private static DashboardTaskDto MapToDtoWithActivity(WorkTask task, IEnumerable activity) { var last = activity .Where(entry => entry.TaskId == task.Id) .OrderByDescending(entry => entry.CreatedAt) .FirstOrDefault(); return new DashboardTaskDto( task.Id, task.Title, task.Detail, task.Source, task.State, task.Priority, task.AssignedTo, task.ParentTaskId, task.DueDate, task.CreatedAt, task.UpdatedAt, task.IsAgentTask, task.ExpectedFrom, last?.Message, last?.CreatedAt, null, 0, 0, task.ParentTaskId.HasValue || task.IsAgentTask); } private static string BuildActivityMessage( WorkTask task, TimeSpan staleThreshold, DateTimeOffset now, DateTimeOffset? lastActivityAt) { var staleAge = now - task.UpdatedAt; var details = new List { "reason=stale-recovery", "previous status In progress", $"stale reference {now:O}", $"stale age {FormatDuration(staleAge)}", $"threshold {FormatDuration(staleThreshold)}" }; if (lastActivityAt.HasValue) details.Add($"last activity {lastActivityAt.Value:O}"); details.Add($"last update {task.UpdatedAt:O}"); details.Add("new status Backlog"); return $"Task \"{task.Title}\" reset from In progress to Backlog by stale recovery ({string.Join("; ", details)})"; } private static string FormatDuration(TimeSpan duration) => duration.ToString(@"dd\.hh\:mm\:ss"); private static int SortByPriorityThenCreatedAt(DashboardTaskDto a, DashboardTaskDto b) { var priorityCompare = PriorityScore(b.Priority).CompareTo(PriorityScore(a.Priority)); return priorityCompare != 0 ? priorityCompare : a.CreatedAt.CompareTo(b.CreatedAt); } private static int PriorityScore(string priority) => priority.ToLowerInvariant() switch { "high" => 3, "medium" => 2, "normal" => 2, "low" => 1, _ => 2 }; }