Brett je Einheit, Stufe 3: Aufgaben sehen, wer sie sehen soll (#684)

- TaskBoard: das Brett einer Aufgabe wird nicht gespeichert, es folgt aus
  der Organisation - Einheit des Zustaendigen, sonst des Anlegers. Offene
  Aufgaben ziehen damit beim Umzug mit. Sichtbar: das eigene Brett, fuer eine
  Leitung alle darunter, und was man selbst angelegt hat (nach oben
  Gereichtes bleibt im Blick).
- TaskItem.CreatedBy, in der Datei als created_by und in der DB. Ein Import
  aus einer Datei ohne das Feld behaelt, was die DB weiss.
- Taskboard-Tool: task_list, task_find und die Dublettenpruefung sehen nur
  die eigenen Bretter - eine aehnliche Buchhaltungsaufgabe haelt keine
  Marketingaufgabe auf und bekommt keinen Kommentar von dort. task_update und
  task_comment antworten auf eine fremde Aufgabe wie auf eine unbekannte.
- TaskboardService.ListAsync mit Sichtfilter: erst filtern, dann kappen.
- WebUI: jede Aufgabe nennt ihr Brett, ein Filter waehlt eines aus.
- Konzept (Abschnitt 3) und Einrichtungshandbuch nachgetragen.

Ohne Einheiten aendert sich nichts - ein Brett fuer alle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-24 17:03:39 +02:00
co-authored by Claude Opus 5.5
parent 609298dd7a
commit 29d953a5eb
17 changed files with 458 additions and 30 deletions
+2
View File
@@ -64,6 +64,8 @@ Instance-{Name}/
├── AgentList.json ← Agenten-Register (Name, Beschreibung, Ordner)
├── SharedWorkspace/ ← Geteilter Arbeitsbereich
│ └── coordination/ ← Status-Dateien für Inter-Agenten-Kommunikation
├── SharedWorkspaces/ ← Bereiche mit Mitgliederliste (#684), einer je Einheit
│ └── {bereich}/ ← in FileRW als workspace "unit" oder "{bereich}"
└── Agent-{Name}/
├── AgentSettings.json ← Agent-Konfiguration (Modell, Tools, Scheduler)
├── Identity.md ← WER ist der Agent (Rolle, Expertise)
+19
View File
@@ -244,6 +244,25 @@ Vier Festlegungen:
Für bestehende Instanzen bleibt `workspace: "shared"` gültig und zeigt auf den einen
Bereich, den es dort gibt. Ohne das bräche jede Aufgabe, die heute läuft.
**Umgesetzt am 2026-09-24 (#684), mit zwei Abweichungen im Wortlaut, nicht in der Sache:**
- Der Ordner `SharedWorkspace` wird **nicht umbenannt**. Er bleibt der Bereich `shared`
mit dem bisherigen `sharedAccessLevel` am Agenten; die neuen Bereiche liegen daneben
unter `SharedWorkspaces/<id>/`. Am alten Pfad hängen FTP, RocketChat, der Agenten-Editor,
die Aufgabendateien und die Sicherungen — eine Umbenennung hätte jede laufende
Installation gebrochen. Für jeden Bereich gibt es genau eine Quelle des Rechts: für
`shared` die Stufe am Agenten, für alle anderen die Mitgliederliste.
- Der eigene Bereich einer Einheit trägt ihre Id; ein Agent erreicht ihn in FileRW als
`unit`, ohne die Id zu kennen. Welche Bereiche er hat, steht im Organisationsabschnitt
seines Prompts.
- **Mit Einheiten ist `shared:/tasks/` für FileRW gesperrt.** Eine direkt geschriebene
Aufgabendatei ginge an jeder Prüfung des Bretts vorbei. Aufgaben laufen nur über das
Taskboard. Ohne Einheiten ändert sich nichts.
- **Das Brett einer Aufgabe wird nicht gespeichert**, es folgt aus der Organisation: die
Einheit des Zuständigen, sonst die des Anlegers (`created_by`). Offene Aufgaben ziehen
damit beim Umzug von selbst mit. Wer eine Aufgabe angelegt hat, sieht sie auch, wenn sie
auf dem Brett der Leitung liegt.
### Die eine Regel, die alles zusammenhält
**Ein Agent kennt nur seine Einheit und seine Leitung.** Alles andere geht über die
@@ -377,6 +377,9 @@ public sealed class SqliteStorage
SpalteErgaenzen(connection, "Tasks", "DeferredUntil", "TEXT NULL");
SpalteErgaenzen(connection, "Tasks", "CatchUpPending", "INTEGER NOT NULL DEFAULT 0");
// #684: Who created a task — decides its board when nobody in particular is assigned.
SpalteErgaenzen(connection, "Tasks", "CreatedBy", "TEXT NOT NULL DEFAULT ''");
// Findet offene Aufrufe beim Start, ohne die ganze Tabelle zu lesen.
using var index = connection.CreateCommand();
index.CommandText = """
@@ -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)
ToolName, JobTypeId, LastOccurrence, ClaimToken, ClaimedAt, CreatedAt, UpdatedAt, CreatedBy)
VALUES
(@id, @title, @status, @type, @priority, @assignee, @whenKind, @whenValue, @whenTz,
@requireApproval, @acceptance, @blockedBy, @market, @body, @fileName,
@toolName, @jobType, NULL, NULL, NULL, @createdAt, @updatedAt);
@toolName, @jobType, NULL, NULL, NULL, @createdAt, @updatedAt, @createdBy);
""";
BindDefinition(insert, task);
insert.Parameters.AddWithValue("@status", TaskText.Of(task.Status));
@@ -60,6 +60,7 @@ public sealed class SqliteTaskRepository : ITaskRepository
RequireApproval = @requireApproval, Acceptance = @acceptance,
BlockedBy = @blockedBy, OnlyWhenMarketOpen = @market, Body = @body,
FileName = @fileName, ToolName = @toolName, JobTypeId = @jobType,
CreatedBy = CASE WHEN @createdBy = '' THEN CreatedBy ELSE @createdBy END,
UpdatedAt = @updatedAt
WHERE Id = @id
""";
@@ -331,7 +332,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";
+ "BudgetMissCount, LastBudgetMissAt, DeferredUntil, CatchUpPending, CreatedBy";
private static void BindDefinition(SqliteCommand cmd, TaskItem task)
{
@@ -351,6 +352,8 @@ public sealed class SqliteTaskRepository : ITaskRepository
cmd.Parameters.AddWithValue("@fileName", task.FileName);
cmd.Parameters.AddWithValue("@toolName", task.ToolName);
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 ?? "");
}
private static async Task<TaskItem?> ReadAsync(SqliteConnection conn, string id, CancellationToken ct)
@@ -401,7 +404,8 @@ public sealed class SqliteTaskRepository : ITaskRepository
BudgetMissCount = r.GetInt32(22),
LastBudgetMissAt = r.IsDBNull(23) ? null : Parse(r.GetString(23)),
DeferredUntil = r.IsDBNull(24) ? null : Parse(r.GetString(24)),
CatchUpPending = r.GetInt32(25) != 0
CatchUpPending = r.GetInt32(25) != 0,
CreatedBy = r.GetString(26)
};
}
+78
View File
@@ -0,0 +1,78 @@
using ClawdDotNet.Core.Organisation;
namespace ClawdDotNet.Core.Tasks;
/// <summary>
/// #684: One board per unit. With 50 agents one board for everything is worthless as an
/// overview — the test instance had 159 tasks with seven agents — and a bookkeeping task
/// could slow down the creation of a marketing task through the duplicate check.
///
/// <para><b>The board of a task is not stored, it follows from the organisation:</b> the unit
/// of the agent it is assigned to, or — for a task for a human or a fresh run — the unit of
/// the agent that created it. So open tasks move with an agent that moves (Organisation-
/// Konzept, section 3), and nothing has to be migrated. A task that belongs to no unit is on
/// the board of the top level.</para>
///
/// <para><b>Who sees which board:</b> an agent its own unit's; a lead also the boards of every
/// unit below the one it leads; everyone the tasks they created themselves, so a task handed
/// up to the lead stays in view of whoever handed it up. A task across units is a delegation
/// to the other unit's lead — the address book decides that, not this.</para>
///
/// <para><b>Without units nothing changes:</b> there is one board, and everyone sees it.</para>
/// </summary>
public static class TaskBoard
{
/// <summary>The unit whose board the task is on; <c>null</c> for the top level.</summary>
public static string? UnitOf(TaskItem task, OrgChart org)
{
var assigned = TaskAssignee.AgentId(task.Assignee);
if (assigned.Length > 0 && org.Agent(assigned) is not null)
return org.UnitOf(assigned)?.Id;
if (!string.IsNullOrWhiteSpace(task.CreatedBy) && org.Agent(task.CreatedBy) is not null)
return org.UnitOf(task.CreatedBy)?.Id;
return null;
}
/// <summary>
/// What an agent may see — <c>null</c> for "everything": without units, without an
/// organisation (outside the engine), or for an agent the organisation does not know.
/// </summary>
public static Func<TaskItem, bool>? VisibleTo(OrgChart? org, string agentId)
{
if (org is not { HasUnits: true } || org.Agent(agentId) is null)
return null;
var boards = BoardsOf(org, agentId);
var topLevel = org.UnitOf(agentId) is null;
return task =>
string.Equals(task.CreatedBy, agentId, StringComparison.OrdinalIgnoreCase)
|| (UnitOf(task, org) is { } unit ? boards.Contains(unit) : topLevel);
}
/// <summary>The units whose boards an agent sees: its own, and all below the unit it leads.</summary>
public static IReadOnlySet<string> BoardsOf(OrgChart org, string agentId)
{
var boards = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (org.UnitOf(agentId) is { } own)
boards.Add(own.Id);
if (org.LedBy(agentId) is { } led)
{
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var pending = new Stack<string>();
pending.Push(led.Id);
while (pending.TryPop(out var unitId))
{
if (!seen.Add(unitId))
continue; // a broken tree must not hang this
boards.Add(unitId);
foreach (var child in org.Children(unitId))
pending.Push(child.Id);
}
}
return boards;
}
}
@@ -152,6 +152,7 @@ public static class TaskFrontmatter
OnlyWhenMarketOpen = ParseBool(scalars.GetValueOrDefault("onlyWhenMarketOpen")),
ToolName = scalars.GetValueOrDefault("tool_name", ""),
JobTypeId = scalars.GetValueOrDefault("job_type", ""),
CreatedBy = scalars.GetValueOrDefault("created_by", ""),
Body = body
};
return true;
@@ -169,6 +170,8 @@ public static class TaskFrontmatter
sb.Append($"type: {TaskText.Of(task.Type)}\n");
sb.Append($"priority: {Math.Clamp(task.Priority, 1, 5)}\n");
sb.Append($"assignee: {Scalar(task.Assignee)}\n");
if (!string.IsNullOrWhiteSpace(task.CreatedBy))
sb.Append($"created_by: {Scalar(task.CreatedBy)}\n");
if (task.When is { } w)
{
+27
View File
@@ -228,6 +228,13 @@ public sealed record TaskItem
/// </summary>
public bool CatchUpPending { get; init; }
/// <summary>
/// #684: The agent that created the task; empty for tasks from a human, from a file
/// written by hand, or from before this field. Decides the board of a task nobody in
/// particular is assigned to — see <see cref="TaskBoard"/>.
/// </summary>
public string CreatedBy { get; init; } = "";
/// <summary>A one-time task that was refused for lack of budget and carried over.</summary>
public bool IsCarriedOver => !IsRecurring && BudgetMissCount > 0;
@@ -269,4 +276,24 @@ public sealed record TaskQuery
public bool IncludeArchived { get; init; }
public int Limit { get; init; } = 50;
/// <summary>
/// #684: The same filter as the repository's query, for a list that is already in
/// memory — when a board has to be filtered first and the database cannot do that.
/// </summary>
public bool Matches(TaskItem task)
{
if (Status is { } status ? task.Status != status : !IncludeArchived && task.Status == TaskItemStatus.Archived)
return false;
if (!string.IsNullOrWhiteSpace(Assignee) && !string.Equals(task.Assignee, Assignee.Trim(), StringComparison.OrdinalIgnoreCase))
return false;
if (!string.IsNullOrWhiteSpace(Search))
{
var search = Search.Trim();
if (!task.Title.Contains(search, StringComparison.OrdinalIgnoreCase)
&& !task.Body.Contains(search, StringComparison.OrdinalIgnoreCase))
return false;
}
return true;
}
}
+22 -1
View File
@@ -31,7 +31,28 @@ public sealed class TaskboardService
public Task<IReadOnlyList<TaskItem>> ListAsync(TaskQuery query, CancellationToken ct)
=> _repo.ListAsync(query, ct);
/// <summary>The whole board, without the cap of <see cref="ListAsync"/>.</summary>
/// <summary>
/// #684: A list restricted to what one agent may see. The boards cannot be expressed in
/// the database query — they follow from the organisation — so the whole board is read
/// and filtered here, then capped: filtering after the database's cap would silently
/// drop tasks of the own unit behind those of others.
/// </summary>
public async Task<IReadOnlyList<TaskItem>> ListAsync(TaskQuery query, Func<TaskItem, bool>? visible, CancellationToken ct)
{
if (visible is null)
return await _repo.ListAsync(query, ct);
var all = await _repo.ListAllAsync(query.IncludeArchived || query.Status == TaskItemStatus.Archived, ct);
return all
.Where(visible)
.Where(query.Matches)
.OrderByDescending(t => t.Priority)
.ThenByDescending(t => t.UpdatedAt)
.Take(Math.Clamp(query.Limit, 1, 500))
.ToList();
}
/// <summary>The whole board, without the cap of <see cref="ListAsync(TaskQuery, CancellationToken)"/>.</summary>
public Task<IReadOnlyList<TaskItem>> ListAllAsync(CancellationToken ct, bool includeArchived = false)
=> _repo.ListAllAsync(includeArchived, ct);
@@ -2372,3 +2372,8 @@ tr.org-ausreisser td {
@media (max-width: 900px) {
.org-raster { grid-template-columns: 1fr; }
}
/* #684: the board filter above the task cards */
.taskboard-bretter {
margin-bottom: 12px;
}
@@ -346,6 +346,9 @@
"tasks.budgetMissedLast": "zuletzt {time}",
"tasks.budgetCarriedOver": "mitgenommen, wieder fällig ab {time}",
"tasks.budgetCatchUp": "ein Nachholauf ab {time}",
"tasks.board": "Brett",
"tasks.boardAll": "Alle Bretter",
"tasks.boardTopLevel": "Oberste Ebene",
"services.title": "Dienste",
"services.newService": "Neuer Dienst",
@@ -346,6 +346,9 @@
"tasks.budgetMissedLast": "last at {time}",
"tasks.budgetCarriedOver": "carried over, due again from {time}",
"tasks.budgetCatchUp": "one catch-up run from {time}",
"tasks.board": "Board",
"tasks.boardAll": "All boards",
"tasks.boardTopLevel": "Top level",
"services.title": "Services",
"services.newService": "New Service",
@@ -58,6 +58,9 @@ const ABGESCHLOSSEN = ["Done", "Canceled", "Archived"];
let zeigeAbgeschlossene = false;
/** #684: The board shown — "" for all, "-" for the top level, otherwise a unit id. */
let brettFilter = "";
export async function rendereAufgaben() {
const container = document.getElementById("ansicht-inhalt");
if (!container) return;
@@ -114,9 +117,14 @@ async function ladeTaskboard() {
try {
const alle = (await api("/tasks?limit=200")) || [];
const tasks = alle.filter((t) => zeigeAbgeschlossene || !ABGESCHLOSSEN.includes(t.status));
const offen = alle.filter((t) => zeigeAbgeschlossene || !ABGESCHLOSSEN.includes(t.status));
const tasks = offen.filter((t) => !brettFilter || (brettFilter === "-" ? !t.board : t.board === brettFilter));
container.replaceChildren();
// #684: With units, every task is on a unit's board — pick one to see only that.
const bretter = brettAuswahl(alle);
if (bretter) container.append(bretter);
if (tasks.length === 0) {
container.append(
erstelleKarte(t("tasks.empty"), [
@@ -141,6 +149,30 @@ async function ladeTaskboard() {
}
}
/**
* #684: The boards the tasks are on, as a filter. Built from the tasks themselves, so it
* shows only boards that hold something; absent when no task is on a unit's board.
*/
function brettAuswahl(tasks) {
const bretter = new Map();
for (const task of tasks) if (task.board) bretter.set(task.board, task.boardPath || task.board);
if (bretter.size === 0) return null;
const auswahl = erstelleElement("select", {
"aria-label": t("tasks.board"),
onchange: (e) => { brettFilter = e.target.value; ladeTaskboard(); }
},
erstelleElement("option", { value: "", text: t("tasks.boardAll") }),
erstelleElement("option", { value: "-", text: t("tasks.boardTopLevel") }),
...[...bretter.entries()]
.sort((a, b) => a[1].localeCompare(b[1]))
.map(([id, pfad]) => erstelleElement("option", { value: id, text: pfad })));
auswahl.value = brettFilter;
return erstelleElement("label", { klasse: "gruppe-horizontal text-klein taskboard-bretter" },
erstelleElement("span", { text: t("tasks.board") }), auswahl);
}
function terminText(task) {
if (!task.when) return t("tasks.scheduleNone");
const art = task.when.kind;
@@ -155,7 +187,10 @@ function erstelleAufgabenKarte(task) {
"div",
{ klasse: "task-status-zeile gruppe-horizontal" },
erstellePlakette(holeStatusText(task.status), STATUS_ART[task.status] || "neutral"),
erstelleElement("span", { klasse: "gedaempft text-klein", text: `${task.id} · ${task.assignee || "—"}` })
erstelleElement("span", {
klasse: "gedaempft text-klein",
text: `${task.id} · ${task.assignee || "—"}${task.boardPath ? ` · ▤ ${task.boardPath}` : ""}`
})
)
);
@@ -22,7 +22,8 @@ public sealed class TaskboardTool : IAgentTool
public string Name => "Taskboard";
public string Description => """
The shared taskboard — persists across runs and restarts.
The taskboard — persists across runs and restarts. If your instance has units, you
see your unit's board; a lead also sees the boards of the units below.
Use it to track and distribute work instead of keeping it in context:
own open items, assignments to other agents, recurring schedules.
@@ -157,15 +158,18 @@ public sealed class TaskboardTool : IAgentTool
&& CheckAssignee(GetString(input, "assignee"), context) is { } outOfReach)
return ToolResult.Fail(outOfReach);
// #684: The boards this agent sees; null means all of them (no units).
var visible = TaskBoard.VisibleTo(context.Organisation, context.AgentId);
try
{
return action switch
{
"task_create" => await CreateAsync(input, board, context.AgentId, ct),
"task_find" => await FindAsync(input, board, ct),
"task_list" => await ListAsync(input, board, ct),
"task_update" => await UpdateAsync(input, board, ct),
"task_comment" => await CommentAsync(input, board, context.AgentId, ct),
"task_create" => await CreateAsync(input, board, context.AgentId, visible, ct),
"task_find" => await FindAsync(input, board, visible, ct),
"task_list" => await ListAsync(input, board, visible, ct),
"task_update" => await UpdateAsync(input, board, visible, ct),
"task_comment" => await CommentAsync(input, board, context.AgentId, visible, ct),
_ => ToolResult.Fail($"Unknown action: {action}")
};
}
@@ -206,13 +210,13 @@ public sealed class TaskboardTool : IAgentTool
/// the first sentence.</para>
/// </summary>
private static async Task<ToolResult> FindAsync(
JsonElement input, TaskboardService board, CancellationToken ct)
JsonElement input, TaskboardService board, Func<TaskItem, bool>? visible, CancellationToken ct)
{
var title = GetString(input, "title") ?? GetString(input, "search");
if (string.IsNullOrWhiteSpace(title))
return ToolResult.Fail("'title' is required — what should be searched for?");
var treffer = await SucheAehnlicheAsync(board, title, null, ct);
var treffer = await SucheAehnlicheAsync(board, title, null, visible, ct);
if (treffer.Count == 0)
return ToolResult.Ok($"No open task resembles \"{title.Trim()}\". You can create it with task_create.");
@@ -232,18 +236,29 @@ public sealed class TaskboardTool : IAgentTool
return ToolResult.Ok(sb.ToString());
}
/// <summary>Offene Aufgaben mit verwandtem Titel, engste zuerst.</summary>
/// <summary>
/// Offene Aufgaben mit verwandtem Titel, engste zuerst — nur auf den Brettern, die der
/// Agent sieht (#684). Eine aehnliche Aufgabe der Buchhaltung haelt keine Marketing-Aufgabe
/// auf, und die Ablehnung verraete sonst, was dort liegt.
/// </summary>
private static async Task<List<(TaskItem Task, TaskTitleMatch Match)>> SucheAehnlicheAsync(
TaskboardService board, string title, string? exceptId, CancellationToken ct)
TaskboardService board, string title, string? exceptId, Func<TaskItem, bool>? visible, CancellationToken ct)
{
// Uncapped (#716): with a limit of 500, a duplicate among older, low-priority tasks
// stayed invisible once the board grew beyond that.
var alle = await board.ListAllAsync(ct);
return TaskSimilarity.FindOpen(alle, title, exceptId);
return TaskSimilarity.FindOpen(visible is null ? alle : alle.Where(visible).ToList(), title, exceptId);
}
/// <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.
/// </summary>
private static async Task<bool> CanSeeAsync(TaskboardService board, string id, Func<TaskItem, bool>? visible, CancellationToken ct)
=> visible is null || (await board.GetAsync(id, ct) is { } task && visible(task));
private static async Task<ToolResult> CreateAsync(
JsonElement input, TaskboardService board, string author, CancellationToken ct)
JsonElement input, TaskboardService board, string author, Func<TaskItem, bool>? visible, CancellationToken ct)
{
var title = GetString(input, "title");
if (string.IsNullOrWhiteSpace(title))
@@ -256,7 +271,7 @@ public sealed class TaskboardTool : IAgentTool
// existing one — so that later one can see how often and for how long.
if (GetBool(input, "force") != true)
{
var dublette = (await SucheAehnlicheAsync(board, title, null, ct))
var dublette = (await SucheAehnlicheAsync(board, title, null, visible, ct))
.FirstOrDefault(t => t.Match == TaskTitleMatch.Duplicate).Task;
if (dublette is not null)
@@ -285,7 +300,9 @@ public sealed class TaskboardTool : IAgentTool
RequireApproval = GetBool(input, "require_approval") ?? false,
Acceptance = GetString(input, "acceptance")?.Trim() ?? "",
BlockedBy = GetStringList(input, "blocked_by"),
OnlyWhenMarketOpen = GetBool(input, "onlyWhenMarketOpen") ?? false
OnlyWhenMarketOpen = GetBool(input, "onlyWhenMarketOpen") ?? false,
// #684: decides the board when the task is for a human or a fresh run.
CreatedBy = author
};
var created = await board.CreateAsync(definition, ct);
@@ -296,7 +313,7 @@ public sealed class TaskboardTool : IAgentTool
}
private static async Task<ToolResult> ListAsync(
JsonElement input, TaskboardService board, CancellationToken ct)
JsonElement input, TaskboardService board, Func<TaskItem, bool>? visible, CancellationToken ct)
{
var query = new TaskQuery
{
@@ -306,7 +323,7 @@ public sealed class TaskboardTool : IAgentTool
Limit = GetInt(input, "limit") ?? 50
};
var tasks = await board.ListAsync(query, ct);
var tasks = await board.ListAsync(query, visible, ct);
if (tasks.Count == 0)
return ToolResult.Ok("No matching tasks.");
@@ -314,11 +331,13 @@ public sealed class TaskboardTool : IAgentTool
}
private static async Task<ToolResult> UpdateAsync(
JsonElement input, TaskboardService board, CancellationToken ct)
JsonElement input, TaskboardService board, Func<TaskItem, bool>? visible, CancellationToken ct)
{
var id = GetString(input, "id");
if (string.IsNullOrWhiteSpace(id))
return ToolResult.Fail("'id' is required.");
if (!await CanSeeAsync(board, id.Trim(), visible, ct))
return ToolResult.Fail($"No task found with ID '{id}'.");
if (!TryReadWhen(input, out var when, out var whenError))
return ToolResult.Fail(whenError!);
@@ -348,11 +367,13 @@ public sealed class TaskboardTool : IAgentTool
}
private static async Task<ToolResult> CommentAsync(
JsonElement input, TaskboardService board, string author, CancellationToken ct)
JsonElement input, TaskboardService board, string author, Func<TaskItem, bool>? visible, CancellationToken ct)
{
var id = GetString(input, "id");
if (string.IsNullOrWhiteSpace(id))
return ToolResult.Fail("'id' is required.");
if (!await CanSeeAsync(board, id.Trim(), visible, ct))
return ToolResult.Fail($"No task found with ID '{id}'.");
var text = GetString(input, "text");
if (string.IsNullOrWhiteSpace(text))
+5 -1
View File
@@ -117,7 +117,11 @@ public sealed record TaskDto(
DateTime? NextRunUtc = null,
TaskLastRunDto? LastRun = null,
string? Hinweis = null,
TaskBudgetMissDto? BudgetMiss = null);
TaskBudgetMissDto? BudgetMiss = null,
string? CreatedBy = 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);
/// <summary>
/// #716: Runs of this task the budget refused — only present while something is open
+10 -1
View File
@@ -153,7 +153,16 @@ internal static class Mapping
BudgetMiss: task.BudgetMissCount > 0 || task.CatchUpPending || task.DeferredUntil is not null
? new TaskBudgetMissDto(task.BudgetMissCount, task.LastBudgetMissAt, task.DeferredUntil,
task.CatchUpPending, task.IsCarriedOver)
: null);
: null,
CreatedBy: string.IsNullOrWhiteSpace(task.CreatedBy) ? null : task.CreatedBy);
/// <summary>#684: The board of a task, for a list the human sees whole.</summary>
public static TaskDto WithBoard(this TaskDto dto, TaskItem task, Core.Organisation.OrgChart org)
{
if (!org.HasUnits || Core.Tasks.TaskBoard.UnitOf(task, org) is not { } unit)
return dto;
return dto with { Board = unit, BoardPath = org.PathOf(unit) };
}
/// <summary>
/// Ein wartender Aufruf samt seiner eingefrorenen Argumente.
@@ -260,9 +260,14 @@ internal static class ApiEndpoints
// #594: Das Taskboard ist die einzige Stelle fuer geplante Arbeit. Die frueheren
// /api/jobs bearbeiteten die Alt-Konfiguration, die nach der Uebernahme nichts mehr bewirkte.
tasks.MapGet("/", async (AppHost host, CancellationToken ct, int limit = 50) =>
await host.TaskAdmin.ListAsync(limit, ct) is { } list
? Results.Ok(list.Select(t => t.ToDto()).ToList())
: Unavailable("Kein Taskboard: Den Agenten dieser Instanz fehlt ein gemeinsamer Arbeitsbereich."));
{
if (await host.TaskAdmin.ListAsync(limit, ct) is not { } list)
return Unavailable("Kein Taskboard: Den Agenten dieser Instanz fehlt ein gemeinsamer Arbeitsbereich.");
// #684: A human sees every board; each task says which one it is on.
var org = host.Organisation.Current();
return Results.Ok(list.Select(t => t.ToDto().WithBoard(t.Task, org)).ToList());
});
tasks.MapPost("/", async (AppHost host, TaskInputDto input, CancellationToken ct) =>
(await host.TaskAdmin.CreateAsync(input.ToInput(), ct)).ToResponse());
@@ -0,0 +1,186 @@
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>
/// #684: One board per unit. The bookkeeper's tasks do not show up for marketing, do not
/// hold up a marketing task as a "duplicate", and cannot be commented on from outside.
///
/// <code>
/// marketing (lead: marketing-lead)
/// └── amazon (lead: amazon-lead) · ads
/// accounting (lead: accounting-lead) · bookkeeper
/// ceo on the top level
/// </code>
/// </summary>
public sealed class UnitBoardTests : IDisposable
{
private readonly string _directory;
private readonly SqliteStorage _storage;
private readonly SqliteTaskRepository _repo;
private readonly TaskboardTool _tool = new();
private readonly List<AgentConfig> _agents =
[
new() { AgentId = "ceo" },
new() { AgentId = "marketing-lead", UnitId = "marketing" },
new() { AgentId = "amazon-lead", UnitId = "amazon" },
new() { AgentId = "ads", UnitId = "amazon" },
new() { AgentId = "accounting-lead", UnitId = "accounting" },
new() { AgentId = "bookkeeper", UnitId = "accounting" }
];
private readonly List<UnitConfig> _units =
[
new() { Id = "marketing", Name = "Marketing", LeaderAgentId = "marketing-lead" },
new() { Id = "amazon", Name = "Amazon", ParentId = "marketing", LeaderAgentId = "amazon-lead" },
new() { Id = "accounting", Name = "Accounting", LeaderAgentId = "accounting-lead" }
];
public UnitBoardTests()
{
_directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
_storage = new SqliteStorage(Path.Combine(_directory, "state.db"));
_repo = new SqliteTaskRepository(_storage);
}
public void Dispose()
{
SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); }
catch { /* cleanup is secondary */ }
}
private string TasksDir => Path.Combine(_directory, "SharedWorkspace", "tasks");
private async Task<ToolResult> Run(string agentId, object input, bool withUnits = true)
{
var context = new AgentToolContext(
agentId, "inst", new Dictionary<string, object?>(), new NullStateStore(), NullLogger.Instance,
CancellationToken.None, SharedWorkspacePath: Path.Combine(_directory, "SharedWorkspace"), Tasks: _repo)
{
Organisation = withUnits ? new OrgChart(_units, _agents) : new OrgChart([], _agents)
};
return await _tool.ExecuteAsync(JsonSerializer.SerializeToElement(input), context, CancellationToken.None);
}
private async Task<string> Create(string agentId, string title, string assignee = "@human")
{
var result = await Run(agentId, new { action = "task_create", title, assignee, force = true });
result.Success.ShouldBeTrue(result.ErrorMessage);
return result.Content.Split(' ', 4)[2]; // "Task created: <id> — …"
}
private async Task<string> List(string agentId, bool withUnits = true)
=> (await Run(agentId, new { action = "task_list" }, withUnits)).Content;
[Fact]
public async Task Each_unit_sees_its_own_board()
{
await Create("bookkeeper", "Book the invoices of September");
await Create("ads", "Raise the bid for chairs");
var marketing = await List("ads");
var accounting = await List("bookkeeper");
marketing.ShouldContain("Raise the bid");
marketing.ShouldNotContain("invoices");
accounting.ShouldContain("invoices");
accounting.ShouldNotContain("Raise the bid");
}
[Fact]
public async Task A_lead_also_sees_the_boards_of_the_units_below()
{
await Create("ads", "Raise the bid for chairs");
await Create("bookkeeper", "Book the invoices of September");
var lead = await List("marketing-lead");
lead.ShouldContain("Raise the bid", Case.Sensitive, "amazon lies below marketing");
lead.ShouldNotContain("invoices");
}
[Fact]
public async Task A_task_handed_up_stays_in_view_of_whoever_handed_it_up()
{
// amazon-lead's lead is marketing-lead — the task lands on marketing's board …
await Create("amazon-lead", "Approve the new budget", "@marketing-lead");
(await List("marketing-lead")).ShouldContain("Approve the new budget");
// … and the one who created it still sees it, although amazon's board does not hold it.
(await List("amazon-lead")).ShouldContain("Approve the new budget");
(await List("ads")).ShouldNotContain("Approve the new budget");
}
[Fact]
public async Task A_similar_task_on_another_board_is_no_duplicate()
{
await Create("bookkeeper", "Check the supplier invoice");
var result = await Run("ads", new { action = "task_create", title = "Check the supplier invoice" });
result.Success.ShouldBeTrue(result.ErrorMessage);
result.Content.ShouldNotContain("Observed again");
(await Run("bookkeeper", new { action = "task_find", title = "Check the supplier invoice" })).Content
.ShouldContain("1 open task(s)", Case.Sensitive, "and nothing was written onto the bookkeeper's task");
}
[Fact]
public async Task A_task_on_another_board_cannot_be_changed_or_commented_on()
{
var id = await Create("bookkeeper", "Book the invoices of September");
var update = await Run("ads", new { action = "task_update", id, status = "canceled" });
var comment = await Run("ads", new { action = "task_comment", id, text = "not needed" });
var unknown = await Run("ads", new { action = "task_comment", id = "t-does-not-exist", text = "x" });
update.Success.ShouldBeFalse();
comment.Success.ShouldBeFalse();
comment.ErrorMessage!.Replace(id, "X").ShouldBe(unknown.ErrorMessage!.Replace("t-does-not-exist", "X"),
"the same answer as for a task that does not exist");
(await _repo.GetAsync(id, default))!.Status.ShouldBe(TaskItemStatus.Todo);
}
[Fact]
public async Task An_open_task_moves_with_its_agent()
{
await Create("amazon-lead", "Prepare the listing", "@ads");
(await List("bookkeeper")).ShouldNotContain("Prepare the listing");
_agents.Single(a => a.AgentId == "ads").UnitId = "accounting";
(await List("bookkeeper")).ShouldContain("Prepare the listing", Case.Sensitive, "the board follows the assignee");
}
[Fact]
public async Task Without_units_there_is_one_board_for_everyone()
{
await Create("bookkeeper", "Book the invoices of September");
(await List("ads", withUnits: false)).ShouldContain("invoices");
}
[Fact]
public async Task The_creator_is_kept_in_the_file_and_survives_a_re_import()
{
var id = await Create("ads", "Raise the bid for chairs");
var file = Directory.GetFiles(TasksDir).Single();
File.ReadAllText(file).ShouldContain("created_by: ads");
// A file written by hand without the field keeps what the database knows.
File.WriteAllText(file, File.ReadAllText(file).Replace("created_by: ads\n", "").Replace("created_by: ads\r\n", ""));
await new TaskboardService(_repo, TasksDir).ImportAllAsync(default);
(await _repo.GetAsync(id, default))!.CreatedBy.ShouldBe("ads");
}
}