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; var resetTaskIds = new List(); 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++; resetTaskIds.Add(task.Id); } if (resetCount > 0) liveUpdateService.Publish( "tasks.board.snapshot", new { taskIds = resetTaskIds }, "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 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"); }