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
@@ -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();