using ClawdDotNet.Core.Config; using ClawdDotNet.Core.Organisation; using ClawdDotNet.Core.State; using ClawdDotNet.Core.Storage; using ClawdDotNet.Core.Tasks; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; namespace ClawdDotNet.Core.Tests.Tasks; /// /// #714: A task tree that has stopped moving while its purpose is not reached gets a review /// — once per state of rest, by the lead of its unit. /// public sealed class TaskTreeWatchTests { private static TaskItem T(string id, TaskItemStatus status, string parent = "", string assignee = "@ads") => new() { Id = id, Title = id, Status = status, ParentId = parent, Assignee = assignee }; // ═══════════════════ what rests ═══════════════════ [Fact] public void An_open_task_whose_sub_tasks_all_stopped_rests() { var trees = TaskTreeWatch.FindResting( [ T("goal", TaskItemStatus.Blocked), T("a", TaskItemStatus.Done, "goal"), T("b", TaskItemStatus.Blocked, "goal"), T("c", TaskItemStatus.InReview, "goal") ]); trees.ShouldHaveSingleItem().Root.Id.ShouldBe("goal"); trees[0].Below.Count.ShouldBe(3); } [Theory] [InlineData(TaskItemStatus.InProgress)] [InlineData(TaskItemStatus.Todo)] public void A_tree_with_anything_running_or_about_to_run_does_not_rest(TaskItemStatus moving) => TaskTreeWatch.FindResting( [ T("goal", TaskItemStatus.Blocked), T("a", TaskItemStatus.Done, "goal"), T("b", moving, "goal") ]).ShouldBeEmpty(); [Fact] public void A_task_waiting_for_a_human_does_not_move_by_itself() => TaskTreeWatch.FindResting( [ T("goal", TaskItemStatus.Todo, assignee: "@human"), T("a", TaskItemStatus.Done, "goal") ]).ShouldHaveSingleItem(); [Fact] public void A_finished_tree_is_not_a_standstill() => TaskTreeWatch.FindResting( [ T("goal", TaskItemStatus.Done), T("a", TaskItemStatus.Done, "goal") ]).ShouldBeEmpty(); [Fact] public void A_single_task_without_sub_tasks_is_not_a_tree() => TaskTreeWatch.FindResting([T("alone", TaskItemStatus.Blocked)]).ShouldBeEmpty(); [Fact] public void Only_the_topmost_resting_tree_is_reported() { var trees = TaskTreeWatch.FindResting( [ T("goal", TaskItemStatus.Blocked), T("part", TaskItemStatus.Blocked, "goal"), T("leaf", TaskItemStatus.Done, "part") ]); trees.ShouldHaveSingleItem().Root.Id.ShouldBe("goal"); trees[0].Below.Select(t => t.Id).ShouldBe(["part", "leaf"], ignoreOrder: true); } [Fact] public void A_cycle_in_the_data_does_not_hang_the_search() => Should.NotThrow(() => TaskTreeWatch.FindResting( [ T("a", TaskItemStatus.Blocked, "b"), T("b", TaskItemStatus.Blocked, "a") ])); // ═══════════════════ who reviews ═══════════════════ private static OrgChart Shop(bool amazonHasLead = true) => new( [ new UnitConfig { Id = "marketing", Name = "Marketing", LeaderAgentId = "marketing-lead" }, new UnitConfig { Id = "amazon", Name = "Amazon", ParentId = "marketing", LeaderAgentId = amazonHasLead ? "amazon-lead" : null } ], [ new AgentConfig { AgentId = "marketing-lead", UnitId = "marketing" }, new AgentConfig { AgentId = "amazon-lead", UnitId = "amazon" }, new AgentConfig { AgentId = "ads", UnitId = "amazon" } ]); [Fact] public void The_lead_of_the_tree_s_unit_reviews_or_the_lead_above() { TaskTreeWatch.ReviewerOf(T("goal", TaskItemStatus.Blocked), Shop()).ShouldBe("@amazon-lead"); TaskTreeWatch.ReviewerOf(T("goal", TaskItemStatus.Blocked), Shop(amazonHasLead: false)).ShouldBe("@marketing-lead"); } [Fact] public void Without_units_or_a_lead_a_human_reviews() { TaskTreeWatch.ReviewerOf(T("goal", TaskItemStatus.Blocked), null).ShouldBe("@human"); TaskTreeWatch.ReviewerOf(T("goal", TaskItemStatus.Blocked, assignee: "@human"), Shop()).ShouldBe("@human"); } [Fact] public void The_review_stands_beside_the_tree_and_may_only_send_work_back() { var tree = new RestingTree(T("goal", TaskItemStatus.Blocked), [T("a", TaskItemStatus.Done, "goal")]); var review = TaskTreeWatch.BuildReview(tree, "@amazon-lead"); review.ParentId.ShouldBeEmpty("inside the tree it would change what it reviews"); review.Assignee.ShouldBe("@amazon-lead"); review.Body.ShouldContain("a [done]"); review.Body.ShouldContain("Do not do the work yourself"); } // ═══════════════════ once per state of rest ═══════════════════ private sealed class MemoryState : IStateStore { private readonly Dictionary _values = new(); public Task GetAsync(string key, CancellationToken ct) => Task.FromResult(_values.GetValueOrDefault(key)); public Task SetAsync(string key, string value, CancellationToken ct) { _values[key] = value; return Task.CompletedTask; } public Task DeleteAsync(string key, CancellationToken ct) { _values.Remove(key); return Task.CompletedTask; } } private static async Task<(SqliteTaskRepository Repo, string Directory)> BoardWith(params TaskItem[] tasks) { var directory = Path.Combine(Path.GetTempPath(), "clawd-tests", "watch-" + Guid.NewGuid().ToString("N")[..8]); var repo = new SqliteTaskRepository(new SqliteStorage(Path.Combine(directory, "state.db"))); foreach (var task in tasks) { await repo.UpsertAsync(task, default); await repo.SetStatusAsync(task.Id, task.Status, DateTime.UtcNow, default); } return (repo, directory); } private static void CleanUp(string directory) { SqliteStorage.ReleasePooledConnections(directory); try { Directory.Delete(directory, recursive: true); } catch { /* cleaning up is secondary */ } } [Fact] public async Task A_rest_is_reviewed_once_and_again_only_after_the_tree_moved() { var (repo, directory) = await BoardWith(T("goal", TaskItemStatus.Blocked), T("a", TaskItemStatus.Done, "goal")); try { var created = new List(); var watch = new TaskTreeWatch(repo, (t, _) => { created.Add(t); return Task.CompletedTask; }, new MemoryState(), () => null, NullLogger.Instance); var now = new DateTime(2027, 3, 10, 12, 0, 0, DateTimeKind.Utc); await watch.CheckAsync(now, default); created.ShouldHaveSingleItem(); await watch.CheckAsync(now + TaskTreeWatch.Interval, default); created.Count.ShouldBe(1, "nothing moved — a reviewer who accepted the rest is not asked again"); // Reopened, worked on, stuck again: a different state of rest. await repo.SetStatusAsync("a", TaskItemStatus.Blocked, DateTime.UtcNow, default); await watch.CheckAsync(now + 2 * TaskTreeWatch.Interval, default); created.Count.ShouldBe(2); } finally { CleanUp(directory); } } [Fact] public async Task The_board_is_looked_at_only_every_interval() { var (repo, directory) = await BoardWith(T("goal", TaskItemStatus.Blocked), T("a", TaskItemStatus.Done, "goal")); try { var created = new List(); var watch = new TaskTreeWatch(repo, (t, _) => { created.Add(t); return Task.CompletedTask; }, new MemoryState(), () => null, NullLogger.Instance); var now = new DateTime(2027, 3, 10, 12, 0, 0, DateTimeKind.Utc); await watch.CheckAsync(now, default); await repo.SetStatusAsync("a", TaskItemStatus.Canceled, DateTime.UtcNow, default); await watch.CheckAsync(now + TimeSpan.FromMinutes(1), default); created.Count.ShouldBe(1, "the next look is due after the interval, not on every tick"); } finally { CleanUp(directory); } } }