diff --git a/src/ClawdDotNet.App/AppHost.cs b/src/ClawdDotNet.App/AppHost.cs
index f0735c6..61a3c80 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)
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.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.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();
+ }
+}