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; /// What an agent is doing, as the team overview shows it (#711). public enum TeamState { /// Nothing running, nothing open, the last run went fine (or there was none). Idle, /// At least one run in progress. Working, /// A run has taken much longer than this agent's runs usually take. Stuck, /// Tool calls of this agent wait for a human decision. WaitingForApproval, /// The last run failed, hit a limit or found the target busy. Problem, /// The last run was refused because the budget is used up. BudgetExhausted } /// One agent in the team overview. public sealed record TeamMember( string AgentId, string DisplayName, string Model, TeamState State, IReadOnlyList Runs, int PendingApprovals, string? LastRunStatus, DateTime? LastRunAtUtc, string? LastRunResult, long TypicalRunMs, string? UnitId = null, bool IsLead = false); /// /// 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. /// public sealed record TeamUnit( string Id, string Name, string? ParentId, string Path, string? LeaderAgentId, string? Goal, int Agents, IReadOnlyDictionary States, int PendingApprovals, TeamState Worst); /// The whole picture at one moment: every agent and unit, and who reached whom recently. public sealed record TeamSnapshot( IReadOnlyList Members, IReadOnlyList RecentLinks, DateTime AtUtc, IReadOnlyList? Units = null, IReadOnlyList? ConflictHotspots = null); /// /// #711: The team overview — who is working, who reached whom, where something is stuck. /// /// Detecting is code, interpreting is model (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. /// /// Order of states. 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. /// public sealed class TeamOverview( AgentEngine? engine, StagingService? staging, IAuditRepository? audit, IUsageRepository? usage, Func> agents, TimeProvider? clock = null, Func? organisation = null) { /// How far back the last receipt of each agent is looked for. public static readonly TimeSpan ReceiptWindow = TimeSpan.FromDays(3); /// How long recent links stay in the snapshot — the overview fades them out. public static readonly TimeSpan LinkWindow = TimeSpan.FromMinutes(10); /// A run counts as stuck after this many times the agent's typical duration… public const int StuckFactor = 3; /// …but never before this — short runs vary a lot in relative terms. public static readonly TimeSpan StuckFloor = TimeSpan.FromMinutes(5); /// Without history, a run counts as stuck after this long. public static readonly TimeSpan StuckWithoutHistory = TimeSpan.FromMinutes(15); /// #720: How far back conflicts count for a hotspot… public static readonly TimeSpan HotspotWindow = TimeSpan.FromDays(7); /// …and how many make one. One conflict is an accident; three in a week are a pattern. public const int HotspotMinimum = 3; private readonly TimeProvider _clock = clock ?? TimeProvider.System; public async Task 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(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(); 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); } /// /// #720: A file path as a human finds it — relative to the instance's Agents folder, /// where the shared workspaces live. A path elsewhere stays as it is. /// public static string Readable(string path, IReadOnlyList 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, '/'); } /// /// 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. /// public static int Urgency(TeamState state) => state switch { TeamState.Stuck => 5, TeamState.Problem => 4, TeamState.WaitingForApproval => 3, TeamState.BudgetExhausted => 2, TeamState.Working => 1, _ => 0 }; /// /// Every unit with the states of all agents in it and below. Pure, like /// : the page draws the numbers, it does not add them up. /// public static IReadOnlyList RollUp(OrgChart org, IReadOnlyList members) { var result = new List(); 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; } /// /// The state from the facts. Pure, so the rules can be tested without an instance. /// public static TeamState Classify( IReadOnlyList 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 }; } /// The agent's typical run duration — the median of its recent runs. private async Task 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]); } }