Author SHA1 Message Date
RichardandClaude Opus 5.5 cd244b8cbd 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>
2026-09-24 20:19:51 +02:00
RichardandClaude Opus 5.5 b22272345e Zielkette, Stufe 1: jede Aufgabe kann sagen, wofuer sie da ist (#714)
- TaskItem.ParentId, in der Datei als parent, in der DB als ParentId. Die
  Elternaufgabe ist der Zweck ("wofuer"), blocked_by die Voraussetzung
  ("wann").
- GoalChain: Eltern bis zu acht Stufen, Zyklen brechen den Gang ab und
  werden beim Setzen abgelehnt. Oben steht das Ziel der Einheit - der
  obersten Aufgabe, die zu einer Einheit gehoert (ein Ziel, das ein Mensch
  auf der obersten Ebene setzt, gehoert keiner), sonst der naechsten Einheit
  darueber mit Ziel.
- Der Lauf einer Aufgabe bekommt den Absatz "Why this task exists" und die
  Bitte, zu melden, wenn die Aufgabe ihrem Zweck nicht mehr dient.
- Taskboard-Tool: parent bei task_create/task_update, geprueft auf Existenz,
  Sichtbarkeit (#684) und Zyklen; task_list zeigt "Serves".
- WebUI: Feld im Aufgabendialog, Anzeige auf der Karte; die Verwaltung
  prueft wie das Tool.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 18:45:15 +02:00
Richard b75b68c786 Merge feat/684-ablage-brett: Ablage und Brett je Einheit (#684) 2026-09-24 17:03:39 +02:00
19 changed files with 918 additions and 20 deletions
+10 -2
View File
@@ -741,7 +741,8 @@ public sealed class AppHost : IAsyncDisposable
dauerfreigaben: dauerfreigaben); dauerfreigaben: dauerfreigaben);
var dispatcher = new EngineTaskDispatcher( var dispatcher = new EngineTaskDispatcher(
engine, () => instance.Agents, instance.InstanceId, tools, stateStore, loggerFactory); engine, () => instance.Agents, instance.InstanceId, tools, stateStore, loggerFactory,
tasks: taskRepository);
var headroom = engine.BudgetGuard is { } guard var headroom = engine.BudgetGuard is { } guard
? new BudgetHeadroom(guard, () => instance.Agents, () => engine.InstanceBudget) ? new BudgetHeadroom(guard, () => instance.Agents, () => engine.InstanceBudget)
@@ -760,7 +761,14 @@ public sealed class AppHost : IAsyncDisposable
// #716: Late work waits for headroom the rest of the day does not need, and a // #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. // day the budget will not last is named while there is still time to act.
lateRunGate: headroom, 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. // Reconciliation nicht blockierend: Der Start soll nicht auf das Dateisystem warten.
_ = Task.Run(async () => _ = Task.Run(async () =>
@@ -20,7 +20,8 @@ public sealed record TaskInput(
string? WhenValue = null, string? WhenValue = null,
string? TimeZone = null, string? TimeZone = null,
string Acceptance = "", string Acceptance = "",
int Priority = 3); int Priority = 3,
string ParentId = "");
/// <summary> /// <summary>
/// Das Taskboard fuer die Oberflaechen (#594): anlegen, aendern, pausieren, sofort ausfuehren, /// Das Taskboard fuer die Oberflaechen (#594): anlegen, aendern, pausieren, sofort ausfuehren,
@@ -85,6 +86,8 @@ public sealed class TaskAdministration(
if (Pruefe(input, out var assignee, out var when) is { } fehler) if (Pruefe(input, out var assignee, out var when) is { } fehler)
return AdminResult.Failure(fehler); return AdminResult.Failure(fehler);
if (await CheckParentAsync(null, input.ParentId, ct) is { } parentProblem)
return AdminResult.Failure(parentProblem);
var created = await board.CreateAsync(new TaskItem var created = await board.CreateAsync(new TaskItem
{ {
@@ -95,7 +98,8 @@ public sealed class TaskAdministration(
Assignee = assignee, Assignee = assignee,
When = when, When = when,
Status = TaskItemStatus.Todo, Status = TaskItemStatus.Todo,
Type = TaskItemType.Work Type = TaskItemType.Work,
ParentId = input.ParentId.Trim()
}, ct); }, ct);
return AdminResult.Success(MitHinweis($"Aufgabe '{created.Title}' angelegt ({created.Id}).", created)); return AdminResult.Success(MitHinweis($"Aufgabe '{created.Title}' angelegt ({created.Id}).", created));
@@ -108,6 +112,8 @@ public sealed class TaskAdministration(
if (Pruefe(input, out var assignee, out var when) is { } fehler) if (Pruefe(input, out var assignee, out var when) is { } fehler)
return AdminResult.Failure(fehler); return AdminResult.Failure(fehler);
if (await CheckParentAsync(id, input.ParentId, ct) is { } parentProblem)
return AdminResult.Failure(parentProblem);
var updated = await board.UpdateAsync(id, current => current with var updated = await board.UpdateAsync(id, current => current with
{ {
@@ -116,7 +122,8 @@ public sealed class TaskAdministration(
Acceptance = input.Acceptance.Trim(), Acceptance = input.Acceptance.Trim(),
Priority = Math.Clamp(input.Priority, 1, 5), Priority = Math.Clamp(input.Priority, 1, 5),
Assignee = assignee, Assignee = assignee,
When = when When = when,
ParentId = input.ParentId.Trim()
}, ct); }, ct);
return updated is null return updated is null
@@ -124,6 +131,19 @@ public sealed class TaskAdministration(
: AdminResult.Success(MitHinweis($"Aufgabe '{updated.Title}' gespeichert.", updated)); : AdminResult.Success(MitHinweis($"Aufgabe '{updated.Title}' gespeichert.", updated));
} }
/// <summary>#714: The parent must exist and must not lie below the task itself.</summary>
private async Task<string?> CheckParentAsync(string? taskId, string parentId, CancellationToken ct)
{
parentId = parentId.Trim();
if (parentId.Length == 0 || board is null)
return null;
if (await board.GetAsync(parentId, ct) is null)
return $"There is no task '{parentId}' to serve as parent.";
if (taskId is not null && await GoalChain.WouldCycleAsync(taskId, parentId, board.GetAsync, ct))
return $"'{parentId}' lies below '{taskId}' (or is the task itself) — a task cannot serve itself.";
return null;
}
/// <summary>Pausieren heisst <c>backlog</c>, Aktivieren <c>todo</c> - dieselben Zustaende, die der Scanner kennt.</summary> /// <summary>Pausieren heisst <c>backlog</c>, Aktivieren <c>todo</c> - dieselben Zustaende, die der Scanner kennt.</summary>
public async Task<AdminResult> SetEnabledAsync(string id, bool enabled, CancellationToken ct) public async Task<AdminResult> SetEnabledAsync(string id, bool enabled, CancellationToken ct)
{ {
@@ -380,6 +380,9 @@ public sealed class SqliteStorage
// #684: Who created a task — decides its board when nobody in particular is assigned. // #684: Who created a task — decides its board when nobody in particular is assigned.
SpalteErgaenzen(connection, "Tasks", "CreatedBy", "TEXT NOT NULL DEFAULT ''"); SpalteErgaenzen(connection, "Tasks", "CreatedBy", "TEXT NOT NULL DEFAULT ''");
// #714: The task a task serves — the goal chain.
SpalteErgaenzen(connection, "Tasks", "ParentId", "TEXT NOT NULL DEFAULT ''");
// Findet offene Aufrufe beim Start, ohne die ganze Tabelle zu lesen. // Findet offene Aufrufe beim Start, ohne die ganze Tabelle zu lesen.
using var index = connection.CreateCommand(); using var index = connection.CreateCommand();
index.CommandText = """ index.CommandText = """
@@ -30,6 +30,7 @@ public sealed class EngineTaskDispatcher : ITaskDispatcher
private readonly IStateStore _stateStore; private readonly IStateStore _stateStore;
private readonly ILogger _logger; private readonly ILogger _logger;
private readonly TimeProvider _clock; private readonly TimeProvider _clock;
private readonly ITaskRepository? _tasks;
public EngineTaskDispatcher( public EngineTaskDispatcher(
AgentEngine engine, AgentEngine engine,
@@ -38,8 +39,10 @@ public sealed class EngineTaskDispatcher : ITaskDispatcher
ToolRegistry toolRegistry, ToolRegistry toolRegistry,
IStateStore stateStore, IStateStore stateStore,
ILoggerFactory loggerFactory, ILoggerFactory loggerFactory,
TimeProvider? clock = null) TimeProvider? clock = null,
ITaskRepository? tasks = null)
{ {
_tasks = tasks;
_engine = engine; _engine = engine;
_agents = agents; _agents = agents;
_instanceId = instanceId; _instanceId = instanceId;
@@ -56,7 +59,7 @@ public sealed class EngineTaskDispatcher : ITaskDispatcher
return await DispatchToolJobAsync(task, ct); return await DispatchToolJobAsync(task, ct);
var agents = _agents(); var agents = _agents();
var message = BuildMessage(task); var message = BuildMessage(task, await WhyAsync(task, ct));
var kind = TaskAssignee.KindOf(task.Assignee); var kind = TaskAssignee.KindOf(task.Assignee);
AgentRunResult result; AgentRunResult result;
@@ -158,7 +161,27 @@ public sealed class EngineTaskDispatcher : ITaskDispatcher
return agents.Count == 1 ? agents[0] : null; return agents.Count == 1 ? agents[0] : null;
} }
internal string BuildMessage(TaskItem task) /// <summary>
/// #714: The goal chain of the task — its parents up to the goal of the unit. A lookup
/// that fails costs the paragraph, never the run.
/// </summary>
private async Task<string?> WhyAsync(TaskItem task, CancellationToken ct)
{
if (_tasks is null)
return null;
try
{
var parents = await GoalChain.ParentsAsync(task, _tasks.GetAsync, ct);
return GoalChain.Describe(task, parents, _engine.OrganisationProvider?.Invoke());
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "Goal chain of task {TaskId} could not be read; the run goes ahead without it", task.Id);
return null;
}
}
internal string BuildMessage(TaskItem task, string? why = null)
{ {
var sb = new StringBuilder(); var sb = new StringBuilder();
sb.AppendLine($"[Aufgabe {task.Id}] {task.Title}"); sb.AppendLine($"[Aufgabe {task.Id}] {task.Title}");
@@ -168,6 +191,12 @@ public sealed class EngineTaskDispatcher : ITaskDispatcher
// und ein Modell ohne diese Zeile raet aus seinem Trainingsstand. // und ein Modell ohne diese Zeile raet aus seinem Trainingsstand.
sb.AppendLine(Zeitangabe(task, _clock.GetUtcNow())); sb.AppendLine(Zeitangabe(task, _clock.GetUtcNow()));
if (!string.IsNullOrWhiteSpace(why))
{
sb.AppendLine();
sb.AppendLine(why.Trim());
}
if (!string.IsNullOrWhiteSpace(task.Body)) if (!string.IsNullOrWhiteSpace(task.Body))
{ {
sb.AppendLine(); sb.AppendLine();
+104
View File
@@ -0,0 +1,104 @@
using System.Text;
using ClawdDotNet.Core.Organisation;
namespace ClawdDotNet.Core.Tasks;
/// <summary>
/// #714: Why a task exists — its parent, that one's parent, up to the goal of the unit.
/// Taken from Paperclip (Organisation-Konzept, 9a): an agent that only sees its own order
/// cannot weigh it. "Write a product text" is a different job when it serves "launch the
/// spring collection by Friday" than when it serves "clean up old listings".
///
/// <para><b>Purpose, not precondition.</b> <see cref="TaskItem.BlockedBy"/> says when a task
/// may run; the parent says what it is for. Both can point to the same task, and mean
/// different things.</para>
///
/// <para>Walked at run time, never stored as text: a parent that is renamed or finished
/// shows up as it is now. The walk stops at a cycle and after <see cref="MaxDepth"/> steps,
/// so a broken chain cannot hang a run or flood its prompt.</para>
/// </summary>
public static class GoalChain
{
public const int MaxDepth = 8;
/// <summary>The parents of a task, nearest first. Stops at a missing parent or a cycle.</summary>
public static async Task<IReadOnlyList<TaskItem>> ParentsAsync(
TaskItem task, Func<string, CancellationToken, Task<TaskItem?>> lookup, CancellationToken ct)
{
var parents = new List<TaskItem>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { task.Id };
var next = task.ParentId;
while (!string.IsNullOrWhiteSpace(next) && parents.Count < MaxDepth && seen.Add(next))
{
if (await lookup(next, ct) is not { } parent)
break;
parents.Add(parent);
next = parent.ParentId;
}
return parents;
}
/// <summary>
/// Whether making <paramref name="parentId"/> the parent of <paramref name="taskId"/>
/// would close a circle — the parent is the task itself or lies below it.
/// </summary>
public static async Task<bool> WouldCycleAsync(
string taskId, string parentId, Func<string, CancellationToken, Task<TaskItem?>> lookup, CancellationToken ct)
{
if (string.Equals(taskId, parentId, StringComparison.OrdinalIgnoreCase))
return true;
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var next = parentId;
while (!string.IsNullOrWhiteSpace(next) && seen.Add(next))
{
if (string.Equals(next, taskId, StringComparison.OrdinalIgnoreCase))
return true;
next = (await lookup(next, ct))?.ParentId;
}
return false;
}
/// <summary>
/// The goal above the chain: that of the unit of the topmost task that belongs to one —
/// a goal a human set on the top level belongs to no unit, so the next one down counts —
/// or of the nearest unit above it that has a goal. <c>null</c> without units or without
/// a goal.
/// </summary>
public static (string UnitName, string Goal)? UnitGoal(IReadOnlyList<TaskItem> fromTop, OrgChart? org)
{
if (org is not { HasUnits: true })
return null;
var unitId = fromTop.Select(t => TaskBoard.UnitOf(t, org)).FirstOrDefault(u => u is not null);
if (org.Unit(unitId) is not { } unit)
return null;
foreach (var candidate in new[] { unit }.Concat(org.Ancestors(unit.Id)))
{
if (!string.IsNullOrWhiteSpace(candidate.Goal))
return (candidate.Name, candidate.Goal.Trim());
}
return null;
}
/// <summary>
/// The paragraph that goes into the run: "you do this because … because … because".
/// <c>null</c> when there is nothing above the task — then the run looks as before.
/// </summary>
public static string? Describe(TaskItem task, IReadOnlyList<TaskItem> parents, OrgChart? org)
{
var goal = UnitGoal([.. parents.Reverse(), task], org);
if (parents.Count == 0 && goal is null)
return null;
var sb = new StringBuilder();
sb.AppendLine("Why this task exists — it serves, from nearest to farthest:");
foreach (var parent in parents)
sb.AppendLine($"- {parent.Id} \"{parent.Title}\" ({TaskText.Of(parent.Status)})");
if (goal is { } g)
sb.AppendLine($"- the goal of the unit \"{g.UnitName}\": {g.Goal}");
sb.Append("Weigh your work against this. If the task no longer serves it — the parent is done or canceled, or the task contradicts the goal — say so in your result instead of working blindly.");
return sb.ToString();
}
}
@@ -34,11 +34,11 @@ public sealed class SqliteTaskRepository : ITaskRepository
INSERT INTO Tasks INSERT INTO Tasks
(Id, Title, Status, Type, Priority, Assignee, WhenKind, WhenValue, WhenTz, (Id, Title, Status, Type, Priority, Assignee, WhenKind, WhenValue, WhenTz,
RequireApproval, Acceptance, BlockedBy, OnlyWhenMarketOpen, Body, FileName, RequireApproval, Acceptance, BlockedBy, OnlyWhenMarketOpen, Body, FileName,
ToolName, JobTypeId, LastOccurrence, ClaimToken, ClaimedAt, CreatedAt, UpdatedAt, CreatedBy) ToolName, JobTypeId, LastOccurrence, ClaimToken, ClaimedAt, CreatedAt, UpdatedAt, CreatedBy, ParentId)
VALUES VALUES
(@id, @title, @status, @type, @priority, @assignee, @whenKind, @whenValue, @whenTz, (@id, @title, @status, @type, @priority, @assignee, @whenKind, @whenValue, @whenTz,
@requireApproval, @acceptance, @blockedBy, @market, @body, @fileName, @requireApproval, @acceptance, @blockedBy, @market, @body, @fileName,
@toolName, @jobType, NULL, NULL, NULL, @createdAt, @updatedAt, @createdBy); @toolName, @jobType, NULL, NULL, NULL, @createdAt, @updatedAt, @createdBy, @parentId);
"""; """;
BindDefinition(insert, task); BindDefinition(insert, task);
insert.Parameters.AddWithValue("@status", TaskText.Of(task.Status)); insert.Parameters.AddWithValue("@status", TaskText.Of(task.Status));
@@ -61,6 +61,7 @@ public sealed class SqliteTaskRepository : ITaskRepository
BlockedBy = @blockedBy, OnlyWhenMarketOpen = @market, Body = @body, BlockedBy = @blockedBy, OnlyWhenMarketOpen = @market, Body = @body,
FileName = @fileName, ToolName = @toolName, JobTypeId = @jobType, FileName = @fileName, ToolName = @toolName, JobTypeId = @jobType,
CreatedBy = CASE WHEN @createdBy = '' THEN CreatedBy ELSE @createdBy END, CreatedBy = CASE WHEN @createdBy = '' THEN CreatedBy ELSE @createdBy END,
ParentId = @parentId,
UpdatedAt = @updatedAt UpdatedAt = @updatedAt
WHERE Id = @id WHERE Id = @id
"""; """;
@@ -332,7 +333,7 @@ public sealed class SqliteTaskRepository : ITaskRepository
"Id, Title, Status, Type, Priority, Assignee, WhenKind, WhenValue, WhenTz, " + "Id, Title, Status, Type, Priority, Assignee, WhenKind, WhenValue, WhenTz, " +
"RequireApproval, Acceptance, BlockedBy, OnlyWhenMarketOpen, Body, FileName, " + "RequireApproval, Acceptance, BlockedBy, OnlyWhenMarketOpen, Body, FileName, " +
"ToolName, JobTypeId, LastOccurrence, ClaimToken, ClaimedAt, CreatedAt, UpdatedAt, " "ToolName, JobTypeId, LastOccurrence, ClaimToken, ClaimedAt, CreatedAt, UpdatedAt, "
+ "BudgetMissCount, LastBudgetMissAt, DeferredUntil, CatchUpPending, CreatedBy"; + "BudgetMissCount, LastBudgetMissAt, DeferredUntil, CatchUpPending, CreatedBy, ParentId";
private static void BindDefinition(SqliteCommand cmd, TaskItem task) private static void BindDefinition(SqliteCommand cmd, TaskItem task)
{ {
@@ -354,6 +355,7 @@ public sealed class SqliteTaskRepository : ITaskRepository
cmd.Parameters.AddWithValue("@jobType", task.JobTypeId); cmd.Parameters.AddWithValue("@jobType", task.JobTypeId);
// #684: a file without it (written by hand, or older) keeps what the database knows. // #684: a file without it (written by hand, or older) keeps what the database knows.
cmd.Parameters.AddWithValue("@createdBy", task.CreatedBy ?? ""); cmd.Parameters.AddWithValue("@createdBy", task.CreatedBy ?? "");
cmd.Parameters.AddWithValue("@parentId", task.ParentId ?? "");
} }
private static async Task<TaskItem?> ReadAsync(SqliteConnection conn, string id, CancellationToken ct) private static async Task<TaskItem?> ReadAsync(SqliteConnection conn, string id, CancellationToken ct)
@@ -405,7 +407,8 @@ public sealed class SqliteTaskRepository : ITaskRepository
LastBudgetMissAt = r.IsDBNull(23) ? null : Parse(r.GetString(23)), LastBudgetMissAt = r.IsDBNull(23) ? null : Parse(r.GetString(23)),
DeferredUntil = r.IsDBNull(24) ? null : Parse(r.GetString(24)), DeferredUntil = r.IsDBNull(24) ? null : Parse(r.GetString(24)),
CatchUpPending = r.GetInt32(25) != 0, CatchUpPending = r.GetInt32(25) != 0,
CreatedBy = r.GetString(26) CreatedBy = r.GetString(26),
ParentId = r.GetString(27)
}; };
} }
@@ -153,6 +153,7 @@ public static class TaskFrontmatter
ToolName = scalars.GetValueOrDefault("tool_name", ""), ToolName = scalars.GetValueOrDefault("tool_name", ""),
JobTypeId = scalars.GetValueOrDefault("job_type", ""), JobTypeId = scalars.GetValueOrDefault("job_type", ""),
CreatedBy = scalars.GetValueOrDefault("created_by", ""), CreatedBy = scalars.GetValueOrDefault("created_by", ""),
ParentId = scalars.GetValueOrDefault("parent", ""),
Body = body Body = body
}; };
return true; return true;
@@ -172,6 +173,8 @@ public static class TaskFrontmatter
sb.Append($"assignee: {Scalar(task.Assignee)}\n"); sb.Append($"assignee: {Scalar(task.Assignee)}\n");
if (!string.IsNullOrWhiteSpace(task.CreatedBy)) if (!string.IsNullOrWhiteSpace(task.CreatedBy))
sb.Append($"created_by: {Scalar(task.CreatedBy)}\n"); sb.Append($"created_by: {Scalar(task.CreatedBy)}\n");
if (!string.IsNullOrWhiteSpace(task.ParentId))
sb.Append($"parent: {Scalar(task.ParentId)}\n");
if (task.When is { } w) if (task.When is { } w)
{ {
+7
View File
@@ -235,6 +235,13 @@ public sealed record TaskItem
/// </summary> /// </summary>
public string CreatedBy { get; init; } = ""; public string CreatedBy { get; init; } = "";
/// <summary>
/// #714: The task this one serves — its purpose ("what for"), where <see cref="BlockedBy"/>
/// is its precondition ("when"). Empty for a task at the top; above the top stands the
/// goal of the unit. See <see cref="GoalChain"/>.
/// </summary>
public string ParentId { get; init; } = "";
/// <summary>A one-time task that was refused for lack of budget and carried over.</summary> /// <summary>A one-time task that was refused for lack of budget and carried over.</summary>
public bool IsCarriedOver => !IsRecurring && BudgetMissCount > 0; public bool IsCarriedOver => !IsRecurring && BudgetMissCount > 0;
+8 -1
View File
@@ -137,6 +137,7 @@ public sealed class TaskScanner : IAsyncDisposable
private readonly ILateRunGate? _lateRunGate; private readonly ILateRunGate? _lateRunGate;
private readonly IBudgetOutlook? _outlook; private readonly IBudgetOutlook? _outlook;
private readonly ITaskTreeWatch? _treeWatch;
/// <summary>How often the day ahead is looked at — a forecast, not a watchdog.</summary> /// <summary>How often the day ahead is looked at — a forecast, not a watchdog.</summary>
public static readonly TimeSpan OutlookInterval = TimeSpan.FromMinutes(30); public static readonly TimeSpan OutlookInterval = TimeSpan.FromMinutes(30);
@@ -163,8 +164,10 @@ public sealed class TaskScanner : IAsyncDisposable
int maxParallelRuns = DefaultMaxParallelRuns, int maxParallelRuns = DefaultMaxParallelRuns,
Func<TaskItem, BudgetMissPolicy>? budgetMissPolicy = null, Func<TaskItem, BudgetMissPolicy>? budgetMissPolicy = null,
ILateRunGate? lateRunGate = null, ILateRunGate? lateRunGate = null,
IBudgetOutlook? budgetOutlook = null) IBudgetOutlook? budgetOutlook = null,
ITaskTreeWatch? treeWatch = null)
{ {
_treeWatch = treeWatch;
_repo = repo; _repo = repo;
_dispatcher = dispatcher; _dispatcher = dispatcher;
_market = market ?? new AlwaysOpenMarketCalendar(); _market = market ?? new AlwaysOpenMarketCalendar();
@@ -286,6 +289,10 @@ public sealed class TaskScanner : IAsyncDisposable
SummarizeMissesIfNewDay(now); SummarizeMissesIfNewDay(now);
await WarnAboutShortfallsAsync(candidates, now, ct); 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)>(); var due = new List<(TaskItem Task, string Occurrence, bool Late)>();
foreach (var task in candidates) 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."
};
}
}
@@ -349,6 +349,8 @@
"tasks.board": "Brett", "tasks.board": "Brett",
"tasks.boardAll": "Alle Bretter", "tasks.boardAll": "Alle Bretter",
"tasks.boardTopLevel": "Oberste Ebene", "tasks.boardTopLevel": "Oberste Ebene",
"tasks.parent": "Dient Aufgabe (Eltern)",
"tasks.serves": "dient {id}",
"services.title": "Dienste", "services.title": "Dienste",
"services.newService": "Neuer Dienst", "services.newService": "Neuer Dienst",
@@ -349,6 +349,8 @@
"tasks.board": "Board", "tasks.board": "Board",
"tasks.boardAll": "All boards", "tasks.boardAll": "All boards",
"tasks.boardTopLevel": "Top level", "tasks.boardTopLevel": "Top level",
"tasks.parent": "Serves task (parent)",
"tasks.serves": "serves {id}",
"services.title": "Services", "services.title": "Services",
"services.newService": "New Service", "services.newService": "New Service",
@@ -189,7 +189,7 @@ function erstelleAufgabenKarte(task) {
erstellePlakette(holeStatusText(task.status), STATUS_ART[task.status] || "neutral"), erstellePlakette(holeStatusText(task.status), STATUS_ART[task.status] || "neutral"),
erstelleElement("span", { erstelleElement("span", {
klasse: "gedaempft text-klein", klasse: "gedaempft text-klein",
text: `${task.id} · ${task.assignee || "—"}${task.boardPath ? ` · ▤ ${task.boardPath}` : ""}` text: `${task.id} · ${task.assignee || "—"}${task.boardPath ? ` · ▤ ${task.boardPath}` : ""}${task.parentId ? ` · ↑ ${t("tasks.serves", { id: task.parentId })}` : ""}`
}) })
) )
); );
@@ -343,6 +343,9 @@ async function oeffneAufgabenDialog(task) {
const abnahme = erstelleElement("textarea", { rows: "2", placeholder: "..." }); const abnahme = erstelleElement("textarea", { rows: "2", placeholder: "..." });
abnahme.value = task?.acceptance || ""; abnahme.value = task?.acceptance || "";
// #714: The task this one serves — the run is told the chain up to the unit's goal.
const eltern = erstelleElement("input", { type: "text", value: task?.parentId || "", placeholder: "t-…" });
const hinweis = erstelleElement("p", { klasse: "gedaempft text-klein" }); const hinweis = erstelleElement("p", { klasse: "gedaempft text-klein" });
const aktualisiere = () => { const aktualisiere = () => {
@@ -369,6 +372,7 @@ async function oeffneAufgabenDialog(task) {
erstelleElement("label", { text: "Value:" }), terminWert, erstelleElement("label", { text: "Value:" }), terminWert,
erstelleElement("label", { text: "Timezone:" }), zeitzone, erstelleElement("label", { text: "Timezone:" }), zeitzone,
erstelleElement("label", { text: "Acceptance Criteria:" }), abnahme, erstelleElement("label", { text: "Acceptance Criteria:" }), abnahme,
erstelleElement("label", { text: t("tasks.parent") + ":" }), eltern,
erstelleElement("span"), hinweis erstelleElement("span"), hinweis
); );
@@ -399,7 +403,8 @@ async function oeffneAufgabenDialog(task) {
whenValue: terminArt.value ? terminWert.value.trim() : null, whenValue: terminArt.value ? terminWert.value.trim() : null,
timeZone: terminArt.value ? zeitzone.value.trim() : null, timeZone: terminArt.value ? zeitzone.value.trim() : null,
acceptance: abnahme.value.trim(), acceptance: abnahme.value.trim(),
priority: task?.priority ?? 3 priority: task?.priority ?? 3,
parentId: eltern.value.trim()
}; };
try { try {
@@ -41,6 +41,10 @@ public sealed class TaskboardTool : IAgentTool
- '@<agentId>'→ the named agent with their context - '@<agentId>'→ the named agent with their context
- '@human' → waits for a human (default if unclear) - '@human' → waits for a human (default if unclear)
Purpose: set 'parent' to the task this one serves (task_create, task_update).
The run of a task is told the chain up to the goal of the unit — so split work
into sub-tasks under the task they serve instead of loose tasks.
Schedules ('when' field): one-time ('at'), recurring ('every') or 'cron'. Schedules ('when' field): one-time ('at'), recurring ('every') or 'cron'.
The time zone 'tz' is required (e.g. 'Europe/Berlin'). The time zone 'tz' is required (e.g. 'Europe/Berlin').
@@ -107,6 +111,10 @@ public sealed class TaskboardTool : IAgentTool
"type": "string", "type": "string",
"description": "Acceptance criteria against which the result is verified." "description": "Acceptance criteria against which the result is verified."
}, },
"parent": {
"type": "string",
"description": "ID of the task this one serves (its purpose — unlike blocked_by, which is a precondition). Empty string removes it (task_update)."
},
"blocked_by": { "blocked_by": {
"type": "array", "type": "array",
"items": { "type": "string" }, "items": { "type": "string" },
@@ -250,6 +258,20 @@ public sealed class TaskboardTool : IAgentTool
return TaskSimilarity.FindOpen(visible is null ? alle : alle.Where(visible).ToList(), title, exceptId); return TaskSimilarity.FindOpen(visible is null ? alle : alle.Where(visible).ToList(), title, exceptId);
} }
/// <summary>
/// #714: A parent must exist, be on a board the agent sees, and must not lie below the
/// task itself — a circle would make the chain endless.
/// </summary>
private static async Task<string?> CheckParentAsync(
string? taskId, string parentId, TaskboardService board, Func<TaskItem, bool>? visible, CancellationToken ct)
{
if (!await CanSeeAsync(board, parentId, visible, ct) || await board.GetAsync(parentId, ct) is null)
return $"No task found with ID '{parentId}' to serve as parent.";
if (taskId is not null && await GoalChain.WouldCycleAsync(taskId, parentId, board.GetAsync, ct))
return $"'{parentId}' lies below '{taskId}' (or is the task itself) — a task cannot serve itself.";
return null;
}
/// <summary> /// <summary>
/// #684: A task on a board the agent does not see is answered like one that does not /// #684: A task on a board the agent does not see is answered like one that does not
/// exist — the answer must not tell that it is there. /// exist — the answer must not tell that it is there.
@@ -267,6 +289,10 @@ public sealed class TaskboardTool : IAgentTool
if (!TryReadWhen(input, out var when, out var whenError)) if (!TryReadWhen(input, out var when, out var whenError))
return ToolResult.Fail(whenError!); return ToolResult.Fail(whenError!);
var parent = GetString(input, "parent")?.Trim() ?? "";
if (parent.Length > 0 && await CheckParentAsync(null, parent, board, visible, ct) is { } parentProblem)
return ToolResult.Fail(parentProblem);
// #674: The same finding a second time is not a second task. It is recorded on the // #674: The same finding a second time is not a second task. It is recorded on the
// existing one — so that later one can see how often and for how long. // existing one — so that later one can see how often and for how long.
if (GetBool(input, "force") != true) if (GetBool(input, "force") != true)
@@ -302,7 +328,8 @@ public sealed class TaskboardTool : IAgentTool
BlockedBy = GetStringList(input, "blocked_by"), BlockedBy = GetStringList(input, "blocked_by"),
OnlyWhenMarketOpen = GetBool(input, "onlyWhenMarketOpen") ?? false, OnlyWhenMarketOpen = GetBool(input, "onlyWhenMarketOpen") ?? false,
// #684: decides the board when the task is for a human or a fresh run. // #684: decides the board when the task is for a human or a fresh run.
CreatedBy = author CreatedBy = author,
ParentId = parent
}; };
var created = await board.CreateAsync(definition, ct); var created = await board.CreateAsync(definition, ct);
@@ -343,6 +370,10 @@ public sealed class TaskboardTool : IAgentTool
return ToolResult.Fail(whenError!); return ToolResult.Fail(whenError!);
var hasWhen = input.TryGetProperty("when", out _); var hasWhen = input.TryGetProperty("when", out _);
var parent = GetString(input, "parent")?.Trim();
if (parent is { Length: > 0 } && await CheckParentAsync(id.Trim(), parent, board, visible, ct) is { } parentProblem)
return ToolResult.Fail(parentProblem);
var result = await board.UpdateAsync(id.Trim(), current => current with var result = await board.UpdateAsync(id.Trim(), current => current with
{ {
Title = GetString(input, "title")?.Trim() ?? current.Title, Title = GetString(input, "title")?.Trim() ?? current.Title,
@@ -355,7 +386,8 @@ public sealed class TaskboardTool : IAgentTool
RequireApproval = GetBool(input, "require_approval") ?? current.RequireApproval, RequireApproval = GetBool(input, "require_approval") ?? current.RequireApproval,
Acceptance = GetString(input, "acceptance")?.Trim() ?? current.Acceptance, Acceptance = GetString(input, "acceptance")?.Trim() ?? current.Acceptance,
BlockedBy = input.TryGetProperty("blocked_by", out _) ? GetStringList(input, "blocked_by") : current.BlockedBy, BlockedBy = input.TryGetProperty("blocked_by", out _) ? GetStringList(input, "blocked_by") : current.BlockedBy,
OnlyWhenMarketOpen = GetBool(input, "onlyWhenMarketOpen") ?? current.OnlyWhenMarketOpen OnlyWhenMarketOpen = GetBool(input, "onlyWhenMarketOpen") ?? current.OnlyWhenMarketOpen,
ParentId = parent ?? current.ParentId
}, ct); }, ct);
if (result is null) if (result is null)
@@ -407,6 +439,8 @@ public sealed class TaskboardTool : IAgentTool
block.Append($"{task.Id} [{TaskText.Of(task.Status)}] {task.Title}"); block.Append($"{task.Id} [{TaskText.Of(task.Status)}] {task.Title}");
block.AppendLine(); block.AppendLine();
block.AppendLine($" Assignee: {task.Assignee} | Priority: {task.Priority}{FormatWhen(task.When)}"); block.AppendLine($" Assignee: {task.Assignee} | Priority: {task.Priority}{FormatWhen(task.When)}");
if (!string.IsNullOrWhiteSpace(task.ParentId))
block.AppendLine($" Serves: {task.ParentId}");
if (task.BlockedBy.Count > 0) if (task.BlockedBy.Count > 0)
block.AppendLine($" Waiting for: {string.Join(", ", task.BlockedBy)}"); block.AppendLine($" Waiting for: {string.Join(", ", task.BlockedBy)}");
+4 -1
View File
@@ -119,6 +119,8 @@ public sealed record TaskDto(
string? Hinweis = null, string? Hinweis = null,
TaskBudgetMissDto? BudgetMiss = null, TaskBudgetMissDto? BudgetMiss = null,
string? CreatedBy = null, string? CreatedBy = null,
// #714: the task this one serves.
string? ParentId = null,
// #684: the unit whose board the task is on, computed from the organisation; null on the top level. // #684: the unit whose board the task is on, computed from the organisation; null on the top level.
string? Board = null, string? Board = null,
string? BoardPath = null); string? BoardPath = null);
@@ -189,7 +191,8 @@ public sealed record TaskInputDto(
string? WhenValue = null, string? WhenValue = null,
string? TimeZone = null, string? TimeZone = null,
string Acceptance = "", string Acceptance = "",
int Priority = 3); int Priority = 3,
string? ParentId = null);
/// <summary>Die Kostenprognose einer Instanz (#595).</summary> /// <summary>Die Kostenprognose einer Instanz (#595).</summary>
public sealed record ForecastDto( public sealed record ForecastDto(
+4 -2
View File
@@ -108,7 +108,8 @@ internal static class Mapping
input.WhenValue, input.WhenValue,
input.TimeZone, input.TimeZone,
input.Acceptance ?? "", input.Acceptance ?? "",
input.Priority); input.Priority,
input.ParentId ?? "");
public static ForecastDto ToDto(this InstanzPrognose prognose) public static ForecastDto ToDto(this InstanzPrognose prognose)
=> new( => new(
@@ -154,7 +155,8 @@ internal static class Mapping
? new TaskBudgetMissDto(task.BudgetMissCount, task.LastBudgetMissAt, task.DeferredUntil, ? new TaskBudgetMissDto(task.BudgetMissCount, task.LastBudgetMissAt, task.DeferredUntil,
task.CatchUpPending, task.IsCarriedOver) task.CatchUpPending, task.IsCarriedOver)
: null, : null,
CreatedBy: string.IsNullOrWhiteSpace(task.CreatedBy) ? null : task.CreatedBy); CreatedBy: string.IsNullOrWhiteSpace(task.CreatedBy) ? null : task.CreatedBy,
ParentId: string.IsNullOrWhiteSpace(task.ParentId) ? null : task.ParentId);
/// <summary>#684: The board of a task, for a list the human sees whole.</summary> /// <summary>#684: The board of a task, for a list the human sees whole.</summary>
public static TaskDto WithBoard(this TaskDto dto, TaskItem task, Core.Organisation.OrgChart org) public static TaskDto WithBoard(this TaskDto dto, TaskItem task, Core.Organisation.OrgChart org)
@@ -0,0 +1,136 @@
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Organisation;
using ClawdDotNet.Core.Storage;
using ClawdDotNet.Core.Tasks;
using ClawdDotNet.Core.Tests.Infrastructure;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Tasks;
/// <summary>
/// #714: Every task can say what it serves, up to the goal of its unit — and the run of a
/// task is told so.
/// </summary>
public sealed class GoalChainTests : IDisposable
{
private readonly string _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", "goal-" + Guid.NewGuid().ToString("N")[..8]);
private readonly SqliteTaskRepository _repo;
public GoalChainTests()
{
_repo = new SqliteTaskRepository(new SqliteStorage(Path.Combine(_directory, "state.db")));
}
public void Dispose()
{
SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); }
catch { /* cleaning up is secondary */ }
}
private Task<TaskItem> Save(string id, string title, string parent = "", string assignee = "@ads")
=> _repo.UpsertAsync(new TaskItem { Id = id, Title = title, ParentId = parent, Assignee = assignee }, default);
private static OrgChart Shop(string marketingGoal = "Grow revenue profitably.", string amazonGoal = "") => new(
[
new UnitConfig { Id = "marketing", Name = "Marketing", Goal = marketingGoal },
new UnitConfig { Id = "amazon", Name = "Amazon", ParentId = "marketing", Goal = amazonGoal }
],
[new AgentConfig { AgentId = "ads", UnitId = "amazon" }]);
[Fact]
public async Task The_parents_are_walked_nearest_first()
{
await Save("t-1", "Spring collection");
await Save("t-2", "Listings for the collection", "t-1");
var leaf = await Save("t-3", "Text for chair A-100", "t-2");
var parents = await GoalChain.ParentsAsync(leaf, _repo.GetAsync, default);
parents.Select(p => p.Id).ShouldBe(["t-2", "t-1"]);
}
[Fact]
public async Task A_cycle_in_the_data_does_not_hang_the_walk()
{
await Save("t-1", "A", "t-2");
var b = await Save("t-2", "B", "t-1");
(await GoalChain.ParentsAsync(b, _repo.GetAsync, default)).Select(p => p.Id).ShouldBe(["t-1"]);
}
[Fact]
public async Task A_task_cannot_be_put_below_itself()
{
await Save("t-1", "Top");
await Save("t-2", "Middle", "t-1");
await Save("t-3", "Bottom", "t-2");
(await GoalChain.WouldCycleAsync("t-1", "t-3", _repo.GetAsync, default)).ShouldBeTrue();
(await GoalChain.WouldCycleAsync("t-1", "t-1", _repo.GetAsync, default)).ShouldBeTrue();
(await GoalChain.WouldCycleAsync("t-3", "t-1", _repo.GetAsync, default)).ShouldBeFalse();
}
[Fact]
public async Task The_description_ends_in_the_goal_of_the_nearest_unit_that_has_one()
{
var top = await Save("t-1", "Spring collection");
var leaf = await Save("t-2", "Text for chair A-100", "t-1");
var text = GoalChain.Describe(leaf, [top], Shop())!;
text.ShouldContain("t-1 \"Spring collection\"");
// amazon has no goal of its own — the one of marketing above it counts.
text.ShouldContain("the goal of the unit \"Marketing\": Grow revenue profitably.");
}
[Fact]
public void Without_parents_and_without_a_goal_nothing_is_added()
{
GoalChain.Describe(new TaskItem { Id = "t-9", Assignee = "@ads" }, [], Shop(marketingGoal: "")).ShouldBeNull();
GoalChain.Describe(new TaskItem { Id = "t-9", Assignee = "@ads" }, [], null).ShouldBeNull();
}
[Fact]
public async Task The_parent_survives_the_file_and_a_re_import()
{
var tasksDir = Path.Combine(_directory, "tasks");
var board = new TaskboardService(_repo, tasksDir);
await board.CreateAsync(new TaskItem { Id = "t-1", Title = "Top" }, default);
await board.CreateAsync(new TaskItem { Id = "t-2", Title = "Below", ParentId = "t-1" }, default);
Directory.GetFiles(tasksDir).Select(File.ReadAllText).Single(text => text.Contains("id: t-2"))
.ShouldContain("parent: t-1");
await board.ImportAllAsync(default);
(await _repo.GetAsync("t-2", default))!.ParentId.ShouldBe("t-1");
}
[Fact]
public async Task The_run_of_a_task_is_told_why()
{
var fixture = new EngineFixture();
var agent = fixture.AddAgent("ads");
agent.UnitId = "amazon";
fixture.Engine.OrganisationProvider = () => new OrgChart(
[
new UnitConfig { Id = "marketing", Name = "Marketing", Goal = "Grow revenue profitably." },
new UnitConfig { Id = "amazon", Name = "Amazon", ParentId = "marketing" }
],
fixture.Agents);
fixture.Client.RespondsWithText("ERGEBNIS: ok");
await Save("t-1", "Spring collection", assignee: "@human");
var leaf = await Save("t-2", "Text for chair A-100", "t-1");
var dispatcher = new EngineTaskDispatcher(
fixture.Engine, () => fixture.Agents, "test-instance", fixture.Registry, fixture.StateStore, TestLogging.Factory,
tasks: _repo);
await dispatcher.DispatchAsync(leaf, default);
var message = fixture.Client.ReceivedRequests[0].Messages.Last(m => m.Role == "user").Content!;
message.ShouldContain("Why this task exists");
message.ShouldContain("t-1 \"Spring collection\"");
message.ShouldContain("Grow revenue profitably.");
}
}
@@ -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);
}
}
}
@@ -0,0 +1,110 @@
using System.Text.Json;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Organisation;
using ClawdDotNet.Core.Storage;
using ClawdDotNet.Core.Tasks;
using ClawdDotNet.Core.Tools;
using ClawdDotNet.Tools.Taskboard;
using ClawdDotNet.Tools.Tests.FileRW;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
namespace ClawdDotNet.Tools.Tests.Taskboard;
/// <summary>#714: Sub-tasks under the task they serve — through the tool, with its checks.</summary>
public sealed class ParentTaskTests : IDisposable
{
private readonly string _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
private readonly SqliteTaskRepository _repo;
private readonly TaskboardTool _tool = new();
private readonly OrgChart _org = new(
[
new UnitConfig { Id = "marketing", Name = "Marketing" },
new UnitConfig { Id = "accounting", Name = "Accounting" }
],
[
new AgentConfig { AgentId = "ads", UnitId = "marketing" },
new AgentConfig { AgentId = "bookkeeper", UnitId = "accounting" }
]);
public ParentTaskTests()
{
_repo = new SqliteTaskRepository(new SqliteStorage(Path.Combine(_directory, "state.db")));
}
public void Dispose()
{
SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); }
catch { /* cleanup is secondary */ }
}
private Task<ToolResult> Run(string agentId, object input)
=> _tool.ExecuteAsync(JsonSerializer.SerializeToElement(input), new AgentToolContext(
agentId, "inst", new Dictionary<string, object?>(), new NullStateStore(), NullLogger.Instance,
CancellationToken.None, SharedWorkspacePath: Path.Combine(_directory, "SharedWorkspace"), Tasks: _repo)
{
Organisation = _org
}, CancellationToken.None);
private async Task<string> Create(string agentId, string title, string? parent = null)
{
var result = await Run(agentId, new { action = "task_create", title, parent, force = true });
result.Success.ShouldBeTrue(result.ErrorMessage);
return result.Content.Split(' ', 4)[2];
}
[Fact]
public async Task A_sub_task_is_created_under_the_task_it_serves_and_listed_so()
{
var top = await Create("ads", "Spring collection");
var sub = await Create("ads", "Listings for the collection", top);
(await _repo.GetAsync(sub, default))!.ParentId.ShouldBe(top);
(await Run("ads", new { action = "task_list" })).Content.ShouldContain($"Serves: {top}");
}
[Fact]
public async Task A_parent_that_does_not_exist_is_refused()
{
var result = await Run("ads", new { action = "task_create", title = "Orphan", parent = "t-nowhere" });
result.Success.ShouldBeFalse();
result.ErrorMessage!.ShouldContain("No task found");
}
[Fact]
public async Task A_parent_on_another_board_is_answered_like_one_that_does_not_exist()
{
var foreign = await Create("bookkeeper", "Close the books");
var result = await Run("ads", new { action = "task_create", title = "Sneaky", parent = foreign });
result.Success.ShouldBeFalse();
result.ErrorMessage.ShouldBe($"No task found with ID '{foreign}' to serve as parent.");
}
[Fact]
public async Task A_task_cannot_be_moved_below_its_own_sub_task()
{
var top = await Create("ads", "Spring collection");
var sub = await Create("ads", "Listings", top);
var result = await Run("ads", new { action = "task_update", id = top, parent = sub });
result.Success.ShouldBeFalse();
result.ErrorMessage!.ShouldContain("cannot serve itself");
}
[Fact]
public async Task An_empty_parent_removes_it()
{
var top = await Create("ads", "Spring collection");
var sub = await Create("ads", "Listings", top);
(await Run("ads", new { action = "task_update", id = sub, parent = "" })).Success.ShouldBeTrue();
(await _repo.GetAsync(sub, default))!.ParentId.ShouldBeEmpty();
}
}