diff --git a/src/ClawdDotNet.App/AppHost.cs b/src/ClawdDotNet.App/AppHost.cs index f0735c6..ce4de41 100644 --- a/src/ClawdDotNet.App/AppHost.cs +++ b/src/ClawdDotNet.App/AppHost.cs @@ -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 () => diff --git a/src/ClawdDotNet.App/Services/TaskAdministration.cs b/src/ClawdDotNet.App/Services/TaskAdministration.cs index 2a71fd5..6946b4e 100644 --- a/src/ClawdDotNet.App/Services/TaskAdministration.cs +++ b/src/ClawdDotNet.App/Services/TaskAdministration.cs @@ -20,7 +20,8 @@ public sealed record TaskInput( string? WhenValue = null, string? TimeZone = null, string Acceptance = "", - int Priority = 3); + int Priority = 3, + string ParentId = ""); /// /// 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)); } + /// #714: The parent must exist and must not lie below the task itself. + private async Task 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; + } + /// Pausieren heisst backlog, Aktivieren todo - dieselben Zustaende, die der Scanner kennt. public async Task SetEnabledAsync(string id, bool enabled, CancellationToken ct) { diff --git a/src/ClawdDotNet.Core/Storage/SqliteStorage.cs b/src/ClawdDotNet.Core/Storage/SqliteStorage.cs index ce6e70e..7c4719b 100644 --- a/src/ClawdDotNet.Core/Storage/SqliteStorage.cs +++ b/src/ClawdDotNet.Core/Storage/SqliteStorage.cs @@ -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 = """ diff --git a/src/ClawdDotNet.Core/Tasks/EngineTaskDispatcher.cs b/src/ClawdDotNet.Core/Tasks/EngineTaskDispatcher.cs index 90bf206..93c9ca7 100644 --- a/src/ClawdDotNet.Core/Tasks/EngineTaskDispatcher.cs +++ b/src/ClawdDotNet.Core/Tasks/EngineTaskDispatcher.cs @@ -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) + /// + /// #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. + /// + private async Task 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(); diff --git a/src/ClawdDotNet.Core/Tasks/GoalChain.cs b/src/ClawdDotNet.Core/Tasks/GoalChain.cs new file mode 100644 index 0000000..b12b571 --- /dev/null +++ b/src/ClawdDotNet.Core/Tasks/GoalChain.cs @@ -0,0 +1,104 @@ +using System.Text; +using ClawdDotNet.Core.Organisation; + +namespace ClawdDotNet.Core.Tasks; + +/// +/// #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". +/// +/// Purpose, not precondition. says when a task +/// may run; the parent says what it is for. Both can point to the same task, and mean +/// different things. +/// +/// 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 steps, +/// so a broken chain cannot hang a run or flood its prompt. +/// +public static class GoalChain +{ + public const int MaxDepth = 8; + + /// The parents of a task, nearest first. Stops at a missing parent or a cycle. + public static async Task> ParentsAsync( + TaskItem task, Func> lookup, CancellationToken ct) + { + var parents = new List(); + var seen = new HashSet(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; + } + + /// + /// Whether making the parent of + /// would close a circle — the parent is the task itself or lies below it. + /// + public static async Task WouldCycleAsync( + string taskId, string parentId, Func> lookup, CancellationToken ct) + { + if (string.Equals(taskId, parentId, StringComparison.OrdinalIgnoreCase)) + return true; + + var seen = new HashSet(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; + } + + /// + /// 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. null without units or without + /// a goal. + /// + public static (string UnitName, string Goal)? UnitGoal(IReadOnlyList 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; + } + + /// + /// The paragraph that goes into the run: "you do this because … because … because". + /// null when there is nothing above the task — then the run looks as before. + /// + public static string? Describe(TaskItem task, IReadOnlyList 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(); + } +} diff --git a/src/ClawdDotNet.Core/Tasks/SqliteTaskRepository.cs b/src/ClawdDotNet.Core/Tasks/SqliteTaskRepository.cs index ae0b0dd..612f0e4 100644 --- a/src/ClawdDotNet.Core/Tasks/SqliteTaskRepository.cs +++ b/src/ClawdDotNet.Core/Tasks/SqliteTaskRepository.cs @@ -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 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) }; } diff --git a/src/ClawdDotNet.Core/Tasks/TaskFrontmatter.cs b/src/ClawdDotNet.Core/Tasks/TaskFrontmatter.cs index b9ce8f8..2b08d8a 100644 --- a/src/ClawdDotNet.Core/Tasks/TaskFrontmatter.cs +++ b/src/ClawdDotNet.Core/Tasks/TaskFrontmatter.cs @@ -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) { diff --git a/src/ClawdDotNet.Core/Tasks/TaskModels.cs b/src/ClawdDotNet.Core/Tasks/TaskModels.cs index 27e8564..9a3f0d0 100644 --- a/src/ClawdDotNet.Core/Tasks/TaskModels.cs +++ b/src/ClawdDotNet.Core/Tasks/TaskModels.cs @@ -235,6 +235,13 @@ public sealed record TaskItem /// public string CreatedBy { get; init; } = ""; + /// + /// #714: The task this one serves — its purpose ("what for"), where + /// is its precondition ("when"). Empty for a task at the top; above the top stands the + /// goal of the unit. See . + /// + public string ParentId { get; init; } = ""; + /// A one-time task that was refused for lack of budget and carried over. public bool IsCarriedOver => !IsRecurring && BudgetMissCount > 0; diff --git a/src/ClawdDotNet.Core/Tasks/TaskScanner.cs b/src/ClawdDotNet.Core/Tasks/TaskScanner.cs index f6f349a..86d80a6 100644 --- a/src/ClawdDotNet.Core/Tasks/TaskScanner.cs +++ b/src/ClawdDotNet.Core/Tasks/TaskScanner.cs @@ -137,6 +137,7 @@ public sealed class TaskScanner : IAsyncDisposable private readonly ILateRunGate? _lateRunGate; private readonly IBudgetOutlook? _outlook; + private readonly ITaskTreeWatch? _treeWatch; /// How often the day ahead is looked at — a forecast, not a watchdog. public static readonly TimeSpan OutlookInterval = TimeSpan.FromMinutes(30); @@ -163,8 +164,10 @@ public sealed class TaskScanner : IAsyncDisposable int maxParallelRuns = DefaultMaxParallelRuns, Func? 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) { diff --git a/src/ClawdDotNet.Core/Tasks/TaskTreeWatch.cs b/src/ClawdDotNet.Core/Tasks/TaskTreeWatch.cs new file mode 100644 index 0000000..aba44cd --- /dev/null +++ b/src/ClawdDotNet.Core/Tasks/TaskTreeWatch.cs @@ -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; + +/// A check the scanner runs on every tick; it decides itself how often it acts. +public interface ITaskTreeWatch +{ + Task CheckAsync(DateTime nowUtc, CancellationToken ct); +} + +/// An open task whose whole subtree has come to rest. +public sealed record RestingTree(TaskItem Root, IReadOnlyList Below); + +/// +/// #714: A task tree that has stopped moving is looked at. Taken from Paperclip's "task +/// watchdog" (Organisation-Konzept, 9a): agents stop for wrong reasons — "done" without +/// evidence, a review nobody does, a blocker read wrongly, an error given up on that could +/// have been fixed. Without a check the tree simply lies still, and nobody notices. +/// +/// Detecting is code, interpreting is a model. Whether a tree rests is a query +/// over the board (this class, the first piece of #646). Whether the rest is justified is +/// decided by the lead of the unit the tree belongs to — or a human, where there is no lead +/// — through a review task. The reviewer may send work back (comment, reopen), not do it +/// itself (the line of #680). +/// +/// Once per state of rest. The review is not repeated while the tree stays +/// exactly as it was: a reviewer who accepts the rest is not asked again. Only when the +/// tree has moved and comes to rest in a different state is it looked at again. The review +/// task is not part of the tree, so it does not change what it reviews. +/// +public sealed class TaskTreeWatch( + ITaskRepository repo, + Func createTask, + IStateStore state, + Func organisation, + ILogger logger) : ITaskTreeWatch +{ + /// How often the board is looked at. + public static readonly TimeSpan Interval = TimeSpan.FromMinutes(30); + + private const string StateKeyPrefix = "tasktree.rest."; + + private DateTime _lastCheckAt = DateTime.MinValue; + + public async Task CheckAsync(DateTime nowUtc, CancellationToken ct) + { + if (nowUtc - _lastCheckAt < Interval) + return; + _lastCheckAt = nowUtc; + + try + { + var all = await repo.ListAllAsync(includeArchived: false, ct); + foreach (var tree in FindResting(all)) + await ReviewOnceAsync(tree, ct); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogWarning(ex, "Check of resting task trees failed — next attempt in {Minutes} min", Interval.TotalMinutes); + } + } + + /// + /// The open tasks whose subtree has come to rest — only the topmost of them, so a tree is + /// reviewed once and not again for each of its resting branches. Pure, so the rules can + /// be tested without an instance. + /// + public static IReadOnlyList FindResting(IReadOnlyList all) + { + var byId = all.ToDictionary(t => t.Id, StringComparer.OrdinalIgnoreCase); + var children = all + .Where(t => !string.IsNullOrWhiteSpace(t.ParentId) && byId.ContainsKey(t.ParentId)) + .GroupBy(t => t.ParentId, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase); + + List Below(string id) + { + var result = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase) { id }; + var pending = new Stack(); + pending.Push(id); + while (pending.TryPop(out var current)) + { + foreach (var child in children.GetValueOrDefault(current) ?? []) + { + if (!seen.Add(child.Id)) + continue; // a cycle in the data must not hang this + result.Add(child); + pending.Push(child.Id); + } + } + return result; + } + + var resting = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var root in all.Where(t => children.ContainsKey(t.Id) && IsOpen(t))) + { + var below = Below(root.Id); + if (below.Count > 0 && below.All(IsAtRest) && IsAtRest(root)) + resting[root.Id] = new RestingTree(root, below); + } + + // Only the topmost: a resting tree inside another resting tree is part of that one. + return resting.Values + .Where(tree => !Ancestors(tree.Root, byId).Any(a => resting.ContainsKey(a.Id))) + .OrderBy(tree => tree.Root.Id, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + /// Not finished: the tree still has a purpose to reach. + private static bool IsOpen(TaskItem task) + => task.Status is not (TaskItemStatus.Done or TaskItemStatus.Canceled or TaskItemStatus.Archived); + + /// + /// Nothing will move this task by itself: it is not running, and the scanner will not + /// start it — a todo for an agent is still on its way, a todo for a human is not. + /// + private static bool IsAtRest(TaskItem task) => task.Status switch + { + TaskItemStatus.InProgress => false, + TaskItemStatus.Todo => TaskAssignee.KindOf(task.Assignee) == TaskAssigneeKind.Human, + _ => true + }; + + private static IEnumerable Ancestors(TaskItem task, IReadOnlyDictionary byId) + { + var seen = new HashSet(StringComparer.OrdinalIgnoreCase) { task.Id }; + var next = task.ParentId; + while (!string.IsNullOrWhiteSpace(next) && seen.Add(next) && byId.TryGetValue(next, out var parent)) + { + yield return parent; + next = parent.ParentId; + } + } + + /// What the tree looks like — the same value means nothing moved since the last review. + public static string Fingerprint(RestingTree tree) + { + var text = string.Join("|", new[] { tree.Root }.Concat(tree.Below) + .OrderBy(t => t.Id, StringComparer.OrdinalIgnoreCase) + .Select(t => $"{t.Id}:{TaskText.Of(t.Status)}")); + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(text)))[..16]; + } + + private async Task ReviewOnceAsync(RestingTree tree, CancellationToken ct) + { + var key = StateKeyPrefix + tree.Root.Id; + var fingerprint = Fingerprint(tree); + if (await state.GetAsync(key, ct) == fingerprint) + return; + + var reviewer = ReviewerOf(tree.Root, organisation()); + await createTask(BuildReview(tree, reviewer), ct); + await state.SetAsync(key, fingerprint, ct); + + logger.LogInformation( + "Task tree {Root} has come to rest ({Count} tasks below) — review assigned to {Reviewer}", + tree.Root.Id, tree.Below.Count, reviewer); + } + + /// + /// The lead of the unit whose board the tree is on — or, where that unit has none, the + /// lead above it; without any, a human. + /// + public static string ReviewerOf(TaskItem root, OrgChart? org) + { + if (org is not { HasUnits: true } || TaskBoard.UnitOf(root, org) is not { } unitId) + return TaskAssignee.Human; + + foreach (var unit in new[] { org.Unit(unitId)! }.Concat(org.Ancestors(unitId))) + { + if (org.LeaderOf(unit.Id) is { } lead) + return "@" + lead.AgentId; + } + return TaskAssignee.Human; + } + + /// The review task. It stands beside the tree, not in it — it must not change what it reviews. + public static TaskItem BuildReview(RestingTree tree, string reviewer) + { + var body = new StringBuilder(); + body.AppendLine($"The work under {tree.Root.Id} \"{tree.Root.Title}\" has come to rest: nothing in it is running or about to run, but {tree.Root.Id} is not finished."); + body.AppendLine(); + body.AppendLine($"- {tree.Root.Id} [{TaskText.Of(tree.Root.Status)}] {tree.Root.Title} ({tree.Root.Assignee})"); + foreach (var task in tree.Below.OrderBy(t => t.Id, StringComparer.OrdinalIgnoreCase)) + body.AppendLine($" - {task.Id} [{TaskText.Of(task.Status)}] {task.Title} ({task.Assignee}{(string.IsNullOrWhiteSpace(task.ParentId) ? "" : $", serves {task.ParentId}")})"); + body.AppendLine(); + body.AppendLine("Decide whether the standstill is justified. Read the tasks and their comments. Typical wrong stops:"); + body.AppendLine("- marked done without evidence that the result exists,"); + body.AppendLine("- waiting for a review that nobody does,"); + body.AppendLine("- blocked by something that is already finished, or was misread,"); + body.AppendLine("- an error given up on that could have been fixed."); + body.AppendLine(); + body.AppendLine("If it is justified, say why in your result. If not, send the work back: comment on the task and reopen it (task_comment with reopen), or finish or cancel the top task. Do not do the work yourself — your job is to judge it, not to redo it."); + + return new TaskItem + { + Title = $"Review: work under {tree.Root.Id} has come to rest", + Body = body.ToString().TrimEnd(), + Assignee = reviewer, + Priority = Math.Clamp(tree.Root.Priority, 1, 5), + Status = TaskItemStatus.Todo, + Type = TaskItemType.Work, + Acceptance = "The result says, for each wrongly stopped task, what was sent back — or why the standstill is justified." + }; + } +} diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json index fd9b567..91a3591 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json +++ b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json @@ -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", diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json index e5ff02c..8c5646e 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json +++ b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json @@ -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", diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/views/tasks.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/tasks.js index 38d3198..50de34a 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/views/tasks.js +++ b/src/ClawdDotNet.Manager.Web/wwwroot/views/tasks.js @@ -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 { diff --git a/src/ClawdDotNet.Tools.Taskboard/TaskboardTool.cs b/src/ClawdDotNet.Tools.Taskboard/TaskboardTool.cs index e1385e4..62c1115 100644 --- a/src/ClawdDotNet.Tools.Taskboard/TaskboardTool.cs +++ b/src/ClawdDotNet.Tools.Taskboard/TaskboardTool.cs @@ -41,6 +41,10 @@ public sealed class TaskboardTool : IAgentTool - '@'→ 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); } + /// + /// #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. + /// + private static async Task CheckParentAsync( + string? taskId, string parentId, TaskboardService board, Func? 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; + } + /// /// #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)}"); diff --git a/src/ClawdDotNet.Web/Contracts/Dtos.cs b/src/ClawdDotNet.Web/Contracts/Dtos.cs index 6bf1378..9a8e031 100644 --- a/src/ClawdDotNet.Web/Contracts/Dtos.cs +++ b/src/ClawdDotNet.Web/Contracts/Dtos.cs @@ -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); /// Die Kostenprognose einer Instanz (#595). public sealed record ForecastDto( diff --git a/src/ClawdDotNet.Web/Contracts/Mapping.cs b/src/ClawdDotNet.Web/Contracts/Mapping.cs index 6142e02..065adea 100644 --- a/src/ClawdDotNet.Web/Contracts/Mapping.cs +++ b/src/ClawdDotNet.Web/Contracts/Mapping.cs @@ -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); /// #684: The board of a task, for a list the human sees whole. public static TaskDto WithBoard(this TaskDto dto, TaskItem task, Core.Organisation.OrgChart org) diff --git a/tests/ClawdDotNet.Core.Tests/Tasks/GoalChainTests.cs b/tests/ClawdDotNet.Core.Tests/Tasks/GoalChainTests.cs new file mode 100644 index 0000000..68b1bf5 --- /dev/null +++ b/tests/ClawdDotNet.Core.Tests/Tasks/GoalChainTests.cs @@ -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; + +/// +/// #714: Every task can say what it serves, up to the goal of its unit — and the run of a +/// task is told so. +/// +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 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."); + } +} diff --git a/tests/ClawdDotNet.Core.Tests/Tasks/TaskTreeWatchTests.cs b/tests/ClawdDotNet.Core.Tests/Tasks/TaskTreeWatchTests.cs new file mode 100644 index 0000000..f78cf53 --- /dev/null +++ b/tests/ClawdDotNet.Core.Tests/Tasks/TaskTreeWatchTests.cs @@ -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; + +/// +/// #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. +/// +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 _values = new(); + public Task 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(); + 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(); + 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); + } + } +} diff --git a/tests/ClawdDotNet.Tools.Tests/Taskboard/ParentTaskTests.cs b/tests/ClawdDotNet.Tools.Tests/Taskboard/ParentTaskTests.cs new file mode 100644 index 0000000..e24d553 --- /dev/null +++ b/tests/ClawdDotNet.Tools.Tests/Taskboard/ParentTaskTests.cs @@ -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; + +/// #714: Sub-tasks under the task they serve — through the tool, with its checks. +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 Run(string agentId, object input) + => _tool.ExecuteAsync(JsonSerializer.SerializeToElement(input), new AgentToolContext( + agentId, "inst", new Dictionary(), new NullStateStore(), NullLogger.Instance, + CancellationToken.None, SharedWorkspacePath: Path.Combine(_directory, "SharedWorkspace"), Tasks: _repo) + { + Organisation = _org + }, CancellationToken.None); + + private async Task 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(); + } +}