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
+94 -15
View File
@@ -131,7 +131,8 @@ public sealed class StaleTaskRecoveryTests
var recoveryService = new StaleTaskRecoveryService(
taskRepository,
activityRepository,
liveUpdateService);
liveUpdateService,
new FakeNotificationService());
var resetCount = await recoveryService.ResetStaleInProgressTasksAsync(TimeSpan.FromHours(2), CancellationToken.None);
@@ -143,7 +144,51 @@ public sealed class StaleTaskRecoveryTests
}
[Fact]
public async Task BackgroundService_RunRecoveryOnceAsync_UsesConfiguredThreshold_AndCallsRecoveryService()
public async Task FlagStalledInProgressTasksAsync_FlagsStalledTask_NotifiesIris_WithoutResetting()
{
await using var fixture = await TaskWorkflowFixture.CreateAsync();
var stalledTimestamp = DateTimeOffset.UtcNow.AddHours(-3);
var stalled = await fixture.TaskRepository.AddAsync(new WorkTask
{
Title = "Stalled agent task",
State = "In progress",
Source = "iris",
UpdatedAt = stalledTimestamp,
CreatedAt = stalledTimestamp
}, CancellationToken.None);
var fresh = await fixture.TaskRepository.AddAsync(new WorkTask
{
Title = "Fresh in progress",
State = "In progress",
Source = "iris",
UpdatedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
CreatedAt = stalledTimestamp
}, CancellationToken.None);
var flagged = await fixture.StaleTaskRecoveryService.FlagStalledInProgressTasksAsync(
TimeSpan.FromMinutes(40), CancellationToken.None);
Assert.Equal(1, flagged);
// Nicht-destruktiv: bleibt In progress, kein Reset auf Backlog.
Assert.Equal("In progress", (await fixture.TaskService.GetByIdAsync(stalled.Id, CancellationToken.None))!.State);
Assert.Equal("In progress", (await fixture.TaskService.GetByIdAsync(fresh.Id, CancellationToken.None))!.State);
var activity = await fixture.TaskService.GetTaskActivityAsync(stalled.Id, CancellationToken.None);
Assert.Contains(activity, entry => string.Equals(entry.Type, "stalled", StringComparison.OrdinalIgnoreCase));
var irisNotifications = await fixture.NotificationService.GetForUserAsync("iris", 50, false, CancellationToken.None);
Assert.Contains(irisNotifications, n => n.Type == "task_stalled" && n.TaskId == stalled.Id);
// Idempotent: erneuter Lauf meldet denselben Hänger nicht nochmal.
var flaggedAgain = await fixture.StaleTaskRecoveryService.FlagStalledInProgressTasksAsync(
TimeSpan.FromMinutes(40), CancellationToken.None);
Assert.Equal(0, flaggedAgain);
}
[Fact]
public async Task BackgroundService_RunWatchdogOnceAsync_UsesStalledThreshold_AndFlags()
{
var fakeRecoveryService = new FakeStaleTaskRecoveryService();
var services = new ServiceCollection();
@@ -154,20 +199,21 @@ public sealed class StaleTaskRecoveryTests
provider.GetRequiredService<IServiceScopeFactory>(),
new TestOptionsMonitor<StaleTaskRecoveryOptions>(new StaleTaskRecoveryOptions
{
StaleHours = 4,
IntervalMinutes = 30
StalledMinutes = 45,
IntervalMinutes = 10
}),
NullLogger<StaleTaskRecoveryBackgroundService>.Instance);
var resetCount = await backgroundService.RunRecoveryOnceAsync(CancellationToken.None);
var flaggedCount = await backgroundService.RunWatchdogOnceAsync(CancellationToken.None);
Assert.Equal(1, fakeRecoveryService.CallCount);
Assert.Equal(TimeSpan.FromHours(4), fakeRecoveryService.LastThreshold);
Assert.Equal(7, resetCount);
Assert.Equal(1, fakeRecoveryService.FlagCallCount);
Assert.Equal(0, fakeRecoveryService.ResetCallCount);
Assert.Equal(TimeSpan.FromMinutes(45), fakeRecoveryService.LastThreshold);
Assert.Equal(7, flaggedCount);
}
[Fact]
public async Task BackgroundService_StartAsync_RunsRecoveryWithoutWaitingForFullInterval()
public async Task BackgroundService_StartAsync_RunsWatchdogWithoutWaitingForFullInterval()
{
var fakeRecoveryService = new FakeStaleTaskRecoveryService();
var firstCall = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
@@ -181,8 +227,8 @@ public sealed class StaleTaskRecoveryTests
provider.GetRequiredService<IServiceScopeFactory>(),
new TestOptionsMonitor<StaleTaskRecoveryOptions>(new StaleTaskRecoveryOptions
{
StaleHours = 2,
IntervalMinutes = 30
StalledMinutes = 40,
IntervalMinutes = 10
}),
NullLogger<StaleTaskRecoveryBackgroundService>.Instance);
@@ -191,8 +237,8 @@ public sealed class StaleTaskRecoveryTests
await firstCall.Task.WaitAsync(cts.Token);
await backgroundService.StopAsync(CancellationToken.None);
Assert.True(fakeRecoveryService.CallCount >= 1);
Assert.Equal(TimeSpan.FromHours(2), fakeRecoveryService.LastThreshold);
Assert.True(fakeRecoveryService.FlagCallCount >= 1);
Assert.Equal(TimeSpan.FromMinutes(40), fakeRecoveryService.LastThreshold);
}
[Fact]
@@ -325,19 +371,52 @@ file sealed class FakeLiveUpdateService : ILiveUpdateService
file sealed class FakeStaleTaskRecoveryService : IStaleTaskRecoveryService
{
public int CallCount { get; private set; }
public int FlagCallCount { get; private set; }
public int ResetCallCount { get; private set; }
public TimeSpan LastThreshold { get; private set; }
public Action? OnCall { get; set; }
public Task<int> FlagStalledInProgressTasksAsync(TimeSpan stalledThreshold, CancellationToken ct = default)
{
FlagCallCount++;
LastThreshold = stalledThreshold;
OnCall?.Invoke();
return Task.FromResult(7);
}
public Task<int> ResetStaleInProgressTasksAsync(TimeSpan staleThreshold, CancellationToken ct = default)
{
CallCount++;
ResetCallCount++;
LastThreshold = staleThreshold;
OnCall?.Invoke();
return Task.FromResult(7);
}
}
file sealed class FakeNotificationService : INotificationService
{
public List<Notification> Created { get; } = [];
public Task<Notification> CreateAsync(string type, string title, string? message, string forUser, Guid? taskId = null, CancellationToken ct = default)
{
var notification = new Notification { Type = type, Title = title, Message = message, ForUser = forUser, TaskId = taskId };
Created.Add(notification);
return Task.FromResult(notification);
}
public Task<IReadOnlyList<Notification>> GetForUserAsync(string forUser, int limit = 50, bool unreadOnly = false, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<Notification>>(Created.Where(n => n.ForUser == forUser).ToList());
public Task<bool> MarkAsReadAsync(Guid id, CancellationToken ct = default) => Task.FromResult(true);
public Task<int> MarkAllAsReadAsync(string forUser, CancellationToken ct = default) => Task.FromResult(0);
public Task<int> GetUnreadCountAsync(string forUser, CancellationToken ct = default) => Task.FromResult(0);
public Task<NotificationSnapshotDto> GetSnapshotAsync(string forUser, int limit = 50, bool unreadOnly = false, CancellationToken ct = default)
=> Task.FromResult(new NotificationSnapshotDto([], 0, forUser));
}
file sealed class TestOptionsMonitor<T>(T currentValue) : IOptionsMonitor<T>
{
public T CurrentValue { get; private set; } = currentValue;