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:
@@ -322,6 +322,52 @@ public class DashboardController(
|
||||
};
|
||||
}
|
||||
|
||||
// ── Review-Aktionen (Bao/Iris) ──
|
||||
|
||||
/// <summary>Review abnehmen: Review → Done. Nur Bao/Iris.</summary>
|
||||
[HttpPost("tasks/{id:guid}/approve")]
|
||||
public async Task<ActionResult<DashboardTaskDto>> ApproveReview(Guid id, CancellationToken ct)
|
||||
{
|
||||
var currentTask = await taskService.GetByIdAsync(id, ct);
|
||||
if (currentTask is null)
|
||||
return NotFound(new { error = "Task not found." });
|
||||
|
||||
if (!TaskStateHelper.CanChangeState(ResolveCallerAgent(), currentTask))
|
||||
return StatusCode(403, new { error = "Review-Abnahme ist nur Iris und Bao vorbehalten." });
|
||||
|
||||
var result = await taskService.ApproveReviewAsync(id, ct);
|
||||
return result.Outcome switch
|
||||
{
|
||||
TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
|
||||
TaskOperationOutcome.InvalidState => BadRequest(new { error = "Nur Tasks im Review können abgenommen werden." }),
|
||||
_ => Ok(MapToDto(result.Task!))
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Änderung anfordern: Review → Zielspalte mit Pflichtkommentar. Nur Bao/Iris.</summary>
|
||||
[HttpPost("tasks/{id:guid}/request-changes")]
|
||||
public async Task<ActionResult<DashboardTaskDto>> RequestChanges(
|
||||
Guid id, [FromBody] RequestChangesRequest request, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Comment))
|
||||
return BadRequest(new { error = "Ein Kommentar ist erforderlich, damit Iris weiß, was zu ändern ist." });
|
||||
|
||||
var currentTask = await taskService.GetByIdAsync(id, ct);
|
||||
if (currentTask is null)
|
||||
return NotFound(new { error = "Task not found." });
|
||||
|
||||
if (!TaskStateHelper.CanChangeState(ResolveCallerAgent(), currentTask))
|
||||
return StatusCode(403, new { error = "Review-Entscheidungen sind nur Iris und Bao vorbehalten." });
|
||||
|
||||
var result = await taskService.RequestChangesAsync(id, request.Comment, request.TargetState, ct);
|
||||
return result.Outcome switch
|
||||
{
|
||||
TaskOperationOutcome.NotFound => NotFound(new { error = "Task not found." }),
|
||||
TaskOperationOutcome.InvalidState => BadRequest(new { error = "Nur Tasks im Review können zurückgegeben werden." }),
|
||||
_ => Ok(MapToDto(result.Task!))
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the caller identity: checks X-Agent-Id header, then JWT name claim.
|
||||
/// Falls back to empty string (which authorization helpers reject accordingly).
|
||||
@@ -353,19 +399,7 @@ public class DashboardController(
|
||||
|
||||
[HttpGet("tasks/{id:guid}/children")]
|
||||
public async Task<ActionResult<List<DashboardTaskDto>>> GetChildren(Guid id, CancellationToken ct)
|
||||
{
|
||||
var board = await taskService.GetBoardAsync(ct);
|
||||
var children = board.Offen
|
||||
.Concat(board.InProgress)
|
||||
.Concat(board.Review)
|
||||
.Concat(board.Blocked)
|
||||
.Concat(board.Done)
|
||||
.Where(task => task.ParentTaskId == id)
|
||||
.OrderByDescending(task => task.UpdatedAt)
|
||||
.ToList();
|
||||
|
||||
return Ok(children);
|
||||
}
|
||||
=> Ok(await taskService.GetChildTaskDtosAsync(id, ct));
|
||||
|
||||
[HttpGet("tasks/{id:guid}")]
|
||||
public async Task<ActionResult<DashboardTaskDto>> GetTask(Guid id, CancellationToken ct)
|
||||
|
||||
Reference in New Issue
Block a user