Author SHA1 Message Date
RichardandClaude Opus 5.5 920d3fed64 Lagebild, Stufe 1: Einheiten als aufklappbare Container (#711)
- TeamOverview.RollUp: je Einheit die Zustaende aller Agenten darin und
  darunter, offene Freigaben und der dringlichste Zustand (Urgency:
  haengt > Problem > Freigabe > Budget > arbeitet > frei). Gezaehlt wird in
  der Instanz, die Seite zeichnet nur.
- /api/team liefert Einheiten und je Agent Einheit und Leitung.
- team.js: Einheiten als Container, zugeklappt als Kasten mit
  Zusammenfassung ("6 agents · ⚠ 1 · ✋ 1 · ▶ 2") in der Farbe des
  dringlichsten Zustands. Kanten laufen zum sichtbaren Stellvertreter und
  werden gebuendelt (×n); was in einer zugeklappten Einheit bleibt, wird
  nicht gezeichnet. Bis 15 Agenten alles offen, darueber nur die oberste
  Ebene; der Zustand je Instanz im localStorage.
- Seitenleiste fuer Einheiten: Pfad, Ziel, Mitglieder (Leitung zuerst),
  Untereinheiten, Auf-/Zuklappen, Link zur Organisation.
- Layout: rectpacking trifft das Seitenverhaeltnis nur grob; es werden
  bis zu vier Vorgaben probiert und die zur Flaeche passendste behalten.
- Sprachschluessel en/de.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:27:33 +02:00
RichardandClaude Opus 5.5 5b8656d969 Tests: Verbindungspools nur der eigenen Datenbank leeren statt aller
Die zufaelligen roten Laeufe der vollen Suite (jedes Mal ein anderer Test,
zuletzt PromptHistoryTests mit einer reinen Funktion) hatten eine Ursache:
Zwanzig Testklassen riefen beim Aufraeumen SqliteConnection.ClearAllPools()
auf. Das ist prozessweit und nahm einem parallel laufenden Test die
Verbindung mitten im Oeffnen weg - ObjectDisposedException in ApplyPragmas.

- SqliteStorage.ReleasePooledConnections(pfad) leert nur die Pools der
  Datenbanken unter einer Datei oder einem Verzeichnis.
- BackupService oeffnet die Quelle fuer VACUUM INTO ohne Pool: eine
  einmalige Kopie soll die Datei danach nicht offen halten.
- PoolReleaseTests: die freigegebene Datenbank laesst sich loeschen, die
  andere arbeitet weiter.

Vier volle Laeufe hintereinander gruen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:27:33 +02:00
RichardandClaude Opus 5.5 f8d0ce94fe Test: Parallelitaet verschiedener Agenten ueber Ueberlappung statt Wanduhr pruefen
Verschiedene_Agenten_laufen_wirklich_parallel liess die Attrappe nur mit Text
antworten: Das langsame Werkzeug lief nie, und geprueft wurde, ob drei leere
Laeufe unter 450 ms bleiben. Das bestand auch mit gegenseitig gesperrten
Agenten und schlug unter Last fehl. Nach dem Merge von #683 fiel ein Core-Test
einmal in einem parallelen Volllauf, danach in elf Laeufen nicht mehr; dieser
war der letzte mit einer engen Wanduhr-Schwelle. Jetzt ruft jeder Agent das
Werkzeug auf, und bewiesen wird die Ueberlappung (MaxConcurrent > 1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 12:59:46 +02:00
RichardandClaude Opus 5.5 f34756f4bb Merge feat/683-einheiten: Einheiten, Adressbuch, Verwaltung, Organisationsseite (#683)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 12:54:17 +02:00
33 changed files with 821 additions and 132 deletions
+2 -1
View File
@@ -114,7 +114,8 @@ public sealed class AppHost : IAsyncDisposable
LoggerFactory.CreateLogger("ClawdDotNet.App.Tasks"));
/// <summary>#711: The team overview. Stateless, like <see cref="TaskAdmin"/>.</summary>
public TeamOverview Team => new(Engine, Staging, AuditRepository, Usage, () => Instance.Agents);
public TeamOverview Team => new(Engine, Staging, AuditRepository, Usage, () => Instance.Agents,
organisation: () => Core.Organisation.OrgChart.From(Instance));
public TelegramClientManager? TelegramClient { get; private init; }
public OpenRouterStatusService? Status { get; private init; }
+77 -6
View File
@@ -3,6 +3,7 @@ using ClawdDotNet.Core.Activity;
using ClawdDotNet.Core.Audit;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Engine;
using ClawdDotNet.Core.Organisation;
using ClawdDotNet.Core.Staging;
namespace ClawdDotNet.App.Services;
@@ -40,10 +41,33 @@ public sealed record TeamMember(
string? LastRunStatus,
DateTime? LastRunAtUtc,
string? LastRunResult,
long TypicalRunMs);
long TypicalRunMs,
string? UnitId = null,
bool IsLead = false);
/// <summary>The whole picture at one moment: every agent, and who reached whom recently.</summary>
public sealed record TeamSnapshot(IReadOnlyList<TeamMember> Members, IReadOnlyList<AgentLink> RecentLinks, DateTime AtUtc);
/// <summary>
/// One unit in the team overview (#711, stage 1), with what happens inside it rolled up —
/// its own members and those of every unit below. A collapsed container shows this, so a
/// problem three levels down is still visible from the top.
/// </summary>
public sealed record TeamUnit(
string Id,
string Name,
string? ParentId,
string Path,
string? LeaderAgentId,
string? Goal,
int Agents,
IReadOnlyDictionary<TeamState, int> States,
int PendingApprovals,
TeamState Worst);
/// <summary>The whole picture at one moment: every agent and unit, and who reached whom recently.</summary>
public sealed record TeamSnapshot(
IReadOnlyList<TeamMember> Members,
IReadOnlyList<AgentLink> RecentLinks,
DateTime AtUtc,
IReadOnlyList<TeamUnit>? Units = null);
/// <summary>
/// #711: The team overview — who is working, who reached whom, where something is stuck.
@@ -63,7 +87,8 @@ public sealed class TeamOverview(
IAuditRepository? audit,
IUsageRepository? usage,
Func<IReadOnlyList<AgentConfig>> agents,
TimeProvider? clock = null)
TimeProvider? clock = null,
Func<OrgChart>? organisation = null)
{
/// <summary>How far back the last receipt of each agent is looked for.</summary>
public static readonly TimeSpan ReceiptWindow = TimeSpan.FromDays(3);
@@ -106,6 +131,8 @@ public sealed class TeamOverview(
}
}
var org = organisation?.Invoke() ?? new OrgChart([], configured);
var members = new List<TeamMember>();
foreach (var agent in configured)
{
@@ -120,10 +147,54 @@ public sealed class TeamOverview(
var state = Classify(runs, approvals, last?.Status, typical, now);
members.Add(new TeamMember(
agent.AgentId, agent.DisplayName, agent.Model, state, runs, approvals,
last?.Status, last?.OccurredAt, last?.ResultRef, (long)typical.TotalMilliseconds));
last?.Status, last?.OccurredAt, last?.ResultRef, (long)typical.TotalMilliseconds,
org.UnitOf(agent.AgentId)?.Id, org.LedBy(agent.AgentId) is not null));
}
return new TeamSnapshot(members, links, now);
return new TeamSnapshot(members, links, now, RollUp(org, members));
}
/// <summary>
/// How much a state needs someone — the higher, the sooner. A container takes the most
/// urgent state of everything inside it, so "somewhere in here" points at the right thing.
/// </summary>
public static int Urgency(TeamState state) => state switch
{
TeamState.Stuck => 5,
TeamState.Problem => 4,
TeamState.WaitingForApproval => 3,
TeamState.BudgetExhausted => 2,
TeamState.Working => 1,
_ => 0
};
/// <summary>
/// Every unit with the states of all agents in it and below. Pure, like
/// <see cref="Classify"/>: the page draws the numbers, it does not add them up.
/// </summary>
public static IReadOnlyList<TeamUnit> RollUp(OrgChart org, IReadOnlyList<TeamMember> members)
{
var result = new List<TeamUnit>();
foreach (var unit in org.Units.OrderBy(u => org.PathOf(u.Id), StringComparer.OrdinalIgnoreCase))
{
// Everyone whose unit is this one or lies below it. Ancestors() stops at a
// cycle, so a broken tree cannot hang the overview.
var inside = members
.Where(m => m.UnitId is { } id
&& (string.Equals(id, unit.Id, StringComparison.OrdinalIgnoreCase)
|| org.Ancestors(id).Any(a => string.Equals(a.Id, unit.Id, StringComparison.OrdinalIgnoreCase))))
.ToList();
var states = inside
.GroupBy(m => m.State)
.ToDictionary(g => g.Key, g => g.Count());
var worst = inside.Select(m => m.State).DefaultIfEmpty(TeamState.Idle).MaxBy(Urgency);
result.Add(new TeamUnit(
unit.Id, unit.Name, unit.ParentId, org.PathOf(unit.Id), org.LeaderOf(unit.Id)?.AgentId, unit.Goal,
inside.Count, states, inside.Sum(m => m.PendingApprovals), worst));
}
return result;
}
/// <summary>
+4 -1
View File
@@ -157,7 +157,10 @@ public sealed class BackupService
var connectionString = new SqliteConnectionStringBuilder
{
DataSource = sourcePath,
Mode = SqliteOpenMode.ReadOnly
Mode = SqliteOpenMode.ReadOnly,
// Eine einmalige Kopie: Aus dem Pool heraus hielte die Verbindung die Datei
// danach offen, und unter Windows ließe sie sich nicht mehr ersetzen oder löschen.
Pooling = false
}.ToString();
using var conn = new SqliteConnection(connectionString);
@@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using Microsoft.Data.Sqlite;
namespace ClawdDotNet.Core.Storage;
@@ -19,6 +20,9 @@ public sealed class SqliteStorage
private readonly string _connectionString;
private readonly SemaphoreSlim _writeGate = new(1, 1);
/// <summary>Every database opened in this process: full path → connection string.</summary>
private static readonly ConcurrentDictionary<string, string> OpenedDatabases = new(StringComparer.OrdinalIgnoreCase);
public string DatabasePath { get; }
public SqliteStorage(string databasePath)
@@ -37,6 +41,7 @@ public sealed class SqliteStorage
Pooling = true,
DefaultTimeout = 30
}.ToString();
OpenedDatabases[Path.GetFullPath(databasePath)] = _connectionString;
Initialize();
@@ -51,6 +56,33 @@ public sealed class SqliteStorage
}
}
/// <summary>
/// Closes the pooled connections of the databases at <paramref name="path"/> — a
/// database file, or a directory and everything below it — so the files can be deleted
/// or replaced.
///
/// <para>Not <see cref="SqliteConnection.ClearAllPools"/>: that is process-wide and also
/// takes the connections of every other database in use. The tests used it to clean up
/// their temporary databases, and a test running in parallel then lost its connection
/// while opening it — an ObjectDisposedException in <c>ApplyPragmas</c>, one in every
/// few full runs, always in a different test.</para>
/// </summary>
public static void ReleasePooledConnections(string path)
{
var full = Path.GetFullPath(path);
var below = full.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar;
foreach (var (database, connectionString) in OpenedDatabases)
{
if (!string.Equals(database, full, StringComparison.OrdinalIgnoreCase)
&& !database.StartsWith(below, StringComparison.OrdinalIgnoreCase))
continue;
using var connection = new SqliteConnection(connectionString);
SqliteConnection.ClearPool(connection);
}
}
public SqliteConnection OpenConnection()
{
var connection = new SqliteConnection(_connectionString);
@@ -1791,6 +1791,12 @@ td.klein {
gap: 16px;
}
.team-eintrag {
text-align: left;
justify-content: flex-start;
width: 100%;
}
@media (max-width: 800px) {
.team-flaeche { flex-direction: column; }
.team-seitenleiste { width: auto; }
@@ -128,7 +128,7 @@
"org.toolsLostConfirm": "Werkzeuge entfernen und übernehmen",
"org.runningRefusal": "Nicht, solange {agents} läuft — ein Agent darf nicht mitten im Lauf andere Rechte bekommen.",
"team.title": "Teamübersicht",
"team.subtitle": "Wer arbeitet, wer hat in den letzten Sekunden wen erreicht, und wo klemmt es. Ein Klick auf einen Agenten zeigt Einzelheiten.",
"team.subtitle": "Wer arbeitet, wer hat wen in den letzten Sekunden erreicht und wo klemmt es. Einheiten zeigen, was in ihnen passiert; ein Klick öffnet sie oder zeigt Einzelheiten.",
"team.noInstance": "Keine Instanz ausgewählt. Oben eine aktive Instanz wählen.",
"team.empty": "Diese Instanz hat keine Agenten.",
"team.legendLink": "hat erreicht (verblasst nach 30 s)",
@@ -148,6 +148,17 @@
"team.openApprovals": "Freigaben öffnen",
"team.openChat": "Chat öffnen",
"team.openRuns": "Läufe anzeigen",
"team.unitAgentsOne": "1 Agent",
"team.unitAgents": "{n} Agenten",
"team.legendLead": "Leitung der Einheit",
"team.legendUnit": "Einheit, Klick öffnet sie",
"team.leads": "Leitet",
"team.memberOf": "Mitglied von",
"team.members": "Mitglieder",
"team.subUnits": "Untereinheiten",
"team.collapse": "Einheit zuklappen",
"team.expand": "Einheit aufklappen",
"team.openOrganisation": "Organisation bearbeiten",
"nav.services": "Dienste",
"nav.group.analytics": "Analyse",
"nav.usage": "Tokennutzung",
@@ -128,7 +128,7 @@
"org.toolsLostConfirm": "Remove the tools and apply",
"org.runningRefusal": "Not while {agents} is running — an agent must not get different rights in the middle of a run.",
"team.title": "Team overview",
"team.subtitle": "Who is working, who reached whom in the last seconds, and where something is stuck. Click an agent for details.",
"team.subtitle": "Who is working, who reached whom in the last seconds, and where something is stuck. Units show what happens inside them; click to open them or to see details.",
"team.noInstance": "No instance selected. Choose an active instance in the header.",
"team.empty": "No agents in this instance.",
"team.legendLink": "reached (fades after 30 s)",
@@ -148,6 +148,17 @@
"team.openApprovals": "Open approvals",
"team.openChat": "Open chat",
"team.openRuns": "Show runs",
"team.unitAgentsOne": "1 agent",
"team.unitAgents": "{n} agents",
"team.legendLead": "lead of the unit",
"team.legendUnit": "unit, click to open",
"team.leads": "Leads",
"team.memberOf": "Member of",
"team.members": "Members",
"team.subUnits": "Sub-units",
"team.collapse": "Collapse unit",
"team.expand": "Expand unit",
"team.openOrganisation": "Edit organisation",
"nav.services": "Services",
"nav.group.analytics": "Analytics",
"nav.usage": "Token Usage",
+456 -76
View File
@@ -1,12 +1,14 @@
// ClawdDotNet — Team overview (#711)
//
// Who is working, who reached whom in the last seconds, and where something is stuck — at a
// glance, like looking across an office. Stage 0: the agents of the active instance and the
// live links between them. With units (#683) they become collapsible containers.
// glance, like looking across an office. Stage 0 drew the agents and the live links between
// them. Stage 1 (with #683) draws units as collapsible containers: a collapsed unit shows
// what happens inside it, so a problem three levels down is still visible from the top.
//
// Three rules from the concept (Organisation-Konzept, 10a):
// - The layout comes from the structure, not from the hand: no free node editor.
// - The state is decided by the instance (code), the page only draws it.
// - The state is decided by the instance (code), the page only draws it. That includes the
// rolled-up counts of a unit — the page does not add them up.
// - A click opens a side panel, not the chat — most problems are solved by an approval.
import { api, holeAktiveInstanz } from "../modules/api.js";
@@ -27,6 +29,15 @@ const LINK_VISIBLE_MS = 30_000;
/** How often the picture is refreshed without an event — for "stuck" and approvals. */
const REFRESH_MS = 30_000;
/** Up to this many agents, every unit starts expanded; above it, only the top level shows. */
const EXPAND_ALL_UP_TO = 15;
/** Unit node ids are prefixed so a unit and an agent may share a name. */
const UNIT_PREFIX = "unit:";
/** Parent chains longer than this are a cycle — the instance reports it, the page must not hang. */
const MAX_DEPTH = 64;
// Every state has a colour AND a symbol: colour alone is not enough.
const STATES = {
Idle: { symbol: "○", tone: "neutral", colour: "--gedaempft" },
@@ -40,7 +51,11 @@ const STATES = {
let librariesLoaded = null;
let cy = null;
let snapshot = null;
let selectedAgent = null;
let unitsById = new Map();
let membersById = new Map();
let expanded = null;
let structureKey = "";
let selected = null;
let panel = null;
let summary = null;
let unsubscribe = null;
@@ -49,6 +64,9 @@ let fadeTimer = null;
let refreshPending = null;
let resizeObserver = null;
/** Links as observed, agent to agent: "from->to" → { from, to, seenAt, count }. */
const links = new Map();
/** Width the last layout was computed for — 0 means "not yet visible", see observeSize. */
let layoutWidth = 0;
@@ -114,11 +132,14 @@ export async function renderTeam() {
}
canvas.replaceChildren();
indexSnapshot();
expanded = loadExpanded();
for (const link of snapshot.links ?? []) recordLink(link);
drawGraph(canvas);
unsubscribe = abonnieren("activity", onActivity);
refreshTimer = setInterval(refresh, REFRESH_MS);
fadeTimer = setInterval(fadeLinks, 1_000);
fadeTimer = setInterval(drawLinks, 1_000);
}
/** Called when the route changes: stop listening, stop timers, free the graph. */
@@ -133,7 +154,84 @@ export function leaveTeam() {
resizeObserver = null;
cy?.destroy();
cy = null;
selectedAgent = null;
selected = null;
structureKey = "";
layoutWidth = 0;
links.clear();
}
// ─── The tree ───
function indexSnapshot() {
unitsById = new Map((snapshot.units ?? []).map((u) => [u.id, u]));
membersById = new Map(snapshot.members.map((m) => [m.agentId, m]));
}
/** The unit and every unit above it, nearest first; stops at a cycle. */
function chain(unitId) {
const result = [];
for (let unit = unitsById.get(unitId); unit && result.length < MAX_DEPTH; unit = unitsById.get(unit.parentId)) {
if (result.includes(unit)) break;
result.push(unit);
}
return result;
}
/** Whether the unit and everything above it are expanded — then its content is drawn. */
function isOpen(unitId) {
return chain(unitId).every((u) => expanded.has(u.id));
}
/** Whether the unit itself is drawn, as a box or as a container. */
function isVisible(unit) {
return !unit.parentId || !unitsById.has(unit.parentId) || isOpen(unit.parentId);
}
/**
* Where an agent is drawn: itself if its unit is open, otherwise the topmost collapsed unit
* on its path. Links are drawn between these, so a collapsed unit collects the links of
* everyone inside it.
*/
function representative(agentId) {
const member = membersById.get(agentId);
if (!member) return null;
let rep = null;
for (const unit of chain(member.unitId)) {
if (!expanded.has(unit.id)) rep = unit.id;
}
return rep ? UNIT_PREFIX + rep : agentId;
}
function storageKey() {
return `clawd.team.expanded.${holeAktiveInstanz()}`;
}
function loadExpanded() {
try {
const stored = localStorage.getItem(storageKey());
if (stored) return new Set(JSON.parse(stored));
} catch {
// Private window or blocked storage: fall back to the default below.
}
// A small team is shown whole; a large one starts at the departments.
return snapshot.members.length <= EXPAND_ALL_UP_TO
? new Set(unitsById.keys())
: new Set();
}
function saveExpanded() {
try {
localStorage.setItem(storageKey(), JSON.stringify([...expanded]));
} catch {
// Only a convenience — the default applies next time.
}
}
function toggleUnit(unitId, open) {
if (open ?? !expanded.has(unitId)) expanded.add(unitId);
else expanded.delete(unitId);
saveExpanded();
applySnapshot();
}
// ─── Graph ───
@@ -143,8 +241,9 @@ function drawGraph(canvas) {
container: canvas,
elements: [],
wheelSensitivity: 0.2,
minZoom: 0.3,
maxZoom: 2.5,
minZoom: 0.2,
// A handful of collapsed units would otherwise fill the area with giant boxes.
maxZoom: 1.6,
// Nodes are not dragged: the layout comes from the structure, not the hand.
autoungrabify: true,
style: [
@@ -168,26 +267,74 @@ function drawGraph(canvas) {
}
},
{ selector: "node.aktiv", style: { "border-width": 4 } },
{ selector: "node.leitung", style: { "font-weight": "bold" } },
// A collapsed unit: a box that stands for everyone inside it.
{
selector: "node.einheit",
style: {
shape: "round-rectangle",
width: 210,
height: 70,
"border-width": 3,
"border-style": "double",
"background-color": token("--surface-subtil"),
"font-weight": "bold"
}
},
{ selector: "node.einheit.aktiv", style: { "border-width": 5 } },
// An expanded unit: a container around its members and sub-units.
{
selector: "node:parent",
style: {
shape: "round-rectangle",
padding: 20,
"border-width": 2,
"border-style": "dashed",
"background-color": token("--surface-subtil"),
"background-opacity": 0.5,
"text-valign": "top",
"text-halign": "center",
"text-margin-y": -4,
"text-max-width": 400,
"font-weight": "bold"
}
},
{ selector: "node:selected", style: { "overlay-color": token("--accent"), "overlay-opacity": 0.15 } },
{
selector: "edge",
style: {
width: 3,
width: "data(width)",
"line-color": token("--accent"),
"target-arrow-color": token("--accent"),
"target-arrow-shape": "triangle",
"curve-style": "bezier",
opacity: "data(opacity)"
opacity: "data(opacity)",
label: "data(label)",
"font-size": 11,
color: token("--text"),
"text-background-color": token("--surface"),
"text-background-opacity": 1,
"text-background-padding": 2
}
}
]
});
cy.on("tap", "node", (event) => openPanel(event.target.id()));
cy.on("tap", "node", (event) => {
const id = event.target.id();
if (!id.startsWith(UNIT_PREFIX)) {
openPanel({ agent: id });
return;
}
const unitId = id.slice(UNIT_PREFIX.length);
// A collapsed unit opens on the first click — looking inside is what the click is for.
if (!expanded.has(unitId)) toggleUnit(unitId, true);
openPanel({ unit: unitId });
});
cy.on("dbltap", "node:parent", (event) => toggleUnit(event.target.id().slice(UNIT_PREFIX.length), false));
cy.on("tap", (event) => { if (event.target === cy) closePanel(); });
applySnapshot(true);
for (const link of snapshot.links ?? []) addLink(link);
applySnapshot();
observeSize(canvas);
}
@@ -207,58 +354,164 @@ function observeSize(canvas) {
resizeObserver.observe(canvas);
}
function runLayout() {
const area = cy.container();
layoutWidth = area.clientWidth;
/** Counts layouts, so a layout overtaken by a newer one stops trying. */
let layoutGeneration = 0;
/** Runs one ELK layout with the given aspect ratio and returns the ratio it achieved. */
function layoutOnce(aspectRatio) {
return new Promise((resolve) => {
const layout = cy.layout({
name: "elk",
fit: false,
// Stage 0 has no structure yet, so the agents are packed evenly into the shape of
// the area. rectpacking keeps their order, so a box does not jump around when a
// state changes. With units (#683) this becomes a layered layout of nested containers.
// The agents are packed evenly into the shape of the area, inside each container as
// on the top level. rectpacking keeps the order they were added in (units by path,
// the lead first), so a box does not jump around when a state changes.
elk: {
algorithm: "rectpacking",
"elk.spacing.nodeNode": 40,
"elk.aspectRatio": area.clientWidth > 0 && area.clientHeight > 0
? Math.max(0.5, area.clientWidth / area.clientHeight)
: 2
"elk.aspectRatio": aspectRatio
},
// Room for the container's own padding and its label above the members.
nodeLayoutOptions: (node) => node.isParent()
? {
"elk.algorithm": "rectpacking",
"elk.padding": "[top=44,left=24,bottom=24,right=24]",
"elk.spacing.nodeNode": 24
}
: {}
});
// ELK computes asynchronously; measure once the positions are there.
layout.one("layoutstop", () => {
const box = cy?.elements().boundingBox();
resolve(box && box.h > 0 ? box.w / box.h : 1);
});
// ELK computes asynchronously; fit once the positions are there.
layout.one("layoutstop", () => cy?.fit(undefined, 30));
layout.run();
});
}
function nodeLabel(member) {
/**
* Lays the graph out in the shape of the area. rectpacking treats the aspect ratio as a
* hint and, with a few large containers on the top level, jumps between a handful of
* shapes — asked for 1.4 it may deliver 0.6 or 2.6. So a few hints are tried and the
* result closest to the area is kept; with the fit afterwards, that is the one that shows
* the boxes largest.
*/
async function runLayout() {
const area = cy.container();
layoutWidth = area.clientWidth;
const target = area.clientWidth > 0 && area.clientHeight > 0
? Math.max(0.5, area.clientWidth / area.clientHeight)
: 2;
const generation = ++layoutGeneration;
const miss = (ratio) => Math.abs(Math.log(ratio / target));
let best = null;
for (const hint of [target, target * 1.2, target * 1.45, target * 0.8]) {
const ratio = await layoutOnce(hint);
if (!cy || generation !== layoutGeneration) return;
if (!best || miss(ratio) < best.miss) best = { hint, miss: miss(ratio), last: true };
else best.last = false;
if (best.miss < 0.15) break;
}
// The best one may not be the last one run.
if (!best.last) await layoutOnce(best.hint);
if (cy && generation === layoutGeneration) cy.fit(undefined, 30);
}
function agentLabel(member) {
const state = STATES[member.state] ?? STATES.Idle;
const runs = member.runs.length > 1 ? ` ×${member.runs.length}` : "";
return `${state.symbol} ${member.displayName || member.agentId}${runs}\n${t(`team.state.${member.state}`)}`;
const lead = member.isLead ? "★ " : "";
return `${state.symbol} ${lead}${member.displayName || member.agentId}${runs}\n${t(`team.state.${member.state}`)}`;
}
function applySnapshot(relayout) {
/** "5 agents · ▶ 2 · ⚠ 1" — everything that is not idle, most urgent first. */
function unitCounts(unit) {
const parts = [unit.agents === 1 ? t("team.unitAgentsOne") : t("team.unitAgents", { n: unit.agents })];
for (const state of ["Stuck", "Problem", "WaitingForApproval", "BudgetExhausted", "Working"]) {
const n = unit.states?.[state];
if (n) parts.push(`${STATES[state].symbol} ${n}`);
}
return parts.join(" · ");
}
function unitLabel(unit, open) {
return open
? `▾ ${unit.name} · ${unitCounts(unit)}`
: `▸ ${unit.name}\n${unitCounts(unit)}`;
}
/**
* The nodes as they should be now, parents before children and in a stable order: agents
* without a unit, then the units by path, each with its lead first.
*/
function desiredNodes() {
const nodes = [];
const byName = (a, b) => (a.displayName || a.agentId).localeCompare(b.displayName || b.agentId);
const colour = (state) => token((STATES[state] ?? STATES.Idle).colour);
for (const member of snapshot.members.filter((m) => !m.unitId || !unitsById.has(m.unitId)).sort(byName)) {
nodes.push({ id: member.agentId, member, colour: colour(member.state) });
}
for (const unit of snapshot.units ?? []) {
if (!isVisible(unit)) continue;
const open = expanded.has(unit.id);
const parent = unit.parentId && unitsById.has(unit.parentId) ? UNIT_PREFIX + unit.parentId : undefined;
nodes.push({ id: UNIT_PREFIX + unit.id, parent, unit, open, colour: colour(unit.worst) });
if (!open) continue;
const members = snapshot.members
.filter((m) => m.unitId === unit.id)
.sort((a, b) => (b.isLead - a.isLead) || byName(a, b));
for (const member of members) {
nodes.push({ id: member.agentId, parent: UNIT_PREFIX + unit.id, member, colour: colour(member.state) });
}
}
return nodes;
}
/**
* Brings the graph up to date. When only states changed, the nodes are updated in place and
* nothing moves. When the structure changed — a unit opened or closed, an agent moved — the
* nodes are rebuilt and laid out again: Cytoscape cannot turn a box into a container.
*/
function applySnapshot() {
if (!cy || !snapshot) return;
const present = new Set();
for (const member of snapshot.members) {
present.add(member.agentId);
const state = STATES[member.state] ?? STATES.Idle;
const data = { id: member.agentId, label: nodeLabel(member), colour: token(state.colour) };
const node = cy.getElementById(member.agentId);
if (node.nonempty()) {
node.data(data);
const nodes = desiredNodes();
const key = nodes.map((n) => `${n.id}<${n.parent ?? ""}${n.open ? "+" : ""}`).join("|");
const data = (n) => ({
id: n.id,
parent: n.parent,
colour: n.colour,
label: n.unit ? unitLabel(n.unit, n.open) : agentLabel(n.member)
});
if (key !== structureKey) {
structureKey = key;
cy.elements().remove();
cy.add(nodes.map((n) => ({ group: "nodes", data: data(n) })));
for (const n of nodes) decorate(n);
drawLinks();
runLayout();
} else {
cy.add({ group: "nodes", data });
relayout = true;
for (const n of nodes) {
const { parent: _parent, ...rest } = data(n);
cy.getElementById(n.id).data(rest);
decorate(n);
}
cy.getElementById(member.agentId).toggleClass("aktiv", member.runs.length > 0);
}
cy.nodes().filter((n) => !present.has(n.id())).remove();
if (relayout) runLayout();
renderSummary();
if (selectedAgent) openPanel(selectedAgent);
if (selected) openPanel(selected);
}
function decorate(n) {
const node = cy.getElementById(n.id);
node.toggleClass("einheit", Boolean(n.unit) && !n.open);
node.toggleClass("aktiv", Boolean(n.member?.runs.length) || Boolean(n.unit && !n.open && n.unit.states?.Working));
node.toggleClass("leitung", Boolean(n.member?.isLead));
}
// ─── Live ───
@@ -271,7 +524,10 @@ function forActiveInstance(data) {
function onActivity(data) {
if (!cy || !forActiveInstance(data)) return;
if (data.kind === "link" && data.link) addLink(data.link);
if (data.kind === "link" && data.link) {
recordLink(data.link);
drawLinks();
}
// Start and end change states; ask the instance instead of guessing here.
clearTimeout(refreshPending);
@@ -282,34 +538,65 @@ async function refresh() {
if (!cy) return;
try {
snapshot = await api("/team");
applySnapshot(false);
indexSnapshot();
applySnapshot();
} catch {
// The next event or tick tries again; a failed refresh keeps the last picture.
}
}
function addLink(link) {
if (!cy) return;
const age = Date.now() - new Date(link.atUtc).getTime();
if (age > LINK_VISIBLE_MS) return;
if (cy.getElementById(link.from).empty() || cy.getElementById(link.to).empty()) return;
// One edge per pair; a new contact renews it.
const id = `link:${link.from}->${link.to}`;
const existing = cy.getElementById(id);
const data = { id, source: link.from, target: link.to, seenAt: new Date(link.atUtc).getTime(), opacity: 1 };
if (existing.nonempty()) existing.data(data);
else cy.add({ group: "edges", data });
function recordLink(link) {
const seenAt = new Date(link.atUtc).getTime();
if (Date.now() - seenAt > LINK_VISIBLE_MS) return;
const key = `${link.from}->${link.to}`;
const known = links.get(key);
// A new contact renews the pair and counts; the snapshot may repeat one already seen.
if (known && known.seenAt >= seenAt) return;
links.set(key, { from: link.from, to: link.to, seenAt, count: (known?.count ?? 0) + 1 });
}
function fadeLinks() {
/**
* The edges between what is drawn: links of agents inside a collapsed unit start or end at
* that unit, and several such links become one edge with a counter. A link that stays
* inside one collapsed unit is not drawn — it is the unit talking to itself.
*/
function drawLinks() {
if (!cy) return;
const now = Date.now();
cy.edges().forEach((edge) => {
const age = now - edge.data("seenAt");
if (age > LINK_VISIBLE_MS) edge.remove();
else edge.data("opacity", Math.max(0.15, 1 - age / LINK_VISIBLE_MS));
});
const bundles = new Map();
for (const [key, link] of links) {
const age = now - link.seenAt;
if (age > LINK_VISIBLE_MS) {
links.delete(key);
continue;
}
const from = representative(link.from);
const to = representative(link.to);
if (!from || !to || from === to) continue;
if (cy.getElementById(from).empty() || cy.getElementById(to).empty()) continue;
const id = `link:${from}->${to}`;
const bundle = bundles.get(id) ?? { id, source: from, target: to, seenAt: 0, count: 0 };
bundle.seenAt = Math.max(bundle.seenAt, link.seenAt);
bundle.count += link.count;
bundles.set(id, bundle);
}
cy.edges().filter((edge) => !bundles.has(edge.id())).remove();
for (const bundle of bundles.values()) {
const data = {
id: bundle.id,
source: bundle.source,
target: bundle.target,
opacity: Math.max(0.15, 1 - (now - bundle.seenAt) / LINK_VISIBLE_MS),
width: Math.min(8, 2 + bundle.count),
label: bundle.count > 1 ? `×${bundle.count}` : ""
};
const edge = cy.getElementById(bundle.id);
if (edge.nonempty()) edge.data(data);
else cy.add({ group: "edges", data });
}
}
// ─── Summary and legend ───
@@ -334,32 +621,56 @@ function buildLegend() {
{ klasse: "team-legende gruppe-horizontal gedaempft text-klein" },
...Object.entries(STATES).map(([state, s]) =>
erstelleElement("span", { text: `${s.symbol} ${t(`team.state.${state}`)}` })),
erstelleElement("span", { text: `★ ${t("team.legendLead")}` }),
erstelleElement("span", { text: `▸ ${t("team.legendUnit")}` }),
erstelleElement("span", { text: `→ ${t("team.legendLink")}` })
);
}
// ─── Side panel ───
function openPanel(agentId) {
const member = snapshot?.members.find((m) => m.agentId === agentId);
if (!member || !panel) return;
selectedAgent = agentId;
cy?.getElementById(agentId).select();
function openPanel(target) {
if (!panel || !snapshot) return;
const rows = target.unit ? unitRows(target.unit) : agentRows(target.agent);
if (!rows) return;
const state = STATES[member.state] ?? STATES.Idle;
const rows = [
erstelleElement("div", { klasse: "gruppe-horizontal team-seitenleiste-kopf" },
erstelleElement("h2", { text: member.displayName || member.agentId }),
selected = target;
cy?.elements().unselect();
cy?.getElementById(target.unit ? UNIT_PREFIX + target.unit : target.agent).select();
panel.replaceChildren(...rows);
panel.hidden = false;
}
function panelHead(title) {
return erstelleElement("div", { klasse: "gruppe-horizontal team-seitenleiste-kopf" },
erstelleElement("h2", { text: title }),
erstelleElement("button", {
klasse: "schaltflaeche flach klein",
text: "×",
title: t("common.close"),
onclick: closePanel
})),
}));
}
function agentRows(agentId) {
const member = membersById.get(agentId);
if (!member) return null;
const state = STATES[member.state] ?? STATES.Idle;
const unit = unitsById.get(member.unitId);
const rows = [
panelHead(member.displayName || member.agentId),
erstelleElement("p", { klasse: "gedaempft text-klein", text: `${member.agentId} · ${member.model}` }),
erstellePlakette(`${state.symbol} ${t(`team.state.${member.state}`)}`, state.tone)
];
if (unit) {
rows.push(erstelleElement("p", { klasse: "text-klein" },
`${member.isLead ? `★ ${t("team.leads")} ` : `${t("team.memberOf")} `}`,
unitButton(unit)));
}
// Top layer: what is happening now.
if (member.runs.length) {
rows.push(erstelleElement("h3", { text: t("team.running") }));
@@ -408,9 +719,78 @@ function openPanel(agentId) {
link(`#/verbrauch/runs?agent=${encodeURIComponent(member.agentId)}`, t("team.openRuns"))
);
rows.push(actions);
return rows;
}
panel.replaceChildren(...rows);
panel.hidden = false;
function unitRows(unitId) {
const unit = unitsById.get(unitId);
if (!unit) return null;
const worst = STATES[unit.worst] ?? STATES.Idle;
const rows = [
panelHead(unit.name),
erstelleElement("p", { klasse: "gedaempft text-klein", text: unit.path }),
erstellePlakette(`${worst.symbol} ${unitCounts(unit)}`, worst.tone)
];
if (unit.goal) rows.push(erstelleElement("p", { klasse: "text-klein", text: unit.goal }));
if (unit.pendingApprovals > 0) {
rows.push(erstelleElement("p", { klasse: "text-klein", text: `✋ ${t("team.approvals", { n: unit.pendingApprovals })}` }));
}
// Direct members, the lead first; what is further down is one click on a sub-unit away.
rows.push(erstelleElement("h3", { text: t("team.members") }));
const members = snapshot.members
.filter((m) => m.unitId === unit.id)
.sort((a, b) => (b.isLead - a.isLead) || (a.displayName || a.agentId).localeCompare(b.displayName || b.agentId));
if (!members.length) rows.push(erstelleElement("p", { klasse: "gedaempft text-klein", text: "—" }));
for (const member of members) {
const state = STATES[member.state] ?? STATES.Idle;
rows.push(erstelleElement("button", {
type: "button",
klasse: "flach klein team-eintrag",
text: `${state.symbol} ${member.isLead ? "★ " : ""}${member.displayName || member.agentId} — ${t(`team.state.${member.state}`)}`,
onclick: () => openPanel({ agent: member.agentId })
}));
}
const children = (snapshot.units ?? []).filter((u) => u.parentId === unit.id);
if (children.length) {
rows.push(erstelleElement("h3", { text: t("team.subUnits") }));
for (const child of children) rows.push(erstelleElement("div", {}, unitButton(child)));
}
const actions = erstelleElement("div", { klasse: "gruppe-vertikal team-aktionen" });
const open = expanded.has(unit.id);
actions.append(
erstelleElement("button", {
type: "button",
klasse: "primaer klein",
text: open ? t("team.collapse") : t("team.expand"),
onclick: () => toggleUnit(unit.id, !open)
}),
link("#/organisation", t("team.openOrganisation"))
);
rows.push(actions);
return rows;
}
/** A unit as a link in the panel, with its most urgent state. */
function unitButton(unit) {
const worst = STATES[unit.worst] ?? STATES.Idle;
return erstelleElement("button", {
type: "button",
klasse: "flach klein team-eintrag",
text: `${worst.symbol} ${unit.name} · ${unitCounts(unit)}`,
onclick: () => {
// Show it in the graph too: open everything above it.
for (const above of chain(unit.parentId)) expanded.add(above.id);
saveExpanded();
applySnapshot();
openPanel({ unit: unit.id });
}
});
}
function link(href, text, style = "flach") {
@@ -423,7 +803,7 @@ function link(href, text, style = "flach") {
}
function closePanel() {
selectedAgent = null;
selected = null;
cy?.elements().unselect();
if (panel) panel.hidden = true;
}
+26 -3
View File
@@ -125,8 +125,29 @@ public sealed record TaskDto(
/// </summary>
public sealed record TaskBudgetMissDto(int Count, DateTime? LastAt, DateTime? DeferredUntil, bool CatchUpPending, bool CarriedOver);
/// <summary>#711: The team overview — every agent and who reached whom recently.</summary>
public sealed record TeamSnapshotDto(IReadOnlyList<TeamMemberDto> Members, IReadOnlyList<AgentLinkDto> Links, DateTime AtUtc);
/// <summary>#711: The team overview — every agent and unit, and who reached whom recently.</summary>
public sealed record TeamSnapshotDto(
IReadOnlyList<TeamMemberDto> Members,
IReadOnlyList<AgentLinkDto> Links,
DateTime AtUtc,
IReadOnlyList<TeamUnitDto> Units);
/// <summary>
/// #711, stage 1: A unit with the states of everyone in it and below, counted by the
/// instance. <see cref="States"/> maps a state name to a count; <see cref="Worst"/> is the
/// most urgent state inside.
/// </summary>
public sealed record TeamUnitDto(
string Id,
string Name,
string? ParentId,
string Path,
string? LeaderAgentId,
string? Goal,
int Agents,
IReadOnlyDictionary<string, int> States,
int PendingApprovals,
string Worst);
/// <summary>
/// #711: One agent in the team overview. <see cref="State"/> is one of Idle, Working, Stuck,
@@ -142,7 +163,9 @@ public sealed record TeamMemberDto(
string? LastRunStatus,
DateTime? LastRunAtUtc,
string? LastRunResult,
long TypicalRunMs);
long TypicalRunMs,
string? UnitId,
bool IsLead);
/// <summary>#711: A run in progress.</summary>
public sealed record ActiveRunDto(string RunId, string AgentId, string Source, string? TaskId, string? CalledBy, DateTime StartedAtUtc);
+7 -2
View File
@@ -74,9 +74,14 @@ internal static class Mapping
snapshot.Members.Select(m => new TeamMemberDto(
m.AgentId, m.DisplayName, m.Model, m.State.ToString(),
m.Runs.Select(ToDto).ToList(),
m.PendingApprovals, m.LastRunStatus, m.LastRunAtUtc, m.LastRunResult, m.TypicalRunMs)).ToList(),
m.PendingApprovals, m.LastRunStatus, m.LastRunAtUtc, m.LastRunResult, m.TypicalRunMs,
m.UnitId, m.IsLead)).ToList(),
snapshot.RecentLinks.Select(ToDto).ToList(),
snapshot.AtUtc);
snapshot.AtUtc,
(snapshot.Units ?? []).Select(u => new TeamUnitDto(
u.Id, u.Name, u.ParentId, u.Path, u.LeaderAgentId, u.Goal, u.Agents,
u.States.ToDictionary(s => s.Key.ToString(), s => s.Value),
u.PendingApprovals, u.Worst.ToString())).ToList());
public static ActiveRunDto ToDto(this Core.Activity.ActiveRun run)
=> new(run.RunId, run.AgentId, run.Source, run.TaskId, run.CalledBy, run.StartedAtUtc);
@@ -28,7 +28,7 @@ public sealed class TaskAdministrationTests : IDisposable
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_dir);
try { Directory.Delete(_dir, recursive: true); }
catch { /* Aufraeumen ist Nebensache */ }
}
@@ -1,5 +1,7 @@
using ClawdDotNet.App.Services;
using ClawdDotNet.Core.Activity;
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Organisation;
using Shouldly;
namespace ClawdDotNet.App.Tests.Services;
@@ -79,4 +81,85 @@ public sealed class TeamOverviewTests
public void A_cancelled_run_is_not_a_problem()
// Cancelled means a human stopped it — that was a decision, not a fault.
=> TeamOverview.Classify([], 0, "Cancelled", TimeSpan.Zero, Now).ShouldBe(TeamState.Idle);
// ═══════════════════ stage 1: units roll up ═══════════════════
/// <summary>marketing (lead) › amazon (lead, ads) ; accounting (bookkeeper) ; ceo at the top.</summary>
private static OrgChart Shop() => new(
[
new UnitConfig { Id = "marketing", Name = "Marketing", LeaderAgentId = "marketing-lead" },
new UnitConfig { Id = "amazon", Name = "Amazon", ParentId = "marketing", LeaderAgentId = "amazon-lead" },
new UnitConfig { Id = "accounting", Name = "Accounting" }
],
[
new AgentConfig { AgentId = "ceo" },
new AgentConfig { AgentId = "marketing-lead", UnitId = "marketing" },
new AgentConfig { AgentId = "amazon-lead", UnitId = "amazon" },
new AgentConfig { AgentId = "ads", UnitId = "amazon" },
new AgentConfig { AgentId = "bookkeeper", UnitId = "accounting" }
]);
private static TeamMember Member(string id, string? unit, TeamState state, int approvals = 0)
=> new(id, id, "m", state, [], approvals, null, null, null, 0, unit);
[Fact]
public void A_problem_deep_down_shows_on_every_container_above_it()
{
var units = TeamOverview.RollUp(Shop(),
[
Member("ceo", null, TeamState.Working),
Member("marketing-lead", "marketing", TeamState.Working),
Member("amazon-lead", "amazon", TeamState.Idle),
Member("ads", "amazon", TeamState.Problem),
Member("bookkeeper", "accounting", TeamState.Idle)
]);
var marketing = units.Single(u => u.Id == "marketing");
marketing.Agents.ShouldBe(3, "its own lead and the two in amazon below");
marketing.Worst.ShouldBe(TeamState.Problem);
marketing.States[TeamState.Working].ShouldBe(1);
marketing.States[TeamState.Problem].ShouldBe(1);
units.Single(u => u.Id == "amazon").Worst.ShouldBe(TeamState.Problem);
units.Single(u => u.Id == "accounting").Worst.ShouldBe(TeamState.Idle);
}
[Fact]
public void Open_approvals_are_added_up_along_the_tree()
{
var units = TeamOverview.RollUp(Shop(),
[
Member("marketing-lead", "marketing", TeamState.WaitingForApproval, approvals: 1),
Member("ads", "amazon", TeamState.WaitingForApproval, approvals: 2)
]);
units.Single(u => u.Id == "marketing").PendingApprovals.ShouldBe(3);
units.Single(u => u.Id == "amazon").PendingApprovals.ShouldBe(2);
}
[Fact]
public void A_unit_knows_its_path_and_its_lead()
{
var amazon = TeamOverview.RollUp(Shop(), []).Single(u => u.Id == "amazon");
amazon.Path.ShouldBe("Marketing / Amazon");
amazon.LeaderAgentId.ShouldBe("amazon-lead");
amazon.Agents.ShouldBe(0);
amazon.Worst.ShouldBe(TeamState.Idle);
}
[Fact]
public void Stuck_is_the_most_urgent_and_idle_the_least()
{
var byUrgency = Enum.GetValues<TeamState>().OrderByDescending(TeamOverview.Urgency).ToList();
byUrgency.First().ShouldBe(TeamState.Stuck);
byUrgency.Last().ShouldBe(TeamState.Idle);
TeamOverview.Urgency(TeamState.Problem).ShouldBeGreaterThan(TeamOverview.Urgency(TeamState.WaitingForApproval));
}
[Fact]
public void Without_units_there_is_nothing_to_roll_up()
=> TeamOverview.RollUp(new OrgChart([], [new AgentConfig { AgentId = "a" }]), [Member("a", null, TeamState.Problem)])
.ShouldBeEmpty();
}
@@ -23,7 +23,7 @@ public sealed class AuditRepositoryTests : IDisposable
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}
@@ -35,7 +35,7 @@ public sealed class BackupServiceTests : IDisposable
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_root);
try { Directory.Delete(_root, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}
@@ -95,7 +95,7 @@ public sealed class BackupServiceTests : IDisposable
}, default);
// Verbindungen schließen, damit VACUUM INTO auf eine ruhige Datei trifft.
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_instanceDir);
}
private string ZipPath => Path.Combine(_root, "sicherung.zip");
@@ -40,7 +40,7 @@ public sealed class LicenseBackupTests : IDisposable
public void Dispose()
{
SecretKeyStore.UseDirectory(null);
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_root);
try { Directory.Delete(_root, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}
@@ -25,7 +25,7 @@ public sealed class BudgetGuardTests : IDisposable
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}
@@ -220,7 +220,7 @@ public sealed class UsageRepositoryTests : IDisposable
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); }
catch { }
}
@@ -116,25 +116,27 @@ public sealed class AgentEngineConcurrencyTests
{
// Wichtige Gegenprobe: Der Fix für B2 darf nicht versehentlich alle Agenten
// gegeneinander sperren.
var toolDelay = TimeSpan.FromMilliseconds(200);
var fixture = new EngineFixture().WithTool(FakeTool.Slow(toolDelay));
//
// Bis zum 2026-09-24 antwortete die Attrappe hier nur mit Text: Das langsame
// Werkzeug lief nie, die Läufe waren sofort fertig, und geprüft wurde nur, ob drei
// leere Läufe unter 450 ms bleiben. Das bestand auch mit gegenseitig gesperrten
// Agenten - und schlug unter Last fehl, weil es die Maschine mass statt der Engine.
// Jetzt ruft jeder Agent das Werkzeug wirklich auf, und bewiesen wird die Überlappung.
var tool = FakeTool.Slow(TimeSpan.FromMilliseconds(500));
var fixture = new EngineFixture().WithTool(tool);
var a = fixture.AddAgent("agent-1", "TestTool");
var b = fixture.AddAgent("agent-2", "TestTool");
var c = fixture.AddAgent("agent-3", "TestTool");
fixture.Client.AlwaysRespondsWithText("Antwort");
fixture.Client.RespondsContextually("TestTool");
var sw = System.Diagnostics.Stopwatch.StartNew();
await Task.WhenAll(
fixture.Engine.ChatAsync(a, "x", "test-instance", default),
fixture.Engine.ChatAsync(b, "x", "test-instance", default),
fixture.Engine.ChatAsync(c, "x", "test-instance", default));
sw.Stop();
// Seriell wären es mindestens 3 × 200 ms. Parallel deutlich weniger.
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromMilliseconds(450),
"Agenten dürfen sich nicht gegenseitig blockieren");
tool.MaxConcurrent.ShouldBeGreaterThan(1, "Agenten dürfen sich nicht gegenseitig blockieren");
}
private static async Task WaitUntilAsync(Func<bool> condition, int timeoutMs = 2_000)
@@ -29,7 +29,7 @@ public sealed class MemoryRepositoryTests : IDisposable
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}
@@ -23,7 +23,7 @@ public sealed class StagingRepositoryTests : IDisposable
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}
@@ -35,7 +35,7 @@ public sealed class StagingServiceTests : IDisposable
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}
@@ -33,7 +33,7 @@ public sealed class WerkzeugrechteTests : IDisposable
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}
@@ -0,0 +1,61 @@
using ClawdDotNet.Core.Storage;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Storage;
/// <summary>
/// Releasing the pool of one database must leave every other database alone. The tests used
/// the process-wide ClearAllPools to clean up, and a test running in parallel lost its
/// connection in the middle of opening it — one red run in every few, always another test.
/// </summary>
public sealed class PoolReleaseTests : IDisposable
{
private readonly string _root = Path.Combine(Path.GetTempPath(), "clawd-tests", "pool-" + Guid.NewGuid().ToString("N")[..8]);
public void Dispose()
{
SqliteStorage.ReleasePooledConnections(_root);
try { Directory.Delete(_root, recursive: true); }
catch { /* cleaning up is secondary */ }
}
private static async Task TouchAsync(SqliteStorage storage)
=> await storage.WriteAsync(async connection =>
{
await using var cmd = connection.CreateCommand();
cmd.CommandText = "INSERT OR REPLACE INTO ToolState (Key, Value) VALUES ('k', 'v')";
await cmd.ExecuteNonQueryAsync();
}, default);
[Fact]
public async Task Releasing_one_directory_frees_its_files_and_nothing_else()
{
var first = new SqliteStorage(Path.Combine(_root, "a", "state.db"));
var second = new SqliteStorage(Path.Combine(_root, "b", "state.db"));
await TouchAsync(first);
await TouchAsync(second);
SqliteStorage.ReleasePooledConnections(Path.Combine(_root, "a"));
// The released database can go — on Windows an open handle would prevent that.
Should.NotThrow(() => Directory.Delete(Path.Combine(_root, "a"), recursive: true));
// The other one keeps working, and so does its pool.
await TouchAsync(second);
await using var connection = await second.OpenConnectionAsync(default);
await using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT Value FROM ToolState WHERE Key = 'k'";
(await cmd.ExecuteScalarAsync()).ShouldBe("v");
}
[Fact]
public async Task A_database_file_can_be_named_directly()
{
var path = Path.Combine(_root, "c", "state.db");
await TouchAsync(new SqliteStorage(path));
SqliteStorage.ReleasePooledConnections(path);
Should.NotThrow(() => File.Delete(path));
}
}
@@ -32,7 +32,7 @@ public sealed class BudgetMissTests : IDisposable
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); }
catch { /* cleanup is secondary */ }
}
@@ -35,7 +35,7 @@ public sealed class CoordinationMigrationTests : IDisposable
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}
@@ -30,7 +30,7 @@ public sealed class ScannerCapacityTests : IDisposable
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); }
catch { /* cleanup is secondary */ }
}
@@ -27,7 +27,7 @@ public sealed class TaskRepositoryTests : IDisposable
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}
@@ -27,7 +27,7 @@ public sealed class TaskScannerTests : IDisposable
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}
@@ -28,7 +28,7 @@ public sealed class TaskboardServiceTests : IDisposable
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}
@@ -26,7 +26,7 @@ public sealed class PromptHistoryTests : IDisposable
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); }
catch { /* cleanup is best effort */ }
}
@@ -34,7 +34,7 @@ public sealed class ManagerBackupsTests : IAsyncDisposable
public async ValueTask DisposeAsync()
{
await Task.CompletedTask;
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_root);
try { Directory.Delete(_root, recursive: true); }
catch { /* Ein liegengebliebener Testordner faerbt keinen Lauf rot. */ }
}
@@ -28,7 +28,7 @@ public sealed class MemoryToolTests : IDisposable
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_dir);
try { Directory.Delete(_dir, recursive: true); }
catch { /* Aufraeumen ist Nebensache */ }
}
@@ -43,7 +43,7 @@ public sealed class AssignmentReachTests : IDisposable
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_directory);
try { Directory.Delete(_directory, recursive: true); }
catch { /* cleanup is secondary */ }
}
@@ -35,7 +35,7 @@ public sealed class DublettenTests : IDisposable
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
ClawdDotNet.Core.Storage.SqliteStorage.ReleasePooledConnections(_verzeichnis);
try { Directory.Delete(_verzeichnis, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}