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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user