Files
ClawdDotNet/src/ClawdDotNet.App/Services/TeamOverview.cs
T
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

242 lines
9.6 KiB
C#

using ClawdDotNet.Core.Accounting;
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;
/// <summary>What an agent is doing, as the team overview shows it (#711).</summary>
public enum TeamState
{
/// <summary>Nothing running, nothing open, the last run went fine (or there was none).</summary>
Idle,
/// <summary>At least one run in progress.</summary>
Working,
/// <summary>A run has taken much longer than this agent's runs usually take.</summary>
Stuck,
/// <summary>Tool calls of this agent wait for a human decision.</summary>
WaitingForApproval,
/// <summary>The last run failed, hit a limit or found the target busy.</summary>
Problem,
/// <summary>The last run was refused because the budget is used up.</summary>
BudgetExhausted
}
/// <summary>One agent in the team overview.</summary>
public sealed record TeamMember(
string AgentId,
string DisplayName,
string Model,
TeamState State,
IReadOnlyList<ActiveRun> Runs,
int PendingApprovals,
string? LastRunStatus,
DateTime? LastRunAtUtc,
string? LastRunResult,
long TypicalRunMs,
string? UnitId = null,
bool IsLead = false);
/// <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.
///
/// <para><b>Detecting is code, interpreting is model</b> (Organisation-Konzept, 10a). Every
/// state here comes from a fact the instance recorded: a run in progress, an open staged
/// call, the status of the last receipt. Nothing is judged by a model, so the picture is
/// the same for everyone who looks and costs nothing to draw.</para>
///
/// <para><b>Order of states.</b> When several apply, the one that needs someone first wins:
/// stuck, then waiting for approval, then working; a problem or an exhausted budget only
/// shows while nothing new is running — a new run is the attempt to get past it.</para>
/// </summary>
public sealed class TeamOverview(
AgentEngine? engine,
StagingService? staging,
IAuditRepository? audit,
IUsageRepository? usage,
Func<IReadOnlyList<AgentConfig>> agents,
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);
/// <summary>How long recent links stay in the snapshot — the overview fades them out.</summary>
public static readonly TimeSpan LinkWindow = TimeSpan.FromMinutes(10);
/// <summary>A run counts as stuck after this many times the agent's typical duration…</summary>
public const int StuckFactor = 3;
/// <summary>…but never before this — short runs vary a lot in relative terms.</summary>
public static readonly TimeSpan StuckFloor = TimeSpan.FromMinutes(5);
/// <summary>Without history, a run counts as stuck after this long.</summary>
public static readonly TimeSpan StuckWithoutHistory = TimeSpan.FromMinutes(15);
private readonly TimeProvider _clock = clock ?? TimeProvider.System;
public async Task<TeamSnapshot> SnapshotAsync(CancellationToken ct)
{
var now = _clock.GetUtcNow().UtcDateTime;
var configured = agents();
var active = engine?.Activity.ActiveRuns() ?? [];
var links = engine?.Activity.RecentLinks(LinkWindow) ?? [];
var pending = staging is null ? [] : await staging.ListPendingAsync(ct);
var pendingByAgent = pending
.GroupBy(c => c.AgentId, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.Count(), StringComparer.OrdinalIgnoreCase);
var lastReceipt = new Dictionary<string, RunReceipt>(StringComparer.OrdinalIgnoreCase);
if (audit is not null)
{
var receipts = await audit.ListReceiptsAsync(now - ReceiptWindow, now, 5_000, ct);
foreach (var receipt in receipts)
{
if (!lastReceipt.TryGetValue(receipt.AgentId, out var known) || receipt.OccurredAt > known.OccurredAt)
lastReceipt[receipt.AgentId] = receipt;
}
}
var org = organisation?.Invoke() ?? new OrgChart([], configured);
var members = new List<TeamMember>();
foreach (var agent in configured)
{
var runs = active
.Where(r => string.Equals(r.AgentId, agent.AgentId, StringComparison.OrdinalIgnoreCase))
.OrderBy(r => r.StartedAtUtc)
.ToList();
var approvals = pendingByAgent.GetValueOrDefault(agent.AgentId);
lastReceipt.TryGetValue(agent.AgentId, out var last);
var typical = await TypicalRunAsync(agent.AgentId, ct);
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,
org.UnitOf(agent.AgentId)?.Id, org.LedBy(agent.AgentId) is not null));
}
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>
/// The state from the facts. Pure, so the rules can be tested without an instance.
/// </summary>
public static TeamState Classify(
IReadOnlyList<ActiveRun> runs, int pendingApprovals, string? lastRunStatus, TimeSpan typicalRun, DateTime nowUtc)
{
if (runs.Count > 0)
{
var limit = typicalRun > TimeSpan.Zero
? TimeSpan.FromTicks(Math.Max(typicalRun.Ticks * StuckFactor, StuckFloor.Ticks))
: StuckWithoutHistory;
if (runs.Any(r => nowUtc - r.StartedAtUtc > limit))
return TeamState.Stuck;
}
if (pendingApprovals > 0)
return TeamState.WaitingForApproval;
if (runs.Count > 0)
return TeamState.Working;
return lastRunStatus switch
{
nameof(AgentRunStatus.BudgetExceeded) => TeamState.BudgetExhausted,
nameof(AgentRunStatus.Failed)
or nameof(AgentRunStatus.LoopLimitExceeded)
or nameof(AgentRunStatus.TargetBusy) => TeamState.Problem,
_ => TeamState.Idle
};
}
/// <summary>The agent's typical run duration — the median of its recent runs.</summary>
private async Task<TimeSpan> TypicalRunAsync(string agentId, CancellationToken ct)
{
if (usage is null)
return TimeSpan.Zero;
var recent = await usage.GetRecentForAgentAsync(agentId, 20, ct);
var durations = recent.Where(r => r.DurationMs > 0).Select(r => r.DurationMs).Order().ToList();
return durations.Count == 0 ? TimeSpan.Zero : TimeSpan.FromMilliseconds(durations[durations.Count / 2]);
}
}