- TaskTreeWatch findet offene Aufgaben, deren ganzer Teilbaum zur Ruhe gekommen ist: nichts laeuft, nichts wird gleich gestartet (eine todo an einen Menschen bewegt sich nicht von selbst). Nur der oberste ruhende Baum zaehlt. Rein und ohne Instanz pruefbar. - Erkennen ist Code, Deuten ist Modell: Die Leitung der Einheit, zu der der Baum gehoert (sonst die darueber, sonst ein Mensch), bekommt eine Pruefaufgabe mit dem Baum und den typischen Fehlhalten. Sie darf Arbeit zurueckgeben, nicht selbst nachbessern. - Einmal je Ruhezustand: Ein Fingerabdruck des Baums im StateStore. Wer den Stillstand hinnimmt, wird nicht erneut gefragt; erst wenn sich der Baum bewegt hat und wieder ruht. Die Pruefaufgabe steht neben dem Baum, nicht darin. - Der Scanner ruft die Pruefung je Takt, sie schaut alle 30 Minuten. Erster Baustein von #646 (Erkennen); die uebrigen Muster dort folgen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
210 lines
8.4 KiB
C#
210 lines
8.4 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// #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.
|
|
/// </summary>
|
|
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<string, string> _values = new();
|
|
public Task<string?> 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<TaskItem>();
|
|
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<TaskItem>();
|
|
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);
|
|
}
|
|
}
|
|
}
|