Taskboard fasst nach: stockende, entblockte und wartende Aufgaben erkennen und nachfassen (#646)

Alle 30 Minuten, als Abfrage statt Modelllauf: Agentenaufgabe in Arbeit ohne
Aenderung (Vorgabe 24 h) -> einmal neu gestartet, stockt sie binnen einer
Woche wieder, geht es an die Leitung bzw. den Menschen; blockiert mit
erledigten Blockern -> wieder offen; @human-Aufgabe ueber 48 h -> in einer
Sammelaufgabe fuer den Menschen; in_review ueber 48 h und wiederholtes
LoopLimitExceeded (3 in 7 Tagen) -> Aufgabe an die Leitung. Einmal je Zustand,
hoechstens 10 Nachfassungen je Durchgang, jede im Audit. Befunde liegen
abfragbar in TaskFindings (fuer #680); WebUI-Karte 'Liegengeblieben'.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-10-02 11:49:04 +02:00
co-authored by Claude Opus 5.5
parent 095722ae9c
commit e42ec100b0
10 changed files with 617 additions and 3 deletions
@@ -0,0 +1,131 @@
using ClawdDotNet.Core.Audit;
using ClawdDotNet.Core.Storage;
using ClawdDotNet.Core.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Tasks;
/// <summary>
/// #646: The board follows up on work that lives but does not move — once per state, braked,
/// and with what it found kept for supervision.
/// </summary>
public sealed class TaskFollowUpTests : IDisposable
{
private readonly string _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", "fu-" + Guid.NewGuid().ToString("N")[..8]);
private readonly SqliteTaskRepository _repo;
private readonly TaskboardService _board;
private readonly SqliteAuditRepository _audit;
private readonly FollowUpSettings _settings = new();
private readonly TaskFollowUp _followUp;
public TaskFollowUpTests()
{
var storage = new SqliteStorage(Path.Combine(_directory, "state.db"));
_repo = new SqliteTaskRepository(storage);
_board = new TaskboardService(_repo, Path.Combine(_directory, "tasks"));
_audit = new SqliteAuditRepository(storage);
_followUp = new TaskFollowUp(_board, _repo, _audit, storage, () => _settings, () => null, NullLogger.Instance);
}
public void Dispose()
{
SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); }
catch { /* cleanup is secondary */ }
}
private static DateTime Later(double hours) => DateTime.UtcNow.AddHours(hours);
private async Task<TaskItem> Task(string title, string assignee, TaskItemStatus status, IReadOnlyList<string>? blockedBy = null)
{
var task = await _board.CreateAsync(new TaskItem { Title = title, Assignee = assignee, BlockedBy = blockedBy ?? [] }, default);
if (status != TaskItemStatus.Todo)
task = (await _board.UpdateAsync(task.Id, t => t with { Status = status }, default))!;
return task;
}
private async Task<IReadOnlyList<TaskItem>> All() => await _repo.ListAllAsync(includeArchived: false, default);
[Fact]
public async Task A_stalled_agent_task_is_woken_once_and_its_lead_hears_when_it_stalls_again()
{
var task = await Task("Write the report", "@desk", TaskItemStatus.InProgress);
(await _followUp.PassAsync(Later(1), default)).ShouldBe(0, "not stalled yet");
(await _followUp.PassAsync(Later(25), default)).ShouldBe(1);
var woken = (await _repo.GetAsync(task.Id, default))!;
woken.Status.ShouldBe(TaskItemStatus.Todo);
woken.Body.ShouldContain("Follow-up: this task is in progress without any change");
(await _audit.ListForAgentAsync("desk", null, 10, default)).ShouldContain(e => e.Tool == "TaskFollowUp");
await _board.UpdateAsync(task.Id, t => t with { Status = TaskItemStatus.InProgress }, default);
(await _followUp.PassAsync(Later(50), default)).ShouldBe(1);
(await All()).ShouldContain(t => t.Title == $"Follow-up: {task.Id} stalls again" && t.Assignee == TaskAssignee.Human,
"without units, a human is the lead");
(await _repo.GetAsync(task.Id, default))!.Status.ShouldBe(TaskItemStatus.InProgress, "woken once, not again");
}
[Fact]
public async Task A_task_whose_blockers_are_finished_is_opened_again()
{
var blocker = await Task("Fetch the rates", "@kurse", TaskItemStatus.Done);
var blocked = await Task("Book the rates", "@desk", TaskItemStatus.Blocked, [blocker.Id]);
var stillBlocked = await Task("Wait for the other", "@desk", TaskItemStatus.Blocked, [blocker.Id, "t-missing"]);
(await _followUp.PassAsync(Later(0.1), default)).ShouldBe(1);
var opened = (await _repo.GetAsync(blocked.Id, default))!;
opened.Status.ShouldBe(TaskItemStatus.Todo);
opened.BlockedBy.ShouldBeEmpty();
(await _repo.GetAsync(stillBlocked.Id, default))!.Status.ShouldBe(TaskItemStatus.Blocked, "a blocker that is not there is not finished");
}
[Fact]
public async Task What_waits_for_a_human_is_collected_in_one_task_once()
{
await Task("Approve the budget", TaskAssignee.Human, TaskItemStatus.Todo);
await Task("Answer the supplier", TaskAssignee.Human, TaskItemStatus.Todo);
(await _followUp.PassAsync(Later(49), default)).ShouldBe(2);
(await _followUp.PassAsync(Later(50), default)).ShouldBe(0, "once per state");
var digest = (await All()).Single(t => t.Title == TaskFollowUp.DigestTitle);
digest.Body.ShouldContain("Approve the budget");
digest.Body.ShouldContain("Answer the supplier");
}
[Fact]
public async Task A_task_that_keeps_ending_at_the_step_limit_goes_to_the_lead_once_a_week()
{
var task = await Task("Digest all news", "@nachrichten", TaskItemStatus.Todo);
for (var i = 0; i < 3; i++)
await _audit.RecordReceiptAsync(new RunReceipt { RunId = $"r{i}", AgentId = "nachrichten", TaskId = task.Id, Status = "LoopLimitExceeded", OccurredAt = DateTime.UtcNow }, default);
(await _followUp.PassAsync(Later(0.1), default)).ShouldBe(1);
(await _followUp.PassAsync(Later(1), default)).ShouldBe(0);
(await All()).ShouldContain(t => t.Title == $"Follow-up: {task.Id} keeps ending at the step limit");
}
[Fact]
public async Task Findings_are_kept_braked_and_resolved_when_the_task_moves()
{
_settings.MaxActionsPerPass = 1;
var a = await Task("A", "@desk", TaskItemStatus.InReview);
await Task("B", "@desk", TaskItemStatus.InReview);
(await _followUp.PassAsync(Later(49), default)).ShouldBe(1, "braked");
(await _followUp.ListAsync(openOnly: true, 10, default)).Count.ShouldBe(2);
(await _followUp.PassAsync(Later(49.1), default)).ShouldBe(1, "the rest follows in the next pass");
await _board.UpdateAsync(a.Id, t => t with { Status = TaskItemStatus.Done }, default);
await _followUp.PassAsync(Later(49.2), default);
var open = await _followUp.ListAsync(openOnly: true, 10, default);
open.ShouldAllBe(f => f.TaskId != a.Id);
(await _followUp.ListAsync(openOnly: false, 10, default)).Single(f => f.TaskId == a.Id).ResolvedAtUtc.ShouldNotBeNull();
}
}