feat(board): master-task board, non-destructive stall watchdog, review flow
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:
@@ -422,6 +422,19 @@ public sealed class TaskService(
|
||||
{
|
||||
var all = (await taskRepo.GetAllAsync(ct)).ToList();
|
||||
var activity = await activityRepo.GetRecentForTasksAsync(all.Select(t => t.Id), ct);
|
||||
return BuildMasterBoard(all, activity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Baut das Board aus NUR den Master-Tasks (Top-Level). Child-Tasks erscheinen
|
||||
/// nicht als eigene Karten, sondern verschachtelt in ihrem Parent — so bleibt das
|
||||
/// Board übersichtlich, auch wenn Iris eine große Aufgabe in viele Teilaufgaben
|
||||
/// zerlegt. Waisen (Parent existiert nicht mehr) werden als Master behandelt,
|
||||
/// damit nichts unsichtbar wird.
|
||||
/// </summary>
|
||||
internal static BoardResponse BuildMasterBoard(IReadOnlyList<WorkTask> all, IReadOnlyList<ActivityEvent> activity)
|
||||
{
|
||||
var ids = all.Select(t => t.Id).ToHashSet();
|
||||
|
||||
var offen = new List<DashboardTaskDto>();
|
||||
var inProgress = new List<DashboardTaskDto>();
|
||||
@@ -431,9 +444,10 @@ public sealed class TaskService(
|
||||
|
||||
foreach (var task in all)
|
||||
{
|
||||
// Ohne verschachtelte Child-DTOs: Children sind als eigene Karten im Board,
|
||||
// die Nested-Duplikate haben die Payload nur verdoppelt (Counts bleiben).
|
||||
var dto = MapToDtoWithChildren(task, all, activity, includeChildren: false);
|
||||
var isMaster = !task.ParentTaskId.HasValue || !ids.Contains(task.ParentTaskId.Value);
|
||||
if (!isMaster) continue;
|
||||
|
||||
var dto = MapToDtoWithChildren(task, all, activity, includeChildren: true);
|
||||
switch (task.State.ToLowerInvariant())
|
||||
{
|
||||
case "backlog": offen.Add(dto); break;
|
||||
@@ -492,6 +506,78 @@ public sealed class TaskService(
|
||||
return await UpdateTaskStatusInternalAsync(task, canonical, caller, "task", $"Task \"{task.Title}\" moved to {canonical}", ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Review-Abnahme durch Bao/Iris: Review → Done. Nur aus dem Review-Status erlaubt.
|
||||
/// </summary>
|
||||
public async Task<TaskOperationResult> ApproveReviewAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||
|
||||
var caller = ResolveCaller();
|
||||
if (!TaskStateHelper.CanChangeState(caller, task))
|
||||
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
|
||||
|
||||
if (!string.Equals(task.State, "Review", StringComparison.OrdinalIgnoreCase))
|
||||
return new TaskOperationResult(TaskOperationOutcome.InvalidState, task);
|
||||
|
||||
task.ExpectedFrom = null;
|
||||
return await UpdateTaskStatusInternalAsync(
|
||||
task,
|
||||
TaskStateHelper.ToStateString(TaskState.Done),
|
||||
caller,
|
||||
"review",
|
||||
$"Review abgenommen von {caller}: \"{task.Title}\" → Done",
|
||||
ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Änderung anfordern: Review → Zielspalte (Default In progress) mit Pflichtkommentar.
|
||||
/// Setzt ExpectedFrom=iris und benachrichtigt sie, damit sie autonom nacharbeitet.
|
||||
/// </summary>
|
||||
public async Task<TaskOperationResult> RequestChangesAsync(Guid id, string comment, string? targetState, CancellationToken ct = default)
|
||||
{
|
||||
var task = await taskRepo.GetByIdAsync(id, ct);
|
||||
if (task is null) return new TaskOperationResult(TaskOperationOutcome.NotFound);
|
||||
|
||||
var caller = ResolveCaller();
|
||||
if (!TaskStateHelper.CanChangeState(caller, task))
|
||||
return new TaskOperationResult(TaskOperationOutcome.InvalidState);
|
||||
|
||||
if (!string.Equals(task.State, "Review", StringComparison.OrdinalIgnoreCase))
|
||||
return new TaskOperationResult(TaskOperationOutcome.InvalidState, task);
|
||||
|
||||
var target = TaskStateHelper.AllStates.FirstOrDefault(s => s.Equals(targetState, StringComparison.OrdinalIgnoreCase))
|
||||
?? TaskStateHelper.ToStateString(TaskState.InProgress);
|
||||
// Aus dem Review geht es zurück in die Arbeit — nie direkt nach Done oder Review.
|
||||
if (string.Equals(target, "Done", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(target, "Review", StringComparison.OrdinalIgnoreCase))
|
||||
target = TaskStateHelper.ToStateString(TaskState.InProgress);
|
||||
|
||||
var trimmed = comment.Trim();
|
||||
await activityRepo.AddAsync(new ActivityEvent
|
||||
{
|
||||
Type = "review_changes_requested",
|
||||
Message = $"Änderung angefordert von {caller}: {trimmed}",
|
||||
TaskId = task.Id
|
||||
}, ct);
|
||||
|
||||
task.ExpectedFrom = "iris";
|
||||
var result = await UpdateTaskStatusInternalAsync(
|
||||
task, target, caller, "review",
|
||||
$"Review zurückgegeben von {caller} → {target}", ct);
|
||||
|
||||
await notificationService.CreateAsync(
|
||||
"task_changes_requested",
|
||||
$"Änderung angefordert: {task.Title}",
|
||||
trimmed,
|
||||
"iris",
|
||||
task.Id,
|
||||
ct);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public Task<int> ResetStaleAsync(int staleHours, CancellationToken ct = default)
|
||||
{
|
||||
var normalizedHours = Math.Max(1, staleHours);
|
||||
@@ -509,20 +595,33 @@ public sealed class TaskService(
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Child-Tasks eines Parents als DTOs — direkt aus dem Repo, nicht aus dem Board
|
||||
/// (das zeigt Children ja nur noch verschachtelt an). Für Detailansicht + Bridge.
|
||||
/// </summary>
|
||||
public async Task<List<DashboardTaskDto>> GetChildTaskDtosAsync(Guid parentId, CancellationToken ct = default)
|
||||
{
|
||||
var all = (await taskRepo.GetAllAsync(ct)).ToList();
|
||||
var activity = await activityRepo.GetRecentForTasksAsync(all.Select(t => t.Id), ct);
|
||||
return all.Where(t => t.ParentTaskId == parentId)
|
||||
.OrderByDescending(t => t.UpdatedAt)
|
||||
.Select(child => MapToDtoWithChildren(child, all, activity, includeChildren: false))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<List<ActivityEvent>> GetTaskActivityAsync(Guid taskId, CancellationToken ct = default)
|
||||
{
|
||||
var all = await activityRepo.GetRecentAsync(100, ct);
|
||||
return all.Where(e => e.TaskId == taskId).ToList();
|
||||
}
|
||||
|
||||
private DashboardTaskDto MapToDtoWithChildren(WorkTask task, IReadOnlyList<WorkTask> allTasks, IEnumerable<ActivityEvent> activity, bool includeChildren = true)
|
||||
private static DashboardTaskDto MapToDtoWithChildren(WorkTask task, IReadOnlyList<WorkTask> allTasks, IEnumerable<ActivityEvent> activity, bool includeChildren = true)
|
||||
{
|
||||
var childTasks = allTasks.Where(t => t.ParentTaskId == task.Id)
|
||||
.OrderByDescending(t => t.UpdatedAt)
|
||||
.ToList();
|
||||
|
||||
// includeChildren=false (Board/SSE-Snapshot): Children erscheinen dort ohnehin
|
||||
// als eigene Karten — verschachtelte Child-DTOs verdoppeln nur die Payload.
|
||||
// includeChildren=false: nur Zähler, keine verschachtelten Child-DTOs (schlanke Payload).
|
||||
var childDtos = includeChildren
|
||||
? childTasks.Select(child => MapToDtoWithActivity(child, activity, allTasks)).ToList()
|
||||
: null;
|
||||
@@ -534,6 +633,7 @@ public sealed class TaskService(
|
||||
ChildTasks = childDtos,
|
||||
ChildTaskCount = childTasks.Count,
|
||||
OpenChildTaskCount = openChildTaskCount,
|
||||
DoneChildTaskCount = childTasks.Count - openChildTaskCount,
|
||||
HasVisibleDelegation = dto.ParentTaskId.HasValue || childTasks.Count > 0 || dto.IsAgentTask
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user