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:
co-authored by
Claude Opus 5.5
parent
095722ae9c
commit
e42ec100b0
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user