using ClawdDotNet.Core.Audit; using ClawdDotNet.Core.Storage; using ClawdDotNet.Core.Tasks; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; namespace ClawdDotNet.Core.Tests.Tasks; /// /// #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. /// 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 Task(string title, string assignee, TaskItemStatus status, IReadOnlyList? 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> 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(); } }