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:
co-authored by
Claude Opus 5.5
parent
b75b68c786
commit
b22272345e
@@ -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.");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user