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
@@ -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)}");