Zielkette, Stufe 2: ruhende Aufgabenbaeume werden geprueft (#714)

- 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>
This commit is contained in:
Richard
2026-09-24 20:19:51 +02:00
co-authored by Claude Opus 5.5
parent b22272345e
commit cd244b8cbd
4 changed files with 436 additions and 2 deletions
+8 -1
View File
@@ -761,7 +761,14 @@ public sealed class AppHost : IAsyncDisposable
// #716: Late work waits for headroom the rest of the day does not need, and a
// day the budget will not last is named while there is still time to act.
lateRunGate: headroom,
budgetOutlook: headroom);
budgetOutlook: headroom,
// #714: A task tree that has come to rest is reviewed by the lead of its unit.
treeWatch: new TaskTreeWatch(
taskRepository,
(task, ct) => board.CreateAsync(task, ct),
stateStore,
() => engine.OrganisationProvider?.Invoke(),
loggerFactory.CreateLogger("ClawdDotNet.Core.Tasks.TreeWatch")));
// Reconciliation nicht blockierend: Der Start soll nicht auf das Dateisystem warten.
_ = Task.Run(async () =>
+8 -1
View File
@@ -137,6 +137,7 @@ public sealed class TaskScanner : IAsyncDisposable
private readonly ILateRunGate? _lateRunGate;
private readonly IBudgetOutlook? _outlook;
private readonly ITaskTreeWatch? _treeWatch;
/// <summary>How often the day ahead is looked at — a forecast, not a watchdog.</summary>
public static readonly TimeSpan OutlookInterval = TimeSpan.FromMinutes(30);
@@ -163,8 +164,10 @@ public sealed class TaskScanner : IAsyncDisposable
int maxParallelRuns = DefaultMaxParallelRuns,
Func<TaskItem, BudgetMissPolicy>? budgetMissPolicy = null,
ILateRunGate? lateRunGate = null,
IBudgetOutlook? budgetOutlook = null)
IBudgetOutlook? budgetOutlook = null,
ITaskTreeWatch? treeWatch = null)
{
_treeWatch = treeWatch;
_repo = repo;
_dispatcher = dispatcher;
_market = market ?? new AlwaysOpenMarketCalendar();
@@ -286,6 +289,10 @@ public sealed class TaskScanner : IAsyncDisposable
SummarizeMissesIfNewDay(now);
await WarnAboutShortfallsAsync(candidates, now, ct);
// #714: resting task trees are looked at — the watch decides itself how often.
if (_treeWatch is not null)
await _treeWatch.CheckAsync(now, ct);
var due = new List<(TaskItem Task, string Occurrence, bool Late)>();
foreach (var task in candidates)
{
+211
View File
@@ -0,0 +1,211 @@
using System.Security.Cryptography;
using System.Text;
using ClawdDotNet.Core.Organisation;
using ClawdDotNet.Core.State;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Core.Tasks;
/// <summary>A check the scanner runs on every tick; it decides itself how often it acts.</summary>
public interface ITaskTreeWatch
{
Task CheckAsync(DateTime nowUtc, CancellationToken ct);
}
/// <summary>An open task whose whole subtree has come to rest.</summary>
public sealed record RestingTree(TaskItem Root, IReadOnlyList<TaskItem> Below);
/// <summary>
/// #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.
///
/// <para><b>Detecting is code, interpreting is a model.</b> 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).</para>
///
/// <para><b>Once per state of rest.</b> 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.</para>
/// </summary>
public sealed class TaskTreeWatch(
ITaskRepository repo,
Func<TaskItem, CancellationToken, Task> createTask,
IStateStore state,
Func<OrgChart?> organisation,
ILogger logger) : ITaskTreeWatch
{
/// <summary>How often the board is looked at.</summary>
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);
}
}
/// <summary>
/// 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.
/// </summary>
public static IReadOnlyList<RestingTree> FindResting(IReadOnlyList<TaskItem> 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<TaskItem> Below(string id)
{
var result = new List<TaskItem>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { id };
var pending = new Stack<string>();
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<string, RestingTree>(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();
}
/// <summary>Not finished: the tree still has a purpose to reach.</summary>
private static bool IsOpen(TaskItem task)
=> task.Status is not (TaskItemStatus.Done or TaskItemStatus.Canceled or TaskItemStatus.Archived);
/// <summary>
/// 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.
/// </summary>
private static bool IsAtRest(TaskItem task) => task.Status switch
{
TaskItemStatus.InProgress => false,
TaskItemStatus.Todo => TaskAssignee.KindOf(task.Assignee) == TaskAssigneeKind.Human,
_ => true
};
private static IEnumerable<TaskItem> Ancestors(TaskItem task, IReadOnlyDictionary<string, TaskItem> byId)
{
var seen = new HashSet<string>(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;
}
}
/// <summary>What the tree looks like — the same value means nothing moved since the last review.</summary>
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);
}
/// <summary>
/// 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.
/// </summary>
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;
}
/// <summary>The review task. It stands beside the tree, not in it — it must not change what it reviews.</summary>
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."
};
}
}
@@ -0,0 +1,209 @@
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);
}
}
}