FileLedger merkt je Agent die gelesene Fassung einer Datei in gemeinsamen Bereichen. write, copy und Downloads (FTP, RocketChat) ersetzen eine Datei nur, wenn sie seit dem Lesen unveraendert ist; eine fremde Datei nicht ungelesen. append bleibt ungeprueft, macht aeltere Lesestaende aber ungueltig. Abgelehnte Schreibvorgaenge: eigener Audit-Status Conflict, Tabelle FileConflicts, Hotspots (>= 3 in 7 Tagen) in der Teamuebersicht. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
272 lines
11 KiB
C#
272 lines
11 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,
|
|
IReadOnlyList<Core.Files.FileConflictHotspot>? ConflictHotspots = 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);
|
|
|
|
/// <summary>#720: How far back conflicts count for a hotspot…</summary>
|
|
public static readonly TimeSpan HotspotWindow = TimeSpan.FromDays(7);
|
|
|
|
/// <summary>…and how many make one. One conflict is an accident; three in a week are a pattern.</summary>
|
|
public const int HotspotMinimum = 3;
|
|
|
|
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));
|
|
}
|
|
|
|
// #720: files where agents keep getting in each other's way — for a human to decide
|
|
// whether they should be split, appended to, or become a table.
|
|
var hotspots = engine?.Files is { } files
|
|
? (await files.HotspotsAsync(now - HotspotWindow, HotspotMinimum, 10, ct))
|
|
.Select(h => h with { Path = Readable(h.Path, configured) }).ToList()
|
|
: [];
|
|
|
|
return new TeamSnapshot(members, links, now, RollUp(org, members), hotspots);
|
|
}
|
|
|
|
/// <summary>
|
|
/// #720: A file path as a human finds it — relative to the instance's <c>Agents</c> folder,
|
|
/// where the shared workspaces live. A path elsewhere stays as it is.
|
|
/// </summary>
|
|
public static string Readable(string path, IReadOnlyList<AgentConfig> agents)
|
|
{
|
|
var shared = agents.Select(a => a.SharedWorkspacePath).FirstOrDefault(p => !string.IsNullOrWhiteSpace(p));
|
|
if (shared is null
|
|
|| Path.GetDirectoryName(Path.TrimEndingDirectorySeparator(Path.GetFullPath(shared))) is not { } agentsDir)
|
|
return path;
|
|
var relative = Path.GetRelativePath(agentsDir, path);
|
|
return relative.StartsWith("..", StringComparison.Ordinal) || Path.IsPathRooted(relative)
|
|
? path
|
|
: relative.Replace(Path.DirectorySeparatorChar, '/');
|
|
}
|
|
|
|
/// <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]);
|
|
}
|
|
}
|