- 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>
242 lines
9.6 KiB
C#
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]);
|
|
}
|
|
}
|