using System.Security.Cryptography;
using System.Text;
using ClawdDotNet.Core.Organisation;
using ClawdDotNet.Core.State;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Core.Tasks;
/// A check the scanner runs on every tick; it decides itself how often it acts.
public interface ITaskTreeWatch
{
Task CheckAsync(DateTime nowUtc, CancellationToken ct);
}
/// An open task whose whole subtree has come to rest.
public sealed record RestingTree(TaskItem Root, IReadOnlyList Below);
///
/// #714: A task tree that has stopped moving is looked at. Taken from Paperclip's "task
/// watchdog" (Organisation-Konzept, 9a): agents stop for wrong reasons — "done" without
/// evidence, a review nobody does, a blocker read wrongly, an error given up on that could
/// have been fixed. Without a check the tree simply lies still, and nobody notices.
///
/// Detecting is code, interpreting is a model. Whether a tree rests is a query
/// over the board (this class, the first piece of #646). Whether the rest is justified is
/// decided by the lead of the unit the tree belongs to — or a human, where there is no lead
/// — through a review task. The reviewer may send work back (comment, reopen), not do it
/// itself (the line of #680).
///
/// Once per state of rest. The review is not repeated while the tree stays
/// exactly as it was: a reviewer who accepts the rest is not asked again. Only when the
/// tree has moved and comes to rest in a different state is it looked at again. The review
/// task is not part of the tree, so it does not change what it reviews.
///
public sealed class TaskTreeWatch(
ITaskRepository repo,
Func createTask,
IStateStore state,
Func organisation,
ILogger logger) : ITaskTreeWatch
{
/// How often the board is looked at.
public static readonly TimeSpan Interval = TimeSpan.FromMinutes(30);
private const string StateKeyPrefix = "tasktree.rest.";
private DateTime _lastCheckAt = DateTime.MinValue;
public async Task CheckAsync(DateTime nowUtc, CancellationToken ct)
{
if (nowUtc - _lastCheckAt < Interval)
return;
_lastCheckAt = nowUtc;
try
{
var all = await repo.ListAllAsync(includeArchived: false, ct);
foreach (var tree in FindResting(all))
await ReviewOnceAsync(tree, ct);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogWarning(ex, "Check of resting task trees failed — next attempt in {Minutes} min", Interval.TotalMinutes);
}
}
///
/// The open tasks whose subtree has come to rest — only the topmost of them, so a tree is
/// reviewed once and not again for each of its resting branches. Pure, so the rules can
/// be tested without an instance.
///
public static IReadOnlyList FindResting(IReadOnlyList all)
{
var byId = all.ToDictionary(t => t.Id, StringComparer.OrdinalIgnoreCase);
var children = all
.Where(t => !string.IsNullOrWhiteSpace(t.ParentId) && byId.ContainsKey(t.ParentId))
.GroupBy(t => t.ParentId, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase);
List Below(string id)
{
var result = new List();
var seen = new HashSet(StringComparer.OrdinalIgnoreCase) { id };
var pending = new Stack();
pending.Push(id);
while (pending.TryPop(out var current))
{
foreach (var child in children.GetValueOrDefault(current) ?? [])
{
if (!seen.Add(child.Id))
continue; // a cycle in the data must not hang this
result.Add(child);
pending.Push(child.Id);
}
}
return result;
}
var resting = new Dictionary(StringComparer.OrdinalIgnoreCase);
foreach (var root in all.Where(t => children.ContainsKey(t.Id) && IsOpen(t)))
{
var below = Below(root.Id);
if (below.Count > 0 && below.All(IsAtRest) && IsAtRest(root))
resting[root.Id] = new RestingTree(root, below);
}
// Only the topmost: a resting tree inside another resting tree is part of that one.
return resting.Values
.Where(tree => !Ancestors(tree.Root, byId).Any(a => resting.ContainsKey(a.Id)))
.OrderBy(tree => tree.Root.Id, StringComparer.OrdinalIgnoreCase)
.ToList();
}
/// Not finished: the tree still has a purpose to reach.
private static bool IsOpen(TaskItem task)
=> task.Status is not (TaskItemStatus.Done or TaskItemStatus.Canceled or TaskItemStatus.Archived);
///
/// Nothing will move this task by itself: it is not running, and the scanner will not
/// start it — a todo for an agent is still on its way, a todo for a human is not.
///
private static bool IsAtRest(TaskItem task) => task.Status switch
{
TaskItemStatus.InProgress => false,
TaskItemStatus.Todo => TaskAssignee.KindOf(task.Assignee) == TaskAssigneeKind.Human,
_ => true
};
private static IEnumerable Ancestors(TaskItem task, IReadOnlyDictionary byId)
{
var seen = new HashSet(StringComparer.OrdinalIgnoreCase) { task.Id };
var next = task.ParentId;
while (!string.IsNullOrWhiteSpace(next) && seen.Add(next) && byId.TryGetValue(next, out var parent))
{
yield return parent;
next = parent.ParentId;
}
}
/// What the tree looks like — the same value means nothing moved since the last review.
public static string Fingerprint(RestingTree tree)
{
var text = string.Join("|", new[] { tree.Root }.Concat(tree.Below)
.OrderBy(t => t.Id, StringComparer.OrdinalIgnoreCase)
.Select(t => $"{t.Id}:{TaskText.Of(t.Status)}"));
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(text)))[..16];
}
private async Task ReviewOnceAsync(RestingTree tree, CancellationToken ct)
{
var key = StateKeyPrefix + tree.Root.Id;
var fingerprint = Fingerprint(tree);
if (await state.GetAsync(key, ct) == fingerprint)
return;
var reviewer = ReviewerOf(tree.Root, organisation());
await createTask(BuildReview(tree, reviewer), ct);
await state.SetAsync(key, fingerprint, ct);
logger.LogInformation(
"Task tree {Root} has come to rest ({Count} tasks below) — review assigned to {Reviewer}",
tree.Root.Id, tree.Below.Count, reviewer);
}
///
/// The lead of the unit whose board the tree is on — or, where that unit has none, the
/// lead above it; without any, a human.
///
public static string ReviewerOf(TaskItem root, OrgChart? org)
{
if (org is not { HasUnits: true } || TaskBoard.UnitOf(root, org) is not { } unitId)
return TaskAssignee.Human;
foreach (var unit in new[] { org.Unit(unitId)! }.Concat(org.Ancestors(unitId)))
{
if (org.LeaderOf(unit.Id) is { } lead)
return "@" + lead.AgentId;
}
return TaskAssignee.Human;
}
/// The review task. It stands beside the tree, not in it — it must not change what it reviews.
public static TaskItem BuildReview(RestingTree tree, string reviewer)
{
var body = new StringBuilder();
body.AppendLine($"The work under {tree.Root.Id} \"{tree.Root.Title}\" has come to rest: nothing in it is running or about to run, but {tree.Root.Id} is not finished.");
body.AppendLine();
body.AppendLine($"- {tree.Root.Id} [{TaskText.Of(tree.Root.Status)}] {tree.Root.Title} ({tree.Root.Assignee})");
foreach (var task in tree.Below.OrderBy(t => t.Id, StringComparer.OrdinalIgnoreCase))
body.AppendLine($" - {task.Id} [{TaskText.Of(task.Status)}] {task.Title} ({task.Assignee}{(string.IsNullOrWhiteSpace(task.ParentId) ? "" : $", serves {task.ParentId}")})");
body.AppendLine();
body.AppendLine("Decide whether the standstill is justified. Read the tasks and their comments. Typical wrong stops:");
body.AppendLine("- marked done without evidence that the result exists,");
body.AppendLine("- waiting for a review that nobody does,");
body.AppendLine("- blocked by something that is already finished, or was misread,");
body.AppendLine("- an error given up on that could have been fixed.");
body.AppendLine();
body.AppendLine("If it is justified, say why in your result. If not, send the work back: comment on the task and reopen it (task_comment with reopen), or finish or cancel the top task. Do not do the work yourself — your job is to judge it, not to redo it.");
return new TaskItem
{
Title = $"Review: work under {tree.Root.Id} has come to rest",
Body = body.ToString().TrimEnd(),
Assignee = reviewer,
Priority = Math.Clamp(tree.Root.Priority, 1, 5),
Status = TaskItemStatus.Todo,
Type = TaskItemType.Work,
Acceptance = "The result says, for each wrongly stopped task, what was sent back — or why the standstill is justified."
};
}
}