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