Zielkette, Stufe 1: jede Aufgabe kann sagen, wofuer sie da ist (#714)

- TaskItem.ParentId, in der Datei als parent, in der DB als ParentId. Die
  Elternaufgabe ist der Zweck ("wofuer"), blocked_by die Voraussetzung
  ("wann").
- GoalChain: Eltern bis zu acht Stufen, Zyklen brechen den Gang ab und
  werden beim Setzen abgelehnt. Oben steht das Ziel der Einheit - der
  obersten Aufgabe, die zu einer Einheit gehoert (ein Ziel, das ein Mensch
  auf der obersten Ebene setzt, gehoert keiner), sonst der naechsten Einheit
  darueber mit Ziel.
- Der Lauf einer Aufgabe bekommt den Absatz "Why this task exists" und die
  Bitte, zu melden, wenn die Aufgabe ihrem Zweck nicht mehr dient.
- Taskboard-Tool: parent bei task_create/task_update, geprueft auf Existenz,
  Sichtbarkeit (#684) und Zyklen; task_list zeigt "Serves".
- WebUI: Feld im Aufgabendialog, Anzeige auf der Karte; die Verwaltung
  prueft wie das Tool.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-24 18:45:15 +02:00
co-authored by Claude Opus 5.5
parent b75b68c786
commit b22272345e
16 changed files with 482 additions and 18 deletions
+2 -1
View File
@@ -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)
@@ -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();
+104
View File
@@ -0,0 +1,104 @@
using System.Text;
using ClawdDotNet.Core.Organisation;
namespace ClawdDotNet.Core.Tasks;
/// <summary>
/// #714: Why a task exists — its parent, that one's parent, up to the goal of the unit.
/// Taken from Paperclip (Organisation-Konzept, 9a): an agent that only sees its own order
/// cannot weigh it. "Write a product text" is a different job when it serves "launch the
/// spring collection by Friday" than when it serves "clean up old listings".
///
/// <para><b>Purpose, not precondition.</b> <see cref="TaskItem.BlockedBy"/> says when a task
/// may run; the parent says what it is for. Both can point to the same task, and mean
/// different things.</para>
///
/// <para>Walked at run time, never stored as text: a parent that is renamed or finished
/// shows up as it is now. The walk stops at a cycle and after <see cref="MaxDepth"/> steps,
/// so a broken chain cannot hang a run or flood its prompt.</para>
/// </summary>
public static class GoalChain
{
public const int MaxDepth = 8;
/// <summary>The parents of a task, nearest first. Stops at a missing parent or a cycle.</summary>
public static async Task<IReadOnlyList<TaskItem>> ParentsAsync(
TaskItem task, Func<string, CancellationToken, Task<TaskItem?>> lookup, CancellationToken ct)
{
var parents = new List<TaskItem>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { task.Id };
var next = task.ParentId;
while (!string.IsNullOrWhiteSpace(next) && parents.Count < MaxDepth && seen.Add(next))
{
if (await lookup(next, ct) is not { } parent)
break;
parents.Add(parent);
next = parent.ParentId;
}
return parents;
}
/// <summary>
/// Whether making <paramref name="parentId"/> the parent of <paramref name="taskId"/>
/// would close a circle — the parent is the task itself or lies below it.
/// </summary>
public static async Task<bool> WouldCycleAsync(
string taskId, string parentId, Func<string, CancellationToken, Task<TaskItem?>> lookup, CancellationToken ct)
{
if (string.Equals(taskId, parentId, StringComparison.OrdinalIgnoreCase))
return true;
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var next = parentId;
while (!string.IsNullOrWhiteSpace(next) && seen.Add(next))
{
if (string.Equals(next, taskId, StringComparison.OrdinalIgnoreCase))
return true;
next = (await lookup(next, ct))?.ParentId;
}
return false;
}
/// <summary>
/// The goal above the chain: that of the unit of the topmost task that belongs to one —
/// a goal a human set on the top level belongs to no unit, so the next one down counts —
/// or of the nearest unit above it that has a goal. <c>null</c> without units or without
/// a goal.
/// </summary>
public static (string UnitName, string Goal)? UnitGoal(IReadOnlyList<TaskItem> fromTop, OrgChart? org)
{
if (org is not { HasUnits: true })
return null;
var unitId = fromTop.Select(t => TaskBoard.UnitOf(t, org)).FirstOrDefault(u => u is not null);
if (org.Unit(unitId) is not { } unit)
return null;
foreach (var candidate in new[] { unit }.Concat(org.Ancestors(unit.Id)))
{
if (!string.IsNullOrWhiteSpace(candidate.Goal))
return (candidate.Name, candidate.Goal.Trim());
}
return null;
}
/// <summary>
/// The paragraph that goes into the run: "you do this because … because … because".
/// <c>null</c> when there is nothing above the task — then the run looks as before.
/// </summary>
public static string? Describe(TaskItem task, IReadOnlyList<TaskItem> parents, OrgChart? org)
{
var goal = UnitGoal([.. parents.Reverse(), task], org);
if (parents.Count == 0 && goal is null)
return null;
var sb = new StringBuilder();
sb.AppendLine("Why this task exists — it serves, from nearest to farthest:");
foreach (var parent in parents)
sb.AppendLine($"- {parent.Id} \"{parent.Title}\" ({TaskText.Of(parent.Status)})");
if (goal is { } g)
sb.AppendLine($"- the goal of the unit \"{g.UnitName}\": {g.Goal}");
sb.Append("Weigh your work against this. If the task no longer serves it — the parent is done or canceled, or the task contradicts the goal — say so in your result instead of working blindly.");
return sb.ToString();
}
}
@@ -34,11 +34,11 @@ public sealed class SqliteTaskRepository : ITaskRepository
INSERT INTO Tasks
(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)
{
+7
View File
@@ -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;
@@ -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)}");
+4 -1
View File
@@ -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(
+4 -2
View File
@@ -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,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();
}
}