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