feat(board): master-task board, non-destructive stall watchdog, review flow
CI - Build & Test / Backend (.NET) (push) Successful in 32s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 18s
CI - Build & Test / Security Check (push) Successful in 2s
CI - Build & Test / Deploy Nexus (push) Has been skipped

Board is now a clean master-task view:
- GetBoardAsync returns only top-level (master) tasks; child-tasks render
  nested inside their parent card instead of as separate column cards, so a
  big task split into many sub-tasks stays one card (orphans treated as master)
- New DoneChildTaskCount on the DTO for real progress bars
- Child/detail consumers (GetChildren endpoint, TaskBridgeService) query
  children directly instead of scraping the flat board

Stall watchdog (replaces destructive auto-reset):
- StaleTaskRecoveryService.FlagStalledInProgressTasksAsync marks In-progress
  tasks with no activity past the threshold as stalled (activity event +
  Iris notification) WITHOUT resetting the column — no work is discarded.
  Idempotent: a task is not re-flagged until real progress happens
- BackgroundService now runs this watchdog (TaskRecovery:StalledMinutes=40,
  interval 10m); hard reset kept only on the explicit manual endpoint

Review flow (Bao/Iris only):
- POST tasks/{id}/approve (Review -> Done)
- POST tasks/{id}/request-changes (Review -> target, mandatory comment,
  ExpectedFrom=iris, notifies Iris)

Frontend:
- BoardCard component: master card with ball chip (who has it), progress from
  children, expand to show children grouped by agent with per-child state +
  stalled marker, stalled chip on the master, review action buttons
- Request-changes modal; tasks store approveReview/requestChanges actions

Tests: watchdog flag/idempotency + review threshold; 135 backend tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 22:26:50 +02:00
parent f564ecfbc7
commit aef76d5f45
15 changed files with 789 additions and 432 deletions
+79 -97
View File
@@ -7,8 +7,81 @@ namespace Nexus.Api.Services;
public sealed class StaleTaskRecoveryService(
ITaskRepository taskRepository,
IActivityRepository activityRepository,
ILiveUpdateService liveUpdateService) : IStaleTaskRecoveryService
ILiveUpdateService liveUpdateService,
INotificationService notificationService) : IStaleTaskRecoveryService
{
private const string StalledActivityType = "stalled";
/// <summary>
/// NICHT-destruktiver Watchdog: markiert „In progress"-Tasks ohne Aktivität seit
/// <paramref name="stalledThreshold"/> als hängend (Activity-Event + Notification an Iris),
/// OHNE die Spalte zu ändern oder Arbeit zu verwerfen. Iris eskaliert dann (nachfragen,
/// neu delegieren, ggf. auf Blocked setzen). Dedup: bereits gemeldete Hänger werden nicht
/// erneut gemeldet, solange kein neuer Fortschritt (andere Activity) dazwischen liegt.
/// </summary>
public async Task<int> FlagStalledInProgressTasksAsync(TimeSpan stalledThreshold, CancellationToken ct = default)
{
var now = DateTimeOffset.UtcNow;
var threshold = now - stalledThreshold;
var allTasks = await taskRepository.GetAllAsync(ct);
var inProgress = allTasks
.Where(t => string.Equals(t.State, TaskStateHelper.ToStateString(TaskState.InProgress), StringComparison.OrdinalIgnoreCase))
.ToList();
if (inProgress.Count == 0)
return 0;
var activities = await activityRepository.GetRecentForTasksAsync(inProgress.Select(t => t.Id), ct);
var activityByTask = activities
.Where(a => a.TaskId.HasValue)
.GroupBy(a => a.TaskId!.Value)
.ToDictionary(g => g.Key, g => g.OrderByDescending(a => a.CreatedAt).ToList());
var flaggedCount = 0;
foreach (var task in inProgress)
{
activityByTask.TryGetValue(task.Id, out var taskActivity);
var latest = taskActivity?.FirstOrDefault();
var lastProgressAt = latest?.CreatedAt ?? task.UpdatedAt;
if (lastProgressAt >= threshold)
continue;
// Dedup: schon als hängend gemeldet und seither kein neuer Fortschritt.
if (latest is not null && string.Equals(latest.Type, StalledActivityType, StringComparison.OrdinalIgnoreCase))
continue;
var silentFor = now - lastProgressAt;
await activityRepository.AddAsync(new ActivityEvent
{
Type = StalledActivityType,
Message = $"Watchdog: keine Aktivität seit {FormatDuration(silentFor)} (Schwelle {FormatDuration(stalledThreshold)}). Task bleibt In progress, Iris zur Eskalation benachrichtigt.",
TaskId = task.Id
}, ct);
await notificationService.CreateAsync(
"task_stalled",
$"Task hängt: {task.Title}",
$"Seit {FormatDuration(silentFor)} keine Aktivität. Bitte nachfassen, neu delegieren oder blockieren.",
"iris",
task.Id,
ct);
flaggedCount++;
}
if (flaggedCount > 0)
liveUpdateService.Publish("tasks.board.snapshot", await BuildBoardSnapshotAsync(ct), "board");
return flaggedCount;
}
/// <summary>
/// Destruktiver Fallback (nur manuell via Endpoint / expliziter Cron): setzt hängende
/// „In progress"-Tasks hart auf Backlog zurück. Verwirft laufenden Kontext — daher NICHT
/// mehr der Standard-Watchdog, sondern nur noch auf Anforderung.
/// </summary>
public async Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default)
{
var threshold = DateTimeOffset.UtcNow - staleThreshold;
@@ -80,88 +153,8 @@ public sealed class StaleTaskRecoveryService(
private async Task<BoardResponse> 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<DashboardTaskDto>();
var inProgress = new List<DashboardTaskDto>();
var review = new List<DashboardTaskDto>();
var blocked = new List<DashboardTaskDto>();
var done = new List<DashboardTaskDto>();
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<WorkTask> allTasks,
IEnumerable<ActivityEvent> 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<ActivityEvent> 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);
var activity = await activityRepository.GetRecentForTasksAsync(allTasks.Select(task => task.Id), ct);
return TaskService.BuildMasterBoard(allTasks, activity);
}
private static string BuildActivityMessage(
@@ -190,20 +183,9 @@ public sealed class StaleTaskRecoveryService(
}
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);
if (duration.TotalHours >= 1)
return $"{(int)duration.TotalHours}h {duration.Minutes}min";
return $"{Math.Max(0, (int)duration.TotalMinutes)}min";
}
private static int PriorityScore(string priority) => priority.ToLowerInvariant() switch
{
"high" => 3,
"medium" => 2,
"normal" => 2,
"low" => 1,
_ => 2
};
}