Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd244b8cbd | ||
|
|
b22272345e | ||
|
|
b75b68c786 |
@@ -741,7 +741,8 @@ public sealed class AppHost : IAsyncDisposable
|
||||
dauerfreigaben: dauerfreigaben);
|
||||
|
||||
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
|
||||
? 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
|
||||
// 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 () =>
|
||||
|
||||
@@ -20,7 +20,8 @@ public sealed record TaskInput(
|
||||
string? WhenValue = null,
|
||||
string? TimeZone = null,
|
||||
string Acceptance = "",
|
||||
int Priority = 3);
|
||||
int Priority = 3,
|
||||
string ParentId = "");
|
||||
|
||||
/// <summary>
|
||||
/// 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)
|
||||
return AdminResult.Failure(fehler);
|
||||
if (await CheckParentAsync(null, input.ParentId, ct) is { } parentProblem)
|
||||
return AdminResult.Failure(parentProblem);
|
||||
|
||||
var created = await board.CreateAsync(new TaskItem
|
||||
{
|
||||
@@ -95,7 +98,8 @@ public sealed class TaskAdministration(
|
||||
Assignee = assignee,
|
||||
When = when,
|
||||
Status = TaskItemStatus.Todo,
|
||||
Type = TaskItemType.Work
|
||||
Type = TaskItemType.Work,
|
||||
ParentId = input.ParentId.Trim()
|
||||
}, ct);
|
||||
|
||||
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)
|
||||
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
|
||||
{
|
||||
@@ -116,7 +122,8 @@ public sealed class TaskAdministration(
|
||||
Acceptance = input.Acceptance.Trim(),
|
||||
Priority = Math.Clamp(input.Priority, 1, 5),
|
||||
Assignee = assignee,
|
||||
When = when
|
||||
When = when,
|
||||
ParentId = input.ParentId.Trim()
|
||||
}, ct);
|
||||
|
||||
return updated is null
|
||||
@@ -124,6 +131,19 @@ public sealed class TaskAdministration(
|
||||
: 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>
|
||||
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.
|
||||
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.
|
||||
using var index = connection.CreateCommand();
|
||||
index.CommandText = """
|
||||
|
||||
@@ -30,6 +30,7 @@ public sealed class EngineTaskDispatcher : ITaskDispatcher
|
||||
private readonly IStateStore _stateStore;
|
||||
private readonly ILogger _logger;
|
||||
private readonly TimeProvider _clock;
|
||||
private readonly ITaskRepository? _tasks;
|
||||
|
||||
public EngineTaskDispatcher(
|
||||
AgentEngine engine,
|
||||
@@ -38,8 +39,10 @@ public sealed class EngineTaskDispatcher : ITaskDispatcher
|
||||
ToolRegistry toolRegistry,
|
||||
IStateStore stateStore,
|
||||
ILoggerFactory loggerFactory,
|
||||
TimeProvider? clock = null)
|
||||
TimeProvider? clock = null,
|
||||
ITaskRepository? tasks = null)
|
||||
{
|
||||
_tasks = tasks;
|
||||
_engine = engine;
|
||||
_agents = agents;
|
||||
_instanceId = instanceId;
|
||||
@@ -56,7 +59,7 @@ public sealed class EngineTaskDispatcher : ITaskDispatcher
|
||||
return await DispatchToolJobAsync(task, ct);
|
||||
|
||||
var agents = _agents();
|
||||
var message = BuildMessage(task);
|
||||
var message = BuildMessage(task, await WhyAsync(task, ct));
|
||||
var kind = TaskAssignee.KindOf(task.Assignee);
|
||||
|
||||
AgentRunResult result;
|
||||
@@ -158,7 +161,27 @@ public sealed class EngineTaskDispatcher : ITaskDispatcher
|
||||
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();
|
||||
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.
|
||||
sb.AppendLine(Zeitangabe(task, _clock.GetUtcNow()));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(why))
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine(why.Trim());
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(task.Body))
|
||||
{
|
||||
sb.AppendLine();
|
||||
|
||||
@@ -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
|
||||
(Id, Title, Status, Type, Priority, Assignee, WhenKind, WhenValue, WhenTz,
|
||||
RequireApproval, Acceptance, BlockedBy, OnlyWhenMarketOpen, Body, FileName,
|
||||
ToolName, JobTypeId, LastOccurrence, ClaimToken, ClaimedAt, CreatedAt, UpdatedAt, CreatedBy)
|
||||
ToolName, JobTypeId, LastOccurrence, ClaimToken, ClaimedAt, CreatedAt, UpdatedAt, CreatedBy, ParentId)
|
||||
VALUES
|
||||
(@id, @title, @status, @type, @priority, @assignee, @whenKind, @whenValue, @whenTz,
|
||||
@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);
|
||||
insert.Parameters.AddWithValue("@status", TaskText.Of(task.Status));
|
||||
@@ -61,6 +61,7 @@ public sealed class SqliteTaskRepository : ITaskRepository
|
||||
BlockedBy = @blockedBy, OnlyWhenMarketOpen = @market, Body = @body,
|
||||
FileName = @fileName, ToolName = @toolName, JobTypeId = @jobType,
|
||||
CreatedBy = CASE WHEN @createdBy = '' THEN CreatedBy ELSE @createdBy END,
|
||||
ParentId = @parentId,
|
||||
UpdatedAt = @updatedAt
|
||||
WHERE Id = @id
|
||||
""";
|
||||
@@ -332,7 +333,7 @@ public sealed class SqliteTaskRepository : ITaskRepository
|
||||
"Id, Title, Status, Type, Priority, Assignee, WhenKind, WhenValue, WhenTz, " +
|
||||
"RequireApproval, Acceptance, BlockedBy, OnlyWhenMarketOpen, Body, FileName, " +
|
||||
"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)
|
||||
{
|
||||
@@ -354,6 +355,7 @@ public sealed class SqliteTaskRepository : ITaskRepository
|
||||
cmd.Parameters.AddWithValue("@jobType", task.JobTypeId);
|
||||
// #684: a file without it (written by hand, or older) keeps what the database knows.
|
||||
cmd.Parameters.AddWithValue("@createdBy", task.CreatedBy ?? "");
|
||||
cmd.Parameters.AddWithValue("@parentId", task.ParentId ?? "");
|
||||
}
|
||||
|
||||
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)),
|
||||
DeferredUntil = r.IsDBNull(24) ? null : Parse(r.GetString(24)),
|
||||
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", ""),
|
||||
JobTypeId = scalars.GetValueOrDefault("job_type", ""),
|
||||
CreatedBy = scalars.GetValueOrDefault("created_by", ""),
|
||||
ParentId = scalars.GetValueOrDefault("parent", ""),
|
||||
Body = body
|
||||
};
|
||||
return true;
|
||||
@@ -172,6 +173,8 @@ public static class TaskFrontmatter
|
||||
sb.Append($"assignee: {Scalar(task.Assignee)}\n");
|
||||
if (!string.IsNullOrWhiteSpace(task.CreatedBy))
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -235,6 +235,13 @@ public sealed record TaskItem
|
||||
/// </summary>
|
||||
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>
|
||||
public bool IsCarriedOver => !IsRecurring && BudgetMissCount > 0;
|
||||
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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.boardAll": "Alle Bretter",
|
||||
"tasks.boardTopLevel": "Oberste Ebene",
|
||||
"tasks.parent": "Dient Aufgabe (Eltern)",
|
||||
"tasks.serves": "dient {id}",
|
||||
|
||||
"services.title": "Dienste",
|
||||
"services.newService": "Neuer Dienst",
|
||||
|
||||
@@ -349,6 +349,8 @@
|
||||
"tasks.board": "Board",
|
||||
"tasks.boardAll": "All boards",
|
||||
"tasks.boardTopLevel": "Top level",
|
||||
"tasks.parent": "Serves task (parent)",
|
||||
"tasks.serves": "serves {id}",
|
||||
|
||||
"services.title": "Services",
|
||||
"services.newService": "New Service",
|
||||
|
||||
@@ -189,7 +189,7 @@ function erstelleAufgabenKarte(task) {
|
||||
erstellePlakette(holeStatusText(task.status), STATUS_ART[task.status] || "neutral"),
|
||||
erstelleElement("span", {
|
||||
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: "..." });
|
||||
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 aktualisiere = () => {
|
||||
@@ -369,6 +372,7 @@ async function oeffneAufgabenDialog(task) {
|
||||
erstelleElement("label", { text: "Value:" }), terminWert,
|
||||
erstelleElement("label", { text: "Timezone:" }), zeitzone,
|
||||
erstelleElement("label", { text: "Acceptance Criteria:" }), abnahme,
|
||||
erstelleElement("label", { text: t("tasks.parent") + ":" }), eltern,
|
||||
erstelleElement("span"), hinweis
|
||||
);
|
||||
|
||||
@@ -399,7 +403,8 @@ async function oeffneAufgabenDialog(task) {
|
||||
whenValue: terminArt.value ? terminWert.value.trim() : null,
|
||||
timeZone: terminArt.value ? zeitzone.value.trim() : null,
|
||||
acceptance: abnahme.value.trim(),
|
||||
priority: task?.priority ?? 3
|
||||
priority: task?.priority ?? 3,
|
||||
parentId: eltern.value.trim()
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
@@ -41,6 +41,10 @@ public sealed class TaskboardTool : IAgentTool
|
||||
- '@<agentId>'→ the named agent with their context
|
||||
- '@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'.
|
||||
The time zone 'tz' is required (e.g. 'Europe/Berlin').
|
||||
|
||||
@@ -107,6 +111,10 @@ public sealed class TaskboardTool : IAgentTool
|
||||
"type": "string",
|
||||
"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": {
|
||||
"type": "array",
|
||||
"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);
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// #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.
|
||||
@@ -267,6 +289,10 @@ public sealed class TaskboardTool : IAgentTool
|
||||
if (!TryReadWhen(input, out var when, out var 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
|
||||
// existing one — so that later one can see how often and for how long.
|
||||
if (GetBool(input, "force") != true)
|
||||
@@ -302,7 +328,8 @@ public sealed class TaskboardTool : IAgentTool
|
||||
BlockedBy = GetStringList(input, "blocked_by"),
|
||||
OnlyWhenMarketOpen = GetBool(input, "onlyWhenMarketOpen") ?? false,
|
||||
// #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);
|
||||
@@ -343,6 +370,10 @@ public sealed class TaskboardTool : IAgentTool
|
||||
return ToolResult.Fail(whenError!);
|
||||
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
|
||||
{
|
||||
Title = GetString(input, "title")?.Trim() ?? current.Title,
|
||||
@@ -355,7 +386,8 @@ public sealed class TaskboardTool : IAgentTool
|
||||
RequireApproval = GetBool(input, "require_approval") ?? current.RequireApproval,
|
||||
Acceptance = GetString(input, "acceptance")?.Trim() ?? current.Acceptance,
|
||||
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);
|
||||
|
||||
if (result is null)
|
||||
@@ -407,6 +439,8 @@ public sealed class TaskboardTool : IAgentTool
|
||||
block.Append($"{task.Id} [{TaskText.Of(task.Status)}] {task.Title}");
|
||||
block.AppendLine();
|
||||
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)
|
||||
block.AppendLine($" Waiting for: {string.Join(", ", task.BlockedBy)}");
|
||||
|
||||
|
||||
@@ -119,6 +119,8 @@ public sealed record TaskDto(
|
||||
string? Hinweis = null,
|
||||
TaskBudgetMissDto? BudgetMiss = 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.
|
||||
string? Board = null,
|
||||
string? BoardPath = null);
|
||||
@@ -189,7 +191,8 @@ public sealed record TaskInputDto(
|
||||
string? WhenValue = null,
|
||||
string? TimeZone = null,
|
||||
string Acceptance = "",
|
||||
int Priority = 3);
|
||||
int Priority = 3,
|
||||
string? ParentId = null);
|
||||
|
||||
/// <summary>Die Kostenprognose einer Instanz (#595).</summary>
|
||||
public sealed record ForecastDto(
|
||||
|
||||
@@ -108,7 +108,8 @@ internal static class Mapping
|
||||
input.WhenValue,
|
||||
input.TimeZone,
|
||||
input.Acceptance ?? "",
|
||||
input.Priority);
|
||||
input.Priority,
|
||||
input.ParentId ?? "");
|
||||
|
||||
public static ForecastDto ToDto(this InstanzPrognose prognose)
|
||||
=> new(
|
||||
@@ -154,7 +155,8 @@ internal static class Mapping
|
||||
? new TaskBudgetMissDto(task.BudgetMissCount, task.LastBudgetMissAt, task.DeferredUntil,
|
||||
task.CatchUpPending, task.IsCarriedOver)
|
||||
: 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>
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user