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);
}
}
}